diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md new file mode 100644 index 00000000000000..1faef45c0b2b72 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionlinkbase.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionLinkBase](./kibana-plugin-core-public.chromehelpextensionlinkbase.md) + +## ChromeHelpExtensionLinkBase type + + +Signature: + +```typescript +export declare type ChromeHelpExtensionLinkBase = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md new file mode 100644 index 00000000000000..dc455ca43d24ab --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [content](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md) + +## ChromeHelpExtensionMenuCustomLink.content property + +Content of the button (in lieu of `children`) + +Signature: + +```typescript +content: React.ReactNode; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md new file mode 100644 index 00000000000000..feb91acd6d9150 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md) + +## ChromeHelpExtensionMenuCustomLink.href property + +URL of the link + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md new file mode 100644 index 00000000000000..a02b2197540427 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md) + +## ChromeHelpExtensionMenuCustomLink.linkType property + +Extend EuiButtonEmpty to provide extra functionality + +Signature: + +```typescript +linkType: 'custom'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md index 29be9b9539ee00..ff4978e69df62a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenucustomlink.md @@ -2,14 +2,20 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) -## ChromeHelpExtensionMenuCustomLink type +## ChromeHelpExtensionMenuCustomLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { - linkType: 'custom'; - content: React.ReactNode; -}; +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [content](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.content.md) | React.ReactNode | Content of the button (in lieu of children) | +| [href](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.href.md) | string | URL of the link | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.linktype.md) | 'custom' | Extend EuiButtonEmpty to provide extra functionality | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md new file mode 100644 index 00000000000000..b6714c39a4699a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md) + +## ChromeHelpExtensionMenuDiscussLink.href property + +URL to discuss page. i.e. `https://discuss.elastic.co/c/${appName}` + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md new file mode 100644 index 00000000000000..0141677b26a40e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md) + +## ChromeHelpExtensionMenuDiscussLink.linkType property + +Creates a generic give feedback link with comment icon + +Signature: + +```typescript +linkType: 'discuss'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md index 63d0596bd98476..a73f6daad28c23 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md @@ -2,14 +2,19 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) -## ChromeHelpExtensionMenuDiscussLink type +## ChromeHelpExtensionMenuDiscussLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { - linkType: 'discuss'; - href: string; -}; +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [href](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.href.md) | string | URL to discuss page. i.e. https://discuss.elastic.co/c/${appName} | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.linktype.md) | 'discuss' | Creates a generic give feedback link with comment icon | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md new file mode 100644 index 00000000000000..9897bc6fcd2f7b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) > [href](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md) + +## ChromeHelpExtensionMenuDocumentationLink.href property + +URL to documentation page. i.e. `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html`, + +Signature: + +```typescript +href: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md new file mode 100644 index 00000000000000..b75a70f9518b32 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md) + +## ChromeHelpExtensionMenuDocumentationLink.linkType property + +Creates a deep-link to app-specific documentation + +Signature: + +```typescript +linkType: 'documentation'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md index c7c1c4153edf89..fab49d06d47748 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md @@ -2,14 +2,19 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) -## ChromeHelpExtensionMenuDocumentationLink type +## ChromeHelpExtensionMenuDocumentationLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { - linkType: 'documentation'; - href: string; -}; +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [href](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.href.md) | string | URL to documentation page. i.e. ${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html, | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.linktype.md) | 'documentation' | Creates a deep-link to app-specific documentation | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md new file mode 100644 index 00000000000000..1976215e7243cb --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [labels](./kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md) + +## ChromeHelpExtensionMenuGitHubLink.labels property + +Include at least one app-specific label to be applied to the new github issue + +Signature: + +```typescript +labels: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md new file mode 100644 index 00000000000000..b3df27213e5b71 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [linkType](./kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md) + +## ChromeHelpExtensionMenuGitHubLink.linkType property + +Creates a link to a new github issue in the Kibana repo + +Signature: + +```typescript +linkType: 'github'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md index 5cb3a79086e118..ca9ceecffa6f13 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.md @@ -2,15 +2,20 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) -## ChromeHelpExtensionMenuGitHubLink type +## ChromeHelpExtensionMenuGitHubLink interface Signature: ```typescript -export declare type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { - linkType: 'github'; - labels: string[]; - title?: string; -}; +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [labels](./kibana-plugin-core-public.chromehelpextensionmenugithublink.labels.md) | string[] | Include at least one app-specific label to be applied to the new github issue | +| [linkType](./kibana-plugin-core-public.chromehelpextensionmenugithublink.linktype.md) | 'github' | Creates a link to a new github issue in the Kibana repo | +| [title](./kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md) | string | Provides initial text for the title of the issue | + diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md new file mode 100644 index 00000000000000..af6091f9e72527 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) > [title](./kibana-plugin-core-public.chromehelpextensionmenugithublink.title.md) + +## ChromeHelpExtensionMenuGitHubLink.title property + +Provides initial text for the title of the issue + +Signature: + +```typescript +title?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md index 7a219d5bfd2f86..cb7d795e3eb8e4 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromehelpextensionmenulink.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; +export declare type ChromeHelpExtensionMenuLink = ChromeHelpExtensionMenuGitHubLink | ChromeHelpExtensionMenuDiscussLink | ChromeHelpExtensionMenuDocumentationLink | ChromeHelpExtensionMenuCustomLink; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md index 5551d52cc12266..842d86db45d73c 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md @@ -4,7 +4,7 @@ ## IExternalUrlPolicy.host property -Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. +Optional host describing the external destination. May be combined with `protocol`. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md index a87dc69d79e236..3a1e5714609741 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -17,6 +17,6 @@ export interface IExternalUrlPolicy | Property | Type | Description | | --- | --- | --- | | [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates if this policy allows or denies access to the described destination. | -| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | -| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | +| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. | +| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md index 67b9b439a54f60..ac73412b6e1437 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md @@ -4,7 +4,7 @@ ## IExternalUrlPolicy.protocol property -Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. +Optional protocol describing the external destination. May be combined with `host`. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index a3df5d30137dfc..da19377054499b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -44,6 +44,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeBrand](./kibana-plugin-core-public.chromebrand.md) | | | [ChromeDocTitle](./kibana-plugin-core-public.chromedoctitle.md) | APIs for accessing and updating the document title. | | [ChromeHelpExtension](./kibana-plugin-core-public.chromehelpextension.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) | | | [ChromeNavControl](./kibana-plugin-core-public.chromenavcontrol.md) | | | [ChromeNavControls](./kibana-plugin-core-public.chromenavcontrols.md) | [APIs](./kibana-plugin-core-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | | [ChromeNavLink](./kibana-plugin-core-public.chromenavlink.md) | | @@ -145,10 +149,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-core-public.appupdater.md). | | [AppUpdater](./kibana-plugin-core-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [ChromeBreadcrumb](./kibana-plugin-core-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-core-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-core-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-core-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-core-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionLinkBase](./kibana-plugin-core-public.chromehelpextensionlinkbase.md) | | | [ChromeHelpExtensionMenuLink](./kibana-plugin-core-public.chromehelpextensionmenulink.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-core-public.chromenavlinkupdateablefields.md) | | | [FatalErrorsStart](./kibana-plugin-core-public.fatalerrorsstart.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md index 474962e614aa7d..5201444e69867e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md @@ -4,7 +4,7 @@ ## Embeddable.getUpdated$() method -Merges input$ and output$ streams and denounces emit till next macro-task Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously In case corresponding state change triggered `reload` this stream is guarantied to emit later which allows to skip any state handling in case `reload` already handled it +Merges input$ and output$ streams and debounces emit till next macro-task. Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously. In case corresponding state change triggered `reload` this stream is guarantied to emit later, which allows to skip any state handling in case `reload` already handled it. Signature: diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md index 4541afec29fa52..fe64bcf7c1177b 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddable.md @@ -44,7 +44,7 @@ export declare abstract class Embeddablereload this stream is guarantied to emit later which allows to skip any state handling in case reload already handled it | +| [getUpdated$()](./kibana-plugin-plugins-embeddable-public.embeddable.getupdated_.md) | | Merges input$ and output$ streams and debounces emit till next macro-task. Could be useful to batch reactions to input$ and output$ updates that happen separately but synchronously. In case corresponding state change triggered reload this stream is guarantied to emit later, which allows to skip any state handling in case reload already handled it. | | [onFatalError(e)](./kibana-plugin-plugins-embeddable-public.embeddable.onfatalerror.md) | | | | [reload()](./kibana-plugin-plugins-embeddable-public.embeddable.reload.md) | | Reload will be called when there is a request to refresh the data or view, even if the input data did not change.In case if input data did change and reload is requested input$ and output$ would still emit before reload is calledThe order would be as follows: input$ output$ reload() \-\-\-- updated$ | | [render(el)](./kibana-plugin-plugins-embeddable-public.embeddable.render.md) | | | diff --git a/packages/kbn-es-archiver/src/lib/__tests__/stats.ts b/packages/kbn-es-archiver/src/lib/stats.test.ts similarity index 84% rename from packages/kbn-es-archiver/src/lib/__tests__/stats.ts rename to packages/kbn-es-archiver/src/lib/stats.test.ts index 0ab7d161feb6e1..13f04451ff7e50 100644 --- a/packages/kbn-es-archiver/src/lib/__tests__/stats.ts +++ b/packages/kbn-es-archiver/src/lib/stats.test.ts @@ -19,10 +19,9 @@ import { uniq } from 'lodash'; import sinon from 'sinon'; -import expect from '@kbn/expect'; import { ToolingLog } from '@kbn/dev-utils'; -import { createStats } from '../'; +import { createStats } from './stats'; function createBufferedLog(): ToolingLog & { buffer: string } { const log: ToolingLog = new ToolingLog({ @@ -40,12 +39,12 @@ function assertDeepClones(a: any, b: any) { try { (function recurse(one, two) { if (typeof one !== 'object' || typeof two !== 'object') { - expect(one).to.be(two); + expect(one).toBe(two); return; } - expect(one).to.eql(two); - expect(one).to.not.be(two); + expect(one).toEqual(two); + expect(one).not.toBe(two); const keys = uniq(Object.keys(one).concat(Object.keys(two))); keys.forEach((k) => { path.push(k); @@ -68,14 +67,14 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.skippedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('skipped', true); + expect(indexStats).toHaveProperty('skipped', true); }); it('logs that the index was skipped', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.skippedIndex('index-name'); - expect(log.buffer).to.contain('Skipped'); + expect(log.buffer).toContain('Skipped'); }); }); @@ -84,13 +83,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.deletedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('deleted', true); + expect(indexStats).toHaveProperty('deleted', true); }); it('logs that the index was deleted', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.deletedIndex('index-name'); - expect(log.buffer).to.contain('Deleted'); + expect(log.buffer).toContain('Deleted'); }); }); @@ -99,13 +98,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.createdIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('created', true); + expect(indexStats).toHaveProperty('created', true); }); it('logs that the index was created', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.createdIndex('index-name'); - expect(log.buffer).to.contain('Created'); + expect(log.buffer).toContain('Created'); }); describe('with metadata', () => { it('debug-logs each key from the metadata', async () => { @@ -114,8 +113,8 @@ describe('esArchiver: Stats', () => { stats.createdIndex('index-name', { foo: 'bar', }); - expect(log.buffer).to.contain('debg'); - expect(log.buffer).to.contain('foo "bar"'); + expect(log.buffer).toContain('debg'); + expect(log.buffer).toContain('foo "bar"'); }); }); describe('without metadata', () => { @@ -123,7 +122,7 @@ describe('esArchiver: Stats', () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.createdIndex('index-name'); - expect(log.buffer).to.not.contain('debg'); + expect(log.buffer).not.toContain('debg'); }); }); }); @@ -133,13 +132,13 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.archivedIndex('index-name'); const indexStats = stats.toJSON()['index-name']; - expect(indexStats).to.have.property('archived', true); + expect(indexStats).toHaveProperty('archived', true); }); it('logs that the index was archived', async () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.archivedIndex('index-name'); - expect(log.buffer).to.contain('Archived'); + expect(log.buffer).toContain('Archived'); }); describe('with metadata', () => { it('debug-logs each key from the metadata', async () => { @@ -148,8 +147,8 @@ describe('esArchiver: Stats', () => { stats.archivedIndex('index-name', { foo: 'bar', }); - expect(log.buffer).to.contain('debg'); - expect(log.buffer).to.contain('foo "bar"'); + expect(log.buffer).toContain('debg'); + expect(log.buffer).toContain('foo "bar"'); }); }); describe('without metadata', () => { @@ -157,7 +156,7 @@ describe('esArchiver: Stats', () => { const log = createBufferedLog(); const stats = createStats('name', log); stats.archivedIndex('index-name'); - expect(log.buffer).to.not.contain('debg'); + expect(log.buffer).not.toContain('debg'); }); }); }); @@ -166,10 +165,10 @@ describe('esArchiver: Stats', () => { it('increases the docs.indexed count for the index', () => { const stats = createStats('name', new ToolingLog()); stats.indexedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.indexed).to.be(1); + expect(stats.toJSON()['index-name'].docs.indexed).toBe(1); stats.indexedDoc('index-name'); stats.indexedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.indexed).to.be(3); + expect(stats.toJSON()['index-name'].docs.indexed).toBe(3); }); }); @@ -177,10 +176,10 @@ describe('esArchiver: Stats', () => { it('increases the docs.archived count for the index', () => { const stats = createStats('name', new ToolingLog()); stats.archivedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.archived).to.be(1); + expect(stats.toJSON()['index-name'].docs.archived).toBe(1); stats.archivedDoc('index-name'); stats.archivedDoc('index-name'); - expect(stats.toJSON()['index-name'].docs.archived).to.be(3); + expect(stats.toJSON()['index-name'].docs.archived).toBe(3); }); }); @@ -189,7 +188,7 @@ describe('esArchiver: Stats', () => { const stats = createStats('name', new ToolingLog()); stats.archivedIndex('index1'); stats.archivedIndex('index2'); - expect(Object.keys(stats.toJSON())).to.eql(['index1', 'index2']); + expect(Object.keys(stats.toJSON())).toEqual(['index1', 'index2']); }); it('returns a deep clone of the stats', () => { const stats = createStats('name', new ToolingLog()); diff --git a/packages/kbn-eslint-plugin-eslint/jest.config.js b/packages/kbn-eslint-plugin-eslint/jest.config.js new file mode 100644 index 00000000000000..f2dbf1268f1c86 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/jest.config.js @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-eslint-plugin-eslint'], +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/client/a.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/client/a.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/client/a.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/client/a.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/b.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/b.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/b.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/b.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/c.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/c.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/c.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/c.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/deep/d.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/deep/d.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/deep/d.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/deep/d.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/index_patterns/index.js b/packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/index_patterns/index.js similarity index 100% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/files/no_restricted_paths/server/index_patterns/index.js rename to packages/kbn-eslint-plugin-eslint/rules/__fixtures__/no_restricted_paths/server/index_patterns/index.js diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js b/packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js similarity index 98% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js rename to packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js index 0bdd4e328b396a..8ba42c7b70f40c 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/disallow_license_headers.js +++ b/packages/kbn-eslint-plugin-eslint/rules/disallow_license_headers.test.js @@ -18,7 +18,7 @@ */ const { RuleTester } = require('eslint'); -const rule = require('../disallow_license_headers'); +const rule = require('./disallow_license_headers'); const dedent = require('dedent'); const ruleTester = new RuleTester({ diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js b/packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js similarity index 70% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js rename to packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js index e16ba0d16bb876..516ffc2b17bf7f 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/no_restricted_paths.js +++ b/packages/kbn-eslint-plugin-eslint/rules/no_restricted_paths.test.js @@ -29,7 +29,7 @@ const path = require('path'); const { RuleTester } = require('eslint'); -const rule = require('../no_restricted_paths'); +const rule = require('./no_restricted_paths'); const ruleTester = new RuleTester({ parser: require.resolve('babel-eslint'), @@ -43,14 +43,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { valid: [ { code: 'import a from "../client/a.js"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/other/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/other/**/*', }, ], }, @@ -58,14 +58,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const a = require("../client/a.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/other/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/other/**/*', }, ], }, @@ -73,7 +73,7 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import b from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, @@ -98,14 +98,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'notrequire("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -142,15 +142,15 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { allowSameFolder: true, - target: 'files/no_restricted_paths/**/*', - from: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', + from: '__fixtures__/no_restricted_paths/**/*', }, ], }, @@ -158,15 +158,18 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { allowSameFolder: true, - target: 'files/no_restricted_paths/**/*', - from: ['files/no_restricted_paths/**/*', '!files/no_restricted_paths/server/b*'], + target: '__fixtures__/no_restricted_paths/**/*', + from: [ + '__fixtures__/no_restricted_paths/**/*', + '!__fixtures__/no_restricted_paths/server/b*', + ], }, ], }, @@ -176,16 +179,16 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Check if dirs that start with 'index' work correctly. code: 'import { X } from "./index_patterns"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: ['files/no_restricted_paths/(public|server)/**/*'], + target: ['__fixtures__/no_restricted_paths/(public|server)/**/*'], from: [ - 'files/no_restricted_paths/server/**/*', - '!files/no_restricted_paths/server/index.{ts,tsx}', + '__fixtures__/no_restricted_paths/server/**/*', + '!__fixtures__/no_restricted_paths/server/index.{ts,tsx}', ], allowSameFolder: true, }, @@ -198,14 +201,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { invalid: [ { code: 'export { b } from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -220,14 +223,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import b from "../server/b.js"', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/client/**/*', - from: 'files/no_restricted_paths/server/**/*', + target: '__fixtures__/no_restricted_paths/client/**/*', + from: '__fixtures__/no_restricted_paths/server/**/*', }, ], }, @@ -242,18 +245,18 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'import a from "../client/a"\nimport c from "./c"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/client/**/*', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/client/**/*', }, { - target: 'files/no_restricted_paths/server/**/*', - from: 'files/no_restricted_paths/server/c.js', + target: '__fixtures__/no_restricted_paths/server/**/*', + from: '__fixtures__/no_restricted_paths/server/c.js', }, ], }, @@ -273,7 +276,7 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const b = require("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, @@ -295,10 +298,10 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { }, { code: 'const b = require("../server/b.js")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { - basePath: path.join(__dirname, 'files', 'no_restricted_paths'), + basePath: path.join(__dirname, '__fixtures__', 'no_restricted_paths'), zones: [ { target: 'client/**/*', @@ -318,14 +321,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { code: 'const d = require("./deep/d.js")', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/**/*', - from: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', + from: '__fixtures__/no_restricted_paths/**/*', }, ], }, @@ -342,13 +345,13 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import deeply within Core, using "src/core/..." Webpack alias. code: 'const d = require("src/core/server/saved_objects")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', from: 'src/core/server/**/*', }, ], @@ -366,14 +369,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import "ui/kfetch". code: 'const d = require("ui/kfetch")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { from: ['src/legacy/ui/**/*', 'ui/**/*'], - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', allowSameFolder: true, }, ], @@ -391,14 +394,14 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { { // Does not allow to import deeply "ui/kfetch/public/index". code: 'const d = require("ui/kfetch/public/index")', - filename: path.join(__dirname, './files/no_restricted_paths/client/a.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/client/a.js'), options: [ { basePath: __dirname, zones: [ { from: ['src/legacy/ui/**/*', 'ui/**/*'], - target: 'files/no_restricted_paths/**/*', + target: '__fixtures__/no_restricted_paths/**/*', allowSameFolder: true, }, ], @@ -417,16 +420,16 @@ ruleTester.run('@kbn/eslint/no-restricted-paths', rule, { // Don't use index*. // It won't work with dirs that start with 'index'. code: 'import { X } from "./index_patterns"', - filename: path.join(__dirname, './files/no_restricted_paths/server/b.js'), + filename: path.join(__dirname, './__fixtures__/no_restricted_paths/server/b.js'), options: [ { basePath: __dirname, zones: [ { - target: ['files/no_restricted_paths/(public|server)/**/*'], + target: ['__fixtures__/no_restricted_paths/(public|server)/**/*'], from: [ - 'files/no_restricted_paths/server/**/*', - '!files/no_restricted_paths/server/index*', + '__fixtures__/no_restricted_paths/server/**/*', + '!__fixtures__/no_restricted_paths/server/index*', ], allowSameFolder: true, }, diff --git a/packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js b/packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js similarity index 98% rename from packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js rename to packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js index f5d3d6b61c5581..f839cc4bad6fa5 100644 --- a/packages/kbn-eslint-plugin-eslint/rules/__tests__/require_license_header.js +++ b/packages/kbn-eslint-plugin-eslint/rules/require_license_header.test.js @@ -18,7 +18,7 @@ */ const { RuleTester } = require('eslint'); -const rule = require('../require_license_header'); +const rule = require('./require_license_header'); const dedent = require('dedent'); const ruleTester = new RuleTester({ diff --git a/packages/kbn-test-subj-selector/__tests__/index.js b/packages/kbn-test-subj-selector/index.test.js similarity index 66% rename from packages/kbn-test-subj-selector/__tests__/index.js rename to packages/kbn-test-subj-selector/index.test.js index 23165cefec94ab..e6a5d0c7312055 100755 --- a/packages/kbn-test-subj-selector/__tests__/index.js +++ b/packages/kbn-test-subj-selector/index.test.js @@ -17,19 +17,18 @@ * under the License. */ -const testSubjSelector = require('../'); -const expect = require('@kbn/expect'); +const testSubjSelector = require('./'); describe('testSubjSelector()', function () { it('converts subjectSelectors to cssSelectors', function () { - expect(testSubjSelector('foo bar')).to.eql('[data-test-subj="foo bar"]'); - expect(testSubjSelector('foo > bar')).to.eql('[data-test-subj="foo"] [data-test-subj="bar"]'); - expect(testSubjSelector('foo > bar baz')).to.eql( + expect(testSubjSelector('foo bar')).toEqual('[data-test-subj="foo bar"]'); + expect(testSubjSelector('foo > bar')).toEqual('[data-test-subj="foo"] [data-test-subj="bar"]'); + expect(testSubjSelector('foo > bar baz')).toEqual( '[data-test-subj="foo"] [data-test-subj="bar baz"]' ); - expect(testSubjSelector('foo> ~bar')).to.eql('[data-test-subj="foo"] [data-test-subj~="bar"]'); - expect(testSubjSelector('~ foo')).to.eql('[data-test-subj~="foo"]'); - expect(testSubjSelector('~foo & ~ bar')).to.eql( + expect(testSubjSelector('foo> ~bar')).toEqual('[data-test-subj="foo"] [data-test-subj~="bar"]'); + expect(testSubjSelector('~ foo')).toEqual('[data-test-subj~="foo"]'); + expect(testSubjSelector('~foo & ~ bar')).toEqual( '[data-test-subj~="foo"][data-test-subj~="bar"]' ); }); diff --git a/packages/kbn-test-subj-selector/jest.config.js b/packages/kbn-test-subj-selector/jest.config.js new file mode 100644 index 00000000000000..78ee88aa13c301 --- /dev/null +++ b/packages/kbn-test-subj-selector/jest.config.js @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-test-subj-selector'], +}; diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js similarity index 91% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js index 37ea49172d2c46..236e299a48c0c6 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js @@ -24,12 +24,12 @@ export default function () { testFiles: [ require.resolve('./tests/before_hook'), require.resolve('./tests/it'), - require.resolve('./tests/after_hook') + require.resolve('./tests/after_hook'), ], services: { hookIntoLIfecycle({ getService }) { const log = getService('log'); - const lifecycle = getService('lifecycle') + const lifecycle = getService('lifecycle'); lifecycle.testFailure.add(async (err, test) => { log.info('testFailure %s %s', err.message, test.fullTitle()); @@ -42,10 +42,10 @@ export default function () { await delay(10); log.info('testHookFailureAfterDelay %s %s', err.message, test.fullTitle()); }); - } + }, }, mochaReporter: { - captureLogOutput: false - } + captureLogOutput: false, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/after_hook.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/after_hook.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/after_hook.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/after_hook.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/before_hook.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/before_hook.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/before_hook.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/before_hook.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/it.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/it.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/failure_hooks/tests/it.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/tests/it.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js similarity index 94% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js index 60f0835b25abeb..5e9669861656fa 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js @@ -20,7 +20,5 @@ import { resolve } from 'path'; export default () => ({ - testFiles: [ - resolve(__dirname, 'tests.js') - ] + testFiles: [resolve(__dirname, 'tests.js')], }); diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/tests.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/tests.js similarity index 100% rename from packages/kbn-test/src/functional_test_runner/__tests__/fixtures/simple_project/tests.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/tests.js diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js b/packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js similarity index 79% rename from packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js index a010d9f0b038ee..f36faed3616923 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/integration/basic.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/basic.test.js @@ -20,21 +20,18 @@ import { spawnSync } from 'child_process'; import { resolve } from 'path'; -import expect from '@kbn/expect'; import { REPO_ROOT } from '@kbn/utils'; const SCRIPT = resolve(REPO_ROOT, 'scripts/functional_test_runner.js'); -const BASIC_CONFIG = require.resolve('../fixtures/simple_project/config.js'); +const BASIC_CONFIG = require.resolve('./__fixtures__/simple_project/config.js'); describe('basic config file with a single app and test', function () { - this.timeout(60 * 1000); - it('runs and prints expected output', () => { const proc = spawnSync(process.execPath, [SCRIPT, '--config', BASIC_CONFIG]); const stdout = proc.stdout.toString('utf8'); - expect(stdout).to.contain('$BEFORE$'); - expect(stdout).to.contain('$TESTNAME$'); - expect(stdout).to.contain('$INTEST$'); - expect(stdout).to.contain('$AFTER$'); + expect(stdout).toContain('$BEFORE$'); + expect(stdout).toContain('$TESTNAME$'); + expect(stdout).toContain('$INTEST$'); + expect(stdout).toContain('$AFTER$'); }); }); diff --git a/packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js similarity index 73% rename from packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js rename to packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js index fa4ef88fd3e703..304365694d0a7f 100644 --- a/packages/kbn-test/src/functional_test_runner/__tests__/integration/failure_hooks.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js @@ -21,15 +21,12 @@ import { spawnSync } from 'child_process'; import { resolve } from 'path'; import stripAnsi from 'strip-ansi'; -import expect from '@kbn/expect'; import { REPO_ROOT } from '@kbn/utils'; const SCRIPT = resolve(REPO_ROOT, 'scripts/functional_test_runner.js'); -const FAILURE_HOOKS_CONFIG = require.resolve('../fixtures/failure_hooks/config.js'); +const FAILURE_HOOKS_CONFIG = require.resolve('./__fixtures__/failure_hooks/config.js'); describe('failure hooks', function () { - this.timeout(60 * 1000); - it('runs and prints expected output', () => { const proc = spawnSync(process.execPath, [SCRIPT, '--config', FAILURE_HOOKS_CONFIG]); const lines = stripAnsi(proc.stdout.toString('utf8')).split(/\r?\n/); @@ -37,8 +34,8 @@ describe('failure hooks', function () { { flag: '$FAILING_BEFORE_HOOK$', assert(lines) { - expect(lines.shift()).to.match(/info\s+testHookFailure\s+\$FAILING_BEFORE_ERROR\$/); - expect(lines.shift()).to.match( + expect(lines.shift()).toMatch(/info\s+testHookFailure\s+\$FAILING_BEFORE_ERROR\$/); + expect(lines.shift()).toMatch( /info\s+testHookFailureAfterDelay\s+\$FAILING_BEFORE_ERROR\$/ ); }, @@ -46,16 +43,16 @@ describe('failure hooks', function () { { flag: '$FAILING_TEST$', assert(lines) { - expect(lines.shift()).to.match(/global before each/); - expect(lines.shift()).to.match(/info\s+testFailure\s+\$FAILING_TEST_ERROR\$/); - expect(lines.shift()).to.match(/info\s+testFailureAfterDelay\s+\$FAILING_TEST_ERROR\$/); + expect(lines.shift()).toMatch(/global before each/); + expect(lines.shift()).toMatch(/info\s+testFailure\s+\$FAILING_TEST_ERROR\$/); + expect(lines.shift()).toMatch(/info\s+testFailureAfterDelay\s+\$FAILING_TEST_ERROR\$/); }, }, { flag: '$FAILING_AFTER_HOOK$', assert(lines) { - expect(lines.shift()).to.match(/info\s+testHookFailure\s+\$FAILING_AFTER_ERROR\$/); - expect(lines.shift()).to.match( + expect(lines.shift()).toMatch(/info\s+testHookFailure\s+\$FAILING_AFTER_ERROR\$/); + expect(lines.shift()).toMatch( /info\s+testHookFailureAfterDelay\s+\$FAILING_AFTER_ERROR\$/ ); }, @@ -70,6 +67,6 @@ describe('failure hooks', function () { } } - expect(tests).to.have.length(0); + expect(tests).toHaveLength(0); }); }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js similarity index 95% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js index 3bce2f2250b045..91462dab3b5630 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.1.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js @@ -19,8 +19,6 @@ export default function () { return { - testFiles: [ - 'config.1' - ] + testFiles: ['config.1'], }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js similarity index 92% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js index 6906779f97ef2e..27c5ec44a96f40 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.2.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js @@ -21,9 +21,6 @@ export default async function ({ readConfigFile }) { const config1 = await readConfigFile(require.resolve('./config.1.js')); return { - testFiles: [ - ...config1.get('testFiles'), - 'config.2' - ] + testFiles: [...config1.get('testFiles'), 'config.2'], }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js similarity index 92% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js index 94ac54ee81b74e..9b9606cba0f59d 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.3.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js @@ -20,11 +20,9 @@ export default async function ({ readConfigFile }) { const config4 = await readConfigFile(require.resolve('./config.4')); return { - testFiles: [ - 'baz' - ], + testFiles: ['baz'], screenshots: { - ...config4.get('screenshots') - } + ...config4.get('screenshots'), + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js similarity index 96% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js index 60239502602e23..e13347f86b360f 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.4.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js @@ -20,7 +20,7 @@ export default function () { return { screenshots: { - directory: 'bar' - } + directory: 'bar', + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js similarity index 98% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js rename to packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js index 8da9021a440e58..19b7c2c410beaa 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/fixtures/config.invalid.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.invalid.js @@ -19,6 +19,6 @@ export default async function () { return { - foo: 'bar' + foo: 'bar', }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js similarity index 74% rename from packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js rename to packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js index 8d02e7262409fc..bbe518a3ac355e 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__tests__/read_config_file.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.test.js @@ -17,42 +17,40 @@ * under the License. */ -import expect from '@kbn/expect'; - import { ToolingLog } from '@kbn/dev-utils'; -import { readConfigFile } from '../read_config_file'; -import { Config } from '../config'; +import { readConfigFile } from './read_config_file'; +import { Config } from './config'; const log = new ToolingLog(); describe('readConfigFile()', () => { it('reads config from a file, returns an instance of Config class', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.1')); - expect(config).to.be.a(Config); - expect(config.get('testFiles')).to.eql(['config.1']); + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1')); + expect(config instanceof Config).toBeTruthy(); + expect(config.get('testFiles')).toEqual(['config.1']); }); it('merges setting overrides into log', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.1'), { + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.1'), { screenshots: { directory: 'foo.bar', }, }); - expect(config.get('screenshots.directory')).to.be('foo.bar'); + expect(config.get('screenshots.directory')).toBe('foo.bar'); }); it('supports loading config files from within config files', async () => { - const config = await readConfigFile(log, require.resolve('./fixtures/config.2')); - expect(config.get('testFiles')).to.eql(['config.1', 'config.2']); + const config = await readConfigFile(log, require.resolve('./__fixtures__/config.2')); + expect(config.get('testFiles')).toEqual(['config.1', 'config.2']); }); it('throws if settings are invalid', async () => { try { - await readConfigFile(log, require.resolve('./fixtures/config.invalid')); + await readConfigFile(log, require.resolve('./__fixtures__/config.invalid')); throw new Error('expected readConfigFile() to fail'); } catch (err) { - expect(err.message).to.match(/"foo"/); + expect(err.message).toMatch(/"foo"/); } }); }); diff --git a/packages/kbn-test/src/mocha/__tests__/fixtures/project/test.js b/packages/kbn-test/src/mocha/__fixtures__/project/test.js similarity index 100% rename from packages/kbn-test/src/mocha/__tests__/fixtures/project/test.js rename to packages/kbn-test/src/mocha/__fixtures__/project/test.js diff --git a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js b/packages/kbn-test/src/mocha/junit_report_generation.test.js similarity index 76% rename from packages/kbn-test/src/mocha/__tests__/junit_report_generation.js rename to packages/kbn-test/src/mocha/junit_report_generation.test.js index 605ad38efbc963..03fceca0df32cb 100644 --- a/packages/kbn-test/src/mocha/__tests__/junit_report_generation.js +++ b/packages/kbn-test/src/mocha/junit_report_generation.test.js @@ -24,12 +24,11 @@ import { fromNode as fcb } from 'bluebird'; import { parseString } from 'xml2js'; import del from 'del'; import Mocha from 'mocha'; -import expect from '@kbn/expect'; -import { getUniqueJunitReportPath } from '../../report_path'; +import { getUniqueJunitReportPath } from '../report_path'; -import { setupJUnitReportGeneration } from '../junit_report_generation'; +import { setupJUnitReportGeneration } from './junit_report_generation'; -const PROJECT_DIR = resolve(__dirname, 'fixtures/project'); +const PROJECT_DIR = resolve(__dirname, '__fixtures__/project'); const DURATION_REGEX = /^\d+\.\d{3}$/; const ISO_DATE_SEC_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; const XML_PATH = getUniqueJunitReportPath(PROJECT_DIR, 'test'); @@ -54,7 +53,7 @@ describe('dev/mocha/junit report generation', () => { const report = await fcb((cb) => parseString(readFileSync(XML_PATH), cb)); // test case results are wrapped in - expect(report).to.eql({ + expect(report).toEqual({ testsuites: { testsuite: [report.testsuites.testsuite[0]], }, @@ -62,9 +61,9 @@ describe('dev/mocha/junit report generation', () => { // the single element at the root contains summary data for all tests results const [testsuite] = report.testsuites.testsuite; - expect(testsuite.$.time).to.match(DURATION_REGEX); - expect(testsuite.$.timestamp).to.match(ISO_DATE_SEC_REGEX); - expect(testsuite).to.eql({ + expect(testsuite.$.time).toMatch(DURATION_REGEX); + expect(testsuite.$.timestamp).toMatch(ISO_DATE_SEC_REGEX); + expect(testsuite).toEqual({ $: { failures: '2', name: 'test', @@ -78,13 +77,13 @@ describe('dev/mocha/junit report generation', () => { // there are actually only three tests, but since the hook failed // it is reported as a test failure - expect(testsuite.testcase).to.have.length(4); + expect(testsuite.testcase).toHaveLength(4); const [testPass, testFail, beforeEachFail, testSkipped] = testsuite.testcase; const sharedClassname = testPass.$.classname; - expect(sharedClassname).to.match(/^test\.test[^\.]js$/); - expect(testPass.$.time).to.match(DURATION_REGEX); - expect(testPass).to.eql({ + expect(sharedClassname).toMatch(/^test\.test[^\.]js$/); + expect(testPass.$.time).toMatch(DURATION_REGEX); + expect(testPass).toEqual({ $: { classname: sharedClassname, name: 'SUITE works', @@ -94,9 +93,10 @@ describe('dev/mocha/junit report generation', () => { 'system-out': testPass['system-out'], }); - expect(testFail.$.time).to.match(DURATION_REGEX); - expect(testFail.failure[0]).to.match(/Error: FORCE_TEST_FAIL\n.+fixtures.project.test.js/); - expect(testFail).to.eql({ + expect(testFail.$.time).toMatch(DURATION_REGEX); + + expect(testFail.failure[0]).toMatch(/Error: FORCE_TEST_FAIL/); + expect(testFail).toEqual({ $: { classname: sharedClassname, name: 'SUITE fails', @@ -107,12 +107,10 @@ describe('dev/mocha/junit report generation', () => { failure: [testFail.failure[0]], }); - expect(beforeEachFail.$.time).to.match(DURATION_REGEX); - expect(beforeEachFail.failure).to.have.length(1); - expect(beforeEachFail.failure[0]).to.match( - /Error: FORCE_HOOK_FAIL\n.+fixtures.project.test.js/ - ); - expect(beforeEachFail).to.eql({ + expect(beforeEachFail.$.time).toMatch(DURATION_REGEX); + expect(beforeEachFail.failure).toHaveLength(1); + expect(beforeEachFail.failure[0]).toMatch(/Error: FORCE_HOOK_FAIL/); + expect(beforeEachFail).toEqual({ $: { classname: sharedClassname, name: 'SUITE SUB_SUITE "before each" hook: fail hook for "never runs"', @@ -123,7 +121,7 @@ describe('dev/mocha/junit report generation', () => { failure: [beforeEachFail.failure[0]], }); - expect(testSkipped).to.eql({ + expect(testSkipped).toEqual({ $: { classname: sharedClassname, name: 'SUITE SUB_SUITE never runs', diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index cc1e0851f59442..6483ede0564c17 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -27,6 +27,7 @@ export { ChromeHelpExtension, } from './chrome_service'; export { + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 5ce5a5f635d64c..46ed76e72db025 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -2041,7 +2041,7 @@ exports[`Header renders 1`] = ` } /> , - , - - + + } - kibanaDocLink="/docs" - kibanaVersion="1.0.0" - useDefaultContent={true} + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + repositionOnScroll={true} > - - - - } - closePopover={[Function]} - data-test-subj="helpMenuButton" - display="inlineBlock" - hasArrow={true} - id="headerHelpMenu" - isOpen={false} - ownFocus={false} - panelPaddingSize="m" - repositionOnScroll={true} + -
-
- - - -
+ /> + + +
-
-
-
-
+ + + + , , ], diff --git a/src/core/public/chrome/ui/header/header_help_menu.tsx b/src/core/public/chrome/ui/header/header_help_menu.tsx index c7dd8c7e8bb1c4..ad8767cefaba32 100644 --- a/src/core/public/chrome/ui/header/header_help_menu.tsx +++ b/src/core/public/chrome/ui/header/header_help_menu.tsx @@ -17,10 +17,10 @@ * under the License. */ -import * as Rx from 'rxjs'; import React, { Component, Fragment } from 'react'; +import { combineLatest, Observable, Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { InjectedIntl, injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiButtonEmptyProps, @@ -35,14 +35,20 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { ExclusiveUnion } from '@elastic/eui'; -import { combineLatest } from 'rxjs'; -import { HeaderExtension } from './header_extension'; -import { ChromeHelpExtension } from '../../chrome_service'; +import { InternalApplicationStart } from '../../../application'; import { GITHUB_CREATE_ISSUE_LINK, KIBANA_FEEDBACK_LINK } from '../../constants'; +import { ChromeHelpExtension } from '../../chrome_service'; +import { HeaderExtension } from './header_extension'; +import { isModifiedOrPrevented } from './nav_link'; + +/** @public */ +export type ChromeHelpExtensionLinkBase = Pick< + EuiButtonEmptyProps, + 'iconType' | 'target' | 'rel' | 'data-test-subj' +>; /** @public */ -export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase { /** * Creates a link to a new github issue in the Kibana repo */ @@ -55,10 +61,10 @@ export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { * Provides initial text for the title of the issue */ title?: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase { /** * Creates a generic give feedback link with comment icon */ @@ -68,10 +74,10 @@ export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { * i.e. `https://discuss.elastic.co/c/${appName}` */ href: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase { /** * Creates a deep-link to app-specific documentation */ @@ -81,35 +87,36 @@ export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { * i.e. `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/${appName}.html`, */ href: string; -}; +} /** @public */ -export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase { /** * Extend EuiButtonEmpty to provide extra functionality */ linkType: 'custom'; + /** + * URL of the link + */ + href: string; /** * Content of the button (in lieu of `children`) */ content: React.ReactNode; -}; +} /** @public */ -export type ChromeHelpExtensionMenuLink = ExclusiveUnion< - ChromeHelpExtensionMenuGitHubLink, - ExclusiveUnion< - ChromeHelpExtensionMenuDiscussLink, - ExclusiveUnion - > ->; +export type ChromeHelpExtensionMenuLink = + | ChromeHelpExtensionMenuGitHubLink + | ChromeHelpExtensionMenuDiscussLink + | ChromeHelpExtensionMenuDocumentationLink + | ChromeHelpExtensionMenuCustomLink; interface Props { - helpExtension$: Rx.Observable; - helpSupportUrl$: Rx.Observable; - intl: InjectedIntl; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + helpExtension$: Observable; + helpSupportUrl$: Observable; kibanaVersion: string; - useDefaultContent?: boolean; kibanaDocLink: string; } @@ -119,8 +126,8 @@ interface State { helpSupportUrl: string; } -class HeaderHelpMenuUI extends Component { - private subscription?: Rx.Subscription; +export class HeaderHelpMenu extends Component { + private subscription?: Subscription; constructor(props: Props) { super(props); @@ -151,41 +158,69 @@ class HeaderHelpMenuUI extends Component { } } - createGithubUrl = (labels: string[], title?: string) => { - const url = new URL('https://github.com/elastic/kibana/issues/new?'); - - if (labels.length) { - url.searchParams.set('labels', labels.join(',')); - } + public render() { + const { kibanaVersion } = this.props; - if (title) { - url.searchParams.set('title', title); - } + const defaultContent = this.renderDefaultContent(); + const customContent = this.renderCustomContent(); - return url.toString(); - }; + const button = ( + + + + ); - createCustomLink = ( - index: number, - text: React.ReactNode, - addSpacer?: boolean, - buttonProps?: EuiButtonEmptyProps - ) => { return ( - - - {text} - - {addSpacer && } - + + + + +

+ +

+
+ + + +
+
+ +
+ {defaultContent} + {defaultContent && customContent && } + {customContent} +
+
); - }; + } - public render() { - const { intl, kibanaVersion, useDefaultContent, kibanaDocLink } = this.props; - const { helpExtension, helpSupportUrl } = this.state; + private renderDefaultContent() { + const { kibanaDocLink } = this.props; + const { helpSupportUrl } = this.state; - const defaultContent = useDefaultContent ? ( + return ( { /> - ) : null; - - let customContent; - if (helpExtension) { - const { appName, links, content } = helpExtension; - - const getFeedbackText = () => - i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackOnApp', { - defaultMessage: 'Give feedback on {appName}', - values: { appName: helpExtension.appName }, - }); - - const customLinks = - links && - links.map((link, index) => { - const { linkType, title, labels = [], content: text, ...rest } = link; - switch (linkType) { - case 'documentation': - return this.createCustomLink( - index, - , - index < links.length - 1, - { - target: '_blank', - rel: 'noopener', - ...rest, - } - ); - case 'github': - return this.createCustomLink(index, getFeedbackText(), index < links.length - 1, { - iconType: 'logoGithub', - href: this.createGithubUrl(labels, title), - target: '_blank', - rel: 'noopener', - ...rest, - }); - case 'discuss': - return this.createCustomLink(index, getFeedbackText(), index < links.length - 1, { - iconType: 'editorComment', + ); + } + + private renderCustomContent() { + const { helpExtension } = this.state; + if (!helpExtension) { + return null; + } + const { navigateToUrl } = this.props; + const { appName, links, content } = helpExtension; + + const getFeedbackText = () => + i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuGiveFeedbackOnApp', { + defaultMessage: 'Give feedback on {appName}', + values: { appName: helpExtension.appName }, + }); + + const customLinks = + links && + links.map((link, index) => { + const addSpacer = index < links.length - 1; + switch (link.linkType) { + case 'documentation': { + const { linkType, ...rest } = link; + return createCustomLink( + index, + , + addSpacer, + { target: '_blank', rel: 'noopener', ...rest, - }); - case 'custom': - return this.createCustomLink(index, text, index < links.length - 1, { ...rest }); - default: - break; + } + ); } - }); - - customContent = ( - <> - -

{appName}

-
- - {customLinks} - {content && ( - <> - {customLinks && } - - - )} - - ); - } - - const button = ( - - - - ); + case 'github': { + const { linkType, labels, title, ...rest } = link; + return createCustomLink(index, getFeedbackText(), addSpacer, { + iconType: 'logoGithub', + href: createGithubUrl(labels, title), + target: '_blank', + rel: 'noopener', + ...rest, + }); + } + case 'discuss': { + const { linkType, ...rest } = link; + return createCustomLink(index, getFeedbackText(), addSpacer, { + iconType: 'editorComment', + target: '_blank', + rel: 'noopener', + ...rest, + }); + } + case 'custom': { + const { linkType, content: text, href, ...rest } = link; + return createCustomLink(index, text, addSpacer, { + href, + onClick: this.createOnClickHandler(href, navigateToUrl), + ...rest, + }); + } + default: + break; + } + }); return ( - - - - -

- -

-
- - - -
-
- -
- {defaultContent} - {defaultContent && customContent && } - {customContent} -
-
+ <> + +

{appName}

+
+ + {customLinks} + {content && ( + <> + {customLinks && } + + + )} + ); } @@ -361,10 +360,44 @@ class HeaderHelpMenuUI extends Component { isOpen: false, }); }; + + private createOnClickHandler(href: string, navigate: Props['navigateToUrl']) { + return (event: React.MouseEvent) => { + if (!isModifiedOrPrevented(event) && event.button === 0) { + event.preventDefault(); + this.closeMenu(); + navigate(href); + } + }; + } } -export const HeaderHelpMenu = injectI18n(HeaderHelpMenuUI); +const createGithubUrl = (labels: string[], title?: string) => { + const url = new URL('https://github.com/elastic/kibana/issues/new?'); + + if (labels.length) { + url.searchParams.set('labels', labels.join(',')); + } + + if (title) { + url.searchParams.set('title', title); + } + + return url.toString(); +}; -HeaderHelpMenu.defaultProps = { - useDefaultContent: true, +const createCustomLink = ( + index: number, + text: React.ReactNode, + addSpacer?: boolean, + buttonProps?: EuiButtonEmptyProps +) => { + return ( + + + {text} + + {addSpacer && } + + ); }; diff --git a/src/core/public/chrome/ui/header/index.ts b/src/core/public/chrome/ui/header/index.ts index a492273a65ba8f..d75cd10af7bed5 100644 --- a/src/core/public/chrome/ui/header/index.ts +++ b/src/core/public/chrome/ui/header/index.ts @@ -20,6 +20,7 @@ export { Header, HeaderProps } from './header'; export { OnIsLockedUpdate, NavType } from './types'; export { + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/chrome/ui/index.ts b/src/core/public/chrome/ui/index.ts index 4f6ad90cb96a35..4894aaac7c914d 100644 --- a/src/core/public/chrome/ui/index.ts +++ b/src/core/public/chrome/ui/index.ts @@ -20,6 +20,7 @@ export { LoadingIndicator } from './loading_indicator'; export { Header, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuLink, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 8e240bfe91d488..2e1238df350e05 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -43,6 +43,7 @@ import { ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, ChromeHelpExtensionMenuDocumentationLink, @@ -300,6 +301,7 @@ export { ChromeBreadcrumb, ChromeHelpExtension, ChromeHelpExtensionMenuLink, + ChromeHelpExtensionLinkBase, ChromeHelpExtensionMenuCustomLink, ChromeHelpExtensionMenuDiscussLink, ChromeHelpExtensionMenuDocumentationLink, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 65912e0954261f..f48fb9092d56d5 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -14,7 +14,6 @@ import { EuiButtonEmptyProps } from '@elastic/eui'; import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; -import { ExclusiveUnion } from '@elastic/eui'; import { History } from 'history'; import { Href } from 'history'; import { IconType } from '@elastic/eui'; @@ -250,32 +249,36 @@ export interface ChromeHelpExtension { } // @public (undocumented) -export type ChromeHelpExtensionMenuCustomLink = EuiButtonEmptyProps & { - linkType: 'custom'; +export type ChromeHelpExtensionLinkBase = Pick; + +// @public (undocumented) +export interface ChromeHelpExtensionMenuCustomLink extends ChromeHelpExtensionLinkBase { content: React.ReactNode; -}; + href: string; + linkType: 'custom'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuDiscussLink = EuiButtonEmptyProps & { - linkType: 'discuss'; +export interface ChromeHelpExtensionMenuDiscussLink extends ChromeHelpExtensionLinkBase { href: string; -}; + linkType: 'discuss'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuDocumentationLink = EuiButtonEmptyProps & { - linkType: 'documentation'; +export interface ChromeHelpExtensionMenuDocumentationLink extends ChromeHelpExtensionLinkBase { href: string; -}; + linkType: 'documentation'; +} // @public (undocumented) -export type ChromeHelpExtensionMenuGitHubLink = EuiButtonEmptyProps & { - linkType: 'github'; +export interface ChromeHelpExtensionMenuGitHubLink extends ChromeHelpExtensionLinkBase { labels: string[]; + linkType: 'github'; title?: string; -}; +} // @public (undocumented) -export type ChromeHelpExtensionMenuLink = ExclusiveUnion>>; +export type ChromeHelpExtensionMenuLink = ChromeHelpExtensionMenuGitHubLink | ChromeHelpExtensionMenuDiscussLink | ChromeHelpExtensionMenuDocumentationLink | ChromeHelpExtensionMenuCustomLink; // @public (undocumented) export interface ChromeNavControl { diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 22cb7275b6a232..27f3ab30ac8f39 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -120,97 +120,43 @@ describe('configureClient', () => { }); describe('Client logging', () => { - it('logs error when the client emits an @elastic/elasticsearch error', () => { - const client = configureClient(config, { logger, scoped: false }); - - const response = createApiResponse({ body: {} }); - client.emit('response', new errors.TimeoutError('message', response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[TimeoutError]: message", - ], - ] - `); - }); - - it('logs error when the client emits an ResponseError returned by elasticsearch', () => { - const client = configureClient(config, { logger, scoped: false }); - - const response = createApiResponse({ - statusCode: 400, - headers: {}, - body: { - error: { - type: 'illegal_argument_exception', - reason: 'request [/_path] contains unrecognized parameter: [name]', - }, + function createResponseWithBody(body?: RequestBody) { + return createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body, }, }); - client.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", - ], - ] - `); - }); - - it('logs default error info when the error response body is empty', () => { - const client = configureClient(config, { logger, scoped: false }); + } + describe('does not log whrn "logQueries: false"', () => { + it('response', () => { + const client = configureClient(config, { logger, scoped: false }); + const response = createResponseWithBody({ + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }); - let response = createApiResponse({ - statusCode: 400, - headers: {}, - body: { - error: {}, - }, + client.emit('response', null, response); + expect(loggingSystemMock.collect(logger).debug).toHaveLength(0); }); - client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[ResponseError]: Response Error", - ], - ] - `); + it('error', () => { + const client = configureClient(config, { logger, scoped: false }); - logger.error.mockClear(); + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); - response = createApiResponse({ - statusCode: 400, - headers: {}, - body: {} as any, + expect(loggingSystemMock.collect(logger).error).toHaveLength(0); }); - client.emit('response', new errors.ResponseError(response), response); - - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` - Array [ - Array [ - "[ResponseError]: Response Error", - ], - ] - `); }); describe('logs each queries if `logQueries` is true', () => { - function createResponseWithBody(body?: RequestBody) { - return createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, - body, - }, - }); - } - it('when request body is an object', () => { const client = configureClient( createFakeConfig({ @@ -374,108 +320,211 @@ describe('configureClient', () => { ] `); }); - }); - it('properly encode queries', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + it('properly encode queries', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - querystring: { city: 'Münich' }, - }, + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + querystring: { city: 'Münich' }, + }, + }); + + client.emit('response', null, response); + + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "200 + GET /foo?city=M%C3%BCnich", + Object { + "tags": Array [ + "query", + ], + }, + ], + ] + `); }); - client.emit('response', null, response); + it('logs queries even in case of errors if `logQueries` is true', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ - Array [ - "200 - GET /foo?city=M%C3%BCnich", - Object { - "tags": Array [ - "query", - ], + const response = createApiResponse({ + statusCode: 500, + body: { + error: { + type: 'internal server error', }, - ], - ] - `); - }); + }, + params: { + method: 'GET', + path: '/foo', + querystring: { hello: 'dolly' }, + body: { + seq_no_primary_term: true, + query: { + term: { user: 'kimchy' }, + }, + }, + }, + }); + client.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "500 + GET /foo?hello=dolly + {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}} [internal server error]: internal server error", + ], + ] + `); + }); - it('logs queries even in case of errors if `logQueries` is true', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); - - const response = createApiResponse({ - statusCode: 500, - body: { - error: { - type: 'internal server error', + it('does not log queries if `logQueries` is false', () => { + const client = configureClient( + createFakeConfig({ + logQueries: false, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + body: {}, + statusCode: 200, + params: { + method: 'GET', + path: '/foo', + }, + }); + + client.emit('response', null, response); + + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('logs error when the client emits an @elastic/elasticsearch error', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "[TimeoutError]: message", + ], + ] + `); + }); + + it('logs error when the client emits an ResponseError returned by elasticsearch', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); + + const response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + querystring: { hello: 'dolly' }, }, - }, - params: { - method: 'GET', - path: '/foo', - querystring: { hello: 'dolly' }, body: { - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, + error: { + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', }, }, - }, - }); - client.emit('response', new errors.ResponseError(response), response); + }); + client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` - Array [ + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ - "500 - GET /foo?hello=dolly - {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - Object { - "tags": Array [ - "query", - ], - }, - ], - ] - `); - }); + Array [ + "400 + GET /_path?hello=dolly [illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", + ], + ] + `); + }); - it('does not log queries if `logQueries` is false', () => { - const client = configureClient( - createFakeConfig({ - logQueries: false, - }), - { logger, scoped: false } - ); + it('logs default error info when the error response body is empty', () => { + const client = configureClient( + createFakeConfig({ + logQueries: true, + }), + { logger, scoped: false } + ); - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - }, - }); + let response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: { + error: {}, + }, + }); + client.emit('response', new errors.ResponseError(response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: Response Error", + ], + ] + `); + + logger.error.mockClear(); - client.emit('response', null, response); + response = createApiResponse({ + statusCode: 400, + headers: {}, + params: { + method: 'GET', + path: '/_path', + }, + body: {} as any, + }); + client.emit('response', new errors.ResponseError(response), response); - expect(logger.debug).not.toHaveBeenCalled(); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "400 + GET /_path [undefined]: Response Error", + ], + ] + `); + }); }); }); }); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index bf07ea604d228d..920a713a603324 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -18,9 +18,8 @@ */ import { Buffer } from 'buffer'; import { stringify } from 'querystring'; -import { Client } from '@elastic/elasticsearch'; -import { RequestBody } from '@elastic/elasticsearch/lib/Transport'; - +import { ApiError, Client, RequestEvent, errors } from '@elastic/elasticsearch'; +import type { RequestBody } from '@elastic/elasticsearch/lib/Transport'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; @@ -36,29 +35,6 @@ export const configureClient = ( return client; }; -const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { - client.on('response', (error, event) => { - if (error) { - const errorMessage = - // error details for response errors provided by elasticsearch, defaults to error name/message - `[${event.body?.error?.type ?? error.name}]: ${event.body?.error?.reason ?? error.message}`; - - logger.error(errorMessage); - } - if (event && logQueries) { - const params = event.meta.request.params; - - // definition is wrong, `params.querystring` can be either a string or an object - const querystring = convertQueryString(params.querystring); - const url = `${params.path}${querystring ? `?${querystring}` : ''}`; - const body = params.body ? `\n${ensureString(params.body)}` : ''; - logger.debug(`${event.statusCode}\n${params.method} ${url}${body}`, { - tags: ['query'], - }); - } - }); -}; - const convertQueryString = (qs: string | Record | undefined): string => { if (qs === undefined || typeof qs === 'string') { return qs ?? ''; @@ -72,3 +48,45 @@ function ensureString(body: RequestBody): string { if ('readable' in body && body.readable && typeof body._read === 'function') return '[stream]'; return JSON.stringify(body); } + +function getErrorMessage(error: ApiError, event: RequestEvent): string { + if (error instanceof errors.ResponseError) { + return `${getResponseMessage(event)} [${event.body?.error?.type}]: ${ + event.body?.error?.reason ?? error.message + }`; + } + return `[${error.name}]: ${error.message}`; +} + +/** + * returns a string in format: + * + * status code + * URL + * request body + * + * so it could be copy-pasted into the Dev console + */ +function getResponseMessage(event: RequestEvent): string { + const params = event.meta.request.params; + + // definition is wrong, `params.querystring` can be either a string or an object + const querystring = convertQueryString(params.querystring); + const url = `${params.path}${querystring ? `?${querystring}` : ''}`; + const body = params.body ? `\n${ensureString(params.body)}` : ''; + return `${event.statusCode}\n${params.method} ${url}${body}`; +} + +const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { + client.on('response', (error, event) => { + if (event && logQueries) { + if (error) { + logger.error(getErrorMessage(error, event)); + } else { + logger.debug(getResponseMessage(event), { + tags: ['query'], + }); + } + } + }); +}; diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/either.test.js b/src/dev/code_coverage/ingest_coverage/either.test.js similarity index 73% rename from src/dev/code_coverage/ingest_coverage/__tests__/either.test.js rename to src/dev/code_coverage/ingest_coverage/either.test.js index 0ae55508e8434d..a64d6c29feee7b 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/either.test.js +++ b/src/dev/code_coverage/ingest_coverage/either.test.js @@ -17,27 +17,26 @@ * under the License. */ -import * as Either from '../either'; -import { noop } from '../utils'; -import expect from '@kbn/expect'; +import * as Either from './either'; +import { noop } from './utils'; const pluck = (x) => (obj) => obj[x]; -const expectNull = (x) => expect(x).to.equal(null); +const expectNull = (x) => expect(x).toBeNull(); const attempt = (obj) => Either.fromNullable(obj).map(pluck('detail')); describe(`either datatype functions`, () => { describe(`helpers`, () => { it(`'fromNullable' should be a fn`, () => { - expect(typeof Either.fromNullable).to.be('function'); + expect(typeof Either.fromNullable).toBe('function'); }); it(`' Either.tryCatch' should be a fn`, () => { - expect(typeof Either.tryCatch).to.be('function'); + expect(typeof Either.tryCatch).toBe('function'); }); it(`'left' should be a fn`, () => { - expect(typeof Either.left).to.be('function'); + expect(typeof Either.left).toBe('function'); }); it(`'right' should be a fn`, () => { - expect(typeof Either.right).to.be('function'); + expect(typeof Either.right).toBe('function'); }); }); describe(' Either.tryCatch', () => { @@ -46,18 +45,18 @@ describe(`either datatype functions`, () => { sut = Either.tryCatch(() => { throw new Error('blah'); }); - expect(sut.inspect()).to.be('Left(Error: blah)'); + expect(sut.inspect()).toBe('Left(Error: blah)'); }); it(`should return a 'Right' on successful execution`, () => { sut = Either.tryCatch(noop); - expect(sut.inspect()).to.be('Right(undefined)'); + expect(sut.inspect()).toBe('Right(undefined)'); }); }); describe(`fromNullable`, () => { it(`should continue processing if a truthy is calculated`, () => { attempt({ detail: 'x' }).fold( () => {}, - (x) => expect(x).to.equal('x') + (x) => expect(x).toBe('x') ); }); it(`should drop processing if a falsey is calculated`, () => { @@ -66,16 +65,16 @@ describe(`either datatype functions`, () => { }); describe(`predicate fns`, () => { it(`right.isRight() is true`, () => { - expect(Either.right('a').isRight()).to.be(true); + expect(Either.right('a').isRight()).toBe(true); }); it(`right.isLeft() is false`, () => { - expect(Either.right('a').isLeft()).to.be(false); + expect(Either.right('a').isLeft()).toBe(false); }); it(`left.isLeft() is true`, () => { - expect(Either.left().isLeft()).to.be(true); + expect(Either.left().isLeft()).toBe(true); }); it(`left.isRight() is true`, () => { - expect(Either.left().isRight()).to.be(false); + expect(Either.left().isRight()).toBe(false); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js b/src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js similarity index 87% rename from src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js rename to src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js index f668c1f86f5b0d..edc6f714beb6ae 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js +++ b/src/dev/code_coverage/ingest_coverage/ingest_helpers.test.js @@ -17,9 +17,8 @@ * under the License. */ -import expect from '@kbn/expect'; -import { whichIndex } from '../ingest_helpers'; -import { TOTALS_INDEX, RESEARCH_TOTALS_INDEX, RESEARCH_COVERAGE_INDEX } from '../constants'; +import { whichIndex } from './ingest_helpers'; +import { TOTALS_INDEX, RESEARCH_TOTALS_INDEX, RESEARCH_COVERAGE_INDEX } from './constants'; describe(`Ingest Helper fns`, () => { describe(`whichIndex`, () => { @@ -29,14 +28,14 @@ describe(`Ingest Helper fns`, () => { const isTotal = true; it(`should return the Research Totals Index`, () => { const actual = whichIndexAgainstResearchJob(isTotal); - expect(actual).to.be(RESEARCH_TOTALS_INDEX); + expect(actual).toBe(RESEARCH_TOTALS_INDEX); }); }); describe(`against the coverage index`, () => { it(`should return the Research Totals Index`, () => { const isTotal = false; const actual = whichIndexAgainstResearchJob(isTotal); - expect(actual).to.be(RESEARCH_COVERAGE_INDEX); + expect(actual).toBe(RESEARCH_COVERAGE_INDEX); }); }); }); @@ -46,7 +45,7 @@ describe(`Ingest Helper fns`, () => { const isTotal = true; it(`should return the "Prod" Totals Index`, () => { const actual = whichIndexAgainstProdJob(isTotal); - expect(actual).to.be(TOTALS_INDEX); + expect(actual).toBe(TOTALS_INDEX); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js b/src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js similarity index 92% rename from src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js rename to src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js index 371695337ed56d..84cedbc75be5a1 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/enumerate_patterns.test.js @@ -17,8 +17,7 @@ * under the License. */ -import expect from '@kbn/expect'; -import { enumeratePatterns } from '../team_assignment/enumerate_patterns'; +import { enumeratePatterns } from './enumerate_patterns'; import { ToolingLog, REPO_ROOT } from '@kbn/dev-utils'; const log = new ToolingLog({ @@ -36,14 +35,14 @@ describe(`enumeratePatterns`, () => { actual[0].includes( 'x-pack/plugins/reporting/server/browsers/extract/unzip.js kibana-reporting' ) - ).to.be(true); + ).toBe(true); }); it(`should resolve src/plugins/charts/public/static/color_maps/color_maps.ts to kibana-app`, () => { const actual = enumeratePatterns(REPO_ROOT)(log)( new Map([['src/plugins/charts/public/static/color_maps', ['kibana-app']]]) ); - expect(actual[0][0]).to.be( + expect(actual[0][0]).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app' ); }); @@ -55,6 +54,6 @@ describe(`enumeratePatterns`, () => { actual[0].includes( `${short}/public/common/components/exceptions/builder/translations.ts kibana-security` ) - ).to.be(true); + ).toBe(true); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js b/src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js similarity index 86% rename from src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js rename to src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js index f480135b45ac64..f96eb61b1e18e3 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumeration_helpers.test.js +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/enumeration_helpers.test.js @@ -17,8 +17,7 @@ * under the License. */ -import expect from '@kbn/expect'; -import { tryPath } from '../team_assignment/enumeration_helpers'; +import { tryPath } from './enumeration_helpers'; describe(`enumeration helper fns`, () => { describe(`tryPath`, () => { @@ -26,24 +25,24 @@ describe(`enumeration helper fns`, () => { it(`should return a right on an existing path`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/ingest.js'; const actual = tryPath(aPath); - expect(actual.isRight()).to.be(true); + expect(actual.isRight()).toBe(true); }); it(`should return a left on a non existing path`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/does_not_exist.js'; const actual = tryPath(aPath); - expect(actual.isLeft()).to.be(true); + expect(actual.isLeft()).toBe(true); }); }); describe(`with glob file paths`, () => { it(`should not error when the glob expands to nothing, but instead return a Left`, () => { const aPath = 'src/legacy/core_plugins/kibana/public/home/*.ts'; const actual = tryPath(aPath); - expect(actual.isLeft()).to.be(true); + expect(actual.isLeft()).toBe(true); }); it(`should return a right on a glob that does indeed expand`, () => { const aPath = 'src/dev/code_coverage/ingest_coverage/*.js'; const actual = tryPath(aPath); - expect(actual.isRight()).to.be(true); + expect(actual.isRight()).toBe(true); }); }); }); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js b/src/dev/code_coverage/ingest_coverage/transforms.test.js similarity index 90% rename from src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js rename to src/dev/code_coverage/ingest_coverage/transforms.test.js index b6d17f83e327ed..ba2762585c79b0 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js +++ b/src/dev/code_coverage/ingest_coverage/transforms.test.js @@ -17,7 +17,6 @@ * under the License. */ -import expect from '@kbn/expect'; import { ciRunUrl, coveredFilePath, @@ -25,18 +24,18 @@ import { prokPrevious, teamAssignment, last, -} from '../transforms'; +} from './transforms'; import { ToolingLog } from '@kbn/dev-utils'; describe(`Transform fns`, () => { describe(`ciRunUrl`, () => { it(`should add the url when present in the environment`, () => { process.env.CI_RUN_URL = 'blah'; - expect(ciRunUrl()).to.have.property('ciRunUrl', 'blah'); + expect(ciRunUrl()).toHaveProperty('ciRunUrl', 'blah'); }); it(`should not include the url if not present in the environment`, () => { process.env.CI_RUN_URL = void 0; - expect(ciRunUrl({ a: 'a' })).not.to.have.property('ciRunUrl'); + expect(ciRunUrl({ a: 'a' })).not.toHaveProperty('ciRunUrl'); }); }); describe(`coveredFilePath`, () => { @@ -48,7 +47,7 @@ describe(`Transform fns`, () => { COVERAGE_INGESTION_KIBANA_ROOT: '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana', }; - expect(coveredFilePath(obj)).to.have.property( + expect(coveredFilePath(obj)).toHaveProperty( 'coveredFilePath', 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' ); @@ -62,7 +61,7 @@ describe(`Transform fns`, () => { COVERAGE_INGESTION_KIBANA_ROOT: '/var/lib/jenkins/workspace/elastic+kibana+qa-research/kibana', }; - expect(coveredFilePath(obj)).to.have.property( + expect(coveredFilePath(obj)).toHaveProperty( 'coveredFilePath', 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' ); @@ -74,7 +73,7 @@ describe(`Transform fns`, () => { process.env.FETCHED_PREVIOUS = 'A'; it(`should return a previous compare url`, () => { const actual = prokPrevious(comparePrefixF)('B'); - expect(actual).to.be(`https://github.com/elastic/kibana/compare/A...B`); + expect(actual).toBe(`https://github.com/elastic/kibana/compare/A...B`); }); }); describe(`itemizeVcs`, () => { @@ -85,7 +84,7 @@ describe(`Transform fns`, () => { `Tre' Seymour`, `Lorem :) ipsum Tre' λ dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`, ]; - expect(itemizeVcs(vcsInfo)({}).vcs).to.have.property( + expect(itemizeVcs(vcsInfo)({}).vcs).toHaveProperty( 'vcsUrl', `https://github.com/elastic/kibana/commit/${vcsInfo[1]}` ); @@ -106,7 +105,7 @@ describe(`Transform fns`, () => { it(`should resolve to ${expected}`, async () => { const actual = await teamAssignment(teamAssignmentsPathMOCK)(log)(obj); const { team } = actual; - expect(team).to.eql(expected); + expect(team).toEqual(expected); }); }); @@ -115,7 +114,7 @@ describe(`Transform fns`, () => { it(`should resolve to ${expected}`, async () => { const actual = await teamAssignment(teamAssignmentsPathMOCK)(log)(obj); const { team } = actual; - expect(team).to.eql(expected); + expect(team).toEqual(expected); }); }); @@ -127,7 +126,7 @@ src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch`; const actual = last(nteams); - expect(actual).to.be( + expect(actual).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch' ); }); @@ -139,7 +138,7 @@ src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch`; const actual = last(nteams); - expect(actual).to.be( + expect(actual).toBe( 'src/plugins/charts/public/static/color_maps/color_maps.ts kibana-app-arch' ); }); diff --git a/src/dev/__tests__/file.js b/src/dev/file.test.js similarity index 71% rename from src/dev/__tests__/file.js rename to src/dev/file.test.js index 0e8790f32c7c96..e61be6ad96f589 100644 --- a/src/dev/__tests__/file.js +++ b/src/dev/file.test.js @@ -19,74 +19,68 @@ import { resolve, sep } from 'path'; -import expect from '@kbn/expect'; - -import { File } from '../file'; +import { File } from './file'; const HERE = resolve(__dirname, __filename); describe('dev/File', () => { describe('constructor', () => { it('throws if path is not a string', () => { - expect(() => new File()).to.throwError(); - expect(() => new File(1)).to.throwError(); - expect(() => new File(false)).to.throwError(); - expect(() => new File(null)).to.throwError(); + expect(() => new File()).toThrow(); + expect(() => new File(1)).toThrow(); + expect(() => new File(false)).toThrow(); + expect(() => new File(null)).toThrow(); }); }); describe('#getRelativePath()', () => { it('returns the path relative to the repo root', () => { const file = new File(HERE); - expect(file.getRelativePath()).to.eql(['src', 'dev', '__tests__', 'file.js'].join(sep)); + expect(file.getRelativePath()).toBe(['src', 'dev', 'file.test.js'].join(sep)); }); }); describe('#isJs()', () => { it('returns true if extension is .js', () => { const file = new File('file.js'); - expect(file.isJs()).to.eql(true); + expect(file.isJs()).toBe(true); }); it('returns false if extension is .xml', () => { const file = new File('file.xml'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if extension is .css', () => { const file = new File('file.css'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if extension is .html', () => { const file = new File('file.html'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); it('returns false if file has no extension', () => { const file = new File('file'); - expect(file.isJs()).to.eql(false); + expect(file.isJs()).toBe(false); }); }); describe('#getRelativeParentDirs()', () => { it('returns the parents of a file, stopping at the repo root, in descending order', () => { const file = new File(HERE); - expect(file.getRelativeParentDirs()).to.eql([ - ['src', 'dev', '__tests__'].join(sep), // src/dev/__tests__ - ['src', 'dev'].join(sep), // src/dev - 'src', - ]); + expect(file.getRelativeParentDirs()).toStrictEqual([['src', 'dev'].join(sep), 'src']); }); }); describe('#toString()', () => { it('returns the relativePath', () => { const file = new File(HERE); - expect(file.toString()).to.eql(file.getRelativePath()); + expect(file.toString()).toBe(file.getRelativePath()); }); }); describe('#toJSON()', () => { it('returns the relativePath', () => { const file = new File(HERE); - expect(file.toJSON()).to.eql(file.getRelativePath()); + expect(file.toJSON()).toBe(file.getRelativePath()); }); }); }); diff --git a/src/dev/license_checker/__tests__/valid.js b/src/dev/license_checker/valid.test.js similarity index 88% rename from src/dev/license_checker/__tests__/valid.js rename to src/dev/license_checker/valid.test.js index b569cdb7a07d75..31f7fb55854be8 100644 --- a/src/dev/license_checker/__tests__/valid.js +++ b/src/dev/license_checker/valid.test.js @@ -19,9 +19,7 @@ import { resolve } from 'path'; -import expect from '@kbn/expect'; - -import { assertLicensesValid } from '../valid'; +import { assertLicensesValid } from './valid'; const ROOT = resolve(__dirname, '../../../../'); const NODE_MODULES = resolve(ROOT, './node_modules'); @@ -42,7 +40,7 @@ describe('tasks/lib/licenses', () => { packages: [PACKAGE], validLicenses: [...PACKAGE.licenses], }) - ).to.be(undefined); + ).toBe(undefined); }); it('throw an error when the packages license is invalid', () => { @@ -51,7 +49,7 @@ describe('tasks/lib/licenses', () => { packages: [PACKAGE], validLicenses: [`not ${PACKAGE.licenses[0]}`], }); - }).to.throwError(PACKAGE.name); + }).toThrow(PACKAGE.name); }); it('throws an error when the package has no licenses', () => { @@ -65,7 +63,7 @@ describe('tasks/lib/licenses', () => { ], validLicenses: [...PACKAGE.licenses], }); - }).to.throwError(PACKAGE.name); + }).toThrow(PACKAGE.name); }); it('includes the relative path to packages in error message', () => { @@ -76,8 +74,8 @@ describe('tasks/lib/licenses', () => { }); throw new Error('expected assertLicensesValid() to throw'); } catch (error) { - expect(error.message).to.contain(PACKAGE.relative); - expect(error.message).to.not.contain(PACKAGE.directory); + expect(error.message).toContain(PACKAGE.relative); + expect(error.message).not.toContain(PACKAGE.directory); } }); }); diff --git a/src/dev/__tests__/node_versions_must_match.js b/src/dev/node_versions_must_match.test.js similarity index 88% rename from src/dev/__tests__/node_versions_must_match.js rename to src/dev/node_versions_must_match.test.js index 99f2e255f47eac..897769214d78ef 100644 --- a/src/dev/__tests__/node_versions_must_match.js +++ b/src/dev/node_versions_must_match.test.js @@ -18,10 +18,9 @@ */ import fs from 'fs'; -import { engines } from '../../../package.json'; +import { engines } from '../../package.json'; import { promisify } from 'util'; const readFile = promisify(fs.readFile); -import expect from '@kbn/expect'; describe('All configs should use a single version of Node', () => { it('should compare .node-version and .nvmrc', async () => { @@ -30,13 +29,13 @@ describe('All configs should use a single version of Node', () => { readFile('./.nvmrc', { encoding: 'utf-8' }), ]); - expect(nodeVersion.trim()).to.be(nvmrc.trim()); + expect(nodeVersion.trim()).toBe(nvmrc.trim()); }); it('should compare .node-version and engines.node from package.json', async () => { const nodeVersion = await readFile('./.node-version', { encoding: 'utf-8', }); - expect(nodeVersion.trim()).to.be(engines.node); + expect(nodeVersion.trim()).toBe(engines.node); }); }); diff --git a/src/legacy/utils/__tests__/unset.js b/src/legacy/utils/unset.test.js similarity index 81% rename from src/legacy/utils/__tests__/unset.js rename to src/legacy/utils/unset.test.js index 69122e06ac5726..3f7a2de44508d3 100644 --- a/src/legacy/utils/__tests__/unset.js +++ b/src/legacy/utils/unset.test.js @@ -17,27 +17,26 @@ * under the License. */ -import { unset } from '../unset'; -import expect from '@kbn/expect'; +import { unset } from './unset'; describe('unset(obj, key)', function () { describe('invalid input', function () { it('should do nothing if not given an object', function () { const obj = 'hello'; unset(obj, 'e'); - expect(obj).to.equal('hello'); + expect(obj).toBe('hello'); }); it('should do nothing if not given a key', function () { const obj = { one: 1 }; unset(obj); - expect(obj).to.eql({ one: 1 }); + expect(obj).toEqual({ one: 1 }); }); it('should do nothing if given an empty string as a key', function () { const obj = { one: 1 }; unset(obj, ''); - expect(obj).to.eql({ one: 1 }); + expect(obj).toEqual({ one: 1 }); }); }); @@ -50,12 +49,12 @@ describe('unset(obj, key)', function () { it('should remove the param using a string key', function () { unset(obj, 'two'); - expect(obj).to.eql({ one: 1, deep: { three: 3, four: 4 } }); + expect(obj).toEqual({ one: 1, deep: { three: 3, four: 4 } }); }); it('should remove the param using an array key', function () { unset(obj, ['two']); - expect(obj).to.eql({ one: 1, deep: { three: 3, four: 4 } }); + expect(obj).toEqual({ one: 1, deep: { three: 3, four: 4 } }); }); }); @@ -68,12 +67,12 @@ describe('unset(obj, key)', function () { it('should remove the param using a string key', function () { unset(obj, 'deep.three'); - expect(obj).to.eql({ one: 1, two: 2, deep: { four: 4 } }); + expect(obj).toEqual({ one: 1, two: 2, deep: { four: 4 } }); }); it('should remove the param using an array key', function () { unset(obj, ['deep', 'three']); - expect(obj).to.eql({ one: 1, two: 2, deep: { four: 4 } }); + expect(obj).toEqual({ one: 1, two: 2, deep: { four: 4 } }); }); }); @@ -81,22 +80,22 @@ describe('unset(obj, key)', function () { it('should clear object if only value is removed', function () { const obj = { one: { two: { three: 3 } } }; unset(obj, 'one.two.three'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); it('should clear object if no props are left', function () { const obj = { one: { two: { three: 3 } } }; unset(obj, 'one.two'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); it('should remove deep property, then clear the object', function () { const obj = { one: { two: { three: 3, four: 4 } } }; unset(obj, 'one.two.three'); - expect(obj).to.eql({ one: { two: { four: 4 } } }); + expect(obj).toEqual({ one: { two: { four: 4 } } }); unset(obj, 'one.two.four'); - expect(obj).to.eql({}); + expect(obj).toEqual({}); }); }); }); diff --git a/src/plugins/console/server/__tests__/elasticsearch_proxy_config.js b/src/plugins/console/server/lib/elasticsearch_proxy_config.test.js similarity index 84% rename from src/plugins/console/server/__tests__/elasticsearch_proxy_config.js rename to src/plugins/console/server/lib/elasticsearch_proxy_config.test.js index fcf385165a5919..cdbede51992865 100644 --- a/src/plugins/console/server/__tests__/elasticsearch_proxy_config.js +++ b/src/plugins/console/server/lib/elasticsearch_proxy_config.test.js @@ -17,7 +17,6 @@ * under the License. */ -import expect from '@kbn/expect'; import moment from 'moment'; import { getElasticsearchProxyConfig } from '../lib/elasticsearch_proxy_config'; import https from 'https'; @@ -39,7 +38,7 @@ describe('plugins/console', function () { ...getDefaultElasticsearchConfig(), requestTimeout: moment.duration(value), }); - expect(proxyConfig.timeout).to.be(value); + expect(proxyConfig.timeout).toBe(value); }); it(`uses https.Agent when url's protocol is https`, function () { @@ -47,12 +46,12 @@ describe('plugins/console', function () { ...getDefaultElasticsearchConfig(), hosts: ['https://localhost:9200'], }); - expect(agent).to.be.a(https.Agent); + expect(agent instanceof https.Agent).toBeTruthy(); }); it(`uses http.Agent when url's protocol is http`, function () { const { agent } = getElasticsearchProxyConfig(getDefaultElasticsearchConfig()); - expect(agent).to.be.a(http.Agent); + expect(agent instanceof http.Agent).toBeTruthy(); }); describe('ssl', function () { @@ -69,7 +68,7 @@ describe('plugins/console', function () { ...config, ssl: { ...config.ssl, verificationMode: 'none' }, }); - expect(agent.options.rejectUnauthorized).to.be(false); + expect(agent.options.rejectUnauthorized).toBe(false); }); it('sets rejectUnauthorized to true when verificationMode is certificate', function () { @@ -77,7 +76,7 @@ describe('plugins/console', function () { ...config, ssl: { ...config.ssl, verificationMode: 'certificate' }, }); - expect(agent.options.rejectUnauthorized).to.be(true); + expect(agent.options.rejectUnauthorized).toBe(true); }); it('sets checkServerIdentity to not check hostname when verificationMode is certificate', function () { @@ -92,11 +91,9 @@ describe('plugins/console', function () { }, }; - expect(agent.options.checkServerIdentity) - .withArgs('right.com', cert) - .to.not.throwException(); + expect(() => agent.options.checkServerIdentity('right.com', cert)).not.toThrow(); const result = agent.options.checkServerIdentity('right.com', cert); - expect(result).to.be(undefined); + expect(result).toBe(undefined); }); it('sets rejectUnauthorized to true when verificationMode is full', function () { @@ -105,7 +102,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, verificationMode: 'full' }, }); - expect(agent.options.rejectUnauthorized).to.be(true); + expect(agent.options.rejectUnauthorized).toBe(true); }); it(`doesn't set checkServerIdentity when verificationMode is full`, function () { @@ -114,7 +111,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, verificationMode: 'full' }, }); - expect(agent.options.checkServerIdentity).to.be(undefined); + expect(agent.options.checkServerIdentity).toBe(undefined); }); it(`sets ca when certificateAuthorities are specified`, function () { @@ -123,7 +120,7 @@ describe('plugins/console', function () { ssl: { ...config.ssl, certificateAuthorities: ['content-of-some-path'] }, }); - expect(agent.options.ca).to.contain('content-of-some-path'); + expect(agent.options.ca).toContain('content-of-some-path'); }); describe('when alwaysPresentCertificate is false', () => { @@ -138,8 +135,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); it(`doesn't set passphrase when certificate, key and keyPassphrase are specified`, function () { @@ -154,7 +151,7 @@ describe('plugins/console', function () { }, }); - expect(agent.options.passphrase).to.be(undefined); + expect(agent.options.passphrase).toBe(undefined); }); }); @@ -170,8 +167,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be('content-of-some-path'); - expect(agent.options.key).to.be('content-of-another-path'); + expect(agent.options.cert).toBe('content-of-some-path'); + expect(agent.options.key).toBe('content-of-another-path'); }); it(`sets passphrase when certificate, key and keyPassphrase are specified`, function () { @@ -186,7 +183,7 @@ describe('plugins/console', function () { }, }); - expect(agent.options.passphrase).to.be('secret'); + expect(agent.options.passphrase).toBe('secret'); }); it(`doesn't set cert when only certificate path is specified`, async function () { @@ -200,8 +197,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); it(`doesn't set key when only key path is specified`, async function () { @@ -215,8 +212,8 @@ describe('plugins/console', function () { }, }); - expect(agent.options.cert).to.be(undefined); - expect(agent.options.key).to.be(undefined); + expect(agent.options.cert).toBe(undefined); + expect(agent.options.key).toBe(undefined); }); }); }); diff --git a/src/plugins/console/server/__tests__/proxy_config.js b/src/plugins/console/server/lib/proxy_config.test.js similarity index 83% rename from src/plugins/console/server/__tests__/proxy_config.js rename to src/plugins/console/server/lib/proxy_config.test.js index 1f3a94c4fe20fb..73b181250fe01a 100644 --- a/src/plugins/console/server/__tests__/proxy_config.js +++ b/src/plugins/console/server/lib/proxy_config.test.js @@ -17,14 +17,11 @@ * under the License. */ -/* eslint-env mocha */ - -import expect from '@kbn/expect'; import sinon from 'sinon'; import https, { Agent as HttpsAgent } from 'https'; import { parse as parseUrl } from 'url'; -import { ProxyConfig } from '../lib/proxy_config'; +import { ProxyConfig } from './proxy_config'; const matchGoogle = { protocol: 'https', @@ -51,10 +48,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: ['content-of-some-path'], cert: undefined, key: undefined, @@ -70,10 +67,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: undefined, cert: 'content-of-some-path', key: 'content-of-another-path', @@ -91,10 +88,10 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.a(https.Agent); + expect(config.sslAgent instanceof https.Agent).toBeTruthy(); sinon.assert.calledOnce(https.Agent); const sslAgentOpts = https.Agent.firstCall.args[0]; - expect(sslAgentOpts).to.eql({ + expect(sslAgentOpts).toEqual({ ca: ['content-of-some-path'], cert: 'content-of-another-path', key: 'content-of-yet-another-path', @@ -111,7 +108,7 @@ describe('ProxyConfig', function () { timeout: 100, }); - expect(config.getForParsedUri(parsedLocalEs)).to.eql({}); + expect(config.getForParsedUri(parsedLocalEs)).toEqual({}); }); }); @@ -123,7 +120,7 @@ describe('ProxyConfig', function () { timeout: football, }); - expect(config.getForParsedUri(parsedGoogle).timeout).to.be(football); + expect(config.getForParsedUri(parsedGoogle).timeout).toBe(football); }); it('assigns ssl.verify to rejectUnauthorized', function () { @@ -135,7 +132,7 @@ describe('ProxyConfig', function () { }, }); - expect(config.getForParsedUri(parsedGoogle).rejectUnauthorized).to.be(football); + expect(config.getForParsedUri(parsedGoogle).rejectUnauthorized).toBe(football); }); describe('uri us http', function () { @@ -147,8 +144,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('cert is set', function () { @@ -159,8 +156,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('key is set', function () { @@ -171,8 +168,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); describe('cert + key are set', function () { @@ -184,8 +181,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'http:' }).agent).toBe(undefined); }); }); }); @@ -199,8 +196,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('cert is set', function () { @@ -211,8 +208,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('key is set', function () { @@ -223,8 +220,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); describe('cert + key are set', function () { @@ -236,8 +233,8 @@ describe('ProxyConfig', function () { }, }); - expect(config.sslAgent).to.be.an(HttpsAgent); - expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent); + expect(config.sslAgent instanceof HttpsAgent).toBeTruthy(); + expect(config.getForParsedUri({ protocol: 'https:' }).agent).toBe(config.sslAgent); }); }); }); diff --git a/src/plugins/console/server/__tests__/proxy_config_collection.js b/src/plugins/console/server/lib/proxy_config_collection.test.js similarity index 80% rename from src/plugins/console/server/__tests__/proxy_config_collection.js rename to src/plugins/console/server/lib/proxy_config_collection.test.js index 729972399f0bba..24dc1753106b12 100644 --- a/src/plugins/console/server/__tests__/proxy_config_collection.js +++ b/src/plugins/console/server/lib/proxy_config_collection.test.js @@ -17,14 +17,11 @@ * under the License. */ -/* eslint-env mocha */ - -import expect from '@kbn/expect'; import sinon from 'sinon'; import fs from 'fs'; import { Agent as HttpsAgent } from 'https'; -import { ProxyConfigCollection } from '../lib/proxy_config_collection'; +import { ProxyConfigCollection } from './proxy_config_collection'; describe('ProxyConfigCollection', function () { beforeEach(function () { @@ -88,61 +85,61 @@ describe('ProxyConfigCollection', function () { describe('http://localhost:5601', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5601')).to.be(3); + expect(getTimeout('http://localhost:5601')).toBe(3); }); }); describe('https://localhost:5601/.kibana', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/.kibana')).to.be(1); + expect(getTimeout('https://localhost:5601/.kibana')).toBe(1); }); }); describe('http://localhost:5602', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5602')).to.be(4); + expect(getTimeout('http://localhost:5602')).toBe(4); }); }); describe('https://localhost:5602', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5602')).to.be(4); + expect(getTimeout('https://localhost:5602')).toBe(4); }); }); describe('http://localhost:5603', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5603')).to.be(4); + expect(getTimeout('http://localhost:5603')).toBe(4); }); }); describe('https://localhost:5603', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5603')).to.be(4); + expect(getTimeout('https://localhost:5603')).toBe(4); }); }); describe('https://localhost:5601/index', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/index')).to.be(2); + expect(getTimeout('https://localhost:5601/index')).toBe(2); }); }); describe('http://localhost:5601/index', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://localhost:5601/index')).to.be(3); + expect(getTimeout('http://localhost:5601/index')).toBe(3); }); }); describe('https://localhost:5601/index/type', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('https://localhost:5601/index/type')).to.be(2); + expect(getTimeout('https://localhost:5601/index/type')).toBe(2); }); }); describe('http://notlocalhost', function () { it('defaults to the first matching timeout', function () { - expect(getTimeout('http://notlocalhost')).to.be(5); + expect(getTimeout('http://notlocalhost')).toBe(5); }); }); @@ -162,14 +159,14 @@ describe('ProxyConfigCollection', function () { it('verifies for config that produces ssl agent', function () { const conf = makeCollection().configForUri('https://es.internal.org/_search'); - expect(conf.agent.options).to.have.property('rejectUnauthorized', true); - expect(conf.agent).to.be.an(HttpsAgent); + expect(conf.agent.options).toHaveProperty('rejectUnauthorized', true); + expect(conf.agent instanceof HttpsAgent).toBeTruthy(); }); it('disabled verification for * config', function () { const conf = makeCollection().configForUri('https://extenal.org/_search'); - expect(conf).to.have.property('rejectUnauthorized', false); - expect(conf.agent).to.be(undefined); + expect(conf).toHaveProperty('rejectUnauthorized', false); + expect(conf.agent).toBe(undefined); }); }); }); diff --git a/src/plugins/console/server/__tests__/set_headers.js b/src/plugins/console/server/lib/set_headers.test.js similarity index 82% rename from src/plugins/console/server/__tests__/set_headers.js rename to src/plugins/console/server/lib/set_headers.test.js index 3ddd30777bb5b9..f8680de8b5f9ae 100644 --- a/src/plugins/console/server/__tests__/set_headers.js +++ b/src/plugins/console/server/lib/set_headers.test.js @@ -17,39 +17,38 @@ * under the License. */ -import expect from '@kbn/expect'; -import { setHeaders } from '../lib'; +import { setHeaders } from './set_headers'; describe('#set_headers', function () { it('throws if not given an object as the first argument', function () { const fn = () => setHeaders(null, {}); - expect(fn).to.throwError(); + expect(fn).toThrow(); }); it('throws if not given an object as the second argument', function () { const fn = () => setHeaders({}, null); - expect(fn).to.throwError(); + expect(fn).toThrow(); }); it('returns a new object', function () { const originalHeaders = {}; const newHeaders = {}; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).not.to.be(originalHeaders); - expect(returnedHeaders).not.to.be(newHeaders); + expect(returnedHeaders).not.toBe(originalHeaders); + expect(returnedHeaders).not.toBe(newHeaders); }); it('returns object with newHeaders merged with originalHeaders', function () { const originalHeaders = { foo: 'bar' }; const newHeaders = { one: 'two' }; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).to.eql({ foo: 'bar', one: 'two' }); + expect(returnedHeaders).toEqual({ foo: 'bar', one: 'two' }); }); it('returns object where newHeaders takes precedence for any matching keys', function () { const originalHeaders = { foo: 'bar' }; const newHeaders = { one: 'two', foo: 'notbar' }; const returnedHeaders = setHeaders(originalHeaders, newHeaders); - expect(returnedHeaders).to.eql({ foo: 'notbar', one: 'two' }); + expect(returnedHeaders).toEqual({ foo: 'notbar', one: 'two' }); }); }); diff --git a/src/plugins/console/server/__tests__/wildcard_matcher.js b/src/plugins/console/server/lib/wildcard_matcher.test.js similarity index 97% rename from src/plugins/console/server/__tests__/wildcard_matcher.js rename to src/plugins/console/server/lib/wildcard_matcher.test.js index 3e0e06efad50fb..fe25b6a50f8a72 100644 --- a/src/plugins/console/server/__tests__/wildcard_matcher.js +++ b/src/plugins/console/server/lib/wildcard_matcher.test.js @@ -17,8 +17,7 @@ * under the License. */ -/* eslint-env mocha */ -import { WildcardMatcher } from '../lib/wildcard_matcher'; +import { WildcardMatcher } from './wildcard_matcher'; function should(candidate, ...constructorArgs) { if (!new WildcardMatcher(...constructorArgs).match(candidate)) { diff --git a/src/plugins/dashboard/public/application/_dashboard_app.scss b/src/plugins/dashboard/public/application/_dashboard_app.scss index 94634d2c408e57..e3447b0a86c2d7 100644 --- a/src/plugins/dashboard/public/application/_dashboard_app.scss +++ b/src/plugins/dashboard/public/application/_dashboard_app.scss @@ -4,6 +4,12 @@ flex: 1; } +.dashboardViewport { + flex: 1; + display: flex; + flex-direction: column; +} + .dshStartScreen { text-align: center; } diff --git a/src/plugins/dashboard/public/application/_hacks.scss b/src/plugins/dashboard/public/application/_hacks.scss deleted file mode 100644 index debcc78792de9e..00000000000000 --- a/src/plugins/dashboard/public/application/_hacks.scss +++ /dev/null @@ -1,13 +0,0 @@ -// ANGULAR SELECTOR HACKS - -/** - * Needs to correspond with the react root nested inside angular. - */ - #dashboardViewport { - flex: 1; - display: flex; - flex-direction: column; - [data-reactroot] { - flex: 1; - } -} diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx index 5f3945e7335274..d9eb0dafe572af 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.test.tsx @@ -16,26 +16,31 @@ * specific language governing permissions and limitations * under the License. */ + +import { AddToLibraryAction } from '.'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput } from '../test_helpers'; + +import { CoreStart } from 'kibana/public'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + import { - isErrorEmbeddable, + EmbeddableInput, + ErrorEmbeddable, IContainer, + isErrorEmbeddable, ReferenceOrValueEmbeddable, - EmbeddableInput, -} from '../../embeddable_plugin'; -import { DashboardContainer } from '../embeddable'; -import { getSampleDashboardInput } from '../test_helpers'; + ViewMode, +} from '../../services/embeddable'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { AddToLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { ErrorEmbeddable, ViewMode } from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -60,6 +65,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx index 08cd0c7a153814..880d40cc3c6125 100644 --- a/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/add_to_library_action.tsx @@ -17,17 +17,20 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; + +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../../../embeddable/public'; -import { NotificationsStart } from '../../../../../core/public'; +} from '../../services/embeddable'; +import { NotificationsStart } from '../../services/core'; +import { dashboardAddToLibraryAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_ADD_TO_LIBRARY = 'addToFromLibrary'; @@ -47,9 +50,7 @@ export class AddToLibraryAction implements ActionByType { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 2d98d419689c19..d27e2d6dce6511 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -17,23 +17,26 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import uuid from 'uuid'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; -import { SavedObject } from '../../../../saved_objects/public'; +import uuid from 'uuid'; + +import { CoreStart } from 'src/core/public'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; +import { SavedObject } from '../../services/saved_objects'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, SavedObjectEmbeddableInput, isErrorEmbeddable, -} from '../../../../embeddable/public'; +} from '../../services/embeddable'; import { placePanelBeside, IPanelPlacementBesideArgs, } from '../embeddable/panel/dashboard_panel_placement'; +import { dashboardClonePanelAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_CLONE_PANEL = 'clonePanel'; @@ -53,9 +56,7 @@ export class ClonePanelAction implements ActionByType if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { throw new IncompatibleActionError(); } - return i18n.translate('dashboard.panel.clonePanel', { - defaultMessage: 'Clone panel', - }); + return dashboardClonePanelAction.getDisplayName(); } public getIconType({ embeddable }: ClonePanelActionContext) { @@ -99,9 +100,7 @@ export class ClonePanelAction implements ActionByType } private async getUniqueTitle(rawTitle: string, embeddableType: string): Promise { - const clonedTag = i18n.translate('dashboard.panel.title.clonedTag', { - defaultMessage: 'copy', - }); + const clonedTag = dashboardClonePanelAction.getClonedTag(); const cloneRegex = new RegExp(`\\(${clonedTag}\\)`, 'g'); const cloneNumberRegex = new RegExp(`\\(${clonedTag} [0-9]+\\)`, 'g'); const baseTitle = rawTitle.replace(cloneNumberRegex, '').replace(cloneRegex, '').trim(); @@ -152,9 +151,7 @@ export class ClonePanelAction implements ActionByType (panelState.explicitInput as SavedObjectEmbeddableInput).savedObjectId = clonedSavedObject.id; } this.core.notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.panel.clonedToast', { - defaultMessage: 'Cloned panel', - }), + title: dashboardClonePanelAction.getSuccessMessage(), 'data-test-subj': 'addObjectToContainerSuccess', }); return panelState; diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx index ff4e3ee5f06eb7..2b86403fc74e47 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.test.tsx @@ -17,18 +17,20 @@ * under the License. */ -import { isErrorEmbeddable } from '../../embeddable_plugin'; import { ExpandPanelAction } from './expand_panel_action'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { isErrorEmbeddable } from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddable, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../services/embeddable_test_samples'; +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; const { setup, doStart } = embeddablePluginMock.createInstance(); @@ -52,6 +54,8 @@ beforeEach(async () => { overlays: {} as any, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx index dcce38cdf94cec..fe14ce13d44bc5 100644 --- a/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/expand_panel_action.tsx @@ -17,9 +17,9 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { IEmbeddable } from '../../embeddable_plugin'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { dashboardExpandPanelAction } from '../../dashboard_strings'; +import { IEmbeddable } from '../../services/embeddable'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer, @@ -59,12 +59,8 @@ export class ExpandPanelAction implements ActionByType { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx b/src/plugins/dashboard/public/application/actions/export_csv_action.tsx index 48a7877f9383e8..4f78a738095d2c 100644 --- a/src/plugins/dashboard/public/application/actions/export_csv_action.tsx +++ b/src/plugins/dashboard/public/application/actions/export_csv_action.tsx @@ -17,14 +17,15 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { Datatable } from 'src/plugins/expressions/public'; -import { FormatFactory } from '../../../../data/common/field_formats/utils'; -import { DataPublicPluginStart, exporters } from '../../../../data/public'; -import { downloadMultipleAs } from '../../../../share/public'; -import { Adapters, IEmbeddable } from '../../../../embeddable/public'; -import { ActionByType } from '../../../../ui_actions/public'; import { CoreStart } from '../../../../../core/public'; +import { FormatFactory } from '../../../../data/common/field_formats/utils'; + +import { DataPublicPluginStart, exporters } from '../../services/data'; +import { downloadMultipleAs } from '../../services/share'; +import { Adapters, IEmbeddable } from '../../services/embeddable'; +import { ActionByType } from '../../services/ui_actions'; +import { dashboardExportCsvAction } from '../../dashboard_strings'; export const ACTION_EXPORT_CSV = 'ACTION_EXPORT_CSV'; @@ -57,9 +58,7 @@ export class ExportCSVAction implements ActionByType { } public readonly getDisplayName = (context: ExportContext): string => - i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { - defaultMessage: 'Download as CSV', - }); + dashboardExportCsvAction.getDisplayName(); public async isCompatible(context: ExportContext): Promise { return !!this.hasDatatableContent(context.embeddable?.getInspectorAdapters?.()); @@ -99,12 +98,7 @@ export class ExportCSVAction implements ActionByType { // skip empty datatables if (datatable) { const postFix = datatables.length > 1 ? `-${i + 1}` : ''; - const untitledFilename = i18n.translate( - 'dashboard.actions.downloadOptionsUnsavedFilename', - { - defaultMessage: 'unsaved', - } - ); + const untitledFilename = dashboardExportCsvAction.getUntitledFilename(); memo[`${context!.embeddable!.getTitle() || untitledFilename}${postFix}.csv`] = { content: exporters.datatableToCSV(datatable, { diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx index f45d64cdc0ab81..3157017d469cac 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.test.tsx @@ -16,21 +16,28 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; + import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { LibraryNotificationAction, UnlinkFromLibraryAction } from '.'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { + ErrorEmbeddable, + IContainer, + isErrorEmbeddable, + ReferenceOrValueEmbeddable, + ViewMode, +} from '../../services/embeddable'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { LibraryNotificationAction, UnlinkFromLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { ErrorEmbeddable, IContainer, ViewMode } from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -62,6 +69,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx index d6e75a3bb132b3..13ccb279df8218 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_action.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_action.tsx @@ -17,18 +17,20 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import React from 'react'; + +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; +import { reactToUiComponent } from '../../services/kibana_react'; import { IEmbeddable, ViewMode, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../embeddable_plugin'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { reactToUiComponent } from '../../../../kibana_react/public'; +} from '../../services/embeddable'; + import { UnlinkFromLibraryAction } from '.'; import { LibraryNotificationPopover } from './library_notification_popover'; +import { dashboardLibraryNotification } from '../../dashboard_strings'; export const ACTION_LIBRARY_NOTIFICATION = 'ACTION_LIBRARY_NOTIFICATION'; @@ -43,9 +45,7 @@ export class LibraryNotificationAction implements ActionByType { const { setup, doStart } = embeddablePluginMock.createInstance(); @@ -64,6 +64,8 @@ describe('LibraryNotificationPopover', () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx index e46851a85a67f7..25b37c69be5890 100644 --- a/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx +++ b/src/plugins/dashboard/public/application/actions/library_notification_popover.tsx @@ -27,8 +27,8 @@ import { EuiPopoverTitle, EuiText, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { LibraryNotificationActionContext, UnlinkFromLibraryAction } from '.'; +import { dashboardLibraryNotification } from '../../dashboard_strings'; export interface LibraryNotificationProps { context: LibraryNotificationActionContext; @@ -52,13 +52,11 @@ export function LibraryNotificationPopover({ setIsPopoverOpen(!isPopoverOpen)} + data-test-subj={`embeddablePanelNotification-${id}`} + aria-label={dashboardLibraryNotification.getPopoverAriaLabel()} /> } isOpen={isPopoverOpen} @@ -68,12 +66,7 @@ export function LibraryNotificationPopover({ {displayName}
-

- {i18n.translate('dashboard.panel.libraryNotification.toolTip', { - defaultMessage: - 'Editing this panel might affect other dashboards. To change to this panel only, unlink it from the library.', - })} -

+

{dashboardLibraryNotification.getTooltip()}

diff --git a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx index 54a294fd2f4aca..2f81353517b142 100644 --- a/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx +++ b/src/plugins/dashboard/public/application/actions/open_replace_panel_flyout.tsx @@ -18,15 +18,15 @@ */ import React from 'react'; import { CoreStart } from 'src/core/public'; -import { toMountPoint } from '../../../../../plugins/kibana_react/public'; +import { toMountPoint } from '../../services/kibana_react'; import { ReplacePanelFlyout } from './replace_panel_flyout'; import { + IContainer, IEmbeddable, + EmbeddableStart, EmbeddableInput, EmbeddableOutput, - EmbeddableStart, - IContainer, -} from '../../embeddable_plugin'; +} from '../../services/embeddable'; export async function openReplacePanelFlyout(options: { embeddable: IContainer; diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx index 38afc226707094..ffc76c77a69169 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.test.tsx @@ -16,20 +16,22 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable } from '../../embeddable_plugin'; + import { ReplacePanelAction } from './replace_panel_action'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; + +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { isErrorEmbeddable } from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddable, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -53,6 +55,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; const input = getSampleDashboardInput({ panels: { diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx index 5526af2f83850c..553a0b9770d017 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx @@ -17,12 +17,12 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { CoreStart } from 'src/core/public'; -import { IEmbeddable, ViewMode, EmbeddableStart } from '../../embeddable_plugin'; +import { IEmbeddable, ViewMode, EmbeddableStart } from '../../services/embeddable'; import { DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '../embeddable'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { openReplacePanelFlyout } from './open_replace_panel_flyout'; +import { dashboardReplacePanelAction } from '../../dashboard_strings'; export const ACTION_REPLACE_PANEL = 'replacePanel'; @@ -50,9 +50,7 @@ export class ReplacePanelAction implements ActionByType { } this.lastToast = this.props.notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { - defaultMessage: '{savedObjectName} was added', - values: { - savedObjectName: name, - }, - }), + title: dashboardReplacePanelAction.getSuccessMessage(name), 'data-test-subj': 'addObjectToContainerSuccess', }); }; @@ -104,9 +99,7 @@ export class ReplacePanelFlyout extends React.Component { const SavedObjectFinder = this.props.savedObjectsFinder; const savedObjectsFinder = ( diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx index 6a9769b0c8d16b..962d25bf0fe1a6 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -16,25 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -import { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { CoreStart } from 'kibana/public'; + +import { + ViewMode, + IContainer, + ErrorEmbeddable, + isErrorEmbeddable, + ReferenceOrValueEmbeddable, + SavedObjectEmbeddableInput, +} from '../../services/embeddable'; +import { UnlinkFromLibraryAction } from '.'; import { DashboardContainer } from '../embeddable'; import { getSampleDashboardInput } from '../test_helpers'; +import { coreMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, ContactCardEmbeddable, + ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddableOutput, -} from '../../embeddable_plugin_test_samples'; -import { coreMock } from '../../../../../core/public/mocks'; -import { CoreStart } from 'kibana/public'; -import { UnlinkFromLibraryAction } from '.'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { - ViewMode, - SavedObjectEmbeddableInput, - ErrorEmbeddable, -} from '../../../../embeddable/public'; + CONTACT_CARD_EMBEDDABLE, +} from '../../services/embeddable_test_samples'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( @@ -59,6 +63,8 @@ beforeEach(async () => { overlays: coreStart.overlays, savedObjectMetaData: {} as any, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreStart.http, }; container = new DashboardContainer(getSampleDashboardInput(), containerOptions); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx index b20bbc6350aaa3..93ceb726242591 100644 --- a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -17,17 +17,19 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; -import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; +import { ActionByType, IncompatibleActionError } from '../../services/ui_actions'; import { + ViewMode, + PanelState, + IEmbeddable, PanelNotFoundError, EmbeddableInput, isReferenceOrValueEmbeddable, isErrorEmbeddable, -} from '../../../../embeddable/public'; +} from '../../services/embeddable'; import { NotificationsStart } from '../../../../../core/public'; +import { dashboardUnlinkFromLibraryAction } from '../../dashboard_strings'; import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; @@ -47,9 +49,7 @@ export class UnlinkFromLibraryAction implements ActionByType string; - savedQueryService: DataPublicPluginStart['query']['savedQueries']; - embeddable: EmbeddableStart; - localStorage: Storage; - share?: SharePluginStart; - usageCollection?: UsageCollectionSetup; - navigateToDefaultApp: UrlForwardingStart['navigateToDefaultApp']; - navigateToLegacyKibanaUrl: UrlForwardingStart['navigateToLegacyKibanaUrl']; - scopedHistory: () => ScopedHistory; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; - savedObjects: SavedObjectsStart; - savedObjectsTagging?: SavedObjectsTaggingApi; - restorePreviousUrl: () => void; -} - -let angularModuleInstance: IModule | null = null; - -export const renderApp = (element: HTMLElement, appBasePath: string, deps: RenderDeps) => { - if (!angularModuleInstance) { - angularModuleInstance = createLocalAngularModule(); - // global routing stuff - configureAppAngularModule( - angularModuleInstance, - { core: deps.core, env: deps.pluginInitializerContext.env }, - true, - deps.scopedHistory - ); - initDashboardApp(angularModuleInstance, deps); - } - - const $injector = mountDashboardApp(appBasePath, element); - - return () => { - ($injector.get('kbnUrlStateStorage') as any).cancel(); - $injector.get('$rootScope').$destroy(); - }; -}; - -const mainTemplate = (basePath: string) => `
- -
`; - -const moduleName = 'app/dashboard'; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -function mountDashboardApp(appBasePath: string, element: HTMLElement) { - const mountpoint = document.createElement('div'); - mountpoint.setAttribute('class', 'dshAppContainer'); - // eslint-disable-next-line no-unsanitized/property - mountpoint.innerHTML = mainTemplate(appBasePath); - // bootstrap angular into detached element and attach it later to - // make angular-within-angular possible - const $injector = angular.bootstrap(mountpoint, [moduleName]); - // initialize global state handler - element.appendChild(mountpoint); - return $injector; -} - -function createLocalAngularModule() { - createLocalI18nModule(); - createLocalIconModule(); - - const dashboardAngularModule = angular.module(moduleName, [ - ...thirdPartyAngularDependencies, - 'app/dashboard/I18n', - 'app/dashboard/icon', - ]); - return dashboardAngularModule; -} - -function createLocalIconModule() { - angular - .module('app/dashboard/icon', ['react']) - .directive('icon', (reactDirective) => reactDirective(EuiIcon)); -} - -function createLocalI18nModule() { - angular - .module('app/dashboard/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} diff --git a/src/plugins/dashboard/public/application/dashboard_app.html b/src/plugins/dashboard/public/application/dashboard_app.html deleted file mode 100644 index 87a5728ac20599..00000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-

{{screenTitle}}

-
- -
diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index 6690ae318fc8f2..8eff48251b371e 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -17,75 +17,231 @@ * under the License. */ -import moment from 'moment'; -import { Subscription } from 'rxjs'; +import _ from 'lodash'; import { History } from 'history'; +import { merge, Subscription } from 'rxjs'; +import React, { useEffect, useCallback, useState } from 'react'; -import { ViewMode } from 'src/plugins/embeddable/public'; -import { IIndexPattern, TimeRange, Query, Filter, SavedQuery } from 'src/plugins/data/public'; -import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; - -import { DashboardAppState, SavedDashboardPanel } from '../types'; -import { DashboardAppController } from './dashboard_app_controller'; -import { RenderDeps } from './application'; -import { SavedObjectDashboard } from '../saved_dashboards'; - -export interface DashboardAppScope extends ng.IScope { - dash: SavedObjectDashboard; - appState: DashboardAppState; - model: { - query: Query; - filters: Filter[]; - timeRestore: boolean; - title: string; - description: string; - timeRange: - | TimeRange - | { to: string | moment.Moment | undefined; from: string | moment.Moment | undefined }; - refreshInterval: any; - }; - savedQuery?: SavedQuery; - refreshInterval: any; - panels: SavedDashboardPanel[]; - indexPatterns: IIndexPattern[]; - dashboardViewMode: ViewMode; - expandedPanel?: string; - getShouldShowEditHelp: () => boolean; - getShouldShowViewHelp: () => boolean; - handleRefresh: ( - { query, dateRange }: { query?: Query; dateRange: TimeRange }, - isUpdate?: boolean - ) => void; - topNavMenu: any; - showAddPanel: any; - showSaveQuery: boolean; - kbnTopNav: any; - enterEditMode: () => void; - timefilterSubscriptions$: Subscription; - isVisible: boolean; +import { useKibana } from '../../../kibana_react/public'; +import { DashboardConstants } from '../dashboard_constants'; +import { DashboardTopNav } from './top_nav/dashboard_top_nav'; +import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from './types'; +import { + getInputSubscription, + getOutputSubscription, + getFiltersSubscription, + getSearchSessionIdFromURL, + getDashboardContainerInput, + getChangesFromAppStateForContainerState, +} from './dashboard_app_functions'; +import { + useDashboardBreadcrumbs, + useDashboardContainer, + useDashboardStateManager, + useSavedDashboard, +} from './hooks'; + +import { removeQueryParam } from '../services/kibana_utils'; +import { IndexPattern } from '../services/data'; +import { EmbeddableRenderer } from '../services/embeddable'; +import { DashboardContainerInput } from '.'; + +export interface DashboardAppProps { + history: History; + savedDashboardId?: string; + redirectTo: DashboardRedirect; + embedSettings?: DashboardEmbedSettings; } -export function initDashboardAppDirective(app: any, deps: RenderDeps) { - app.directive('dashboardApp', () => ({ - restrict: 'E', - controllerAs: 'dashboardApp', - controller: ( - $scope: DashboardAppScope, - $route: any, - $routeParams: { - id?: string; - }, - kbnUrlStateStorage: IKbnUrlStateStorage, - history: History - ) => - new DashboardAppController({ - $route, - $scope, - $routeParams, - indexPatterns: deps.data.indexPatterns, - kbnUrlStateStorage, - history, - ...deps, - }), - })); +export function DashboardApp({ + savedDashboardId, + embedSettings, + redirectTo, + history, +}: DashboardAppProps) { + const { + data, + core, + onAppLeave, + uiSettings, + indexPatterns: indexPatternService, + dashboardCapabilities, + } = useKibana().services; + + const [lastReloadTime, setLastReloadTime] = useState(0); + const [indexPatterns, setIndexPatterns] = useState([]); + + const savedDashboard = useSavedDashboard(savedDashboardId, history); + const dashboardStateManager = useDashboardStateManager(savedDashboard, history); + const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false); + + const refreshDashboardContainer = useCallback( + (lastReloadRequestTime?: number) => { + if (!dashboardContainer || !dashboardStateManager) { + return; + } + + const changes = getChangesFromAppStateForContainerState({ + dashboardContainer, + appStateDashboardInput: getDashboardContainerInput({ + isEmbeddedExternally: Boolean(embedSettings), + dashboardStateManager, + lastReloadRequestTime, + dashboardCapabilities, + query: data.query, + }), + }); + + if (changes) { + // state keys change in which likely won't need a data fetch + const noRefetchKeys: Array = [ + 'viewMode', + 'title', + 'description', + 'expandedPanelId', + 'useMargins', + 'isEmbeddedExternally', + 'isFullScreenMode', + ]; + const shouldRefetch = Object.keys(changes).some( + (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) + ); + if (getSearchSessionIdFromURL(history)) { + // going away from a background search results + removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true); + } + + dashboardContainer.updateInput({ + ...changes, + // do not start a new session if this is irrelevant state change to prevent excessive searches + ...(shouldRefetch && { searchSessionId: data.search.session.start() }), + }); + } + }, + [ + history, + data.query, + embedSettings, + dashboardContainer, + data.search.session, + dashboardCapabilities, + dashboardStateManager, + ] + ); + + // Manage dashboard container subscriptions + useEffect(() => { + if (!dashboardStateManager || !dashboardContainer) { + return; + } + const timeFilter = data.query.timefilter.timefilter; + const subscriptions = new Subscription(); + + subscriptions.add( + getInputSubscription({ + dashboardContainer, + dashboardStateManager, + filterManager: data.query.filterManager, + }) + ); + subscriptions.add( + getOutputSubscription({ + dashboardContainer, + indexPatterns: indexPatternService, + onUpdateIndexPatterns: (newIndexPatterns) => setIndexPatterns(newIndexPatterns), + }) + ); + subscriptions.add( + getFiltersSubscription({ + query: data.query, + dashboardStateManager, + }) + ); + subscriptions.add( + merge( + ...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()] + ).subscribe(() => refreshDashboardContainer()) + ); + subscriptions.add( + data.search.session.onRefresh$.subscribe(() => { + setLastReloadTime(() => new Date().getTime()); + }) + ); + dashboardStateManager.registerChangeListener(() => { + // we aren't checking dirty state because there are changes the container needs to know about + // that won't make the dashboard "dirty" - like a view mode change. + refreshDashboardContainer(); + }); + + return () => { + subscriptions.unsubscribe(); + }; + }, [ + core.http, + uiSettings, + data.query, + dashboardContainer, + data.search.session, + indexPatternService, + dashboardStateManager, + refreshDashboardContainer, + ]); + + // Sync breadcrumbs when Dashboard State Manager changes + useDashboardBreadcrumbs(dashboardStateManager, redirectTo); + + // Build onAppLeave when Dashboard State Manager changes + useEffect(() => { + if (!dashboardStateManager || !dashboardContainer) { + return; + } + onAppLeave((actions) => { + if (dashboardStateManager?.getIsDirty()) { + // TODO: Finish App leave handler with overrides when redirecting to an editor. + // return actions.confirm(leaveConfirmStrings.leaveSubtitle, leaveConfirmStrings.leaveTitle); + } + return actions.default(); + }); + return () => { + // reset on app leave handler so leaving from the listing page doesn't trigger a confirmation + onAppLeave((actions) => actions.default()); + }; + }, [dashboardStateManager, dashboardContainer, onAppLeave]); + + // Refresh the dashboard container when lastReloadTime changes + useEffect(() => { + refreshDashboardContainer(lastReloadTime); + }, [lastReloadTime, refreshDashboardContainer]); + + return ( +
+ {savedDashboard && dashboardStateManager && dashboardContainer && ( + <> + { + if (isUpdate === false) { + // The user can still request a reload in the query bar, even if the + // query is the same, and in that case, we have to explicitly ask for + // a reload, since no state changes will cause it. + setLastReloadTime(() => new Date().getTime()); + } + }} + /> +
+ +
+ + )} +
+ ); } diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx deleted file mode 100644 index 4d5a3fb9a8cc9f..00000000000000 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ /dev/null @@ -1,1242 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _, { uniqBy } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui'; -import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group'; -import React, { useState, ReactElement } from 'react'; -import ReactDOM from 'react-dom'; -import angular from 'angular'; -import deepEqual from 'fast-deep-equal'; - -import { Observable, pipe, Subscription, merge, EMPTY } from 'rxjs'; -import { - filter, - map, - debounceTime, - mapTo, - startWith, - switchMap, - distinctUntilChanged, - catchError, -} from 'rxjs/operators'; -import { History } from 'history'; -import { SavedObjectSaveOpts, SavedObject } from 'src/plugins/saved_objects/public'; -import type { TagDecoratedSavedObject } from 'src/plugins/saved_objects_tagging_oss/public'; -import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; -import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; - -import { - connectToQueryState, - esFilters, - IndexPattern, - IndexPatternsContract, - QueryState, - SavedQuery, - syncQueryStateWithUrl, -} from '../../../data/public'; -import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../../saved_objects/public'; - -import { - DASHBOARD_CONTAINER_TYPE, - DashboardContainer, - DashboardContainerInput, - DashboardPanelState, -} from './embeddable'; -import { - EmbeddableFactoryNotFoundError, - ErrorEmbeddable, - isErrorEmbeddable, - openAddPanelFlyout, - ViewMode, - ContainerOutput, - EmbeddableInput, -} from '../../../embeddable/public'; -import { NavAction, SavedDashboardPanel } from '../types'; - -import { showOptionsPopover } from './top_nav/show_options_popover'; -import { DashboardSaveModal, SaveOptions } from './top_nav/save_modal'; -import { showCloneModal } from './top_nav/show_clone_modal'; -import { createSessionRestorationDataProvider, saveDashboard } from './lib'; -import { DashboardStateManager } from './dashboard_state_manager'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { getTopNavConfig } from './top_nav/get_top_nav_config'; -import { TopNavIds } from './top_nav/top_nav_ids'; -import { getDashboardTitle } from './dashboard_strings'; -import { DashboardAppScope } from './dashboard_app'; -import { RenderDeps } from './application'; -import { - IKbnUrlStateStorage, - removeQueryParam, - setStateToKbnUrl, - unhashUrl, - getQueryParams, -} from '../../../kibana_utils/public'; -import { - addFatalError, - AngularHttpError, - KibanaLegacyStart, - subscribeWithScope, -} from '../../../kibana_legacy/public'; -import { migrateLegacyQuery } from './lib/migrate_legacy_query'; -import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; - -export interface DashboardAppControllerDependencies extends RenderDeps { - $scope: DashboardAppScope; - $route: any; - $routeParams: any; - indexPatterns: IndexPatternsContract; - dashboardConfig: KibanaLegacyStart['dashboardConfig']; - history: History; - kbnUrlStateStorage: IKbnUrlStateStorage; - navigation: NavigationStart; -} - -enum UrlParams { - SHOW_TOP_MENU = 'show-top-menu', - SHOW_QUERY_INPUT = 'show-query-input', - SHOW_TIME_FILTER = 'show-time-filter', - SHOW_FILTER_BAR = 'show-filter-bar', - HIDE_FILTER_BAR = 'hide-filter-bar', -} - -interface UrlParamsSelectedMap { - [UrlParams.SHOW_TOP_MENU]: boolean; - [UrlParams.SHOW_QUERY_INPUT]: boolean; - [UrlParams.SHOW_TIME_FILTER]: boolean; - [UrlParams.SHOW_FILTER_BAR]: boolean; -} - -interface UrlParamValues extends Omit { - [UrlParams.HIDE_FILTER_BAR]: boolean; -} - -const getSearchSessionIdFromURL = (history: History): string | undefined => - getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined; - -export class DashboardAppController { - // Part of the exposed plugin API - do not remove without careful consideration. - appStatus: { - dirty: boolean; - }; - - constructor({ - pluginInitializerContext, - $scope, - $route, - $routeParams, - dashboardConfig, - indexPatterns, - savedQueryService, - embeddable, - share, - dashboardCapabilities, - scopedHistory, - embeddableCapabilities: { visualizeCapabilities, mapsCapabilities }, - data, - core: { - notifications, - overlays, - chrome, - fatalErrors, - uiSettings, - savedObjects, - http, - i18n: i18nStart, - }, - history, - setHeaderActionMenu, - kbnUrlStateStorage, - usageCollection, - navigation, - savedObjectsTagging, - }: DashboardAppControllerDependencies) { - const queryService = data.query; - const searchService = data.search; - const filterManager = queryService.filterManager; - const timefilter = queryService.timefilter.timefilter; - const queryStringManager = queryService.queryString; - const isEmbeddedExternally = Boolean($routeParams.embed); - - // url param rules should only apply when embedded (e.g. url?embed=true) - const shouldForceDisplay = (param: string): boolean => - isEmbeddedExternally && Boolean($routeParams[param]); - - const forceShowTopNavMenu = shouldForceDisplay(UrlParams.SHOW_TOP_MENU); - const forceShowQueryInput = shouldForceDisplay(UrlParams.SHOW_QUERY_INPUT); - const forceShowDatePicker = shouldForceDisplay(UrlParams.SHOW_TIME_FILTER); - const forceHideFilterBar = shouldForceDisplay(UrlParams.HIDE_FILTER_BAR); - - let lastReloadRequestTime = 0; - const dash = ($scope.dash = $route.current.locals.dash); - if (dash.id) { - chrome.docTitle.change(dash.title); - } - - let incomingEmbeddable = embeddable - .getStateTransfer(scopedHistory()) - .getIncomingEmbeddablePackage(); - - // TS is picky with type guards, we can't just inline `() => false` - function defaultTaggingGuard(obj: SavedObject): obj is TagDecoratedSavedObject { - return false; - } - - const dashboardStateManager = new DashboardStateManager({ - savedDashboard: dash, - hideWriteControls: dashboardConfig.getHideWriteControls(), - kibanaVersion: pluginInitializerContext.env.packageInfo.version, - kbnUrlStateStorage, - history, - usageCollection, - hasTaggingCapabilities: savedObjectsTagging?.ui.hasTagDecoration ?? defaultTaggingGuard, - }); - - // sync initial app filters from state to filterManager - // if there is an existing similar global filter, then leave it as global - filterManager.setAppFilters(_.cloneDeep(dashboardStateManager.appState.filters)); - queryStringManager.setQuery(migrateLegacyQuery(dashboardStateManager.appState.query)); - - // setup syncing of app filters between appState and filterManager - const stopSyncingAppFilters = connectToQueryState( - queryService, - { - set: ({ filters, query }) => { - dashboardStateManager.setFilters(filters || []); - dashboardStateManager.setQuery(query || queryStringManager.getDefaultQuery()); - }, - get: () => ({ - filters: dashboardStateManager.appState.filters, - query: dashboardStateManager.getQuery(), - }), - state$: dashboardStateManager.appState$.pipe( - map((state) => ({ - filters: state.filters, - query: queryStringManager.formatQuery(state.query), - })) - ), - }, - { - filters: esFilters.FilterStateStore.APP_STATE, - query: true, - } - ); - - // The hash check is so we only update the time filter on dashboard open, not during - // normal cross app navigation. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - const initialGlobalStateInUrl = kbnUrlStateStorage.get('_g'); - if (!initialGlobalStateInUrl?.time) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - } - if (!initialGlobalStateInUrl?.refreshInterval) { - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - } - - // starts syncing `_g` portion of url with query services - // it is important to start this syncing after `dashboardStateManager.syncTimefilterWithDashboard(timefilter);` above is run, - // otherwise it will case redundant browser history records - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - queryService, - kbnUrlStateStorage - ); - - // starts syncing `_a` portion of url - dashboardStateManager.startStateSyncing(); - - $scope.showSaveQuery = dashboardCapabilities.saveQuery as boolean; - - const landingPageUrl = () => `#${DashboardConstants.LANDING_PAGE_PATH}`; - - const getDashTitle = () => - getDashboardTitle( - dashboardStateManager.getTitle(), - dashboardStateManager.getViewMode(), - dashboardStateManager.getIsDirty(timefilter), - dashboardStateManager.isNew() - ); - - const getShouldShowEditHelp = () => - !dashboardStateManager.getPanels().length && - dashboardStateManager.getIsEditMode() && - !dashboardConfig.getHideWriteControls(); - - const getShouldShowViewHelp = () => - !dashboardStateManager.getPanels().length && - dashboardStateManager.getIsViewMode() && - !dashboardConfig.getHideWriteControls(); - - const shouldShowUnauthorizedEmptyState = () => { - const readonlyMode = - !dashboardStateManager.getPanels().length && - !getShouldShowEditHelp() && - !getShouldShowViewHelp() && - dashboardConfig.getHideWriteControls(); - const userHasNoPermissions = - !dashboardStateManager.getPanels().length && - !visualizeCapabilities.save && - !mapsCapabilities.save; - return readonlyMode || userHasNoPermissions; - }; - - const addVisualization = () => { - navActions[TopNavIds.VISUALIZE](); - }; - - function getDashboardIndexPatterns(container: DashboardContainer): IndexPattern[] { - let panelIndexPatterns: IndexPattern[] = []; - Object.values(container.getChildIds()).forEach((id) => { - const embeddableInstance = container.getChild(id); - if (isErrorEmbeddable(embeddableInstance)) return; - const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns; - if (!embeddableIndexPatterns) return; - panelIndexPatterns.push(...embeddableIndexPatterns); - }); - panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); - return panelIndexPatterns; - } - - const updateIndexPatternsOperator = pipe( - filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), - map(getDashboardIndexPatterns), - distinctUntilChanged((a, b) => - deepEqual( - a.map((ip) => ip.id), - b.map((ip) => ip.id) - ) - ), - // using switchMap for previous task cancellation - switchMap((panelIndexPatterns: IndexPattern[]) => { - return new Observable((observer) => { - if (panelIndexPatterns && panelIndexPatterns.length > 0) { - $scope.$evalAsync(() => { - if (observer.closed) return; - $scope.indexPatterns = panelIndexPatterns; - observer.complete(); - }); - } else { - indexPatterns.getDefault().then((defaultIndexPattern) => { - if (observer.closed) return; - $scope.$evalAsync(() => { - if (observer.closed) return; - $scope.indexPatterns = [defaultIndexPattern as IndexPattern]; - observer.complete(); - }); - }); - } - }); - }) - ); - - const getEmptyScreenProps = ( - shouldShowEditHelp: boolean, - isEmptyInReadOnlyMode: boolean - ): DashboardEmptyScreenProps => { - const emptyScreenProps: DashboardEmptyScreenProps = { - onLinkClick: shouldShowEditHelp ? $scope.showAddPanel : $scope.enterEditMode, - showLinkToVisualize: shouldShowEditHelp, - uiSettings, - http, - }; - if (shouldShowEditHelp) { - emptyScreenProps.onVisualizeClick = addVisualization; - } - if (isEmptyInReadOnlyMode) { - emptyScreenProps.isReadonlyMode = true; - } - return emptyScreenProps; - }; - - const getDashboardInput = (): DashboardContainerInput => { - const embeddablesMap: { - [key: string]: DashboardPanelState; - } = {}; - dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => { - embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); - }); - - // If the incoming embeddable state's id already exists in the embeddables map, replace the input, retaining the existing gridData for that panel. - if (incomingEmbeddable?.embeddableId && embeddablesMap[incomingEmbeddable.embeddableId]) { - const originalPanelState = embeddablesMap[incomingEmbeddable.embeddableId]; - embeddablesMap[incomingEmbeddable.embeddableId] = { - gridData: originalPanelState.gridData, - type: incomingEmbeddable.type, - explicitInput: { - ...originalPanelState.explicitInput, - ...incomingEmbeddable.input, - id: incomingEmbeddable.embeddableId, - }, - }; - incomingEmbeddable = undefined; - } - - const shouldShowEditHelp = getShouldShowEditHelp(); - const shouldShowViewHelp = getShouldShowViewHelp(); - const isEmptyInReadonlyMode = shouldShowUnauthorizedEmptyState(); - return { - id: dashboardStateManager.savedDashboard.id || '', - filters: filterManager.getFilters(), - hidePanelTitles: dashboardStateManager.getHidePanelTitles(), - query: $scope.model.query, - timeRange: { - ..._.cloneDeep(timefilter.getTime()), - }, - refreshConfig: timefilter.getRefreshInterval(), - viewMode: dashboardStateManager.getViewMode(), - panels: embeddablesMap, - isFullScreenMode: dashboardStateManager.getFullScreenMode(), - isEmbeddedExternally, - isEmptyState: shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadonlyMode, - useMargins: dashboardStateManager.getUseMargins(), - lastReloadRequestTime, - title: dashboardStateManager.getTitle(), - description: dashboardStateManager.getDescription(), - expandedPanelId: dashboardStateManager.getExpandedPanelId(), - }; - }; - - const updateState = () => { - // Following the "best practice" of always have a '.' in your ng-models – - // https://github.com/angular/angular.js/wiki/Understanding-Scopes - $scope.model = { - query: dashboardStateManager.getQuery(), - filters: filterManager.getFilters(), - timeRestore: dashboardStateManager.getTimeRestore(), - title: dashboardStateManager.getTitle(), - description: dashboardStateManager.getDescription(), - timeRange: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - }; - $scope.panels = dashboardStateManager.getPanels(); - }; - - updateState(); - - let dashboardContainer: DashboardContainer | undefined; - let inputSubscription: Subscription | undefined; - let outputSubscription: Subscription | undefined; - - const dashboardDom = document.getElementById('dashboardViewport'); - const dashboardFactory = embeddable.getEmbeddableFactory< - DashboardContainerInput, - ContainerOutput, - DashboardContainer - >(DASHBOARD_CONTAINER_TYPE); - - searchService.session.setSearchSessionInfoProvider( - createSessionRestorationDataProvider({ - data, - getDashboardTitle: () => getDashTitle(), - getDashboardId: () => dash.id, - getAppState: () => dashboardStateManager.getAppState(), - }) - ); - - if (dashboardFactory) { - const searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - searchService.session.restore(searchSessionIdFromURL); - } - const searchSessionId = searchSessionIdFromURL ?? searchService.session.start(); - dashboardFactory - .create({ ...getDashboardInput(), searchSessionId }) - .then((container: DashboardContainer | ErrorEmbeddable | undefined) => { - if (container && !isErrorEmbeddable(container)) { - dashboardContainer = container; - - dashboardContainer.renderEmpty = () => { - const shouldShowEditHelp = getShouldShowEditHelp(); - const shouldShowViewHelp = getShouldShowViewHelp(); - const isEmptyInReadOnlyMode = shouldShowUnauthorizedEmptyState(); - const isEmptyState = - shouldShowEditHelp || shouldShowViewHelp || isEmptyInReadOnlyMode; - return isEmptyState ? ( - - ) : null; - }; - - outputSubscription = merge( - // output of dashboard container itself - dashboardContainer.getOutput$(), - // plus output of dashboard container children, - // children may change, so make sure we subscribe/unsubscribe with switchMap - dashboardContainer.getOutput$().pipe( - map(() => dashboardContainer!.getChildIds()), - distinctUntilChanged(deepEqual), - switchMap((newChildIds: string[]) => - merge( - ...newChildIds.map((childId) => - dashboardContainer! - .getChild(childId) - .getOutput$() - .pipe(catchError(() => EMPTY)) - ) - ) - ) - ) - ) - .pipe( - mapTo(dashboardContainer), - startWith(dashboardContainer), // to trigger initial index pattern update - updateIndexPatternsOperator - ) - .subscribe(); - - inputSubscription = dashboardContainer.getInput$().subscribe(() => { - let dirty = false; - - // This has to be first because handleDashboardContainerChanges causes - // appState.save which will cause refreshDashboardContainer to be called. - - if ( - !esFilters.compareFilters( - container.getInput().filters, - filterManager.getFilters(), - esFilters.COMPARE_ALL_OPTIONS - ) - ) { - // Add filters modifies the object passed to it, hence the clone deep. - filterManager.addFilters(_.cloneDeep(container.getInput().filters)); - - dashboardStateManager.applyFilters( - $scope.model.query, - container.getInput().filters - ); - dirty = true; - } - - dashboardStateManager.handleDashboardContainerChanges(container); - $scope.$evalAsync(() => { - if (dirty) { - updateState(); - } - }); - }); - - dashboardStateManager.registerChangeListener(() => { - // we aren't checking dirty state because there are changes the container needs to know about - // that won't make the dashboard "dirty" - like a view mode change. - refreshDashboardContainer(); - }); - - // If the incomingEmbeddable does not yet exist in the panels listing, create a new panel using the container's addEmbeddable method. - if ( - incomingEmbeddable && - (!incomingEmbeddable.embeddableId || - !container.getInput().panels[incomingEmbeddable.embeddableId]) - ) { - container.addNewEmbeddable( - incomingEmbeddable.type, - incomingEmbeddable.input - ); - updateViewMode(ViewMode.EDIT); - } - } - - if (dashboardDom && container) { - container.render(dashboardDom); - } - }); - } - - // Part of the exposed plugin API - do not remove without careful consideration. - this.appStatus = { - dirty: !dash.id, - }; - - dashboardStateManager.registerChangeListener((status) => { - this.appStatus.dirty = status.dirty || !dash.id; - updateState(); - }); - - dashboardStateManager.applyFilters( - dashboardStateManager.getQuery() || queryStringManager.getDefaultQuery(), - filterManager.getFilters() - ); - - // Push breadcrumbs to new header navigation - const updateBreadcrumbs = () => { - chrome.setBreadcrumbs([ - { - text: i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { - defaultMessage: 'Dashboard', - }), - href: landingPageUrl(), - }, - { text: getDashTitle() }, - ]); - }; - - updateBreadcrumbs(); - dashboardStateManager.registerChangeListener(updateBreadcrumbs); - - const getChangesFromAppStateForContainerState = () => { - const appStateDashboardInput = getDashboardInput(); - if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { - return appStateDashboardInput; - } - - const containerInput = dashboardContainer.getInput(); - const differences: Partial = {}; - - // Filters shouldn't be compared using regular isEqual - if ( - !esFilters.compareFilters( - containerInput.filters, - appStateDashboardInput.filters, - esFilters.COMPARE_ALL_OPTIONS - ) - ) { - differences.filters = appStateDashboardInput.filters; - } - - Object.keys(_.omit(containerInput, ['filters', 'searchSessionId'])).forEach((key) => { - const containerValue = (containerInput as { [key: string]: unknown })[key]; - const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[ - key - ]; - if (!_.isEqual(containerValue, appStateValue)) { - (differences as { [key: string]: unknown })[key] = appStateValue; - } - }); - - // cloneDeep hack is needed, as there are multiple place, where container's input mutated, - // but values from appStateValue are deeply frozen, as they can't be mutated directly - return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences); - }; - - const refreshDashboardContainer = () => { - const changes = getChangesFromAppStateForContainerState(); - if (changes && dashboardContainer) { - if (getSearchSessionIdFromURL(history)) { - // going away from a background search results - removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true); - } - - // state keys change in which likely won't need a data fetch - const noRefetchKeys: Array = [ - 'viewMode', - 'title', - 'description', - 'expandedPanelId', - 'useMargins', - 'isEmbeddedExternally', - 'isFullScreenMode', - 'isEmptyState', - ]; - - const shouldRefetch = Object.keys(changes).some( - (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) - ); - - dashboardContainer.updateInput({ - ...changes, - // do not start a new session if this is irrelevant state change to prevent excessive searches - ...(shouldRefetch && { searchSessionId: searchService.session.start() }), - }); - } - }; - - $scope.handleRefresh = function (_payload, isUpdate) { - if (isUpdate === false) { - // The user can still request a reload in the query bar, even if the - // query is the same, and in that case, we have to explicitly ask for - // a reload, since no state changes will cause it. - lastReloadRequestTime = new Date().getTime(); - refreshDashboardContainer(); - } - }; - - const searchServiceSessionRefreshSubscribtion = searchService.session.onRefresh$.subscribe( - () => { - lastReloadRequestTime = new Date().getTime(); - refreshDashboardContainer(); - } - ); - - const updateStateFromSavedQuery = (savedQuery: SavedQuery) => { - const allFilters = filterManager.getFilters(); - dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters); - if (savedQuery.attributes.timefilter) { - timefilter.setTime({ - from: savedQuery.attributes.timefilter.from, - to: savedQuery.attributes.timefilter.to, - }); - if (savedQuery.attributes.timefilter.refreshInterval) { - timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); - } - } - // Making this method sync broke the updates. - // Temporary fix, until we fix the complex state in this file. - setTimeout(() => { - filterManager.setFilters(allFilters); - }, 0); - }; - - $scope.$watch('savedQuery', (newSavedQuery: SavedQuery) => { - if (!newSavedQuery) return; - dashboardStateManager.setSavedQueryId(newSavedQuery.id); - - updateStateFromSavedQuery(newSavedQuery); - }); - - $scope.$watch( - () => { - return dashboardStateManager.getSavedQueryId(); - }, - (newSavedQueryId) => { - if (!newSavedQueryId) { - $scope.savedQuery = undefined; - return; - } - if (!$scope.savedQuery || newSavedQueryId !== $scope.savedQuery.id) { - savedQueryService.getSavedQuery(newSavedQueryId).then((savedQuery: SavedQuery) => { - $scope.$evalAsync(() => { - $scope.savedQuery = savedQuery; - updateStateFromSavedQuery(savedQuery); - }); - }); - } - } - ); - - $scope.indexPatterns = []; - - $scope.$watch( - () => dashboardCapabilities.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability as boolean; - } - ); - - const onSavedQueryIdChange = (savedQueryId?: string) => { - dashboardStateManager.setSavedQueryId(savedQueryId); - }; - - const shouldShowFilterBar = (forceHide: boolean): boolean => - !forceHide && ($scope.model.filters.length > 0 || !dashboardStateManager.getFullScreenMode()); - - const shouldShowNavBarComponent = (forceShow: boolean): boolean => - (forceShow || $scope.isVisible) && !dashboardStateManager.getFullScreenMode(); - - const getNavBarProps = () => { - const isFullScreenMode = dashboardStateManager.getFullScreenMode(); - const screenTitle = dashboardStateManager.getTitle(); - const showTopNavMenu = shouldShowNavBarComponent(forceShowTopNavMenu); - const showQueryInput = shouldShowNavBarComponent(forceShowQueryInput); - const showDatePicker = shouldShowNavBarComponent(forceShowDatePicker); - const showQueryBar = showQueryInput || showDatePicker; - const showFilterBar = shouldShowFilterBar(forceHideFilterBar); - const showSearchBar = showQueryBar || showFilterBar; - - return { - appName: 'dashboard', - config: showTopNavMenu ? $scope.topNavMenu : undefined, - className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, - screenTitle, - showTopNavMenu, - showSearchBar, - showQueryBar, - showQueryInput, - showDatePicker, - showFilterBar, - indexPatterns: $scope.indexPatterns, - showSaveQuery: $scope.showSaveQuery, - savedQuery: $scope.savedQuery, - onSavedQueryIdChange, - savedQueryId: dashboardStateManager.getSavedQueryId(), - useDefaultBehaviors: true, - onQuerySubmit: $scope.handleRefresh, - }; - }; - const dashboardNavBar = document.getElementById('dashboardChrome'); - const updateNavBar = () => { - ReactDOM.render( - , - dashboardNavBar - ); - }; - - const unmountNavBar = () => { - if (dashboardNavBar) { - ReactDOM.unmountComponentAtNode(dashboardNavBar); - } - }; - - $scope.timefilterSubscriptions$ = new Subscription(); - const timeChanges$ = merge(timefilter.getRefreshIntervalUpdate$(), timefilter.getTimeUpdate$()); - $scope.timefilterSubscriptions$.add( - subscribeWithScope( - $scope, - timeChanges$, - { - next: () => { - updateState(); - refreshDashboardContainer(); - }, - }, - (error: AngularHttpError | Error | string) => addFatalError(fatalErrors, error) - ) - ); - - function updateViewMode(newMode: ViewMode) { - dashboardStateManager.switchViewMode(newMode); - } - - const onChangeViewMode = (newMode: ViewMode) => { - const isPageRefresh = newMode === dashboardStateManager.getViewMode(); - const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; - const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); - - if (!willLoseChanges) { - updateViewMode(newMode); - return; - } - - function revertChangesAndExitEditMode() { - dashboardStateManager.resetState(); - // This is only necessary for new dashboards, which will default to Edit mode. - updateViewMode(ViewMode.VIEW); - - // We need to do a hard reset of the timepicker. appState will not reload like - // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on - // reload will cause it not to sync. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - - // Angular's $location skips this update because of history updates from syncState which happen simultaneously - // when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it, - // the update is considered outdated and angular skips it - // so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues - dashboardStateManager.changeDashboardUrl( - dash.id ? createDashboardEditUrl(dash.id) : DashboardConstants.CREATE_NEW_DASHBOARD_URL - ); - } - - overlays - .openConfirm( - i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', { - defaultMessage: `Once you discard your changes, there's no getting them back.`, - }), - { - confirmButtonText: i18n.translate( - 'dashboard.changeViewModeConfirmModal.confirmButtonLabel', - { defaultMessage: 'Discard changes' } - ), - cancelButtonText: i18n.translate( - 'dashboard.changeViewModeConfirmModal.cancelButtonLabel', - { defaultMessage: 'Continue editing' } - ), - defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, - title: i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { - defaultMessage: 'Discard changes to dashboard?', - }), - } - ) - .then((isConfirmed) => { - if (isConfirmed) { - revertChangesAndExitEditMode(); - } - }); - - updateNavBar(); - }; - - /** - * Saves the dashboard. - * - * @param {object} [saveOptions={}] - * @property {boolean} [saveOptions.confirmOverwrite=false] - If true, attempts to create the source so it - * can confirm an overwrite if a document with the id already exists. - * @property {boolean} [saveOptions.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title - * @property {func} [saveOptions.onTitleDuplicate] - function called if duplicate title exists. - * When not provided, confirm modal will be displayed asking user to confirm or cancel save. - * @return {Promise} - * @resolved {String} - The id of the doc - */ - function save(saveOptions: SavedObjectSaveOpts): Promise { - return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions) - .then(function (id) { - if (id) { - notifications.toasts.addSuccess({ - title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { - defaultMessage: `Dashboard '{dashTitle}' was saved`, - values: { dashTitle: dash.title }, - }), - 'data-test-subj': 'saveDashboardSuccess', - }); - - if (dash.id !== $routeParams.id) { - // Angular's $location skips this update because of history updates from syncState which happen simultaneously - // when calling kbnUrl.change() angular schedules url update and when angular finally starts to process it, - // the update is considered outdated and angular skips it - // so have to use implementation of dashboardStateManager.changeDashboardUrl, which workarounds those issues - dashboardStateManager.changeDashboardUrl(createDashboardEditUrl(dash.id)); - } else { - chrome.docTitle.change(dash.lastSavedTitle); - updateViewMode(ViewMode.VIEW); - } - } - return { id }; - }) - .catch((error) => { - notifications.toasts.addDanger({ - title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { - defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, - values: { - dashTitle: dash.title, - errorMessage: error.message, - }, - }), - 'data-test-subj': 'saveDashboardFailure', - }); - return { error }; - }); - } - - $scope.showAddPanel = () => { - dashboardStateManager.setFullScreenMode(false); - /* - * Temp solution for triggering menu click. - * When de-angularizing this code, please call the underlaying action function - * directly and not via the top nav object. - **/ - navActions[TopNavIds.ADD_EXISTING](); - }; - $scope.enterEditMode = () => { - dashboardStateManager.setFullScreenMode(false); - /* - * Temp solution for triggering menu click. - * When de-angularizing this code, please call the underlaying action function - * directly and not via the top nav object. - **/ - navActions[TopNavIds.ENTER_EDIT_MODE](); - }; - const navActions: { - [key: string]: NavAction; - } = {}; - navActions[TopNavIds.FULL_SCREEN] = () => { - dashboardStateManager.setFullScreenMode(true); - updateNavBar(); - }; - navActions[TopNavIds.EXIT_EDIT_MODE] = () => onChangeViewMode(ViewMode.VIEW); - navActions[TopNavIds.ENTER_EDIT_MODE] = () => onChangeViewMode(ViewMode.EDIT); - navActions[TopNavIds.SAVE] = () => { - const currentTitle = dashboardStateManager.getTitle(); - const currentDescription = dashboardStateManager.getDescription(); - const currentTimeRestore = dashboardStateManager.getTimeRestore(); - - let currentTags: string[] = []; - if (savedObjectsTagging) { - const dashboard = dashboardStateManager.savedDashboard; - if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { - currentTags = dashboard.getTags(); - } - } - - const onSave = ({ - newTitle, - newDescription, - newCopyOnSave, - newTimeRestore, - isTitleDuplicateConfirmed, - onTitleDuplicate, - newTags, - }: SaveOptions) => { - dashboardStateManager.setTitle(newTitle); - dashboardStateManager.setDescription(newDescription); - dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; - dashboardStateManager.setTimeRestore(newTimeRestore); - if (savedObjectsTagging && newTags) { - dashboardStateManager.setTags(newTags); - } - - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return save(saveOptions).then((response: SaveResult) => { - // If the save wasn't successful, put the original values back. - if (!(response as { id: string }).id) { - dashboardStateManager.setTitle(currentTitle); - dashboardStateManager.setDescription(currentDescription); - dashboardStateManager.setTimeRestore(currentTimeRestore); - if (savedObjectsTagging) { - dashboardStateManager.setTags(currentTags); - } - } - return response; - }); - }; - - const dashboardSaveModal = ( - {}} - title={currentTitle} - description={currentDescription} - tags={currentTags} - savedObjectsTagging={savedObjectsTagging} - timeRestore={currentTimeRestore} - showCopyOnSave={dash.id ? true : false} - /> - ); - showSaveModal(dashboardSaveModal, i18nStart.Context); - }; - navActions[TopNavIds.CLONE] = () => { - const currentTitle = dashboardStateManager.getTitle(); - const onClone = ( - newTitle: string, - isTitleDuplicateConfirmed: boolean, - onTitleDuplicate: () => void - ) => { - dashboardStateManager.savedDashboard.copyOnSave = true; - dashboardStateManager.setTitle(newTitle); - const saveOptions = { - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - }; - return save(saveOptions).then((response: { id?: string } | { error: Error }) => { - // If the save wasn't successful, put the original title back. - if ((response as { error: Error }).error) { - dashboardStateManager.setTitle(currentTitle); - } - updateNavBar(); - return response; - }); - }; - - showCloneModal(onClone, currentTitle); - }; - - navActions[TopNavIds.ADD_EXISTING] = () => { - if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { - openAddPanelFlyout({ - embeddable: dashboardContainer, - getAllFactories: embeddable.getEmbeddableFactories, - getFactory: embeddable.getEmbeddableFactory, - notifications, - overlays, - SavedObjectFinder: getSavedObjectFinder(savedObjects, uiSettings), - }); - } - }; - - navActions[TopNavIds.VISUALIZE] = async () => { - const type = 'visualization'; - const factory = embeddable.getEmbeddableFactory(type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(type); - } - const explicitInput = await factory.getExplicitInput(); - if (dashboardContainer) { - await dashboardContainer.addNewEmbeddable(type, explicitInput); - } - }; - - navActions[TopNavIds.OPTIONS] = (anchorElement) => { - showOptionsPopover({ - anchorElement, - useMargins: dashboardStateManager.getUseMargins(), - onUseMarginsChange: (isChecked: boolean) => { - dashboardStateManager.setUseMargins(isChecked); - }, - hidePanelTitles: dashboardStateManager.getHidePanelTitles(), - onHidePanelTitlesChange: (isChecked: boolean) => { - dashboardStateManager.setHidePanelTitles(isChecked); - }, - }); - }; - - if (share) { - // the share button is only availabale if "share" plugin contract enabled - navActions[TopNavIds.SHARE] = (anchorElement) => { - const EmbedUrlParamExtension = ({ - setParamValue, - }: { - setParamValue: (paramUpdate: UrlParamValues) => void; - }): ReactElement => { - const [urlParamsSelectedMap, setUrlParamsSelectedMap] = useState({ - [UrlParams.SHOW_TOP_MENU]: false, - [UrlParams.SHOW_QUERY_INPUT]: false, - [UrlParams.SHOW_TIME_FILTER]: false, - [UrlParams.SHOW_FILTER_BAR]: true, - }); - - const checkboxes = [ - { - id: UrlParams.SHOW_TOP_MENU, - label: i18n.translate('dashboard.embedUrlParamExtension.topMenu', { - defaultMessage: 'Top menu', - }), - }, - { - id: UrlParams.SHOW_QUERY_INPUT, - label: i18n.translate('dashboard.embedUrlParamExtension.query', { - defaultMessage: 'Query', - }), - }, - { - id: UrlParams.SHOW_TIME_FILTER, - label: i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { - defaultMessage: 'Time filter', - }), - }, - { - id: UrlParams.SHOW_FILTER_BAR, - label: i18n.translate('dashboard.embedUrlParamExtension.filterBar', { - defaultMessage: 'Filter bar', - }), - }, - ]; - - const handleChange = (param: string): void => { - const urlParamsSelectedMapUpdate = { - ...urlParamsSelectedMap, - [param]: !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], - }; - setUrlParamsSelectedMap(urlParamsSelectedMapUpdate); - - const urlParamValues = { - [UrlParams.SHOW_TOP_MENU]: urlParamsSelectedMap[UrlParams.SHOW_TOP_MENU], - [UrlParams.SHOW_QUERY_INPUT]: urlParamsSelectedMap[UrlParams.SHOW_QUERY_INPUT], - [UrlParams.SHOW_TIME_FILTER]: urlParamsSelectedMap[UrlParams.SHOW_TIME_FILTER], - [UrlParams.HIDE_FILTER_BAR]: !urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR], - [param === UrlParams.SHOW_FILTER_BAR ? UrlParams.HIDE_FILTER_BAR : param]: - param === UrlParams.SHOW_FILTER_BAR - ? urlParamsSelectedMap[UrlParams.SHOW_FILTER_BAR] - : !urlParamsSelectedMap[param as keyof UrlParamsSelectedMap], - }; - setParamValue(urlParamValues); - }; - - return ( - - ); - }; - - share.toggleShareContextMenu({ - anchorElement, - allowEmbed: true, - allowShortUrl: - !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, - shareableUrl: setStateToKbnUrl( - '_a', - dashboardStateManager.getAppState(), - { useHash: false, storeInHashQuery: true }, - unhashUrl(window.location.href) - ), - objectId: dash.id, - objectType: 'dashboard', - sharingData: { - title: dash.title, - }, - isDirty: dashboardStateManager.getIsDirty(), - embedUrlParamExtensions: [ - { - paramName: 'embed', - component: EmbedUrlParamExtension, - }, - ], - }); - }; - } - - updateViewMode(dashboardStateManager.getViewMode()); - - const filterChanges = merge(filterManager.getUpdates$(), queryStringManager.getUpdates$()).pipe( - debounceTime(100) - ); - - // update root source when filters update - const updateSubscription = filterChanges.subscribe({ - next: () => { - $scope.model.filters = filterManager.getFilters(); - $scope.model.query = queryStringManager.getQuery(); - dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters); - }, - }); - - const visibleSubscription = chrome.getIsVisible$().subscribe((isVisible) => { - $scope.$evalAsync(() => { - $scope.isVisible = isVisible; - updateNavBar(); - }); - }); - - dashboardStateManager.registerChangeListener(() => { - // view mode could have changed, so trigger top nav update - $scope.topNavMenu = getTopNavConfig( - dashboardStateManager.getViewMode(), - navActions, - dashboardConfig.getHideWriteControls() - ); - updateNavBar(); - }); - - $scope.$watch('indexPatterns', () => { - updateNavBar(); - }); - - $scope.$on('$destroy', () => { - // we have to unmount nav bar manually to make sure all internal subscriptions are unsubscribed - unmountNavBar(); - - updateSubscription.unsubscribe(); - stopSyncingQueryServiceStateWithUrl(); - stopSyncingAppFilters(); - visibleSubscription.unsubscribe(); - $scope.timefilterSubscriptions$.unsubscribe(); - - dashboardStateManager.destroy(); - if (inputSubscription) { - inputSubscription.unsubscribe(); - } - if (outputSubscription) { - outputSubscription.unsubscribe(); - } - if (dashboardContainer) { - dashboardContainer.destroy(); - } - searchServiceSessionRefreshSubscribtion.unsubscribe(); - searchService.session.clear(); - }); - } -} diff --git a/src/plugins/dashboard/public/application/dashboard_app_functions.ts b/src/plugins/dashboard/public/application/dashboard_app_functions.ts new file mode 100644 index 00000000000000..0381fdb2e55b55 --- /dev/null +++ b/src/plugins/dashboard/public/application/dashboard_app_functions.ts @@ -0,0 +1,287 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; + +import _, { uniqBy } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { merge, Observable, pipe } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + filter, + map, + mapTo, + startWith, + switchMap, +} from 'rxjs/operators'; + +import { DashboardCapabilities } from './types'; +import { DashboardConstants } from '../dashboard_constants'; +import { DashboardStateManager } from './dashboard_state_manager'; +import { convertSavedDashboardPanelToPanelState } from '../../common/embeddable/embeddable_saved_object_converters'; +import { + DashboardPanelState, + DashboardContainer, + DashboardContainerInput, + SavedDashboardPanel, +} from '.'; + +import { getQueryParams } from '../services/kibana_utils'; +import { EmbeddablePackageState, isErrorEmbeddable } from '../services/embeddable'; +import { + esFilters, + FilterManager, + IndexPattern, + IndexPatternsContract, + QueryStart, +} from '../services/data'; + +export const getChangesFromAppStateForContainerState = ({ + dashboardContainer, + appStateDashboardInput, +}: { + dashboardContainer: DashboardContainer; + appStateDashboardInput: DashboardContainerInput; +}) => { + if (!dashboardContainer || isErrorEmbeddable(dashboardContainer)) { + return appStateDashboardInput; + } + const containerInput = dashboardContainer.getInput(); + const differences: Partial = {}; + + // Filters shouldn't be compared using regular isEqual + if ( + !esFilters.compareFilters( + containerInput.filters, + appStateDashboardInput.filters, + esFilters.COMPARE_ALL_OPTIONS + ) + ) { + differences.filters = appStateDashboardInput.filters; + } + + Object.keys( + _.omit(containerInput, [ + 'filters', + 'searchSessionId', + 'lastReloadRequestTime', + 'switchViewMode', + ]) + ).forEach((key) => { + const containerValue = (containerInput as { [key: string]: unknown })[key]; + const appStateValue = ((appStateDashboardInput as unknown) as { [key: string]: unknown })[key]; + if (!_.isEqual(containerValue, appStateValue)) { + (differences as { [key: string]: unknown })[key] = appStateValue; + } + }); + + // last reload request time can be undefined without causing a refresh + if ( + appStateDashboardInput.lastReloadRequestTime && + containerInput.lastReloadRequestTime !== appStateDashboardInput.lastReloadRequestTime + ) { + differences.lastReloadRequestTime = appStateDashboardInput.lastReloadRequestTime; + } + + // cloneDeep hack is needed, as there are multiple places, where container's input mutated, + // but values from appStateValue are deeply frozen, as they can't be mutated directly + return Object.values(differences).length === 0 ? undefined : _.cloneDeep(differences); +}; + +export const getDashboardContainerInput = ({ + query, + searchSessionId, + incomingEmbeddable, + isEmbeddedExternally, + lastReloadRequestTime, + dashboardStateManager, + dashboardCapabilities, +}: { + dashboardCapabilities: DashboardCapabilities; + dashboardStateManager: DashboardStateManager; + incomingEmbeddable?: EmbeddablePackageState; + lastReloadRequestTime?: number; + isEmbeddedExternally: boolean; + searchSessionId?: string; + query: QueryStart; +}): DashboardContainerInput => { + const embeddablesMap: { + [key: string]: DashboardPanelState; + } = {}; + dashboardStateManager.getPanels().forEach((panel: SavedDashboardPanel) => { + embeddablesMap[panel.panelIndex] = convertSavedDashboardPanelToPanelState(panel); + }); + + // If the incoming embeddable state's id already exists in the embeddables map, replace the input, retaining the existing gridData for that panel. + if (incomingEmbeddable?.embeddableId && embeddablesMap[incomingEmbeddable.embeddableId]) { + const originalPanelState = embeddablesMap[incomingEmbeddable.embeddableId]; + embeddablesMap[incomingEmbeddable.embeddableId] = { + gridData: originalPanelState.gridData, + type: incomingEmbeddable.type, + explicitInput: { + ...originalPanelState.explicitInput, + ...incomingEmbeddable.input, + id: incomingEmbeddable.embeddableId, + }, + }; + } + + return { + refreshConfig: query.timefilter.timefilter.getRefreshInterval(), + hidePanelTitles: dashboardStateManager.getHidePanelTitles(), + isFullScreenMode: dashboardStateManager.getFullScreenMode(), + expandedPanelId: dashboardStateManager.getExpandedPanelId(), + description: dashboardStateManager.getDescription(), + id: dashboardStateManager.savedDashboard.id || '', + useMargins: dashboardStateManager.getUseMargins(), + viewMode: dashboardStateManager.getViewMode(), + filters: query.filterManager.getFilters(), + query: dashboardStateManager.getQuery(), + title: dashboardStateManager.getTitle(), + panels: embeddablesMap, + lastReloadRequestTime, + dashboardCapabilities, + isEmbeddedExternally, + searchSessionId, + timeRange: { + ..._.cloneDeep(query.timefilter.timefilter.getTime()), + }, + }; +}; + +export const getInputSubscription = ({ + dashboardContainer, + dashboardStateManager, + filterManager, +}: { + dashboardContainer: DashboardContainer; + dashboardStateManager: DashboardStateManager; + filterManager: FilterManager; +}) => + dashboardContainer.getInput$().subscribe(() => { + // This has to be first because handleDashboardContainerChanges causes + // appState.save which will cause refreshDashboardContainer to be called. + + if ( + !esFilters.compareFilters( + dashboardContainer.getInput().filters, + filterManager.getFilters(), + esFilters.COMPARE_ALL_OPTIONS + ) + ) { + // Add filters modifies the object passed to it, hence the clone deep. + filterManager.addFilters(_.cloneDeep(dashboardContainer.getInput().filters)); + + dashboardStateManager.applyFilters( + dashboardStateManager.getQuery(), + dashboardContainer.getInput().filters + ); + } + + dashboardStateManager.handleDashboardContainerChanges(dashboardContainer); + }); + +export const getOutputSubscription = ({ + dashboardContainer, + indexPatterns, + onUpdateIndexPatterns, +}: { + dashboardContainer: DashboardContainer; + indexPatterns: IndexPatternsContract; + onUpdateIndexPatterns: (newIndexPatterns: IndexPattern[]) => void; +}) => { + const updateIndexPatternsOperator = pipe( + filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), + map((container: DashboardContainer): IndexPattern[] => { + let panelIndexPatterns: IndexPattern[] = []; + Object.values(container.getChildIds()).forEach((id) => { + const embeddableInstance = container.getChild(id); + if (isErrorEmbeddable(embeddableInstance)) return; + const embeddableIndexPatterns = (embeddableInstance.getOutput() as any).indexPatterns; + if (!embeddableIndexPatterns) return; + panelIndexPatterns.push(...embeddableIndexPatterns); + }); + panelIndexPatterns = uniqBy(panelIndexPatterns, 'id'); + return panelIndexPatterns; + }), + distinctUntilChanged((a, b) => + deepEqual( + a.map((ip) => ip.id), + b.map((ip) => ip.id) + ) + ), + // using switchMap for previous task cancellation + switchMap((panelIndexPatterns: IndexPattern[]) => { + return new Observable((observer) => { + if (panelIndexPatterns && panelIndexPatterns.length > 0) { + if (observer.closed) return; + onUpdateIndexPatterns(panelIndexPatterns); + observer.complete(); + } else { + indexPatterns.getDefault().then((defaultIndexPattern) => { + if (observer.closed) return; + onUpdateIndexPatterns([defaultIndexPattern as IndexPattern]); + observer.complete(); + }); + } + }); + }) + ); + + return merge( + // output of dashboard container itself + dashboardContainer.getOutput$(), + // plus output of dashboard container children, + // children may change, so make sure we subscribe/unsubscribe with switchMap + dashboardContainer.getOutput$().pipe( + map(() => dashboardContainer!.getChildIds()), + distinctUntilChanged(deepEqual), + switchMap((newChildIds: string[]) => + merge(...newChildIds.map((childId) => dashboardContainer!.getChild(childId).getOutput$())) + ) + ) + ) + .pipe( + mapTo(dashboardContainer), + startWith(dashboardContainer), // to trigger initial index pattern update + updateIndexPatternsOperator + ) + .subscribe(); +}; + +export const getFiltersSubscription = ({ + query, + dashboardStateManager, +}: { + query: QueryStart; + dashboardStateManager: DashboardStateManager; +}) => { + return merge(query.filterManager.getUpdates$(), query.queryString.getUpdates$()) + .pipe(debounceTime(100)) + .subscribe(() => { + dashboardStateManager.applyFilters( + query.queryString.getQuery(), + query.filterManager.getFilters() + ); + }); +}; + +export const getSearchSessionIdFromURL = (history: History): string | undefined => + getQueryParams(history.location)[DashboardConstants.SEARCH_SESSION_ID] as string | undefined; diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx b/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx deleted file mode 100644 index 4904d08e958d5c..00000000000000 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen_constants.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; - -/** READONLY VIEW CONSTANTS **/ -export const emptyDashboardTitle: string = i18n.translate('dashboard.emptyDashboardTitle', { - defaultMessage: 'This dashboard is empty.', -}); -export const emptyDashboardAdditionalPrivilege = i18n.translate( - 'dashboard.emptyDashboardAdditionalPrivilege', - { - defaultMessage: 'You need additional privileges to edit this dashboard.', - } -); -/** VIEW MODE CONSTANTS **/ -export const fillDashboardTitle: string = i18n.translate('dashboard.fillDashboardTitle', { - defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', -}); -export const howToStartWorkingOnNewDashboardDescription1: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardDescription1', - { - defaultMessage: 'Click', - } -); -export const howToStartWorkingOnNewDashboardDescription2: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardDescription2', - { - defaultMessage: 'in the menu bar above to start adding panels.', - } -); -export const howToStartWorkingOnNewDashboardEditLinkText: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardEditLinkText', - { - defaultMessage: 'Edit', - } -); -export const howToStartWorkingOnNewDashboardEditLinkAriaLabel: string = i18n.translate( - 'dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', - { - defaultMessage: 'Edit dashboard', - } -); -/** EDIT MODE CONSTANTS **/ -export const addExistingVisualizationLinkText: string = i18n.translate( - 'dashboard.addExistingVisualizationLinkText', - { - defaultMessage: 'Add an existing', - } -); -export const addExistingVisualizationLinkAriaLabel: string = i18n.translate( - 'dashboard.addVisualizationLinkAriaLabel', - { - defaultMessage: 'Add an existing visualization', - } -); -export const addNewVisualizationDescription: string = i18n.translate( - 'dashboard.addNewVisualizationText', - { - defaultMessage: 'or new object to this dashboard', - } -); -export const createNewVisualizationButton: string = i18n.translate( - 'dashboard.createNewVisualizationButton', - { - defaultMessage: 'Create new', - } -); -export const createNewVisualizationButtonAriaLabel: string = i18n.translate( - 'dashboard.createNewVisualizationButtonAriaLabel', - { - defaultMessage: 'Create new visualization button', - } -); diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx new file mode 100644 index 00000000000000..96737373724789 --- /dev/null +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -0,0 +1,226 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './index.scss'; +import React from 'react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { parse, ParsedQuery } from 'query-string'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Switch, Route, RouteComponentProps, HashRouter } from 'react-router-dom'; + +import { DashboardListing } from './listing'; +import { DashboardApp } from './dashboard_app'; +import { addHelpMenuToAppChrome } from './lib'; +import { createDashboardListingFilterUrl } from '../dashboard_constants'; +import { getDashboardPageTitle, dashboardReadonlyBadge } from '../dashboard_strings'; +import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; +import { DashboardAppServices, DashboardEmbedSettings, RedirectToProps } from './types'; +import { DashboardSetupDependencies, DashboardStart, DashboardStartDependencies } from '../plugin'; + +import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils'; +import { KibanaContextProvider } from '../services/kibana_react'; +import { + AppMountParameters, + CoreSetup, + PluginInitializerContext, + ScopedHistory, +} from '../services/core'; + +export const dashboardUrlParams = { + showTopMenu: 'show-top-menu', + showQueryInput: 'show-query-input', + showTimeFilter: 'show-time-filter', + hideFilterBar: 'hide-filter-bar', +}; + +export interface DashboardMountProps { + appUnMounted: () => void; + restorePreviousUrl: () => void; + scopedHistory: ScopedHistory; + element: AppMountParameters['element']; + initializerContext: PluginInitializerContext; + onAppLeave: AppMountParameters['onAppLeave']; + core: CoreSetup; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + usageCollection: DashboardSetupDependencies['usageCollection']; +} + +export async function mountApp({ + core, + element, + onAppLeave, + appUnMounted, + scopedHistory, + usageCollection, + initializerContext, + restorePreviousUrl, + setHeaderActionMenu, +}: DashboardMountProps) { + const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); + + const { + navigation, + savedObjects, + data: dataStart, + share: shareStart, + embeddable: embeddableStart, + kibanaLegacy: { dashboardConfig }, + savedObjectsTaggingOss, + } = pluginsStart; + + const dashboardServices: DashboardAppServices = { + navigation, + onAppLeave, + savedObjects, + usageCollection, + core: coreStart, + data: dataStart, + share: shareStart, + initializerContext, + restorePreviousUrl, + setHeaderActionMenu, + chrome: coreStart.chrome, + embeddable: embeddableStart, + uiSettings: coreStart.uiSettings, + scopedHistory: () => scopedHistory, + indexPatterns: dataStart.indexPatterns, + savedQueryService: dataStart.query.savedQueries, + savedObjectsClient: coreStart.savedObjects.client, + savedDashboards: dashboardStart.getSavedDashboardLoader(), + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + dashboardCapabilities: { + hideWriteControls: dashboardConfig.getHideWriteControls(), + show: Boolean(coreStart.application.capabilities.dashboard.show), + saveQuery: Boolean(coreStart.application.capabilities.dashboard.saveQuery), + createNew: Boolean(coreStart.application.capabilities.dashboard.createNew), + mapsCapabilities: { save: Boolean(coreStart.application.capabilities.maps?.save) }, + createShortUrl: Boolean(coreStart.application.capabilities.dashboard.createShortUrl), + visualizeCapabilities: { save: Boolean(coreStart.application.capabilities.visualize?.save) }, + }, + }; + + const getUrlStateStorage = (history: RouteComponentProps['history']) => + createKbnUrlStateStorage({ + history, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(core.notifications.toasts), + }); + + const redirect = (routeProps: RouteComponentProps, redirectTo: RedirectToProps) => { + const historyFunction = redirectTo.useReplace + ? routeProps.history.replace + : routeProps.history.push; + let destination; + if (redirectTo.destination === 'dashboard') { + destination = redirectTo.id + ? createDashboardEditUrl(redirectTo.id) + : DashboardConstants.CREATE_NEW_DASHBOARD_URL; + } else { + destination = createDashboardListingFilterUrl(redirectTo.filter); + } + historyFunction(destination); + }; + + const getDashboardEmbedSettings = ( + routeParams: ParsedQuery + ): DashboardEmbedSettings | undefined => { + if (!routeParams.embed) { + return undefined; + } + return { + forceShowTopNavMenu: Boolean(routeParams[dashboardUrlParams.showTopMenu]), + forceShowQueryInput: Boolean(routeParams[dashboardUrlParams.showQueryInput]), + forceShowDatePicker: Boolean(routeParams[dashboardUrlParams.showTimeFilter]), + forceHideFilterBar: Boolean(routeParams[dashboardUrlParams.hideFilterBar]), + }; + }; + + const renderDashboard = (routeProps: RouteComponentProps<{ id?: string }>) => { + const routeParams = parse(routeProps.history.location.search); + const embedSettings = getDashboardEmbedSettings(routeParams); + return ( + redirect(routeProps, props)} + /> + ); + }; + + const renderListingPage = (routeProps: RouteComponentProps) => { + coreStart.chrome.docTitle.change(getDashboardPageTitle()); + const routeParams = parse(routeProps.history.location.search); + const title = (routeParams.title as string) || undefined; + const filter = (routeParams.filter as string) || undefined; + + return ( + redirect(routeProps, props)} + /> + ); + }; + + // make sure the index pattern list is up to date + await dataStart.indexPatterns.clearCache(); + + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + const unlistenParentHistory = scopedHistory.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + const app = ( + + + + + + + + + + + ); + + addHelpMenuToAppChrome(dashboardServices.chrome, coreStart.docLinks); + if (dashboardServices.dashboardCapabilities.hideWriteControls) { + coreStart.chrome.setBadge({ + text: dashboardReadonlyBadge.getText(), + tooltip: dashboardReadonlyBadge.getTooltip(), + iconType: 'glasses', + }); + } + render(app, element); + return () => { + dataStart.search.session.clear(); + unlistenParentHistory(); + unmountComponentAtNode(element); + appUnMounted(); + }; +} diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index 14c12115fd8f5c..b07ea762f35e0c 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -18,14 +18,16 @@ */ import { createBrowserHistory } from 'history'; -import { DashboardStateManager } from './dashboard_state_manager'; import { getSavedDashboardMock } from './test_helpers'; -import { InputTimeRange, TimefilterContract, TimeRange } from 'src/plugins/data/public'; -import { ViewMode } from 'src/plugins/embeddable/public'; -import { createKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; import { DashboardContainer, DashboardContainerInput } from '.'; -import { DashboardContainerOptions } from './embeddable/dashboard_container'; -import { embeddablePluginMock } from '../../../embeddable/public/mocks'; +import { DashboardStateManager } from './dashboard_state_manager'; +import { DashboardContainerServices } from './embeddable/dashboard_container'; + +import { ViewMode } from '../services/embeddable'; +import { createKbnUrlStateStorage } from '../services/kibana_utils'; +import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data'; + +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; describe('DashboardState', function () { let dashboardState: DashboardStateManager; @@ -71,7 +73,7 @@ describe('DashboardState', function () { panels: {} as DashboardContainerInput['panels'], }; const input = { ...defaultInput, ...(initialInput ?? {}) }; - return new DashboardContainer(input, { embeddable: doStart() } as DashboardContainerOptions); + return new DashboardContainer(input, { embeddable: doStart() } as DashboardContainerServices); } describe('syncTimefilterWithDashboard', function () { diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 6ef109ff60e421..daa0bbdfc9f8a9 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -17,20 +17,18 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import _ from 'lodash'; -import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; +import { i18n } from '@kbn/i18n'; import { History } from 'history'; +import { Observable, Subscription } from 'rxjs'; -import { Filter, Query, TimefilterContract as Timefilter } from 'src/plugins/data/public'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; +import { FilterUtils } from './lib/filter_utils'; +import { DashboardContainer } from './embeddable'; +import { DashboardSavedObject } from '../saved_dashboards'; import { migrateLegacyQuery } from './lib/migrate_legacy_query'; - -import { ViewMode } from '../embeddable_plugin'; import { getAppStateDefaults, migrateAppState, getDashboardIdFromUrl } from './lib'; -import { FilterUtils } from './lib/filter_utils'; +import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters'; import { DashboardAppState, DashboardAppStateDefaults, @@ -38,16 +36,18 @@ import { DashboardAppStateTransitions, SavedDashboardPanel, } from '../types'; + +import { ViewMode } from '../services/embeddable'; +import { UsageCollectionSetup } from '../services/usage_collection'; +import { Filter, Query, TimefilterContract as Timefilter } from '../services/data'; +import type { SavedObjectTagDecoratorTypeGuard } from '../services/saved_objects_tagging_oss'; import { createStateContainer, IKbnUrlStateStorage, ISyncStateRef, ReduxLikeStateContainer, syncState, -} from '../../../kibana_utils/public'; -import { SavedObjectDashboard } from '../saved_dashboards'; -import { DashboardContainer } from './embeddable'; -import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/embeddable_saved_object_converters'; +} from '../services/kibana_utils'; /** * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the @@ -56,7 +56,7 @@ import { convertPanelStateToSavedDashboardPanel } from '../../common/embeddable/ * versa. They should be as decoupled as possible so updating the store won't affect bwc of urls. */ export class DashboardStateManager { - public savedDashboard: SavedObjectDashboard; + public savedDashboard: DashboardSavedObject; public lastSavedDashboardFilters: { timeTo?: string | Moment; timeFrom?: string | Moment; @@ -105,7 +105,7 @@ export class DashboardStateManager { usageCollection, hasTaggingCapabilities, }: { - savedDashboard: SavedObjectDashboard; + savedDashboard: DashboardSavedObject; hideWriteControls: boolean; kibanaVersion: string; kbnUrlStateStorage: IKbnUrlStateStorage; diff --git a/src/plugins/dashboard/public/application/dashboard_strings.ts b/src/plugins/dashboard/public/application/dashboard_strings.ts deleted file mode 100644 index 9109012adcfa6e..00000000000000 --- a/src/plugins/dashboard/public/application/dashboard_strings.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import { ViewMode } from '../embeddable_plugin'; - -/** - * @param title {string} the current title of the dashboard - * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. - * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the - * end of the title. - * @returns {string} A title to display to the user based on the above parameters. - */ -export function getDashboardTitle( - title: string, - viewMode: ViewMode, - isDirty: boolean, - isNew: boolean -): string { - const isEditMode = viewMode === ViewMode.EDIT; - let displayTitle: string; - const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', { - defaultMessage: 'New Dashboard', - }); - const dashboardTitle = isNew ? newDashboardTitle : title; - - if (isEditMode && isDirty) { - displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { - defaultMessage: 'Editing {title} (unsaved)', - values: { title: dashboardTitle }, - }); - } else if (isEditMode) { - displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { - defaultMessage: 'Editing {title}', - values: { title: dashboardTitle }, - }); - } else { - displayTitle = dashboardTitle; - } - - return displayTitle; -} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 9c337ef1259a95..2d892ac49e9d2e 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -17,21 +17,40 @@ * under the License. */ -import { nextTick } from '@kbn/test/jest'; -import { isErrorEmbeddable, ViewMode } from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerOptions } from './dashboard_container'; +import React from 'react'; +import { mount } from 'enzyme'; + +import { findTestSubject, nextTick } from '@kbn/test/jest'; +import { DashboardContainer, DashboardContainerServices } from './dashboard_container'; import { getSampleDashboardInput, getSampleDashboardPanel } from '../test_helpers'; +import { I18nProvider } from '@kbn/i18n/react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + +import { KibanaContextProvider } from '../../services/kibana_react'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddablePanel, + isErrorEmbeddable, + ViewMode, +} from '../../services/embeddable'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, ContactCardEmbeddableInput, ContactCardEmbeddable, - ContactCardEmbeddableOutput, EMPTY_EMBEDDABLE, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; + ContactCardEmbeddableOutput, + createEditModeAction, +} from '../../services/embeddable_test_samples'; +import { + applicationServiceMock, + coreMock, + uiSettingsServiceMock, +} from '../../../../../core/public/mocks'; +import { inspectorPluginMock } from '../../../../inspector/public/mocks'; +import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; -const options: DashboardContainerOptions = { +const options: DashboardContainerServices = { application: {} as any, embeddable: {} as any, notifications: {} as any, @@ -40,6 +59,8 @@ const options: DashboardContainerOptions = { SavedObjectFinder: () => null, ExitFullScreenButton: () => null, uiActions: {} as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; beforeEach(() => { @@ -49,6 +70,7 @@ beforeEach(() => { new ContactCardEmbeddableFactory((() => null) as any, {} as any) ); options.embeddable = doStart(); + options.application = applicationServiceMock.createStartContract(); }); test('DashboardContainer initializes embeddables', async (done) => { @@ -199,3 +221,71 @@ test('searchSessionId propagates to children', async () => { expect(embeddable.getInput().searchSessionId).toBe(searchSessionId2); }); + +test('DashboardContainer in edit mode shows edit mode actions', async () => { + const inspector = inspectorPluginMock.createStartContract(); + const uiActionsSetup = uiActionsPluginMock.createSetupContract(); + + const editModeAction = createEditModeAction(); + uiActionsSetup.registerAction(editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); + + const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); + const container = new DashboardContainer(initialInput, options); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Bob', + }); + + const component = mount( + + + Promise.resolve([])} + getAllEmbeddableFactories={(() => []) as any} + getEmbeddableFactory={(() => null) as any} + notifications={{} as any} + application={options.application} + overlays={{} as any} + inspector={inspector} + SavedObjectFinder={() => null} + /> + + + ); + + const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); + + expect(button.length).toBe(1); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + + expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); + + const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); + + expect(editAction.length).toBe(0); + + container.updateInput({ viewMode: ViewMode.EDIT }); + await nextTick(); + component.update(); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + await nextTick(); + component.update(); + expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0); + findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); + await nextTick(); + component.update(); + expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1); + + await nextTick(); + component.update(); + + // TODO: Address this. + // const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); + // expect(action.length).toBe(1); +}); diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index e80d387fa3066d..a4a79a5d183ae6 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -20,21 +20,24 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; -import { RefreshInterval, TimeRange, Query, Filter } from 'src/plugins/data/public'; -import { CoreStart } from 'src/core/public'; -import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import uuid from 'uuid'; -import { UiActionsStart } from '../../ui_actions_plugin'; +import { CoreStart, IUiSettingsClient } from 'src/core/public'; +import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; + +import { UiActionsStart } from '../../services/ui_actions'; +import { RefreshInterval, TimeRange, Query, Filter } from '../../services/data'; import { + ViewMode, Container, + PanelState, + IEmbeddable, ContainerInput, EmbeddableInput, - ViewMode, - EmbeddableFactory, - IEmbeddable, EmbeddableStart, - PanelState, -} from '../../embeddable_plugin'; + EmbeddableOutput, + EmbeddableFactory, + EmbeddableStateTransfer, +} from '../../services/embeddable'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; import { createPanelState } from './panel'; import { DashboardPanelState } from './types'; @@ -43,27 +46,39 @@ import { KibanaContextProvider, KibanaReactContext, KibanaReactContextValue, -} from '../../../../kibana_react/public'; +} from '../../services/kibana_react'; import { PLACEHOLDER_EMBEDDABLE } from './placeholder'; import { PanelPlacementMethod, IPanelPlacementArgs } from './panel/dashboard_panel_placement'; -import { EmbeddableStateTransfer, EmbeddableOutput } from '../../../../embeddable/public'; +import { DashboardCapabilities } from '../types'; export interface DashboardContainerInput extends ContainerInput { - viewMode: ViewMode; - filters: Filter[]; - query: Query; - timeRange: TimeRange; + dashboardCapabilities?: DashboardCapabilities; refreshConfig?: RefreshInterval; + isEmbeddedExternally?: boolean; + isFullScreenMode: boolean; expandedPanelId?: string; + timeRange: TimeRange; + description?: string; useMargins: boolean; + viewMode: ViewMode; + filters: Filter[]; title: string; - description?: string; - isEmbeddedExternally?: boolean; - isFullScreenMode: boolean; + query: Query; panels: { [panelId: string]: DashboardPanelState; }; - isEmptyState?: boolean; +} +export interface DashboardContainerServices { + ExitFullScreenButton: React.ComponentType; + SavedObjectFinder: React.ComponentType; + notifications: CoreStart['notifications']; + application: CoreStart['application']; + inspector: InspectorStartContract; + overlays: CoreStart['overlays']; + uiSettings: IUiSettingsClient; + embeddable: EmbeddableStart; + uiActions: UiActionsStart; + http: CoreStart['http']; } interface IndexSignature { @@ -81,42 +96,45 @@ export interface InheritedChildInput extends IndexSignature { searchSessionId?: string; } -export interface DashboardContainerOptions { - application: CoreStart['application']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - embeddable: EmbeddableStart; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; - ExitFullScreenButton: React.ComponentType; - uiActions: UiActionsStart; -} +export type DashboardReactContextValue = KibanaReactContextValue; +export type DashboardReactContext = KibanaReactContext; -export type DashboardReactContextValue = KibanaReactContextValue; -export type DashboardReactContext = KibanaReactContext; +const defaultCapabilities = { + show: false, + createNew: false, + saveQuery: false, + createShortUrl: false, + hideWriteControls: true, + mapsCapabilities: { save: false }, + visualizeCapabilities: { save: false }, +}; export class DashboardContainer extends Container { public readonly type = DASHBOARD_CONTAINER_TYPE; - public renderEmpty?: undefined | (() => React.ReactNode); - private embeddablePanel: EmbeddableStart['EmbeddablePanel']; + public switchViewMode?: (newViewMode: ViewMode) => void; + + public getPanelCount = () => { + return Object.keys(this.getInput().panels).length; + }; constructor( initialInput: DashboardContainerInput, - private readonly options: DashboardContainerOptions, + private readonly services: DashboardContainerServices, stateTransfer?: EmbeddableStateTransfer, parent?: Container ) { super( { + dashboardCapabilities: defaultCapabilities, ...initialInput, }, { embeddableLoaded: {} }, - options.embeddable.getEmbeddableFactory, + services.embeddable.getEmbeddableFactory, parent ); - this.embeddablePanel = options.embeddable.getEmbeddablePanel(stateTransfer); + this.embeddablePanel = services.embeddable.getEmbeddablePanel(stateTransfer); } protected createNewPanelState< @@ -239,11 +257,11 @@ export class DashboardContainer extends Container - + , diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx index 77b836ee54f5e0..ebae3ff2fc7cc2 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_by_value_renderer.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { DashboardContainerInput } from './dashboard_container'; import { DashboardContainerFactory } from './dashboard_container_factory'; -import { EmbeddableRenderer } from '../../../../embeddable/public'; +import { EmbeddableRenderer } from '../../services/embeddable'; interface Props { input: DashboardContainerInput; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 4107a00ba80cef..98b4947066c005 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -18,31 +18,21 @@ */ import { i18n } from '@kbn/i18n'; -import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { CoreStart, ScopedHistory } from 'src/core/public'; -import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; -import { EmbeddableFactory, EmbeddableStart } from '../../../../embeddable/public'; +import { ScopedHistory } from 'src/core/public'; import { + Container, + ErrorEmbeddable, ContainerOutput, + EmbeddableFactory, EmbeddableFactoryDefinition, - ErrorEmbeddable, - Container, -} from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerInput } from './dashboard_container'; +} from '../../services/embeddable'; +import { + DashboardContainer, + DashboardContainerInput, + DashboardContainerServices, +} from './dashboard_container'; import { DASHBOARD_CONTAINER_TYPE } from './dashboard_constants'; -interface StartServices { - capabilities: CoreStart['application']['capabilities']; - application: CoreStart['application']; - overlays: CoreStart['overlays']; - notifications: CoreStart['notifications']; - embeddable: EmbeddableStart; - inspector: InspectorStartContract; - SavedObjectFinder: React.ComponentType; - ExitFullScreenButton: React.ComponentType; - uiActions: UiActionsStart; -} - export type DashboardContainerFactory = EmbeddableFactory< DashboardContainerInput, ContainerOutput, @@ -55,13 +45,13 @@ export class DashboardContainerFactoryDefinition public readonly type = DASHBOARD_CONTAINER_TYPE; constructor( - private readonly getStartServices: () => Promise, + private readonly getStartServices: () => Promise, private getHistory: () => ScopedHistory ) {} public isEditable = async () => { - const { capabilities } = await this.getStartServices(); - return !!capabilities.createNew && !!capabilities.showWriteControls; + // Currently unused for dashboards + return false; }; public readonly getDisplayName = () => { diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap similarity index 99% rename from src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap rename to src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 68d8a6a42eb5d0..1f86a8eb7f28be 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -620,7 +620,7 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` >
{ const setupMock = coreMock.createSetup(); diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx similarity index 82% rename from src/plugins/dashboard/public/application/dashboard_empty_screen.tsx rename to src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx index 955d5244ce1904..6529cae6f50cba 100644 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.tsx @@ -29,7 +29,7 @@ import { EuiButton, } from '@elastic/eui'; import { IUiSettingsClient, HttpStart } from 'kibana/public'; -import * as constants from './dashboard_empty_screen_constants'; +import { emptyScreenStrings } from '../../../dashboard_strings'; export interface DashboardEmptyScreenProps { showLinkToVisualize: boolean; @@ -52,6 +52,7 @@ export function DashboardEmptyScreen({ const emptyStateGraphicURL = IS_DARK_THEME ? '/plugins/home/assets/welcome_graphic_dark_2x.png' : '/plugins/home/assets/welcome_graphic_light_2x.png'; + const linkToVisualizeParagraph = (

- {constants.createNewVisualizationButton} + {emptyScreenStrings.getCreateNewVisualizationButton()}

); @@ -88,16 +89,16 @@ export function DashboardEmptyScreen({ ); }; const enterEditModeParagraph = paragraph( - constants.howToStartWorkingOnNewDashboardDescription1, - constants.howToStartWorkingOnNewDashboardDescription2, - constants.howToStartWorkingOnNewDashboardEditLinkText, - constants.howToStartWorkingOnNewDashboardEditLinkAriaLabel + emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription1(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardDescription2(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkText(), + emptyScreenStrings.getHowToStartWorkingOnNewDashboardEditLinkAriaLabel() ); const enterViewModeParagraph = paragraph( null, - constants.addNewVisualizationDescription, - constants.addExistingVisualizationLinkText, - constants.addExistingVisualizationLinkAriaLabel + emptyScreenStrings.getAddNewVisualizationDescription(), + emptyScreenStrings.getAddExistingVisualizationLinkText(), + emptyScreenStrings.getAddExistingVisualizationLinkAriaLabel() ); const page = (mainText: string, showAdditionalParagraph?: boolean, additionalText?: string) => { return ( @@ -130,13 +131,13 @@ export function DashboardEmptyScreen({ ); }; const readonlyMode = page( - constants.emptyDashboardTitle, + emptyScreenStrings.getEmptyDashboardTitle(), false, - constants.emptyDashboardAdditionalPrivilege + emptyScreenStrings.getEmptyDashboardAdditionalPrivilege() ); - const viewMode = page(constants.fillDashboardTitle, true); + const viewMode = page(emptyScreenStrings.getFillDashboardTitle(), true); const editMode = ( -
+
{enterViewModeParagraph} {linkToVisualizeParagraph} diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx index 5c4b976b152256..fb29ef7b3c036d 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.test.tsx @@ -24,14 +24,15 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test/jest'; import { skip } from 'rxjs/operators'; import { DashboardGrid, DashboardGridProps } from './dashboard_grid'; -import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container'; +import { DashboardContainer, DashboardContainerServices } from '../dashboard_container'; import { getSampleDashboardInput } from '../../test_helpers'; +import { KibanaContextProvider } from '../../../services/kibana_react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; import { CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, -} from '../../../embeddable_plugin_test_samples'; -import { KibanaContextProvider } from '../../../../../kibana_react/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +} from '../../../services/embeddable_test_samples'; +import { coreMock, uiSettingsServiceMock } from '../../../../../../core/public/mocks'; let dashboardContainer: DashboardContainer | undefined; @@ -58,7 +59,7 @@ function prepare(props?: Partial) { }, }, }); - const options: DashboardContainerOptions = { + const options: DashboardContainerServices = { application: {} as any, embeddable: { getTriggerCompatibleActions: (() => []) as any, @@ -76,6 +77,8 @@ function prepare(props?: Partial) { uiActions: { getTriggerCompatibleActions: (() => []) as any, } as any, + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, }; dashboardContainer = new DashboardContainer(initialInput, options); const defaultTestProps: DashboardGridProps = { diff --git a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx index 03c92d91a80ccb..c2e8661e2ab122 100644 --- a/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx +++ b/src/plugins/dashboard/public/application/embeddable/grid/dashboard_grid.tsx @@ -30,10 +30,10 @@ import React from 'react'; import { Subscription } from 'rxjs'; import ReactGridLayout, { Layout } from 'react-grid-layout'; import { GridData } from '../../../../common'; -import { ViewMode, EmbeddableChildPanel, EmbeddableStart } from '../../../embeddable_plugin'; +import { ViewMode, EmbeddableChildPanel, EmbeddableStart } from '../../../services/embeddable'; import { DASHBOARD_GRID_COLUMN_COUNT, DASHBOARD_GRID_HEIGHT } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; -import { withKibana } from '../../../../../kibana_react/public'; +import { withKibana } from '../../../services/kibana_react'; import { DashboardContainerInput } from '../dashboard_container'; import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container'; diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts index 7c11ac8a5031bf..ef7de640328ab1 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.test.ts @@ -17,11 +17,11 @@ * under the License. */ +import { EmbeddableInput } from '../../../services/embeddable'; +import { CONTACT_CARD_EMBEDDABLE } from '../../../../../embeddable/public/lib/test_samples'; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; import { createPanelState } from './create_panel_state'; -import { EmbeddableInput } from '../../../embeddable_plugin'; -import { CONTACT_CARD_EMBEDDABLE } from '../../../embeddable_plugin_test_samples'; interface TestInput extends EmbeddableInput { test: string; diff --git a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts index a6928c0608bd28..bcae9abce4bf61 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/create_panel_state.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PanelState, EmbeddableInput } from '../../../embeddable_plugin'; +import { PanelState, EmbeddableInput } from '../../../services/embeddable'; import { DEFAULT_PANEL_HEIGHT, DEFAULT_PANEL_WIDTH } from '../dashboard_constants'; import { DashboardPanelState } from '../types'; import { diff --git a/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts b/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts index 5ecd57d670ae83..392172debdc3aa 100644 --- a/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts +++ b/src/plugins/dashboard/public/application/embeddable/panel/dashboard_panel_placement.ts @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import { PanelNotFoundError } from '../../../embeddable_plugin'; +import { PanelNotFoundError } from '../../../services/embeddable'; import { GridData } from '../../../../common'; import { DashboardPanelState, DASHBOARD_GRID_COLUMN_COUNT } from '..'; diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx index 1a5c3386bdeda0..c1be365d79a138 100644 --- a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx +++ b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable.tsx @@ -21,7 +21,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { EuiLoadingChart } from '@elastic/eui'; import classNames from 'classnames'; -import { Embeddable, EmbeddableInput, IContainer } from '../../../embeddable_plugin'; +import { Embeddable, EmbeddableInput, IContainer } from '../../../services/embeddable'; export const PLACEHOLDER_EMBEDDABLE = 'placeholder'; diff --git a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts index b3ce2f1e57d5fc..ece0e4e49c81ba 100644 --- a/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts +++ b/src/plugins/dashboard/public/application/embeddable/placeholder/placeholder_embeddable_factory.ts @@ -23,7 +23,7 @@ import { EmbeddableFactoryDefinition, EmbeddableInput, IContainer, -} from '../../../embeddable_plugin'; +} from '../../../services/embeddable'; import { PlaceholderEmbeddable, PLACEHOLDER_EMBEDDABLE } from './placeholder_embeddable'; export class PlaceholderEmbeddableFactory implements EmbeddableFactoryDefinition { diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 94d0f8890c494d..e5a1852fa61a55 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -24,15 +24,19 @@ import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { nextTick } from '@kbn/test/jest'; import { DashboardViewport, DashboardViewportProps } from './dashboard_viewport'; -import { DashboardContainer, DashboardContainerOptions } from '../dashboard_container'; +import { DashboardContainer, DashboardContainerServices } from '../dashboard_container'; import { getSampleDashboardInput } from '../../test_helpers'; +import { KibanaContextProvider } from '../../../services/kibana_react'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { + applicationServiceMock, + coreMock, + uiSettingsServiceMock, +} from '../../../../../../core/public/mocks'; import { - CONTACT_CARD_EMBEDDABLE, ContactCardEmbeddableFactory, -} from '../../../embeddable_plugin_test_samples'; -import { KibanaContextProvider } from '../../../../../kibana_react/public'; -import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; -import { applicationServiceMock } from '../../../../../../core/public/mocks'; + CONTACT_CARD_EMBEDDABLE, +} from '../../../../../embeddable/public/lib/test_samples'; let dashboardContainer: DashboardContainer | undefined; @@ -40,7 +44,7 @@ const ExitFullScreenButton = () =>
function getProps( props?: Partial -): { props: DashboardViewportProps; options: DashboardContainerOptions } { +): { props: DashboardViewportProps; options: DashboardContainerServices } { const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, @@ -48,8 +52,10 @@ function getProps( ); const start = doStart(); - const options: DashboardContainerOptions = { + const options: DashboardContainerServices = { application: applicationServiceMock.createStartContract(), + uiSettings: uiSettingsServiceMock.createStartContract(), + http: coreMock.createStart().http, embeddable: { getTriggerCompatibleActions: (() => []) as any, getEmbeddablePanel: jest.fn(), @@ -125,9 +131,8 @@ test('renders DashboardViewport with no visualizations', () => { }); test('renders DashboardEmptyScreen', () => { - const renderEmptyScreen = jest.fn(); - const { props, options } = getProps({ renderEmpty: renderEmptyScreen }); - props.container.updateInput({ isEmptyState: true }); + const { props, options } = getProps(); + props.container.updateInput({ panels: {} }); const component = mount( @@ -137,7 +142,6 @@ test('renders DashboardEmptyScreen', () => { ); const dashboardEmptyScreenDiv = component.find('.dshDashboardEmptyScreen'); expect(dashboardEmptyScreenDiv.length).toBe(1); - expect(renderEmptyScreen).toHaveBeenCalled(); component.unmount(); }); @@ -169,10 +173,8 @@ test('renders exit full screen button when in full screen mode', async () => { }); test('renders exit full screen button when in full screen mode and empty screen', async () => { - const renderEmptyScreen = jest.fn(); - renderEmptyScreen.mockReturnValue(React.createElement('div')); - const { props, options } = getProps({ renderEmpty: renderEmptyScreen }); - props.container.updateInput({ isEmptyState: true, isFullScreenMode: true }); + const { props, options } = getProps(); + props.container.updateInput({ panels: {}, isFullScreenMode: true }); const component = mount( @@ -180,7 +182,7 @@ test('renders exit full screen button when in full screen mode and empty screen' ); - expect((component.find('.dshDashboardEmptyScreen').childAt(0).type() as any).name).toBe( + expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).toBe( 'ExitFullScreenButton' ); @@ -188,7 +190,7 @@ test('renders exit full screen button when in full screen mode and empty screen' component.update(); await nextTick(); - expect((component.find('.dshDashboardEmptyScreen').childAt(0).type() as any).name).not.toBe( + expect((component.find('.dshDashboardViewport').childAt(0).type() as any).name).not.toBe( 'ExitFullScreenButton' ); diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx index 15a486f99a37f5..558867ba500919 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.tsx @@ -19,15 +19,23 @@ import React from 'react'; import { Subscription } from 'rxjs'; -import { PanelState, EmbeddableStart } from '../../../embeddable_plugin'; +import { + PanelState, + EmbeddableStart, + ViewMode, + isErrorEmbeddable, + openAddPanelFlyout, + EmbeddableFactoryNotFoundError, +} from '../../../services/embeddable'; import { DashboardContainer, DashboardReactContextValue } from '../dashboard_container'; import { DashboardGrid } from '../grid'; -import { context } from '../../../../../kibana_react/public'; +import { context } from '../../../services/kibana_react'; +import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen'; export interface DashboardViewportProps { - container: DashboardContainer; PanelComponent: EmbeddableStart['EmbeddablePanel']; - renderEmpty?: () => React.ReactNode; + switchViewMode?: (newViewMode: ViewMode) => void; + container: DashboardContainer; } interface State { @@ -37,7 +45,6 @@ interface State { description?: string; panels: { [key: string]: PanelState }; isEmbeddedExternally?: boolean; - isEmptyState?: boolean; } export class DashboardViewport extends React.Component { @@ -54,7 +61,6 @@ export class DashboardViewport extends React.Component - {isFullScreenMode && ( - - )} - {renderEmpty && renderEmpty()} -
- ); - } + private createNewEmbeddable = async () => { + const type = 'visualization'; + const factory = this.context.services.embeddable.getEmbeddableFactory(type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + const explicitInput = await factory.getExplicitInput(); + await this.props.container.addNewEmbeddable(type, explicitInput); + }; + + private addFromLibrary = () => { + if (!isErrorEmbeddable(this.props.container)) { + openAddPanelFlyout({ + embeddable: this.props.container, + getAllFactories: this.context.services.embeddable.getEmbeddableFactories, + getFactory: this.context.services.embeddable.getEmbeddableFactory, + notifications: this.context.services.notifications, + overlays: this.context.services.overlays, + SavedObjectFinder: this.context.services.SavedObjectFinder, + }); + } + }; - private renderContainerScreen() { + public render() { const { container, PanelComponent } = this.props; + const isEditMode = container.getInput().viewMode !== ViewMode.VIEW; const { isEmbeddedExternally, isFullScreenMode, @@ -130,30 +141,41 @@ export class DashboardViewport extends React.Component - {isFullScreenMode && ( - - )} - -
- ); - } - - public render() { return ( - {this.state.isEmptyState ? this.renderEmptyScreen() : null} - {this.renderContainerScreen()} +
+ {isFullScreenMode && ( + + )} + {this.props.container.getPanelCount() === 0 && ( +
+ this.props.switchViewMode?.(ViewMode.EDIT) + } + showLinkToVisualize={isEditMode} + onVisualizeClick={this.createNewEmbeddable} + uiSettings={this.context.services.uiSettings} + http={this.context.services.http} + /> +
+ )} + +
); } diff --git a/src/plugins/dashboard/public/application/hooks/index.ts b/src/plugins/dashboard/public/application/hooks/index.ts new file mode 100644 index 00000000000000..33d771e8b11d62 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { useSavedDashboard } from './use_saved_dashboard'; +export { useDashboardContainer } from './use_dashboard_container'; +export { useDashboardBreadcrumbs } from './use_dashboard_breadcrumbs'; +export { useDashboardStateManager } from './use_dashboard_state_manager'; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts new file mode 100644 index 00000000000000..2a9e3e0a5a9b20 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect } from 'react'; +import _ from 'lodash'; +import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; + +import { useKibana } from '../../services/kibana_react'; + +import { DashboardStateManager } from '../dashboard_state_manager'; +import { + getDashboardBreadcrumb, + getDashboardTitle, + leaveConfirmStrings, +} from '../../dashboard_strings'; +import { DashboardAppServices, DashboardRedirect } from '../types'; + +export const useDashboardBreadcrumbs = ( + dashboardStateManager: DashboardStateManager | null, + redirectTo: DashboardRedirect +) => { + const { data, core, chrome } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { setBreadcrumbs } = chrome; + const { timefilter } = data.query.timefilter; + const { openConfirm } = core.overlays; + + // Sync breadcrumbs when Dashboard State Manager changes + useEffect(() => { + if (!dashboardStateManager) { + return; + } + + const { + getConfirmButtonText, + getCancelButtonText, + getLeaveTitle, + getLeaveSubtitle, + } = leaveConfirmStrings; + + setBreadcrumbs([ + { + text: getDashboardBreadcrumb(), + 'data-test-subj': 'dashboardListingBreadcrumb', + onClick: () => { + if (dashboardStateManager.getIsDirty()) { + openConfirm(getLeaveSubtitle(), { + confirmButtonText: getConfirmButtonText(), + cancelButtonText: getCancelButtonText(), + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: getLeaveTitle(), + }).then((isConfirmed) => { + if (isConfirmed) { + redirectTo({ destination: 'listing' }); + } + }); + } else { + redirectTo({ destination: 'listing' }); + } + }, + }, + { + text: getDashboardTitle( + dashboardStateManager.getTitle(), + dashboardStateManager.getViewMode(), + dashboardStateManager.getIsDirty(timefilter), + dashboardStateManager.isNew() + ), + }, + ]); + }, [dashboardStateManager, timefilter, openConfirm, redirectTo, setBreadcrumbs]); +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts new file mode 100644 index 00000000000000..a331871ea7e36e --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -0,0 +1,135 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { History } from 'history'; + +import { useKibana } from '../../services/kibana_react'; +import { + ContainerOutput, + EmbeddableFactoryNotFoundError, + EmbeddableInput, + isErrorEmbeddable, + ViewMode, +} from '../../services/embeddable'; + +import { DashboardStateManager } from '../dashboard_state_manager'; +import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashboard_app_functions'; +import { DashboardContainer, DashboardContainerInput } from '../..'; +import { DashboardAppServices } from '../types'; +import { DASHBOARD_CONTAINER_TYPE } from '..'; + +export const useDashboardContainer = ( + dashboardStateManager: DashboardStateManager | null, + history: History, + isEmbeddedExternally: boolean +) => { + const { + dashboardCapabilities, + data, + embeddable, + scopedHistory, + } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { query } = data; + const { session: searchSession } = data.search; + + const [dashboardContainer, setDashboardContainer] = useState(null); + + useEffect(() => { + if (!dashboardStateManager) { + return; + } + + // Load dashboard container + const dashboardFactory = embeddable.getEmbeddableFactory< + DashboardContainerInput, + ContainerOutput, + DashboardContainer + >(DASHBOARD_CONTAINER_TYPE); + + if (!dashboardFactory) { + throw new EmbeddableFactoryNotFoundError( + 'dashboard app requires dashboard embeddable factory' + ); + } + + const searchSessionIdFromURL = getSearchSessionIdFromURL(history); + + if (searchSessionIdFromURL) { + searchSession.restore(searchSessionIdFromURL); + } + + const incomingEmbeddable = embeddable + .getStateTransfer(scopedHistory()) + .getIncomingEmbeddablePackage(); + + (async function createContainer() { + const newContainer = await dashboardFactory.create( + getDashboardContainerInput({ + dashboardCapabilities, + dashboardStateManager, + incomingEmbeddable, + isEmbeddedExternally, + query, + searchSessionId: searchSessionIdFromURL ?? searchSession.start(), + }) + ); + + if (!newContainer || isErrorEmbeddable(newContainer)) { + return; + } + + // inject switch view mode callback for the empty screen to use + newContainer.switchViewMode = (newViewMode: ViewMode) => + dashboardStateManager.switchViewMode(newViewMode); + + // If the incoming embeddable is newly created, or doesn't exist in the current panels list, + // add it with `addNewEmbeddable` + if ( + incomingEmbeddable && + (!incomingEmbeddable?.embeddableId || + (incomingEmbeddable.embeddableId && + !newContainer.getInput().panels[incomingEmbeddable.embeddableId])) + ) { + dashboardStateManager.switchViewMode(ViewMode.EDIT); + newContainer.addNewEmbeddable( + incomingEmbeddable.type, + incomingEmbeddable.input + ); + } + setDashboardContainer(newContainer); + })(); + return () => setDashboardContainer(null); + }, [ + dashboardCapabilities, + dashboardStateManager, + isEmbeddedExternally, + searchSession, + scopedHistory, + embeddable, + history, + query, + ]); + + return dashboardContainer; +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts new file mode 100644 index 00000000000000..7aadfe40ebf087 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts @@ -0,0 +1,200 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { History } from 'history'; +import _ from 'lodash'; +import { map } from 'rxjs/operators'; + +import { createKbnUrlStateStorage, withNotifyOnErrors } from '../../services/kibana_utils'; +import { useKibana } from '../../services/kibana_react'; +import { + connectToQueryState, + esFilters, + QueryState, + syncQueryStateWithUrl, +} from '../../services/data'; +import { SavedObject } from '../../services/saved_objects'; +import type { TagDecoratedSavedObject } from '../../services/saved_objects_tagging_oss'; + +import { DashboardSavedObject } from '../../saved_dashboards'; +import { migrateLegacyQuery } from '../lib/migrate_legacy_query'; +import { createSessionRestorationDataProvider } from '../lib/session_restoration'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { getDashboardTitle } from '../../dashboard_strings'; +import { DashboardAppServices } from '../types'; + +// TS is picky with type guards, we can't just inline `() => false` +function defaultTaggingGuard(_obj: SavedObject): _obj is TagDecoratedSavedObject { + return false; +} + +export const useDashboardStateManager = ( + savedDashboard: DashboardSavedObject | null, + history: History +): DashboardStateManager | null => { + const { + data: dataPlugin, + core, + uiSettings, + usageCollection, + initializerContext, + dashboardCapabilities, + savedObjectsTagging, + } = useKibana().services; + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { query: queryService } = dataPlugin; + const { session: searchSession } = dataPlugin.search; + const { filterManager, queryString: queryStringManager } = queryService; + const { timefilter } = queryService.timefilter; + const { toasts } = core.notifications; + const { hideWriteControls } = dashboardCapabilities; + const { version: kibanaVersion } = initializerContext.env.packageInfo; + + const [dashboardStateManager, setDashboardStateManager] = useState( + null + ); + + const hasTaggingCapabilities = savedObjectsTagging?.ui.hasTagDecoration || defaultTaggingGuard; + + useEffect(() => { + if (!savedDashboard) { + return; + } + + const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(toasts), + }); + + const stateManager = new DashboardStateManager({ + hasTaggingCapabilities, + hideWriteControls, + history, + kbnUrlStateStorage, + kibanaVersion, + savedDashboard, + usageCollection, + }); + + // sync initial app filters from state to filterManager + // if there is an existing similar global filter, then leave it as global + filterManager.setAppFilters(_.cloneDeep(stateManager.appState.filters)); + queryStringManager.setQuery(migrateLegacyQuery(stateManager.appState.query)); + + // setup syncing of app filters between appState and filterManager + const stopSyncingAppFilters = connectToQueryState( + queryService, + { + set: ({ filters, query }) => { + stateManager.setFilters(filters || []); + stateManager.setQuery(query || queryStringManager.getDefaultQuery()); + }, + get: () => ({ + filters: stateManager.appState.filters, + query: stateManager.getQuery(), + }), + state$: stateManager.appState$.pipe( + map((appState) => ({ + filters: appState.filters, + query: queryStringManager.formatQuery(appState.query), + })) + ), + }, + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + // Apply initial filters to Dashboard State Manager + stateManager.applyFilters( + stateManager.getQuery() || queryStringManager.getDefaultQuery(), + filterManager.getFilters() + ); + + // The hash check is so we only update the time filter on dashboard open, not during + // normal cross app navigation. + if (stateManager.getIsTimeSavedWithDashboard()) { + const initialGlobalStateInUrl = kbnUrlStateStorage.get('_g'); + if (!initialGlobalStateInUrl?.time) { + stateManager.syncTimefilterWithDashboardTime(timefilter); + } + if (!initialGlobalStateInUrl?.refreshInterval) { + stateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + } + + // starts syncing `_g` portion of url with query services + // it is important to start this syncing after `dashboardStateManager.syncTimefilterWithDashboard(timefilter);` above is run, + // otherwise it will case redundant browser history records + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + queryService, + kbnUrlStateStorage + ); + + // starts syncing `_a` portion of url + stateManager.startStateSyncing(); + + const dashboardTitle = getDashboardTitle( + stateManager.getTitle(), + stateManager.getViewMode(), + stateManager.getIsDirty(timefilter), + stateManager.isNew() + ); + + searchSession.setSearchSessionInfoProvider( + createSessionRestorationDataProvider({ + data: dataPlugin, + getDashboardTitle: () => dashboardTitle, + getDashboardId: () => savedDashboard?.id || '', + getAppState: () => stateManager.getAppState(), + }) + ); + + setDashboardStateManager(stateManager); + + return () => { + stateManager?.destroy(); + setDashboardStateManager(null); + stopSyncingAppFilters(); + stopSyncingQueryServiceStateWithUrl(); + }; + }, [ + dataPlugin, + filterManager, + hasTaggingCapabilities, + hideWriteControls, + history, + kibanaVersion, + queryService, + queryStringManager, + savedDashboard, + searchSession, + timefilter, + toasts, + uiSettings, + usageCollection, + ]); + + return dashboardStateManager; +}; diff --git a/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts new file mode 100644 index 00000000000000..f0d8b5f5e000d9 --- /dev/null +++ b/src/plugins/dashboard/public/application/hooks/use_saved_dashboard.ts @@ -0,0 +1,83 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { History } from 'history'; +import _ from 'lodash'; + +import { useKibana } from '../../services/kibana_react'; + +import { DashboardConstants } from '../..'; +import { DashboardSavedObject } from '../../saved_dashboards'; +import { getDashboard60Warning } from '../../dashboard_strings'; +import { DashboardAppServices } from '../types'; + +export const useSavedDashboard = (savedDashboardId: string | undefined, history: History) => { + const { data, core, chrome, savedDashboards } = useKibana().services; + const [savedDashboard, setSavedDashboard] = useState(null); + + // Destructure and rename services; makes the Effect hook more specific, makes later + // abstraction of service dependencies easier. + const { indexPatterns } = data; + const { recentlyAccessed: recentlyAccessedPaths, docTitle } = chrome; + const { addDanger: showDangerToast, addWarning: showWarningToast } = core.notifications.toasts; + + useEffect(() => { + (async function loadSavedDashboard() { + if (savedDashboardId === 'create') { + history.replace({ + ...history.location, // preserve query, + pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, + }); + + showWarningToast(getDashboard60Warning()); + return; + } + + await indexPatterns.ensureDefaultIndexPattern(); + + try { + const dashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject; + const { title, getFullPath } = dashboard; + if (savedDashboardId) { + recentlyAccessedPaths.add(getFullPath(), title, savedDashboardId); + } + + docTitle.change(title); + setSavedDashboard(dashboard); + } catch (error) { + // E.g. a corrupt or deleted dashboard + showDangerToast(error.message); + history.push(DashboardConstants.LANDING_PAGE_PATH); + } + })(); + return () => setSavedDashboard(null); + }, [ + docTitle, + history, + indexPatterns, + recentlyAccessedPaths, + savedDashboardId, + savedDashboards, + showDangerToast, + showWarningToast, + ]); + + return savedDashboard; +}; diff --git a/src/plugins/dashboard/public/application/index.scss b/src/plugins/dashboard/public/application/index.scss index 6e158b2ec2e47f..d76e022c97ccff 100644 --- a/src/plugins/dashboard/public/application/index.scss +++ b/src/plugins/dashboard/public/application/index.scss @@ -4,9 +4,6 @@ @import './embeddable/panel/index'; @import './embeddable/viewport/index'; -// Temporary hacks -@import './hacks'; - // Prefix all styles with "dsh" to avoid conflicts. // Examples // dshChart @@ -15,4 +12,3 @@ // dshChart__legend-isLoading @import './dashboard_app'; - diff --git a/src/plugins/dashboard/public/application/index.ts b/src/plugins/dashboard/public/application/index.ts index 2558c49648b103..fdc370ad7570f3 100644 --- a/src/plugins/dashboard/public/application/index.ts +++ b/src/plugins/dashboard/public/application/index.ts @@ -19,4 +19,3 @@ export * from './embeddable'; export * from './actions'; -export type { RenderDeps } from './application'; diff --git a/src/plugins/dashboard/public/application/legacy_app.js b/src/plugins/dashboard/public/application/legacy_app.js deleted file mode 100644 index 3867991d942959..00000000000000 --- a/src/plugins/dashboard/public/application/legacy_app.js +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import { parse } from 'query-string'; - -import dashboardTemplate from './dashboard_app.html'; -import dashboardListingTemplate from './listing/dashboard_listing_ng_wrapper.html'; -import { createHashHistory } from 'history'; - -import { initDashboardAppDirective } from './dashboard_app'; -import { createDashboardEditUrl, DashboardConstants } from '../dashboard_constants'; -import { - createKbnUrlStateStorage, - redirectWhenMissing, - SavedObjectNotFound, - withNotifyOnErrors, -} from '../../../kibana_utils/public'; -import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; -import { addHelpMenuToAppChrome } from './help_menu/help_menu_util'; -import { syncQueryStateWithUrl } from '../../../data/public'; - -export function initDashboardApp(app, deps) { - initDashboardAppDirective(app, deps); - - app.directive('dashboardListing', function (reactDirective) { - return reactDirective(DashboardListing, [ - ['core', { watchDepth: 'reference' }], - ['createItem', { watchDepth: 'reference' }], - ['getViewUrl', { watchDepth: 'reference' }], - ['editItem', { watchDepth: 'reference' }], - ['findItems', { watchDepth: 'reference' }], - ['deleteItems', { watchDepth: 'reference' }], - ['listingLimit', { watchDepth: 'reference' }], - ['hideWriteControls', { watchDepth: 'reference' }], - ['initialFilter', { watchDepth: 'reference' }], - ['initialPageSize', { watchDepth: 'reference' }], - ['taggingApi', { watchDepth: 'reference' }], - ]); - }); - - function createNewDashboardCtrl($scope) { - $scope.visitVisualizeAppLinkText = i18n.translate('dashboard.visitVisualizeAppLinkText', { - defaultMessage: 'visit the Visualize app', - }); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - } - - app.factory('history', () => createHashHistory()); - app.factory('kbnUrlStateStorage', (history) => - createKbnUrlStateStorage({ - history, - useHash: deps.uiSettings.get('state:storeInSessionStorage'), - ...withNotifyOnErrors(deps.core.notifications.toasts), - }) - ); - - app.config(function ($routeProvider) { - const defaults = { - reloadOnSearch: false, - requireUICapability: 'dashboard.show', - badge: () => { - if (deps.dashboardCapabilities.showWriteControls) { - return undefined; - } - - return { - text: i18n.translate('dashboard.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('dashboard.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save dashboards', - }), - iconType: 'glasses', - }; - }, - }; - - $routeProvider - .when('/', { - redirectTo: DashboardConstants.LANDING_PAGE_PATH, - }) - .when(DashboardConstants.LANDING_PAGE_PATH, { - ...defaults, - template: dashboardListingTemplate, - controller: function ($scope, kbnUrlStateStorage, history) { - deps.core.chrome.docTitle.change( - i18n.translate('dashboard.dashboardPageTitle', { defaultMessage: 'Dashboards' }) - ); - const service = deps.savedDashboards; - const dashboardConfig = deps.dashboardConfig; - - // syncs `_g` portion of url with query services - const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( - deps.data.query, - kbnUrlStateStorage - ); - - $scope.listingLimit = deps.savedObjects.settings.getListingLimit(); - $scope.initialPageSize = deps.savedObjects.settings.getPerPage(); - $scope.taggingApi = deps.savedObjectsTagging; - $scope.create = () => { - history.push(DashboardConstants.CREATE_NEW_DASHBOARD_URL); - }; - $scope.find = async (search) => { - let searchTerm = search; - let references = undefined; - - if (deps.savedObjectsTagging) { - const parsed = deps.savedObjectsTagging.ui.parseSearchQuery(search, { - useName: true, - }); - searchTerm = parsed.searchTerm; - references = parsed.tagReferences; - } - - return service.find(searchTerm, { - size: $scope.listingLimit, - hasReference: references, - }); - }; - $scope.editItem = ({ id }) => { - history.push(`${createDashboardEditUrl(id)}?_a=(viewMode:edit)`); - }; - $scope.getViewUrl = ({ id }) => { - return deps.addBasePath(`#${createDashboardEditUrl(id)}`); - }; - $scope.delete = (dashboards) => { - return service.delete(dashboards.map((d) => d.id)); - }; - $scope.hideWriteControls = dashboardConfig.getHideWriteControls(); - $scope.initialFilter = parse(history.location.search).filter || EMPTY_FILTER; - deps.chrome.setBreadcrumbs([ - { - text: i18n.translate('dashboard.dashboardBreadcrumbsTitle', { - defaultMessage: 'Dashboards', - }), - }, - ]); - addHelpMenuToAppChrome(deps.chrome, deps.core.docLinks); - $scope.core = deps.core; - - $scope.$on('$destroy', () => { - stopSyncingQueryServiceStateWithUrl(); - }); - }, - resolve: { - dash: function ($route, history) { - return deps.data.indexPatterns.ensureDefaultIndexPattern(history).then(() => { - const savedObjectsClient = deps.savedObjectsClient; - const title = $route.current.params.title; - if (title) { - return savedObjectsClient - .find({ - search: `"${title}"`, - search_fields: 'title', - type: 'dashboard', - }) - .then((results) => { - // The search isn't an exact match, lets see if we can find a single exact match to use - const matchingDashboards = results.savedObjects.filter( - (dashboard) => - dashboard.attributes.title.toLowerCase() === title.toLowerCase() - ); - if (matchingDashboards.length === 1) { - history.replace(createDashboardEditUrl(matchingDashboards[0].id)); - } else { - history.replace(`${DashboardConstants.LANDING_PAGE_PATH}?filter="${title}"`); - $route.reload(); - } - return new Promise(() => {}); - }); - } - }); - }, - }, - }) - .when(DashboardConstants.CREATE_NEW_DASHBOARD_URL, { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - requireUICapability: 'dashboard.createNew', - resolve: { - dash: (history) => - deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get()) - .catch( - redirectWhenMissing({ - history, - navigateToApp: deps.core.application.navigateToApp, - mapping: { - dashboard: DashboardConstants.LANDING_PAGE_PATH, - }, - toastNotifications: deps.core.notifications.toasts, - }) - ), - }, - }) - .when(createDashboardEditUrl(':id'), { - ...defaults, - template: dashboardTemplate, - controller: createNewDashboardCtrl, - resolve: { - dash: function ($route, history) { - const id = $route.current.params.id; - - return deps.data.indexPatterns - .ensureDefaultIndexPattern(history) - .then(() => deps.savedDashboards.get(id)) - .then((savedDashboard) => { - deps.chrome.recentlyAccessed.add( - savedDashboard.getFullPath(), - savedDashboard.title, - id - ); - return savedDashboard; - }) - .catch((error) => { - // Preserve BWC of v5.3.0 links for new, unsaved dashboards. - // See https://github.com/elastic/kibana/issues/10951 for more context. - if (error instanceof SavedObjectNotFound && id === 'create') { - // Note preserve querystring part is necessary so the state is preserved through the redirect. - history.replace({ - ...history.location, // preserve query, - pathname: DashboardConstants.CREATE_NEW_DASHBOARD_URL, - }); - - deps.core.notifications.toasts.addWarning( - i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { - defaultMessage: - 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', - }) - ); - return new Promise(() => {}); - } else { - // E.g. a corrupt or deleted dashboard - deps.core.notifications.toasts.addDanger(error.message); - history.push(DashboardConstants.LANDING_PAGE_PATH); - return new Promise(() => {}); - } - }); - }, - }, - }) - .otherwise({ - resolveRedirectTo: function ($rootScope) { - const path = window.location.hash.substr(1); - deps.restorePreviousUrl(); - $rootScope.$applyAsync(() => { - const { navigated } = deps.navigateToLegacyKibanaUrl(path); - if (!navigated) { - deps.navigateToDefaultApp(); - } - }); - // prevent angular from completing the navigation - return new Promise(() => {}); - }, - }); - }); -} diff --git a/src/plugins/dashboard/public/application/lib/filter_utils.ts b/src/plugins/dashboard/public/application/lib/filter_utils.ts index b6b935d6050ae0..ada5ec593cb51d 100644 --- a/src/plugins/dashboard/public/application/lib/filter_utils.ts +++ b/src/plugins/dashboard/public/application/lib/filter_utils.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import moment, { Moment } from 'moment'; -import { Filter } from '../../../../data/public'; +import { Filter } from '../../services/data'; /** * @typedef {Object} QueryFilter diff --git a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts index 5599aafe688f06..37952b8beda0d5 100644 --- a/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts +++ b/src/plugins/dashboard/public/application/lib/get_app_state_defaults.ts @@ -17,13 +17,13 @@ * under the License. */ -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; -import { ViewMode } from '../../embeddable_plugin'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss'; +import { ViewMode } from '../../services/embeddable'; +import { DashboardSavedObject } from '../../saved_dashboards'; import { DashboardAppStateDefaults } from '../../types'; export function getAppStateDefaults( - savedDashboard: SavedObjectDashboard, + savedDashboard: DashboardSavedObject, hideWriteControls: boolean, hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard ): DashboardAppStateDefaults { diff --git a/src/plugins/dashboard/public/application/help_menu/help_menu_util.ts b/src/plugins/dashboard/public/application/lib/help_menu_util.ts similarity index 100% rename from src/plugins/dashboard/public/application/help_menu/help_menu_util.ts rename to src/plugins/dashboard/public/application/lib/help_menu_util.ts diff --git a/src/plugins/dashboard/public/application/lib/index.ts b/src/plugins/dashboard/public/application/lib/index.ts index 6741bbbc5d4b13..03825ad7765f86 100644 --- a/src/plugins/dashboard/public/application/lib/index.ts +++ b/src/plugins/dashboard/public/application/lib/index.ts @@ -22,3 +22,5 @@ export { getAppStateDefaults } from './get_app_state_defaults'; export { migrateAppState } from './migrate_app_state'; export { getDashboardIdFromUrl } from './url'; export { createSessionRestorationDataProvider } from './session_restoration'; +export { addHelpMenuToAppChrome } from './help_menu_util'; +export { attemptLoadDashboardByTitle } from './load_dashboard_by_title'; diff --git a/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts b/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts new file mode 100644 index 00000000000000..4adf229baf34be --- /dev/null +++ b/src/plugins/dashboard/public/application/lib/load_dashboard_by_title.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DashboardSavedObject } from '../..'; +import { SavedObjectsClientContract } from '../../../../../core/public'; + +export async function attemptLoadDashboardByTitle( + title: string, + savedObjectsClient: SavedObjectsClientContract +): Promise<{ id: string } | undefined> { + const results = await savedObjectsClient.find({ + search: `"${title}"`, + searchFields: ['title'], + type: 'dashboard', + }); + // The search isn't an exact match, lets see if we can find a single exact match to use + const matchingDashboards = results.savedObjects.filter( + (dashboard) => dashboard.attributes.title.toLowerCase() === title.toLowerCase() + ); + if (matchingDashboards.length === 1) { + return { id: matchingDashboards[0].id }; + } +} diff --git a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts index eaa774e272b2ba..2dfb8608a547ab 100644 --- a/src/plugins/dashboard/public/application/lib/migrate_app_state.ts +++ b/src/plugins/dashboard/public/application/lib/migrate_app_state.ts @@ -21,7 +21,7 @@ import semverSatisfies from 'semver/functions/satisfies'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; +import { UsageCollectionSetup } from '../../services/usage_collection'; import { DashboardAppState, SavedDashboardPanel } from '../../types'; import { migratePanelsTo730, diff --git a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts b/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts index 8d9b50d5a66b2a..92c7c4fb55b892 100644 --- a/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts +++ b/src/plugins/dashboard/public/application/lib/migrate_legacy_query.ts @@ -18,7 +18,7 @@ */ import { has } from 'lodash'; -import { Query } from 'src/plugins/data/public'; +import { Query } from '../../services/data'; /** * Creates a standardized query object from old queries that were either strings or pure ES query DSL diff --git a/src/plugins/dashboard/public/application/lib/save_dashboard.ts b/src/plugins/dashboard/public/application/lib/save_dashboard.ts index 9560b3d90892ca..85742cba888dfd 100644 --- a/src/plugins/dashboard/public/application/lib/save_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/save_dashboard.ts @@ -17,8 +17,8 @@ * under the License. */ -import { TimefilterContract } from 'src/plugins/data/public'; -import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; +import { TimefilterContract } from '../../services/data'; +import { SavedObjectSaveOpts } from '../../services/saved_objects'; import { updateSavedDashboard } from './update_saved_dashboard'; import { DashboardStateManager } from '../dashboard_state_manager'; diff --git a/src/plugins/dashboard/public/application/lib/session_restoration.ts b/src/plugins/dashboard/public/application/lib/session_restoration.ts index f8ea8f8dcd76dc..5f05fa122e161c 100644 --- a/src/plugins/dashboard/public/application/lib/session_restoration.ts +++ b/src/plugins/dashboard/public/application/lib/session_restoration.ts @@ -18,7 +18,7 @@ */ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGeneratorState } from '../../url_generator'; -import { DataPublicPluginStart } from '../../../../data/public'; +import { DataPublicPluginStart } from '../../services/data'; import { DashboardAppState } from '../../types'; export function createSessionRestorationDataProvider(deps: { diff --git a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts index 9a4fa0822d5af3..b8f05962a7338b 100644 --- a/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts +++ b/src/plugins/dashboard/public/application/lib/update_saved_dashboard.ts @@ -18,15 +18,14 @@ */ import _ from 'lodash'; -import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; -import type { SavedObjectTagDecoratorTypeGuard } from 'src/plugins/saved_objects_tagging_oss/public'; +import type { SavedObjectTagDecoratorTypeGuard } from '../../services/saved_objects_tagging_oss'; +import { RefreshInterval, TimefilterContract, esFilters } from '../../services/data'; import { FilterUtils } from './filter_utils'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import { DashboardSavedObject } from '../../saved_dashboards'; import { DashboardAppState } from '../../types'; -import { esFilters } from '../../../../data/public'; export function updateSavedDashboard( - savedDashboard: SavedObjectDashboard, + savedDashboard: DashboardSavedObject, appState: DashboardAppState, timeFilter: TimefilterContract, hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard, diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap similarity index 70% rename from src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap rename to src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index e817e898cca675..fad7d8ddaabfe9 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -1,68 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`after fetch hideWriteControls 1`] = ` - - - - - - } - /> -
- } - rowHeader="title" - searchFilters={Array []} - tableCaption="Dashboards" - tableColumns={ - Array [ - Object { - "field": "title", - "name": "Title", - "render": [Function], - "sortable": true, - }, - Object { - "dataType": "string", - "field": "description", - "name": "Description", - "sortable": true, - }, - ] - } - tableListTitle="Dashboards" - toastNotifications={Object {}} - uiSettings={ - Object { - "get": [MockFunction], - } +exports[`after fetch When given a title that matches multiple dashboards, filter on the title 1`] = ` + - -`; - -exports[`after fetch initialFilter 1`] = ` - + } + redirectTo={[MockFunction]} + title="search by title" +> - + > + +
+ +
+ +
+
+ +
`; -exports[`after fetch renders call to action when no dashboards exist 1`] = ` - +exports[`after fetch hideWriteControls 1`] = ` + + + + + + } + /> +
+ } + rowHeader="title" + searchFilters={Array []} + tableCaption="Dashboards" + tableColumns={ + Array [ + Object { + "field": "title", + "name": "Title", + "render": [Function], + "sortable": true, + }, + Object { + "field": "description", + "name": "Description", + "render": [Function], + "sortable": true, + }, + ] + } + tableListTitle="Dashboards" + toastNotifications={ + Object { + "add": [MockFunction], + "addDanger": [MockFunction], + "addError": [MockFunction], + "addInfo": [MockFunction], + "addSuccess": [MockFunction], + "addWarning": [MockFunction], + "get$": [MockFunction], + "remove": [MockFunction], + } + } + > + +
+ +
+ +
+
+ +
+`; + +exports[`after fetch initialFilter 1`] = ` + - + > + +
+ +
+ +
+
+
+
`; -exports[`after fetch renders table rows 1`] = ` - +exports[`after fetch renders all table rows 1`] = ` + - + > + +
+ +
+ +
+
+ + `; -exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` - +exports[`after fetch renders call to action when no dashboards exist 1`] = ` + - + > + +
+ +
+ +
+
+ + `; -exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` - +exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` + - + > + +
+ +
+ +
+
+ + `; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.js deleted file mode 100644 index 1af89f4bcb71f6..00000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.js +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; - -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; - -import { TableListView } from '../../../../kibana_react/public'; - -export const EMPTY_FILTER = ''; - -// saved object client does not support sorting by title because title is only mapped as analyzed -// the legacy implementation got around this by pulling `listingLimit` items and doing client side sorting -// and not supporting server-side paging. -// This component does not try to tackle these problems (yet) and is just feature matching the legacy component -// TODO support server side sorting/paging once title and description are sortable on the server. -export class DashboardListing extends React.Component { - constructor(props) { - super(props); - } - - render() { - return ( - - - - ); - } - - getNoItemsMessage() { - if (this.props.hideWriteControls) { - return ( -
- - - - } - /> -
- ); - } - - return ( -
- - - - } - body={ - -

- -

-

- - this.props.core.application.navigateToApp('home', { - path: '#/tutorial_directory/sampleData', - }) - } - > - - - ), - }} - /> -

-
- } - actions={ - - - - } - /> -
- ); - } - - getTableColumns() { - const { taggingApi } = this.props; - - const tableColumns = [ - { - field: 'title', - name: i18n.translate('dashboard.listing.table.titleColumnName', { - defaultMessage: 'Title', - }), - sortable: true, - render: (field, record) => ( - - {field} - - ), - }, - { - field: 'description', - name: i18n.translate('dashboard.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - dataType: 'string', - sortable: true, - }, - ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), - ]; - return tableColumns; - } -} - -DashboardListing.propTypes = { - createItem: PropTypes.func.isRequired, - findItems: PropTypes.func.isRequired, - deleteItems: PropTypes.func.isRequired, - editItem: PropTypes.func.isRequired, - getViewUrl: PropTypes.func.isRequired, - listingLimit: PropTypes.number.isRequired, - hideWriteControls: PropTypes.bool.isRequired, - initialFilter: PropTypes.string, - initialPageSize: PropTypes.number.isRequired, - taggingApi: PropTypes.object, -}; - -DashboardListing.defaultProps = { - initialFilter: EMPTY_FILTER, -}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js deleted file mode 100644 index cc2c0a2e828cab..00000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock( - 'lodash', - () => ({ - ...jest.requireActual('lodash'), - // mock debounce to fire immediately with no internal timer - debounce: (func) => { - function debounced(...args) { - return func.apply(this, args); - } - return debounced; - }, - }), - { virtual: true } -); - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { DashboardListing } from './dashboard_listing'; - -const find = (num) => { - const hits = []; - for (let i = 0; i < num; i++) { - hits.push({ - id: `dashboard${i}`, - title: `dashboard${i} title`, - description: `dashboard${i} desc`, - }); - } - return Promise.resolve({ - total: num, - hits: hits, - }); -}; - -test('renders empty page in before initial fetch to avoid flickering', () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - hideWriteControls={false} - initialPageSize={10} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - expect(component).toMatchSnapshot(); -}); - -describe('after fetch', () => { - test('initialFilter', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - hideWriteControls={false} - initialPageSize={10} - initialFilter="my dashboard" - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders table rows', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1000} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders call to action when no dashboards exist', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('hideWriteControls', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={true} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); - - test('renders warning when listingLimit is exceeded', async () => { - const component = shallow( - {}} - createItem={() => {}} - editItem={() => {}} - getViewUrl={() => {}} - listingLimit={1} - initialPageSize={10} - hideWriteControls={false} - core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} - /> - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx new file mode 100644 index 00000000000000..3aee05554b0d9f --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -0,0 +1,219 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mount } from 'enzyme'; +import { + IUiSettingsClient, + PluginInitializerContext, + ScopedHistory, + SimpleSavedObject, +} from '../../../../../core/public'; + +import { SavedObjectLoader, SavedObjectLoaderFindOptions } from '../../services/saved_objects'; +import { IndexPatternsContract, SavedQueryService } from '../../services/data'; +import { NavigationPublicPluginStart } from '../../services/navigation'; +import { KibanaContextProvider } from '../../services/kibana_react'; +import { createKbnUrlStateStorage } from '../../services/kibana_utils'; + +import { savedObjectsPluginMock } from '../../../../saved_objects/public/mocks'; +import { DashboardListing, DashboardListingProps } from './dashboard_listing'; +import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; +import { DashboardAppServices, DashboardCapabilities } from '../types'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { chromeServiceMock, coreMock } from '../../../../../core/public/mocks'; +import { I18nProvider } from '@kbn/i18n/react'; +import React from 'react'; + +function makeDefaultServices(): DashboardAppServices { + const core = coreMock.createStart(); + const savedDashboards = {} as SavedObjectLoader; + savedDashboards.find = (search: string, sizeOrOptions: number | SavedObjectLoaderFindOptions) => { + const size = typeof sizeOrOptions === 'number' ? sizeOrOptions : sizeOrOptions.size ?? 10; + const hits = []; + for (let i = 0; i < size; i++) { + hits.push({ + id: `dashboard${i}`, + title: `dashboard${i} - ${search} - title`, + description: `dashboard${i} desc`, + }); + } + return Promise.resolve({ + total: size, + hits, + }); + }; + return { + savedObjects: savedObjectsPluginMock.createStartContract(), + embeddable: embeddablePluginMock.createInstance().doStart(), + dashboardCapabilities: {} as DashboardCapabilities, + initializerContext: {} as PluginInitializerContext, + chrome: chromeServiceMock.createStartContract(), + navigation: {} as NavigationPublicPluginStart, + savedObjectsClient: core.savedObjects.client, + data: dataPluginMock.createStartContract(), + indexPatterns: {} as IndexPatternsContract, + scopedHistory: () => ({} as ScopedHistory), + savedQueryService: {} as SavedQueryService, + setHeaderActionMenu: (mountPoint) => {}, + uiSettings: {} as IUiSettingsClient, + restorePreviousUrl: () => {}, + onAppLeave: (handler) => {}, + savedDashboards, + core, + }; +} + +function makeDefaultProps(): DashboardListingProps { + return { + redirectTo: jest.fn(), + kbnUrlStateStorage: createKbnUrlStateStorage(), + }; +} + +function mountWith({ + props: incomingProps, + services: incomingServices, +}: { + props?: DashboardListingProps; + services?: DashboardAppServices; +}) { + const services = incomingServices ?? makeDefaultServices(); + const props = incomingProps ?? makeDefaultProps(); + const wrappingComponent: React.FC<{ + children: React.ReactNode; + }> = ({ children }) => { + return ( + + {children} + + ); + }; + const component = mount(, { wrappingComponent }); + return { component, props, services }; +} + +describe('after fetch', () => { + test('renders all table rows', async () => { + const { component } = mountWith({}); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('renders call to action when no dashboards exist', async () => { + const services = makeDefaultServices(); + services.savedDashboards.find = () => { + return Promise.resolve({ + total: 0, + hits: [], + }); + }; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('initialFilter', async () => { + const props = makeDefaultProps(); + props.initialFilter = 'testFilter'; + const { component } = mountWith({ props }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('When given a title that matches multiple dashboards, filter on the title', async () => { + const title = 'search by title'; + const props = makeDefaultProps(); + props.title = title; + const services = makeDefaultServices(); + services.savedObjectsClient.find = () => { + return Promise.resolve({ + perPage: 10, + total: 2, + page: 0, + savedObjects: [ + { attributes: { title: `${title}_number1` }, id: 'hello there' } as SimpleSavedObject, + { attributes: { title: `${title}_number2` }, id: 'goodbye' } as SimpleSavedObject, + ], + }); + }; + const { component } = mountWith({ props, services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + expect(props.redirectTo).not.toHaveBeenCalled(); + }); + + test('When given a title that matches one dashboard, redirect to dashboard', async () => { + const title = 'search by title'; + const props = makeDefaultProps(); + props.title = title; + const services = makeDefaultServices(); + services.savedObjectsClient.find = () => { + return Promise.resolve({ + perPage: 10, + total: 1, + page: 0, + savedObjects: [{ attributes: { title }, id: 'you_found_me' } as SimpleSavedObject], + }); + }; + const { component } = mountWith({ props, services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(props.redirectTo).toHaveBeenCalledWith({ + destination: 'dashboard', + id: 'you_found_me', + useReplace: true, + }); + }); + + test('hideWriteControls', async () => { + const services = makeDefaultServices(); + services.dashboardCapabilities.hideWriteControls = true; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('renders warning when listingLimit is exceeded', async () => { + const services = makeDefaultServices(); + services.savedObjects.settings.getListingLimit = () => 1; + const { component } = mountWith({ services }); + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx new file mode 100644 index 00000000000000..40c033322799f9 --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -0,0 +1,288 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import React, { Fragment, useCallback, useEffect, useMemo } from 'react'; + +import { attemptLoadDashboardByTitle } from '../lib'; +import { DashboardAppServices, DashboardRedirect } from '../types'; +import { getDashboardBreadcrumb, dashboardListingTable } from '../../dashboard_strings'; +import { ApplicationStart, SavedObjectsFindOptionsReference } from '../../../../../core/public'; + +import { syncQueryStateWithUrl } from '../../services/data'; +import { IKbnUrlStateStorage } from '../../services/kibana_utils'; +import { TableListView, useKibana } from '../../services/kibana_react'; +import { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss'; + +export interface DashboardListingProps { + kbnUrlStateStorage: IKbnUrlStateStorage; + redirectTo: DashboardRedirect; + initialFilter?: string; + title?: string; +} + +export const DashboardListing = ({ + title, + redirectTo, + initialFilter, + kbnUrlStateStorage, +}: DashboardListingProps) => { + const { + services: { + core, + data, + savedObjects, + savedDashboards, + savedObjectsClient, + savedObjectsTagging, + dashboardCapabilities, + chrome: { setBreadcrumbs }, + }, + } = useKibana(); + + // Set breadcrumbs useEffect + useEffect(() => { + setBreadcrumbs([ + { + text: getDashboardBreadcrumb(), + }, + ]); + }, [setBreadcrumbs]); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + data.query, + kbnUrlStateStorage + ); + if (title) { + attemptLoadDashboardByTitle(title, savedObjectsClient).then((result) => { + if (!result) return; + redirectTo({ + destination: 'dashboard', + id: result.id, + useReplace: true, + }); + }); + } + + return () => { + stopSyncingQueryServiceStateWithUrl(); + }; + }, [title, savedObjectsClient, redirectTo, data.query, kbnUrlStateStorage]); + + const hideWriteControls = dashboardCapabilities.hideWriteControls; + const listingLimit = savedObjects.settings.getListingLimit(); + const defaultFilter = title ? `"${title}"` : ''; + + const tableColumns = useMemo( + () => + getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging), + [savedObjectsTagging, redirectTo] + ); + + const noItemsFragment = useMemo( + () => + getNoItemsMessage(hideWriteControls, core.application, () => + redirectTo({ destination: 'dashboard' }) + ), + [redirectTo, core.application, hideWriteControls] + ); + + const fetchItems = useCallback( + (filter: string) => { + let searchTerm = filter; + let references: SavedObjectsFindOptionsReference[] | undefined; + + if (savedObjectsTagging) { + const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, { + useName: true, + }); + searchTerm = parsed.searchTerm; + references = parsed.tagReferences; + } + + return savedDashboards.find(searchTerm, { + size: listingLimit, + hasReference: references, + }); + }, + [listingLimit, savedDashboards, savedObjectsTagging] + ); + + const deleteItems = useCallback( + (dashboards: Array<{ id: string }>) => savedDashboards.delete(dashboards.map((d) => d.id)), + [savedDashboards] + ); + + const editItem = useCallback( + ({ id }: { id: string | undefined }) => redirectTo({ destination: 'dashboard', id }), + [redirectTo] + ); + + const searchFilters = useMemo(() => { + return savedObjectsTagging + ? [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })] + : []; + }, [savedObjectsTagging]); + + const { + getEntityName, + getTableCaption, + getTableListTitle, + getEntityNamePlural, + } = dashboardListingTable; + return ( + redirectTo({ destination: 'dashboard' })} + deleteItems={hideWriteControls ? undefined : deleteItems} + initialPageSize={savedObjects.settings.getPerPage()} + editItem={hideWriteControls ? undefined : editItem} + initialFilter={initialFilter ?? defaultFilter} + toastNotifications={core.notifications.toasts} + headingId="dashboardListingHeading" + findItems={fetchItems} + rowHeader="title" + entityNamePlural={getEntityNamePlural()} + tableListTitle={getTableListTitle()} + tableCaption={getTableCaption()} + entityName={getEntityName()} + {...{ + noItemsFragment, + searchFilters, + listingLimit, + tableColumns, + }} + /> + ); +}; + +const getTableColumns = ( + redirectTo: (id?: string) => void, + savedObjectsTagging?: SavedObjectsTaggingApi +) => { + return [ + { + field: 'title', + name: dashboardListingTable.getTitleColumnName(), + sortable: true, + render: (field: string, record: { id: string; title: string }) => ( + redirectTo(record.id)} + data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`} + > + {field} + + ), + }, + { + field: 'description', + name: dashboardListingTable.getDescriptionColumnName(), + render: (field: string, record: { description: string }) => {record.description}, + sortable: true, + }, + ...(savedObjectsTagging ? [savedObjectsTagging.ui.getTableColumnDefinition()] : []), + ]; +}; + +const getNoItemsMessage = ( + hideWriteControls: boolean, + application: ApplicationStart, + createItem: () => void +) => { + if (hideWriteControls) { + return ( +
+ + + + } + /> +
+ ); + } + + return ( +
+ + + + } + body={ + +

+ +

+

+ + application.navigateToApp('home', { + path: '#/tutorial_directory/sampleData', + }) + } + > + + + ), + }} + /> +

+
+ } + actions={ + + + + } + /> +
+ ); +}; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html b/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html deleted file mode 100644 index dd0a40f71beb85..00000000000000 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing_ng_wrapper.html +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/src/plugins/dashboard/public/ui_actions_plugin.ts b/src/plugins/dashboard/public/application/listing/index.ts similarity index 93% rename from src/plugins/dashboard/public/ui_actions_plugin.ts rename to src/plugins/dashboard/public/application/listing/index.ts index c8778025e77132..ab5ef3441112d3 100644 --- a/src/plugins/dashboard/public/ui_actions_plugin.ts +++ b/src/plugins/dashboard/public/application/listing/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/ui_actions/public'; +export { DashboardListing } from './dashboard_listing'; diff --git a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts b/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts index ca5b146f9a370e..26db5c1ace9842 100644 --- a/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts +++ b/src/plugins/dashboard/public/application/test_helpers/get_sample_dashboard_input.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ViewMode, EmbeddableInput } from '../../embeddable_plugin'; +import { ViewMode, EmbeddableInput } from '../../services/embeddable'; import { DashboardContainerInput, DashboardPanelState } from '../embeddable'; export function getSampleDashboardInput( diff --git a/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts b/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts index ee59c68cce4515..ea6792e466127b 100644 --- a/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts +++ b/src/plugins/dashboard/public/application/test_helpers/get_saved_dashboard_mock.ts @@ -18,11 +18,11 @@ */ import { dataPluginMock } from '../../../../data/public/mocks'; -import { SavedObjectDashboard } from '../../saved_dashboards'; +import { DashboardSavedObject } from '../../saved_dashboards'; export function getSavedDashboardMock( - config?: Partial -): SavedObjectDashboard { + config?: Partial +): DashboardSavedObject { const searchSource = dataPluginMock.createStartContract(); return { @@ -43,5 +43,5 @@ export function getSavedDashboardMock( getQuery: () => ({ query: '', language: 'kuery' }), getFilters: () => [], ...config, - } as SavedObjectDashboard; + } as DashboardSavedObject; } diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx deleted file mode 100644 index f8f7226d234544..00000000000000 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { findTestSubject } from '@elastic/eui/lib/test'; -import React from 'react'; -import { mount } from 'enzyme'; -import { nextTick } from '@kbn/test/jest'; -import { I18nProvider } from '@kbn/i18n/react'; -import { ViewMode, CONTEXT_MENU_TRIGGER, EmbeddablePanel } from '../../embeddable_plugin'; -import { DashboardContainer, DashboardContainerOptions } from '../embeddable/dashboard_container'; -import { getSampleDashboardInput } from '../test_helpers'; -import { - CONTACT_CARD_EMBEDDABLE, - ContactCardEmbeddableFactory, - ContactCardEmbeddableInput, - ContactCardEmbeddable, - ContactCardEmbeddableOutput, - createEditModeAction, -} from '../../embeddable_plugin_test_samples'; -import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; -import { inspectorPluginMock } from '../../../../inspector/public/mocks'; -import { KibanaContextProvider } from '../../../../kibana_react/public'; -import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; -import { applicationServiceMock } from '../../../../../core/public/mocks'; - -test('DashboardContainer in edit mode shows edit mode actions', async () => { - const inspector = inspectorPluginMock.createStartContract(); - const { setup, doStart } = embeddablePluginMock.createInstance(); - const uiActionsSetup = uiActionsPluginMock.createSetupContract(); - - const editModeAction = createEditModeAction(); - uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); - setup.registerEmbeddableFactory( - CONTACT_CARD_EMBEDDABLE, - new ContactCardEmbeddableFactory((() => null) as any, {} as any) - ); - - const start = doStart(); - - const initialInput = getSampleDashboardInput({ viewMode: ViewMode.VIEW }); - const options: DashboardContainerOptions = { - application: applicationServiceMock.createStartContract(), - embeddable: start, - notifications: {} as any, - overlays: {} as any, - inspector: {} as any, - SavedObjectFinder: () => null, - ExitFullScreenButton: () => null, - uiActions: {} as any, - }; - const container = new DashboardContainer(initialInput, options); - - const embeddable = await container.addNewEmbeddable< - ContactCardEmbeddableInput, - ContactCardEmbeddableOutput, - ContactCardEmbeddable - >(CONTACT_CARD_EMBEDDABLE, { - firstName: 'Bob', - }); - - const component = mount( - - - Promise.resolve([])} - getAllEmbeddableFactories={(() => []) as any} - getEmbeddableFactory={(() => null) as any} - notifications={{} as any} - application={options.application} - overlays={{} as any} - inspector={inspector} - SavedObjectFinder={() => null} - /> - - - ); - - const button = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); - - expect(button.length).toBe(1); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - - expect(findTestSubject(component, `embeddablePanelContextMenuOpen`).length).toBe(1); - - const editAction = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); - - expect(editAction.length).toBe(0); - - container.updateInput({ viewMode: ViewMode.EDIT }); - await nextTick(); - component.update(); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - await nextTick(); - component.update(); - expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(0); - findTestSubject(component, 'embeddablePanelToggleMenuIcon').simulate('click'); - await nextTick(); - component.update(); - expect(findTestSubject(component, 'embeddablePanelContextMenuOpen').length).toBe(1); - - await nextTick(); - component.update(); - - // TODO: Address this. - // const action = findTestSubject(component, `embeddablePanelAction-${editModeAction.id}`); - // expect(action.length).toBe(1); -}); diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx new file mode 100644 index 00000000000000..38d46c9ec5a68a --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -0,0 +1,446 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import angular from 'angular'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useKibana } from '../../services/kibana_react'; +import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data'; +import { + EmbeddableFactoryNotFoundError, + isErrorEmbeddable, + openAddPanelFlyout, + ViewMode, +} from '../../services/embeddable'; +import { + getSavedObjectFinder, + SavedObjectSaveOpts, + SaveResult, + showSaveModal, +} from '../../services/saved_objects'; + +import { NavAction } from '../../types'; +import { DashboardSavedObject } from '../..'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { leaveConfirmStrings } from '../../dashboard_strings'; +import { saveDashboard } from '../lib'; +import { + DashboardAppServices, + DashboardEmbedSettings, + DashboardRedirect, + DashboardSaveOptions, +} from '../types'; +import { getTopNavConfig } from './get_top_nav_config'; +import { DashboardSaveModal } from './save_modal'; +import { showCloneModal } from './show_clone_modal'; +import { showOptionsPopover } from './show_options_popover'; +import { TopNavIds } from './top_nav_ids'; +import { ShowShareModal } from './show_share_modal'; +import { DashboardContainer } from '..'; + +export interface DashboardTopNavState { + chromeIsVisible: boolean; + savedQuery?: SavedQuery; +} + +export interface DashboardTopNavProps { + onQuerySubmit: (_payload: unknown, isUpdate: boolean | undefined) => void; + dashboardStateManager: DashboardStateManager; + dashboardContainer: DashboardContainer; + embedSettings?: DashboardEmbedSettings; + savedDashboard: DashboardSavedObject; + timefilter: TimefilterContract; + indexPatterns: IndexPattern[]; + redirectTo: DashboardRedirect; + lastDashboardId?: string; +} + +export function DashboardTopNav({ + dashboardStateManager, + dashboardContainer, + lastDashboardId, + savedDashboard, + onQuerySubmit, + embedSettings, + indexPatterns, + redirectTo, + timefilter, +}: DashboardTopNavProps) { + const { + core, + data, + share, + chrome, + embeddable, + navigation, + uiSettings, + setHeaderActionMenu, + savedObjectsTagging, + dashboardCapabilities, + } = useKibana().services; + + const [state, setState] = useState({ chromeIsVisible: false }); + + useEffect(() => { + const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { + setState((s) => ({ ...s, chromeIsVisible })); + }); + return () => visibleSubscription.unsubscribe(); + }, [chrome]); + + const addFromLibrary = useCallback(() => { + if (!isErrorEmbeddable(dashboardContainer)) { + openAddPanelFlyout({ + embeddable: dashboardContainer, + getAllFactories: embeddable.getEmbeddableFactories, + getFactory: embeddable.getEmbeddableFactory, + notifications: core.notifications, + overlays: core.overlays, + SavedObjectFinder: getSavedObjectFinder(core.savedObjects, uiSettings), + }); + } + }, [ + embeddable.getEmbeddableFactories, + embeddable.getEmbeddableFactory, + dashboardContainer, + core.notifications, + core.savedObjects, + core.overlays, + uiSettings, + ]); + + const createNew = useCallback(async () => { + const type = 'visualization'; + const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + const explicitInput = await factory.getExplicitInput(); + if (dashboardContainer) { + await dashboardContainer.addNewEmbeddable(type, explicitInput); + } + }, [dashboardContainer, embeddable]); + + const onChangeViewMode = useCallback( + (newMode: ViewMode) => { + const isPageRefresh = newMode === dashboardStateManager.getViewMode(); + const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; + const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); + + if (!willLoseChanges) { + dashboardStateManager.switchViewMode(newMode); + return; + } + + function revertChangesAndExitEditMode() { + dashboardStateManager.resetState(); + // This is only necessary for new dashboards, which will default to Edit mode. + dashboardStateManager.switchViewMode(ViewMode.VIEW); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardStateManager.getIsTimeSavedWithDashboard()) { + dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); + dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + redirectTo({ destination: 'dashboard', id: savedDashboard.id }); + } + + core.overlays + .openConfirm(leaveConfirmStrings.getDiscardSubtitle(), { + confirmButtonText: leaveConfirmStrings.getConfirmButtonText(), + cancelButtonText: leaveConfirmStrings.getCancelButtonText(), + defaultFocusedButton: EUI_MODAL_CANCEL_BUTTON, + title: leaveConfirmStrings.getDiscardTitle(), + }) + .then((isConfirmed) => { + if (isConfirmed) { + revertChangesAndExitEditMode(); + } + }); + }, + [redirectTo, timefilter, core.overlays, savedDashboard.id, dashboardStateManager] + ); + + /** + * Saves the dashboard. + * + * @param {object} [saveOptions={}] + * @property {boolean} [saveOptions.confirmOverwrite=false] - If true, attempts to create the source so it + * can confirm an overwrite if a document with the id already exists. + * @property {boolean} [saveOptions.isTitleDuplicateConfirmed=false] - If true, save allowed with duplicate title + * @property {func} [saveOptions.onTitleDuplicate] - function called if duplicate title exists. + * When not provided, confirm modal will be displayed asking user to confirm or cancel save. + * @return {Promise} + * @resolved {String} - The id of the doc + */ + const save = useCallback( + async (saveOptions: SavedObjectSaveOpts) => { + return saveDashboard(angular.toJson, timefilter, dashboardStateManager, saveOptions) + .then(function (id) { + if (id) { + core.notifications.toasts.addSuccess({ + title: i18n.translate('dashboard.dashboardWasSavedSuccessMessage', { + defaultMessage: `Dashboard '{dashTitle}' was saved`, + values: { dashTitle: dashboardStateManager.savedDashboard.title }, + }), + 'data-test-subj': 'saveDashboardSuccess', + }); + + if (id !== lastDashboardId) { + redirectTo({ destination: 'dashboard', id }); + } else { + chrome.docTitle.change(dashboardStateManager.savedDashboard.lastSavedTitle); + dashboardStateManager.switchViewMode(ViewMode.VIEW); + } + } + return { id }; + }) + .catch((error) => { + core.notifications?.toasts.addDanger({ + title: i18n.translate('dashboard.dashboardWasNotSavedDangerMessage', { + defaultMessage: `Dashboard '{dashTitle}' was not saved. Error: {errorMessage}`, + values: { + dashTitle: dashboardStateManager.savedDashboard.title, + errorMessage: error.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return { error }; + }); + }, + [ + core.notifications.toasts, + dashboardStateManager, + lastDashboardId, + chrome.docTitle, + redirectTo, + timefilter, + ] + ); + + const runSave = useCallback(async () => { + const currentTitle = dashboardStateManager.getTitle(); + const currentDescription = dashboardStateManager.getDescription(); + const currentTimeRestore = dashboardStateManager.getTimeRestore(); + + let currentTags: string[] = []; + if (savedObjectsTagging) { + const dashboard = dashboardStateManager.savedDashboard; + if (savedObjectsTagging.ui.hasTagDecoration(dashboard)) { + currentTags = dashboard.getTags(); + } + } + + const onSave = ({ + newTitle, + newDescription, + newCopyOnSave, + newTimeRestore, + onTitleDuplicate, + isTitleDuplicateConfirmed, + newTags, + }: DashboardSaveOptions): Promise => { + dashboardStateManager.setTitle(newTitle); + dashboardStateManager.setDescription(newDescription); + dashboardStateManager.savedDashboard.copyOnSave = newCopyOnSave; + dashboardStateManager.setTimeRestore(newTimeRestore); + if (savedObjectsTagging && newTags) { + dashboardStateManager.setTags(newTags); + } + + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + return save(saveOptions).then((response: SaveResult) => { + // If the save wasn't successful, put the original values back. + if (!(response as { id: string }).id) { + dashboardStateManager.setTitle(currentTitle); + dashboardStateManager.setDescription(currentDescription); + dashboardStateManager.setTimeRestore(currentTimeRestore); + if (savedObjectsTagging) { + dashboardStateManager.setTags(currentTags); + } + } + return response; + }); + }; + + const dashboardSaveModal = ( + {}} + title={currentTitle} + description={currentDescription} + tags={currentTags} + savedObjectsTagging={savedObjectsTagging} + timeRestore={currentTimeRestore} + showCopyOnSave={lastDashboardId ? true : false} + /> + ); + showSaveModal(dashboardSaveModal, core.i18n.Context); + }, [save, core.i18n.Context, savedObjectsTagging, dashboardStateManager, lastDashboardId]); + + const runClone = useCallback(() => { + const currentTitle = dashboardStateManager.getTitle(); + const onClone = async ( + newTitle: string, + isTitleDuplicateConfirmed: boolean, + onTitleDuplicate: () => void + ) => { + dashboardStateManager.savedDashboard.copyOnSave = true; + dashboardStateManager.setTitle(newTitle); + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + return save(saveOptions).then((response: { id?: string } | { error: Error }) => { + // If the save wasn't successful, put the original title back. + if ((response as { error: Error }).error) { + dashboardStateManager.setTitle(currentTitle); + } + return response; + }); + }; + + showCloneModal(onClone, currentTitle); + }, [dashboardStateManager, save]); + + const dashboardTopNavActions = useMemo(() => { + const actions = { + [TopNavIds.FULL_SCREEN]: () => { + dashboardStateManager.setFullScreenMode(true); + }, + [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), + [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), + [TopNavIds.SAVE]: runSave, + [TopNavIds.CLONE]: runClone, + [TopNavIds.ADD_EXISTING]: addFromLibrary, + [TopNavIds.VISUALIZE]: createNew, + [TopNavIds.OPTIONS]: (anchorElement) => { + showOptionsPopover({ + anchorElement, + useMargins: dashboardStateManager.getUseMargins(), + onUseMarginsChange: (isChecked: boolean) => { + dashboardStateManager.setUseMargins(isChecked); + }, + hidePanelTitles: dashboardStateManager.getHidePanelTitles(), + onHidePanelTitlesChange: (isChecked: boolean) => { + dashboardStateManager.setHidePanelTitles(isChecked); + }, + }); + }, + } as { [key: string]: NavAction }; + if (share) { + actions[TopNavIds.SHARE] = (anchorElement) => + ShowShareModal({ + share, + anchorElement, + savedDashboard, + dashboardStateManager, + dashboardCapabilities, + }); + } + return actions; + }, [ + dashboardCapabilities, + dashboardStateManager, + onChangeViewMode, + savedDashboard, + addFromLibrary, + createNew, + runClone, + runSave, + share, + ]); + + const getNavBarProps = () => { + const shouldShowNavBarComponent = (forceShow: boolean): boolean => + (forceShow || state.chromeIsVisible) && !dashboardStateManager.getFullScreenMode(); + + const shouldShowFilterBar = (forceHide: boolean): boolean => + !forceHide && + (data.query.filterManager.getFilters().length > 0 || + !dashboardStateManager.getFullScreenMode()); + + const isFullScreenMode = dashboardStateManager.getFullScreenMode(); + const screenTitle = dashboardStateManager.getTitle(); + const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); + const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput)); + const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); + const showQueryBar = showQueryInput || showDatePicker; + const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); + const showSearchBar = showQueryBar || showFilterBar; + + const topNav = getTopNavConfig( + dashboardStateManager.getViewMode(), + dashboardTopNavActions, + dashboardCapabilities.hideWriteControls + ); + + return { + appName: 'dashboard', + config: showTopNavMenu ? topNav : undefined, + className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, + screenTitle, + showTopNavMenu, + showSearchBar, + showQueryBar, + showQueryInput, + showDatePicker, + showFilterBar, + setMenuMountPoint: embedSettings ? undefined : setHeaderActionMenu, + indexPatterns, + showSaveQuery: dashboardCapabilities.saveQuery, + useDefaultBehaviors: true, + onQuerySubmit, + onSavedQueryUpdated: (savedQuery: SavedQuery) => { + const allFilters = data.query.filterManager.getFilters(); + data.query.filterManager.setFilters(allFilters); + dashboardStateManager.applyFilters(savedQuery.attributes.query, allFilters); + if (savedQuery.attributes.timefilter) { + timefilter.setTime({ + from: savedQuery.attributes.timefilter.from, + to: savedQuery.attributes.timefilter.to, + }); + if (savedQuery.attributes.timefilter.refreshInterval) { + timefilter.setRefreshInterval(savedQuery.attributes.timefilter.refreshInterval); + } + } + setState((s) => ({ ...s, savedQuery })); + }, + savedQuery: state.savedQuery, + savedQueryId: dashboardStateManager.getSavedQueryId(), + onSavedQueryIdChange: (newId: string | undefined) => + dashboardStateManager.setSavedQueryId(newId), + }; + }; + + const { TopNavMenu } = navigation.ui; + return ; +} diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index 5713996ca9f787..c8d65923de2fc8 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -18,8 +18,7 @@ */ import { i18n } from '@kbn/i18n'; -import { AppMountParameters } from 'kibana/public'; -import { ViewMode } from '../../embeddable_plugin'; +import { ViewMode } from '../../services/embeddable'; import { TopNavIds } from './top_nav_ids'; import { NavAction } from '../../types'; @@ -32,8 +31,7 @@ import { NavAction } from '../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean, - onAppLeave?: AppMountParameters['onAppLeave'] + hideWriteControls: boolean ) { switch (dashboardMode) { case ViewMode.VIEW: @@ -185,9 +183,9 @@ function getCreateNewConfig(action: NavAction) { }; } -/** - * @returns {kbnTopNavConfig} - */ +// /** +// * @returns {kbnTopNavConfig} +// */ function getShareConfig(action: NavAction | undefined) { return { id: 'share', @@ -198,7 +196,7 @@ function getShareConfig(action: NavAction | undefined) { defaultMessage: 'Share Dashboard', }), testId: 'shareTopNavButton', - run: action, + run: action ?? (() => {}), // disable the Share button if no action specified disableButton: !action, }; diff --git a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx index 71c36238054628..4a30944ba86161 100644 --- a/src/plugins/dashboard/public/application/top_nav/save_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/save_modal.tsx @@ -21,21 +21,20 @@ import React, { Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiTextArea, EuiSwitch } from '@elastic/eui'; -import type { SavedObjectsTaggingApi } from '../../../../saved_objects_tagging_oss/public'; -import { SavedObjectSaveModal } from '../../../../saved_objects/public'; - -export interface SaveOptions { - newTitle: string; - newDescription: string; - newTags?: string[]; - newCopyOnSave: boolean; - newTimeRestore: boolean; - isTitleDuplicateConfirmed: boolean; - onTitleDuplicate: () => void; -} +import type { SavedObjectsTaggingApi } from '../../services/saved_objects_tagging_oss'; +import { SavedObjectSaveModal } from '../../services/saved_objects'; +import { DashboardSaveOptions } from '../types'; interface Props { - onSave: (options: SaveOptions) => void; + onSave: ({ + newTitle, + newDescription, + newCopyOnSave, + newTags, + newTimeRestore, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: DashboardSaveOptions) => void; onClose: () => void; title: string; description: string; diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx new file mode 100644 index 00000000000000..236789ef82e566 --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -0,0 +1,128 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiCheckboxGroup } from '@elastic/eui'; +import React from 'react'; +import { ReactElement, useState } from 'react'; +import { DashboardSavedObject } from '../..'; +import { setStateToKbnUrl, unhashUrl } from '../../services/kibana_utils'; +import { SharePluginStart } from '../../services/share'; +import { dashboardUrlParams } from '../dashboard_router'; +import { DashboardStateManager } from '../dashboard_state_manager'; +import { shareModalStrings } from '../../dashboard_strings'; +import { DashboardCapabilities } from '../types'; + +const showFilterBarId = 'showFilterBar'; + +interface ShowShareModalProps { + share: SharePluginStart; + anchorElement: HTMLElement; + savedDashboard: DashboardSavedObject; + dashboardCapabilities: DashboardCapabilities; + dashboardStateManager: DashboardStateManager; +} + +export function ShowShareModal({ + share, + anchorElement, + savedDashboard, + dashboardCapabilities, + dashboardStateManager, +}: ShowShareModalProps) { + const EmbedUrlParamExtension = ({ + setParamValue, + }: { + setParamValue: (paramUpdate: { [key: string]: boolean }) => void; + }): ReactElement => { + const [urlParamsSelectedMap, seturlParamsSelectedMap] = useState<{ [key: string]: boolean }>({ + showFilterBar: true, + }); + + const checkboxes = [ + { + id: dashboardUrlParams.showTopMenu, + label: shareModalStrings.getTopMenuCheckbox(), + }, + { + id: dashboardUrlParams.showQueryInput, + label: shareModalStrings.getQueryCheckbox(), + }, + { + id: dashboardUrlParams.showTimeFilter, + label: shareModalStrings.getTimeFilterCheckbox(), + }, + { + id: showFilterBarId, + label: shareModalStrings.getFilterBarCheckbox(), + }, + ]; + + const handleChange = (param: string): void => { + const newSelectedMap = { + ...urlParamsSelectedMap, + [param]: !urlParamsSelectedMap[param], + }; + + const urlParamValues = { + [dashboardUrlParams.showTopMenu]: newSelectedMap[dashboardUrlParams.showTopMenu], + [dashboardUrlParams.showQueryInput]: newSelectedMap[dashboardUrlParams.showQueryInput], + [dashboardUrlParams.showTimeFilter]: newSelectedMap[dashboardUrlParams.showTimeFilter], + [dashboardUrlParams.hideFilterBar]: !newSelectedMap[showFilterBarId], + }; + seturlParamsSelectedMap(newSelectedMap); + setParamValue(urlParamValues); + }; + + return ( + + ); + }; + + share.toggleShareContextMenu({ + anchorElement, + allowEmbed: true, + allowShortUrl: !dashboardCapabilities.hideWriteControls || dashboardCapabilities.createShortUrl, + shareableUrl: setStateToKbnUrl( + '_a', + dashboardStateManager.getAppState(), + { useHash: false, storeInHashQuery: true }, + unhashUrl(window.location.href) + ), + objectId: savedDashboard.id, + objectType: 'dashboard', + sharingData: { + title: savedDashboard.title, + }, + isDirty: dashboardStateManager.getIsDirty(), + embedUrlParamExtensions: [ + { + paramName: 'embed', + component: EmbedUrlParamExtension, + }, + ], + }); +} diff --git a/src/plugins/dashboard/public/application/types.ts b/src/plugins/dashboard/public/application/types.ts new file mode 100644 index 00000000000000..d1caaa349d80b4 --- /dev/null +++ b/src/plugins/dashboard/public/application/types.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + AppMountParameters, + CoreStart, + SavedObjectsClientContract, + ScopedHistory, + ChromeStart, + IUiSettingsClient, + PluginInitializerContext, +} from 'kibana/public'; + +import { SharePluginStart } from '../services/share'; +import { EmbeddableStart } from '../services/embeddable'; +import { UsageCollectionSetup } from '../services/usage_collection'; +import { NavigationPublicPluginStart } from '../services/navigation'; +import { SavedObjectsTaggingApi } from '../services/saved_objects_tagging_oss'; +import { DataPublicPluginStart, IndexPatternsContract } from '../services/data'; +import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; + +export type DashboardRedirect = (props: RedirectToProps) => void; +export type RedirectToProps = + | { destination: 'dashboard'; id?: string; useReplace?: boolean } + | { destination: 'listing'; filter?: string; useReplace?: boolean }; + +export interface DashboardEmbedSettings { + forceShowTopNavMenu?: boolean; + forceShowQueryInput?: boolean; + forceShowDatePicker?: boolean; + forceHideFilterBar?: boolean; +} + +export interface DashboardSaveOptions { + newTitle: string; + newTags?: string[]; + newDescription: string; + newCopyOnSave: boolean; + newTimeRestore: boolean; + onTitleDuplicate: () => void; + isTitleDuplicateConfirmed: boolean; +} + +export interface DashboardCapabilities { + visualizeCapabilities: { save: boolean }; + mapsCapabilities: { save: boolean }; + hideWriteControls: boolean; + createShortUrl: boolean; + saveQuery: boolean; + createNew: boolean; + show: boolean; +} + +export interface DashboardAppServices { + core: CoreStart; + chrome: ChromeStart; + share?: SharePluginStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; + uiSettings: IUiSettingsClient; + restorePreviousUrl: () => void; + savedObjects: SavedObjectsStart; + savedDashboards: SavedObjectLoader; + scopedHistory: () => ScopedHistory; + indexPatterns: IndexPatternsContract; + usageCollection?: UsageCollectionSetup; + navigation: NavigationPublicPluginStart; + dashboardCapabilities: DashboardCapabilities; + initializerContext: PluginInitializerContext; + onAppLeave: AppMountParameters['onAppLeave']; + savedObjectsTagging?: SavedObjectsTaggingApi; + savedObjectsClient: SavedObjectsClientContract; + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + savedQueryService: DataPublicPluginStart['query']['savedQueries']; +} diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 12f6b706179075..1498485816adc3 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -20,6 +20,7 @@ export const DashboardConstants = { LANDING_PAGE_PATH: '/list', CREATE_NEW_DASHBOARD_URL: '/create', + VIEW_DASHBOARD_URL: '/view', ADD_EMBEDDABLE_ID: 'addEmbeddableId', ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', DASHBOARDS_ID: 'dashboards', @@ -28,5 +29,11 @@ export const DashboardConstants = { }; export function createDashboardEditUrl(id: string) { - return `/view/${id}`; + return `${DashboardConstants.VIEW_DASHBOARD_URL}/${id}`; +} + +export function createDashboardListingFilterUrl(filter: string | undefined) { + return filter + ? `${DashboardConstants.LANDING_PAGE_PATH}?filter="${filter}"` + : DashboardConstants.LANDING_PAGE_PATH; } diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts new file mode 100644 index 00000000000000..239846638d3aa3 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -0,0 +1,332 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { ViewMode } from './services/embeddable'; + +/** + * @param title {string} the current title of the dashboard + * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. + * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the + * end of the title. + * @returns {string} A title to display to the user based on the above parameters. + */ +export function getDashboardTitle( + title: string, + viewMode: ViewMode, + isDirty: boolean, + isNew: boolean +): string { + const isEditMode = viewMode === ViewMode.EDIT; + let displayTitle: string; + const newDashboardTitle = i18n.translate('dashboard.savedDashboard.newDashboardTitle', { + defaultMessage: 'New Dashboard', + }); + const dashboardTitle = isNew ? newDashboardTitle : title; + + if (isEditMode && isDirty) { + displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { + defaultMessage: 'Editing {title} (unsaved)', + values: { title: dashboardTitle }, + }); + } else if (isEditMode) { + displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { + defaultMessage: 'Editing {title}', + values: { title: dashboardTitle }, + }); + } else { + displayTitle = dashboardTitle; + } + + return displayTitle; +} + +/* + Plugin +*/ + +export const getDashboardBreadcrumb = () => + i18n.translate('dashboard.dashboardAppBreadcrumbsTitle', { + defaultMessage: 'Dashboard', + }); + +export const getDashboardPageTitle = () => + i18n.translate('dashboard.dashboardPageTitle', { + defaultMessage: 'Dashboards', + }); + +export const dashboardFeatureCatalog = { + getTitle: () => + i18n.translate('dashboard.featureCatalogue.dashboardTitle', { + defaultMessage: 'Dashboard', + }), + getSubtitle: () => + i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { + defaultMessage: 'Analyze data in dashboards.', + }), + getDescription: () => + i18n.translate('dashboard.featureCatalogue.dashboardDescription', { + defaultMessage: 'Display and share a collection of visualizations and saved searches.', + }), +}; + +/* + Actions +*/ +export const dashboardAddToLibraryAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.AddToLibrary', { + defaultMessage: 'Add to library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.addToLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} was added to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardClonePanelAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.clonePanel', { + defaultMessage: 'Clone panel', + }), + getClonedTag: () => + i18n.translate('dashboard.panel.title.clonedTag', { + defaultMessage: 'copy', + }), + getSuccessMessage: () => + i18n.translate('dashboard.panel.clonedToast', { + defaultMessage: 'Cloned panel', + }), +}; + +export const dashboardExpandPanelAction = { + getMinimizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.expandedDisplayName', { + defaultMessage: 'Minimize', + }), + getMaximizeTitle: () => + i18n.translate('dashboard.actions.toggleExpandPanelMenuItem.notExpandedDisplayName', { + defaultMessage: 'Maximize panel', + }), +}; + +export const dashboardExportCsvAction = { + getDisplayName: () => + i18n.translate('dashboard.actions.DownloadCreateDrilldownAction.displayName', { + defaultMessage: 'Download as CSV', + }), + getUntitledFilename: () => + i18n.translate('dashboard.actions.downloadOptionsUnsavedFilename', { + defaultMessage: 'unsaved', + }), +}; + +export const dashboardUnlinkFromLibraryAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from library', + }), + getSuccessMessage: (panelTitle: string) => + i18n.translate('dashboard.panel.unlinkFromLibrary.successMessage', { + defaultMessage: `Panel {panelTitle} is no longer connected to the visualize library`, + values: { panelTitle }, + }), +}; + +export const dashboardLibraryNotification = { + getDisplayName: () => + i18n.translate('dashboard.panel.LibraryNotification', { + defaultMessage: 'Visualize Library Notification', + }), + getTooltip: () => + i18n.translate('dashboard.panel.libraryNotification.toolTip', { + defaultMessage: + 'Editing this panel might affect other dashboards. To change to this panel only, unlink it from the library.', + }), + getPopoverAriaLabel: () => + i18n.translate('dashboard.panel.libraryNotification.ariaLabel', { + defaultMessage: 'View library information and unlink this panel', + }), +}; + +export const dashboardReplacePanelAction = { + getDisplayName: () => + i18n.translate('dashboard.panel.removePanel.replacePanel', { + defaultMessage: 'Replace panel', + }), + getSuccessMessage: (savedObjectName: string) => + i18n.translate('dashboard.addPanel.savedObjectAddedToContainerSuccessMessageTitle', { + defaultMessage: '{savedObjectName} was added', + values: { + savedObjectName, + }, + }), + getNoMatchingObjectsMessage: () => + i18n.translate('dashboard.addPanel.noMatchingObjectsMessage', { + defaultMessage: 'No matching objects found.', + }), +}; + +/* + Dashboard Editor +*/ +export const shareModalStrings = { + getTopMenuCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.topMenu', { + defaultMessage: 'Top menu', + }), + getQueryCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.query', { + defaultMessage: 'Query', + }), + getTimeFilterCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.timeFilter', { + defaultMessage: 'Time filter', + }), + getFilterBarCheckbox: () => + i18n.translate('dashboard.embedUrlParamExtension.filterBar', { + defaultMessage: 'Filter bar', + }), + getCheckboxLegend: () => + i18n.translate('dashboard.embedUrlParamExtension.include', { + defaultMessage: 'Include', + }), +}; + +export const getDashboard60Warning = () => + i18n.translate('dashboard.urlWasRemovedInSixZeroWarningMessage', { + defaultMessage: 'The url "dashboard/create" was removed in 6.0. Please update your bookmarks.', + }); + +export const dashboardReadonlyBadge = { + getText: () => + i18n.translate('dashboard.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + getTooltip: () => + i18n.translate('dashboard.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save dashboards', + }), +}; + +export const leaveConfirmStrings = { + getLeaveTitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesTitle', { + defaultMessage: 'Unsaved changes', + }), + getLeaveSubtitle: () => + i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { + defaultMessage: 'Leave Dashboard with unsaved work?', + }), + getDiscardTitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { + defaultMessage: 'Discard changes to dashboard?', + }), + getDiscardSubtitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesDescription', { + defaultMessage: `Once you discard your changes, there's no getting them back.`, + }), + getConfirmButtonText: () => + i18n.translate('dashboard.changeViewModeConfirmModal.confirmButtonLabel', { + defaultMessage: 'Discard changes', + }), + getCancelButtonText: () => + i18n.translate('dashboard.changeViewModeConfirmModal.cancelButtonLabel', { + defaultMessage: 'Continue editing', + }), +}; + +/* + Empty Screen +*/ +export const emptyScreenStrings = { + getEmptyDashboardTitle: () => + i18n.translate('dashboard.emptyDashboardTitle', { + defaultMessage: 'This dashboard is empty.', + }), + getEmptyDashboardAdditionalPrivilege: () => + i18n.translate('dashboard.emptyDashboardAdditionalPrivilege', { + defaultMessage: 'You need additional privileges to edit this dashboard.', + }), + getFillDashboardTitle: () => + i18n.translate('dashboard.fillDashboardTitle', { + defaultMessage: 'This dashboard is empty. Let\u2019s fill it up!', + }), + getHowToStartWorkingOnNewDashboardDescription1: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription1', { + defaultMessage: 'Click', + }), + getHowToStartWorkingOnNewDashboardDescription2: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardDescription2', { + defaultMessage: 'in the menu bar above to start adding panels.', + }), + getHowToStartWorkingOnNewDashboardEditLinkText: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkText', { + defaultMessage: 'Edit', + }), + getHowToStartWorkingOnNewDashboardEditLinkAriaLabel: () => + i18n.translate('dashboard.howToStartWorkingOnNewDashboardEditLinkAriaLabel', { + defaultMessage: 'Edit dashboard', + }), + getAddExistingVisualizationLinkText: () => + i18n.translate('dashboard.addExistingVisualizationLinkText', { + defaultMessage: 'Add an existing', + }), + getAddExistingVisualizationLinkAriaLabel: () => + i18n.translate('dashboard.addVisualizationLinkAriaLabel', { + defaultMessage: 'Add an existing visualization', + }), + getAddNewVisualizationDescription: () => + i18n.translate('dashboard.addNewVisualizationText', { + defaultMessage: 'or new object to this dashboard', + }), + getCreateNewVisualizationButton: () => + i18n.translate('dashboard.createNewVisualizationButton', { + defaultMessage: 'Create new', + }), + getCreateNewVisualizationButtonAriaLabel: () => + i18n.translate('dashboard.createNewVisualizationButtonAriaLabel', { + defaultMessage: 'Create new visualization button', + }), +}; + +/* + Dashboard Listing Page +*/ +export const dashboardListingTable = { + getEntityName: () => + i18n.translate('dashboard.listing.table.entityName', { + defaultMessage: 'dashboard', + }), + getEntityNamePlural: () => + i18n.translate('dashboard.listing.table.entityNamePlural', { + defaultMessage: 'dashboards', + }), + getTableListTitle: () => getDashboardPageTitle(), + getTableCaption: () => getDashboardPageTitle(), + getTitleColumnName: () => + i18n.translate('dashboard.listing.table.titleColumnName', { + defaultMessage: 'Title', + }), + getDescriptionColumnName: () => + i18n.translate('dashboard.listing.table.descriptionColumnName', { + defaultMessage: 'Description', + }), +}; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 004b1a901bca91..9a70598e43addd 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -25,9 +25,6 @@ export { DashboardContainerInput, DashboardContainerFactoryDefinition, DASHBOARD_CONTAINER_TYPE, - // Types below here can likely be made private when dashboard app moved into this NP plugin. - DEFAULT_PANEL_WIDTH, - DEFAULT_PANEL_HEIGHT, } from './application'; export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; @@ -42,8 +39,7 @@ export { createDashboardUrlGenerator, DashboardUrlGeneratorState, } from './url_generator'; -export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; -export { SavedObjectDashboard } from './saved_dashboards'; +export { DashboardSavedObject } from './saved_dashboards'; export { SavedDashboardPanel } from './types'; export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 76b1ccc037e89c..97e3174fba0988 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -20,51 +20,46 @@ import * as React from 'react'; import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; +import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; +import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import { App, - AppMountParameters, - AppUpdater, + Plugin, CoreSetup, CoreStart, - Plugin, + AppUpdater, + ScopedHistory, + AppMountParameters, + DEFAULT_APP_CATEGORIES, PluginInitializerContext, SavedObjectsClientContract, - ScopedHistory, -} from 'src/core/public'; -import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; -import { UsageCollectionSetup } from '../../usage_collection/public'; +} from '../../../core/public'; + +import { createKbnUrlTracker } from './services/kibana_utils'; +import { UsageCollectionSetup } from './services/usage_collection'; +import { UiActionsSetup, UiActionsStart } from './services/ui_actions'; +import { KibanaLegacySetup, KibanaLegacyStart } from './services/kibana_legacy'; +import { FeatureCatalogueCategory, HomePublicPluginSetup } from './services/home'; +import { NavigationPublicPluginStart as NavigationStart } from './services/navigation'; +import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from './services/data'; +import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from './services/share'; +import type { SavedObjectTaggingOssPluginStart } from './services/saved_objects_tagging_oss'; +import { + getSavedObjectFinder, + SavedObjectLoader, + SavedObjectsStart, +} from './services/saved_objects'; import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart, PANEL_NOTIFICATION_TRIGGER, -} from '../../embeddable/public'; -import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from '../../data/public'; -import { SharePluginSetup, SharePluginStart, UrlGeneratorContract } from '../../share/public'; -import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; - -import { Start as InspectorStartContract } from '../../inspector/public'; -import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; -import { - getSavedObjectFinder, - SavedObjectLoader, - SavedObjectsStart, -} from '../../saved_objects/public'; +} from './services/embeddable'; import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, -} from '../../kibana_react/public'; -import { createKbnUrlTracker, Storage } from '../../kibana_utils/public'; -import { - initAngularBootstrap, - KibanaLegacySetup, - KibanaLegacyStart, -} from '../../kibana_legacy/public'; -import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../plugins/home/public'; -import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +} from './services/kibana_react'; import { ACTION_CLONE_PANEL, @@ -78,7 +73,6 @@ import { DashboardContainerFactoryDefinition, ExpandPanelAction, ExpandPanelActionContext, - RenderDeps, ReplacePanelAction, ReplacePanelActionContext, ACTION_UNLINK_FROM_LIBRARY, @@ -98,7 +92,6 @@ import { } from './url_generator'; import { createSavedDashboardLoader } from './saved_dashboards'; import { DashboardConstants } from './dashboard_constants'; -import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { @@ -106,6 +99,7 @@ import { ExportContext, ExportCSVAction, } from './application/actions/export_csv_action'; +import { dashboardFeatureCatalog } from './dashboard_strings'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -119,7 +113,7 @@ export interface DashboardFeatureFlagConfig { allowByValueEmbeddables: boolean; } -interface SetupDependencies { +export interface DashboardSetupDependencies { data: DataPublicPluginSetup; embeddable: EmbeddableSetup; home?: HomePublicPluginSetup; @@ -130,7 +124,7 @@ interface SetupDependencies { usageCollection?: UsageCollectionSetup; } -interface StartDependencies { +export interface DashboardStartDependencies { data: DataPublicPluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; @@ -148,10 +142,6 @@ export type DashboardSetup = void; export interface DashboardStart { getSavedDashboardLoader: () => SavedObjectLoader; - addEmbeddableToDashboard: (options: { - embeddableId: string; - embeddableType: string; - }) => void | undefined; dashboardUrlGenerator?: DashboardUrlGenerator; dashboardFeatureFlagConfig: DashboardFeatureFlagConfig; DashboardContainerByValueRenderer: ReturnType; @@ -170,25 +160,30 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardPlugin - implements Plugin { + implements + Plugin { constructor(private initializerContext: PluginInitializerContext) {} private appStateUpdater = new BehaviorSubject(() => ({})); private stopUrlTracking: (() => void) | undefined = undefined; - private getActiveUrl: (() => string) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; private dashboardUrlGenerator?: DashboardUrlGenerator; public setup( - core: CoreSetup, - { share, uiActions, embeddable, home, urlForwarding, data, usageCollection }: SetupDependencies + core: CoreSetup, + { + share, + uiActions, + embeddable, + home, + urlForwarding, + data, + usageCollection, + }: DashboardSetupDependencies ): DashboardSetup { this.dashboardFeatureFlagConfig = this.initializerContext.config.get(); - const expandPanelAction = new ExpandPanelAction(); - uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -230,32 +225,37 @@ export class DashboardPlugin return ; }; return { - capabilities: coreStart.application.capabilities, - application: coreStart.application, + SavedObjectFinder: getSavedObjectFinder(coreStart.savedObjects, coreStart.uiSettings), + hideWriteControls: deps.kibanaLegacy.dashboardConfig.getHideWriteControls(), notifications: coreStart.notifications, + application: coreStart.application, + uiSettings: coreStart.uiSettings, overlays: coreStart.overlays, embeddable: deps.embeddable, + uiActions: deps.uiActions, inspector: deps.inspector, - SavedObjectFinder: getSavedObjectFinder(coreStart.savedObjects, coreStart.uiSettings), + http: coreStart.http, ExitFullScreenButton, - uiActions: deps.uiActions, }; }; - const factory = new DashboardContainerFactoryDefinition( - getStartServices, - () => this.currentHistory! - ); - embeddable.registerEmbeddableFactory(factory.type, factory); - - const placeholderFactory = new PlaceholderEmbeddableFactory(); - embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); + if (share) { + this.dashboardUrlGenerator = share.urlGenerators.registerUrlGenerator( + createDashboardUrlGenerator(async () => { + const [coreStart, , selfStart] = await core.getStartServices(); + return { + appBasePath: coreStart.application.getUrlForApp('dashboards'), + useHashedUrl: coreStart.uiSettings.get('state:storeInSessionStorage'), + savedDashboardLoader: selfStart.getSavedDashboardLoader(), + }; + }) + ); + } const { appMounted, appUnMounted, stop: stopUrlTracker, - getActiveUrl, restorePreviousUrl, } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/dashboards'), @@ -280,7 +280,15 @@ export class DashboardPlugin getHistory: () => this.currentHistory!, }); - this.getActiveUrl = getActiveUrl; + const factory = new DashboardContainerFactoryDefinition( + getStartServices, + () => this.currentHistory! + ); + embeddable.registerEmbeddableFactory(factory.type, factory); + + const placeholderFactory = new PlaceholderEmbeddableFactory(); + embeddable.registerEmbeddableFactory(placeholderFactory.type, placeholderFactory); + this.stopUrlTracking = () => { stopUrlTracker(); }; @@ -294,63 +302,24 @@ export class DashboardPlugin updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { - const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); this.currentHistory = params.history; + params.element.classList.add('dshAppContainer'); + const { mountApp } = await import('./application/dashboard_router'); appMounted(); - const { - embeddable: embeddableStart, - navigation, - share: shareStart, - data: dataStart, - kibanaLegacy: { dashboardConfig }, - urlForwarding: { navigateToDefaultApp, navigateToLegacyKibanaUrl }, - savedObjects, - savedObjectsTaggingOss, - } = pluginsStart; - - const deps: RenderDeps = { - pluginInitializerContext: this.initializerContext, - core: coreStart, - dashboardConfig, - navigateToDefaultApp, - navigateToLegacyKibanaUrl, - navigation, - share: shareStart, - data: dataStart, - savedObjectsClient: coreStart.savedObjects.client, - savedDashboards: dashboardStart.getSavedDashboardLoader(), - chrome: coreStart.chrome, - addBasePath: coreStart.http.basePath.prepend, - uiSettings: coreStart.uiSettings, - savedQueryService: dataStart.query.savedQueries, - embeddable: embeddableStart, - dashboardCapabilities: coreStart.application.capabilities.dashboard, - embeddableCapabilities: { - visualizeCapabilities: coreStart.application.capabilities.visualize, - mapsCapabilities: coreStart.application.capabilities.maps, - }, - localStorage: new Storage(localStorage), + return mountApp({ + core, + appUnMounted, usageCollection, - scopedHistory: () => this.currentHistory!, - setHeaderActionMenu: params.setHeaderActionMenu, - savedObjects, - savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + onAppLeave: params.onAppLeave, + initializerContext: this.initializerContext, restorePreviousUrl, - }; - // make sure the index pattern list is up to date - await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./application/application'); - params.element.classList.add('dshAppContainer'); - const unmount = renderApp(params.element, params.appBasePath, deps); - return () => { - unmount(); - appUnMounted(); - }; + element: params.element, + scopedHistory: this.currentHistory!, + setHeaderActionMenu: params.setHeaderActionMenu, + }); }, }; - initAngularBootstrap(); - core.application.register(app); urlForwarding.forwardApp( DashboardConstants.DASHBOARDS_ID, @@ -382,15 +351,9 @@ export class DashboardPlugin if (home) { home.featureCatalogue.register({ id: DashboardConstants.DASHBOARD_ID, - title: i18n.translate('dashboard.featureCatalogue.dashboardTitle', { - defaultMessage: 'Dashboard', - }), - subtitle: i18n.translate('dashboard.featureCatalogue.dashboardSubtitle', { - defaultMessage: 'Analyze data in dashboards.', - }), - description: i18n.translate('dashboard.featureCatalogue.dashboardDescription', { - defaultMessage: 'Display and share a collection of visualizations and saved searches.', - }), + title: dashboardFeatureCatalog.getTitle(), + subtitle: dashboardFeatureCatalog.getSubtitle(), + description: dashboardFeatureCatalog.getDescription(), icon: 'dashboardApp', path: `/app/dashboards#${DashboardConstants.LANDING_PAGE_PATH}`, showOnHomePage: false, @@ -401,29 +364,16 @@ export class DashboardPlugin } } - private addEmbeddableToDashboard( - core: CoreStart, - { embeddableId, embeddableType }: { embeddableId: string; embeddableType: string } - ) { - if (!this.getActiveUrl) { - throw new Error('dashboard is not ready yet.'); - } - - const lastDashboardUrl = this.getActiveUrl(); - const dashboardUrl = addEmbeddableToDashboardUrl( - lastDashboardUrl, - embeddableId, - embeddableType - ); - core.application.navigateToApp('dashboards', { path: dashboardUrl }); - } - - public start(core: CoreStart, plugins: StartDependencies): DashboardStart { + public start(core: CoreStart, plugins: DashboardStartDependencies): DashboardStart { const { notifications } = core; const { uiActions, data, share } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); + const expandPanelAction = new ExpandPanelAction(); + uiActions.registerAction(expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + const changeViewAction = new ReplacePanelAction( core, SavedObjectFinder, @@ -467,7 +417,6 @@ export class DashboardPlugin return { getSavedDashboardLoader: () => savedDashboardLoader, - addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core), dashboardUrlGenerator: this.dashboardUrlGenerator, dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index e3bfe346fbc07b..e9645b36af660e 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -16,15 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { SavedObject, SavedObjectsStart } from '../../../../plugins/saved_objects/public'; +import { EmbeddableStart } from '../services/embeddable'; +import { SavedObject, SavedObjectsStart } from '../services/saved_objects'; +import { Filter, ISearchSource, Query, RefreshInterval } from '../services/data'; -import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public'; import { createDashboardEditUrl } from '../dashboard_constants'; -import { EmbeddableStart } from '../../../embeddable/public'; -import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { extractReferences, injectReferences } from '../../common/saved_dashboard_references'; -export interface SavedObjectDashboard extends SavedObject { +import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; + +export interface DashboardSavedObject extends SavedObject { id?: string; timeRestore: boolean; timeTo?: string; @@ -45,7 +46,7 @@ export interface SavedObjectDashboard extends SavedObject { export function createSavedDashboardClass( savedObjectStart: SavedObjectsStart, embeddableStart: EmbeddableStart -): new (id: string) => SavedObjectDashboard { +): new (id: string) => DashboardSavedObject { class SavedDashboard extends savedObjectStart.SavedObjectClass { // save these objects with the 'dashboard' type public static type = 'dashboard'; @@ -84,7 +85,7 @@ export function createSavedDashboardClass( attributes: SavedObjectAttributes; references: SavedObjectReference[]; }) => extractReferences(opts, { embeddablePersistableStateService: embeddableStart }), - injectReferences: (so: SavedObjectDashboard, references: SavedObjectReference[]) => { + injectReferences: (so: DashboardSavedObject, references: SavedObjectReference[]) => { const newAttributes = injectReferences( { attributes: so._serialize().attributes, references }, { @@ -129,5 +130,5 @@ export function createSavedDashboardClass( // Unfortunately this throws a typescript error without the casting. I think it's due to the // convoluted way SavedObjects are created. - return (SavedDashboard as unknown) as new (id: string) => SavedObjectDashboard; + return (SavedDashboard as unknown) as new (id: string) => DashboardSavedObject; } diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 7193a77fd0ec9c..85deab0b1711d3 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -18,9 +18,11 @@ */ import { SavedObjectsClientContract } from 'kibana/public'; -import { SavedObjectLoader, SavedObjectsStart } from '../../../../plugins/saved_objects/public'; + +import { EmbeddableStart } from '../services/embeddable'; +import { SavedObjectLoader, SavedObjectsStart } from '../services/saved_objects'; + import { createSavedDashboardClass } from './saved_dashboard'; -import { EmbeddableStart } from '../../../embeddable/public'; interface Services { savedObjectsClient: SavedObjectsClientContract; diff --git a/src/plugins/dashboard/public/services/core.ts b/src/plugins/dashboard/public/services/core.ts new file mode 100644 index 00000000000000..e7dcc8463bb505 --- /dev/null +++ b/src/plugins/dashboard/public/services/core.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + AppMountParameters, + CoreSetup, + PluginInitializerContext, + ScopedHistory, + NotificationsStart, +} from '../../../../core/public'; diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js b/src/plugins/dashboard/public/services/data.ts similarity index 95% rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js rename to src/plugins/dashboard/public/services/data.ts index d675702ae54e93..8e1c96fea388cf 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/index.js +++ b/src/plugins/dashboard/public/services/data.ts @@ -17,4 +17,4 @@ * under the License. */ -export { jobs } from './jobs'; +export * from '../../../data/public'; diff --git a/src/plugins/dashboard/public/embeddable_plugin.ts b/src/plugins/dashboard/public/services/embeddable.ts similarity index 93% rename from src/plugins/dashboard/public/embeddable_plugin.ts rename to src/plugins/dashboard/public/services/embeddable.ts index 30c0ec49751416..8e1d91ca76f927 100644 --- a/src/plugins/dashboard/public/embeddable_plugin.ts +++ b/src/plugins/dashboard/public/services/embeddable.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/embeddable/public'; +export * from '../../../embeddable/public'; diff --git a/src/plugins/dashboard/public/embeddable_plugin_test_samples.ts b/src/plugins/dashboard/public/services/embeddable_test_samples.ts similarity index 92% rename from src/plugins/dashboard/public/embeddable_plugin_test_samples.ts rename to src/plugins/dashboard/public/services/embeddable_test_samples.ts index 45759bf0789112..3e2c188420828f 100644 --- a/src/plugins/dashboard/public/embeddable_plugin_test_samples.ts +++ b/src/plugins/dashboard/public/services/embeddable_test_samples.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from '../../../plugins/embeddable/public/lib/test_samples'; +export * from '../../../embeddable/public/lib/test_samples'; diff --git a/src/plugins/dashboard/public/services/home.ts b/src/plugins/dashboard/public/services/home.ts new file mode 100644 index 00000000000000..d5fb6f77f19227 --- /dev/null +++ b/src/plugins/dashboard/public/services/home.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../home/public'; diff --git a/src/plugins/dashboard/public/services/kibana_legacy.ts b/src/plugins/dashboard/public/services/kibana_legacy.ts new file mode 100644 index 00000000000000..4f6f87b3bdab37 --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_legacy.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { KibanaLegacySetup, KibanaLegacyStart } from '../../../kibana_legacy/public'; diff --git a/src/plugins/dashboard/public/services/kibana_react.ts b/src/plugins/dashboard/public/services/kibana_react.ts new file mode 100644 index 00000000000000..6cb70c1526eb7d --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_react.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + context, + useKibana, + withKibana, + toMountPoint, + TableListView, + reactToUiComponent, + KibanaReactContext, + ExitFullScreenButton, + KibanaContextProvider, + KibanaReactContextValue, + ExitFullScreenButtonProps, +} from '../../../kibana_react/public'; diff --git a/src/plugins/dashboard/public/services/kibana_utils.ts b/src/plugins/dashboard/public/services/kibana_utils.ts new file mode 100644 index 00000000000000..876e9c0773542f --- /dev/null +++ b/src/plugins/dashboard/public/services/kibana_utils.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + unhashUrl, + syncState, + ISyncStateRef, + getQueryParams, + setStateToKbnUrl, + removeQueryParam, + withNotifyOnErrors, + IKbnUrlStateStorage, + createKbnUrlTracker, + SavedObjectNotFound, + createStateContainer, + ReduxLikeStateContainer, + createKbnUrlStateStorage, +} from '../../../kibana_utils/public'; diff --git a/src/plugins/dashboard/public/services/navigation.ts b/src/plugins/dashboard/public/services/navigation.ts new file mode 100644 index 00000000000000..23049caaf48f30 --- /dev/null +++ b/src/plugins/dashboard/public/services/navigation.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { NavigationPublicPluginStart } from '../../../navigation/public'; diff --git a/src/plugins/dashboard/public/services/saved_objects.ts b/src/plugins/dashboard/public/services/saved_objects.ts new file mode 100644 index 00000000000000..d93c69ab25571a --- /dev/null +++ b/src/plugins/dashboard/public/services/saved_objects.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + SaveResult, + SavedObject, + showSaveModal, + SavedObjectLoader, + SavedObjectsStart, + SavedObjectSaveOpts, + SavedObjectSaveModal, + getSavedObjectFinder, + SavedObjectLoaderFindOptions, +} from '../../../saved_objects/public'; diff --git a/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts b/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts new file mode 100644 index 00000000000000..858af8f79d866f --- /dev/null +++ b/src/plugins/dashboard/public/services/saved_objects_tagging_oss.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type { + SavedObjectsTaggingApi, + TagDecoratedSavedObject, + SavedObjectTagDecoratorTypeGuard, + SavedObjectTaggingOssPluginStart, +} from '../../../saved_objects_tagging_oss/public'; diff --git a/src/plugins/dashboard/public/services/share.ts b/src/plugins/dashboard/public/services/share.ts new file mode 100644 index 00000000000000..b1c5df542711c5 --- /dev/null +++ b/src/plugins/dashboard/public/services/share.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + SharePluginStart, + SharePluginSetup, + downloadMultipleAs, + UrlGeneratorContract, +} from '../../../share/public'; diff --git a/src/plugins/dashboard/public/services/ui_actions.ts b/src/plugins/dashboard/public/services/ui_actions.ts new file mode 100644 index 00000000000000..4c9ac590191f6b --- /dev/null +++ b/src/plugins/dashboard/public/services/ui_actions.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + ActionByType, + IncompatibleActionError, + UiActionsSetup, + UiActionsStart, +} from '../../../ui_actions/public'; diff --git a/src/plugins/dashboard/public/services/usage_collection.ts b/src/plugins/dashboard/public/services/usage_collection.ts new file mode 100644 index 00000000000000..e740a42cede5ad --- /dev/null +++ b/src/plugins/dashboard/public/services/usage_collection.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { UsageCollectionSetup } from '../../../usage_collection/public'; diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 8f6fe7fce5cfe5..7e859a81d9d4d5 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -17,19 +17,13 @@ * under the License. */ -import { Query, Filter } from 'src/plugins/data/public'; import { SavedObject as SavedObjectType, SavedObjectAttributes } from 'src/core/public'; - -import { ViewMode } from './embeddable_plugin'; +import { Query, Filter } from './services/data'; +import { ViewMode } from './services/embeddable'; import { SavedDashboardPanel } from '../common/types'; export { SavedDashboardPanel }; -export interface DashboardCapabilities { - showWriteControls: boolean; - createNew: boolean; -} - // TODO: Replace Saved object interfaces by the ones Core will provide when it is ready. export type SavedObjectAttribute = | string diff --git a/src/plugins/dashboard/public/url_utils/url_helper.test.ts b/src/plugins/dashboard/public/url_utils/url_helper.test.ts deleted file mode 100644 index d2210e73806677..00000000000000 --- a/src/plugins/dashboard/public/url_utils/url_helper.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { addEmbeddableToDashboardUrl } from './url_helper'; - -describe('', () => { - it('addEmbeddableToDashboardUrl when dashboard is not saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/dashboards#/create?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - - expect(addEmbeddableToDashboardUrl(url, id, 'visualization')).toBe( - '/pep/app/dashboards?addEmbeddableId=123eb456cd&addEmbeddableType=visualization#%2Fcreate%3F_g%3D%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29%26_a%3D%28description%3A%27%27%2Cfilters%3A%21%28%29%29' - ); - }); - it('addEmbeddableToDashboardUrl when dashboard is saved', () => { - const id = '123eb456cd'; - const url = - "/pep/app/dashboards#/view/9b780cd0-3dd3-11e8-b2b9-5d5dc1715159?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!())"; - expect(addEmbeddableToDashboardUrl(url, id, 'visualization')).toBe( - '/pep/app/dashboards?addEmbeddableId=123eb456cd&addEmbeddableType=visualization#%2Fview%2F9b780cd0-3dd3-11e8-b2b9-5d5dc1715159%3F_g%3D%28refreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%2Ctime%3A%28from%3Anow-15m%2Cto%3Anow%29%29%26_a%3D%28description%3A%27%27%2Cfilters%3A%21%28%29%29' - ); - }); -}); diff --git a/src/plugins/dashboard/public/url_utils/url_helper.ts b/src/plugins/dashboard/public/url_utils/url_helper.ts deleted file mode 100644 index 1f4706f0b8a4dc..00000000000000 --- a/src/plugins/dashboard/public/url_utils/url_helper.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { parseUrl, stringifyUrl } from 'query-string'; -import { DashboardConstants } from '../index'; - -/** * - * Returns relative dashboard URL with added embeddableType and embeddableId query params - * eg. - * input: url: #/create?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345 - * output: #/create?addEmbeddableType=visualization&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) - * @param url dasbhoard hash part of the url - * @param embeddableId id of the saved embeddable - * @param embeddableType type of the embeddable - */ -export function addEmbeddableToDashboardUrl( - dashboardUrl: string, - embeddableId: string, - embeddableType: string -) { - const { url, query, fragmentIdentifier } = parseUrl(dashboardUrl, { - parseFragmentIdentifier: true, - }); - - if (embeddableId) { - query[DashboardConstants.ADD_EMBEDDABLE_TYPE] = embeddableType; - query[DashboardConstants.ADD_EMBEDDABLE_ID] = embeddableId; - } - - return stringifyUrl({ url, query, fragmentIdentifier }); -} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 179d6c35b3ab6d..35546c33aaa804 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -31,7 +31,6 @@ import { EuiConfirmModalProps } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { ExclusiveUnion } from '@elastic/eui'; import { ExecutionContext } from 'src/plugins/expressions/common'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; diff --git a/src/plugins/data/public/ui/filter_bar/_index.scss b/src/plugins/data/public/ui/filter_bar/_index.scss index 9e2478cb0704ea..5333aff8b87da3 100644 --- a/src/plugins/data/public/ui/filter_bar/_index.scss +++ b/src/plugins/data/public/ui/filter_bar/_index.scss @@ -1,4 +1,3 @@ @import 'variables'; @import 'global_filter_group'; @import 'global_filter_item'; -@import 'filter_editor/index'; diff --git a/src/plugins/data/public/ui/filter_bar/_variables.scss b/src/plugins/data/public/ui/filter_bar/_variables.scss index efe2e28ac3b8aa..3a9a0df4332c81 100644 --- a/src/plugins/data/public/ui/filter_bar/_variables.scss +++ b/src/plugins/data/public/ui/filter_bar/_variables.scss @@ -1,4 +1,3 @@ $kbnGlobalFilterItemBorderColor: tintOrShade($euiColorMediumShade, 35%, 20%); $kbnGlobalFilterItemBorderColorExcluded: tintOrShade($euiColorDanger, 70%, 50%); $kbnGlobalFilterItemPinnedColorExcluded: tintOrShade($euiColorDanger, 30%, 20%); -$kbnGlobalFilterItemEditorWidth: 420px; // if changing this make sure to also change `FILTER_EDITOR_WIDTH` in ./filter_item.tsx diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss b/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss deleted file mode 100644 index 736e06ee9bdea0..00000000000000 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/_filter_editor.scss +++ /dev/null @@ -1,5 +0,0 @@ -.globalFilterEditor__fieldInput { - @include euiBreakpoint('m', 'l', 'xl') { - max-width: $kbnGlobalFilterItemEditorWidth * 0.66; - } -} diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss b/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss deleted file mode 100644 index 3d416aade9a533..00000000000000 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'filter_editor'; diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx index 2b8e11fc8f756e..d25c092bc97f46 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/index.tsx @@ -154,10 +154,12 @@ class FilterEditorUI extends Component { id: 'data.filter.filterEditor.createCustomLabelInputLabel', defaultMessage: 'Custom label', })} + fullWidth > @@ -218,12 +220,14 @@ class FilterEditorUI extends Component { { return ( { onChange={this.onFieldChange} singleSelection={{ asPlainText: true }} isClearable={false} - className="globalFilterEditor__fieldInput" data-test-subj="filterFieldSuggestionList" /> @@ -293,12 +298,14 @@ class FilterEditorUI extends Component { const operators = selectedField ? getOperatorOptions(selectedField) : []; return ( { private renderCustomEditor() { return ( { value={this.state.params} onChange={this.onParamsChange} data-test-subj="phraseValueInput" + fullWidth /> ); case 'phrases': @@ -367,6 +376,7 @@ class FilterEditorUI extends Component { field={this.state.selectedField} values={this.state.params} onChange={this.onParamsChange} + fullWidth /> ); case 'range': @@ -375,6 +385,7 @@ class FilterEditorUI extends Component { field={this.state.selectedField} value={this.state.params} onChange={this.onParamsChange} + fullWidth /> ); } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx index ca94970afbafd1..1ae88b8a218320 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/phrase_value_input.tsx @@ -30,12 +30,14 @@ interface Props extends PhraseSuggestorProps { value?: string; onChange: (value: string | number | boolean) => void; intl: InjectedIntl; + fullWidth?: boolean; } class PhraseValueInputUI extends PhraseSuggestorUI { public render() { return ( { this.renderWithSuggestions() ) : ( { private renderWithSuggestions() { const { suggestions } = this.state; - const { value, intl, onChange } = this.props; + const { value, intl, onChange, fullWidth } = this.props; // there are cases when the value is a number, this would cause an exception const valueAsStr = String(value); const options = value ? uniq([valueAsStr, ...suggestions]) : suggestions; return ( void; intl: InjectedIntl; + fullWidth?: boolean; } class PhrasesValuesInputUI extends PhraseSuggestorUI { public render() { const { suggestions } = this.state; - const { values, intl, onChange } = this.props; + const { values, intl, onChange, fullWidth } = this.props; const options = values ? uniq([...values, ...suggestions]) : suggestions; return ( void; intl: InjectedIntl; + fullWidth?: boolean; } function RangeValueInputUI(props: Props) { @@ -71,6 +72,7 @@ function RangeValueInputUI(props: Props) { return (
{ @@ -42,6 +43,7 @@ class ValueInputTypeUI extends Component { case 'string': inputElement = ( { case 'number': inputElement = ( { case 'date': inputElement = ( { case 'ip': inputElement = ( { value={value} onChange={this.onBoolChange} className={this.props.className} + fullWidth={this.props.fullWidth} /> ); break; diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index 48dbfea634256b..5e6fd5323c0b70 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -62,12 +62,7 @@ export type FilterLabelStatus = | typeof FILTER_ITEM_WARNING | typeof FILTER_ITEM_ERROR; -/** - * @remarks - * if changing this make sure to also change - * $kbnGlobalFilterItemEditorWidth - */ -export const FILTER_EDITOR_WIDTH = 420; +export const FILTER_EDITOR_WIDTH = 800; export function FilterItem(props: Props) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js b/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js deleted file mode 100644 index 39ebd9595eeafc..00000000000000 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/fixtures/jobs.js +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export const jobs = [ - { - job_id: 'foo1', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - node: [ - { - agg: 'terms', - }, - ], - temperature: [ - { - agg: 'min', - }, - { - agg: 'max', - }, - { - agg: 'sum', - }, - ], - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'UTC', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 5, - }, - { - agg: 'sum', - }, - ], - }, - }, - { - job_id: 'foo2', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - host: [ - { - agg: 'terms', - }, - ], - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'UTC', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 20, - }, - ], - }, - }, - { - job_id: 'foo3', - rollup_index: 'foo_rollup', - index_pattern: 'foo-*', - fields: { - timestamp: [ - { - agg: 'date_histogram', - time_zone: 'PST', - interval: '1h', - delay: '7d', - }, - ], - voltage: [ - { - agg: 'histogram', - interval: 5, - }, - { - agg: 'sum', - }, - ], - }, - }, -]; diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js b/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js similarity index 52% rename from src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js rename to src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js index e3c93ac1f86160..c8b0b90eb79995 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/__tests__/jobs_compatibility.js +++ b/src/plugins/data/server/index_patterns/fetcher/lib/jobs_compatibility.test.js @@ -17,46 +17,137 @@ * under the License. */ -import expect from '@kbn/expect'; -import { areJobsCompatible, mergeJobConfigurations } from '../jobs_compatibility'; -import { jobs } from './fixtures'; +import { areJobsCompatible, mergeJobConfigurations } from './jobs_compatibility'; + +const jobs = [ + { + job_id: 'foo1', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + node: [ + { + agg: 'terms', + }, + ], + temperature: [ + { + agg: 'min', + }, + { + agg: 'max', + }, + { + agg: 'sum', + }, + ], + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'UTC', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 5, + }, + { + agg: 'sum', + }, + ], + }, + }, + { + job_id: 'foo2', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + host: [ + { + agg: 'terms', + }, + ], + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'UTC', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 20, + }, + ], + }, + }, + { + job_id: 'foo3', + rollup_index: 'foo_rollup', + index_pattern: 'foo-*', + fields: { + timestamp: [ + { + agg: 'date_histogram', + time_zone: 'PST', + interval: '1h', + delay: '7d', + }, + ], + voltage: [ + { + agg: 'histogram', + interval: 5, + }, + { + agg: 'sum', + }, + ], + }, + }, +]; describe('areJobsCompatible', () => { it('should return false for invalid jobs arg', () => { - expect(areJobsCompatible(123)).to.eql(false); - expect(areJobsCompatible('foo')).to.eql(false); + expect(areJobsCompatible(123)).toEqual(false); + expect(areJobsCompatible('foo')).toEqual(false); }); it('should return true for no jobs or one job', () => { - expect(areJobsCompatible()).to.eql(true); - expect(areJobsCompatible([])).to.eql(true); - expect(areJobsCompatible([jobs[1]])).to.eql(true); + expect(areJobsCompatible()).toEqual(true); + expect(areJobsCompatible([])).toEqual(true); + expect(areJobsCompatible([jobs[1]])).toEqual(true); }); it('should return true for 2 or more compatible jobs', () => { - expect(areJobsCompatible([jobs[0], jobs[1]])).to.eql(true); - expect(areJobsCompatible([jobs[1], jobs[0], jobs[1]])).to.eql(true); + expect(areJobsCompatible([jobs[0], jobs[1]])).toEqual(true); + expect(areJobsCompatible([jobs[1], jobs[0], jobs[1]])).toEqual(true); }); it('should return false for 2 or more incompatible jobs', () => { - expect(areJobsCompatible([jobs[1], jobs[2]])).to.eql(false); - expect(areJobsCompatible([jobs[2], jobs[1], jobs[0]])).to.eql(false); + expect(areJobsCompatible([jobs[1], jobs[2]])).toEqual(false); + expect(areJobsCompatible([jobs[2], jobs[1], jobs[0]])).toEqual(false); }); }); describe('mergeJobConfigurations', () => { it('should throw an error for null/invalid jobs', () => { - expect(mergeJobConfigurations).withArgs().to.throwException(); - expect(mergeJobConfigurations).withArgs(null).to.throwException(); - expect(mergeJobConfigurations).withArgs(undefined).to.throwException(); - expect(mergeJobConfigurations).withArgs(true).to.throwException(); - expect(mergeJobConfigurations).withArgs('foo').to.throwException(); - expect(mergeJobConfigurations).withArgs(123).to.throwException(); - expect(mergeJobConfigurations).withArgs([]).to.throwException(); + expect(() => mergeJobConfigurations()).toThrow(); + expect(() => mergeJobConfigurations(null)).toThrow(); + expect(() => mergeJobConfigurations(undefined)).toThrow(); + expect(() => mergeJobConfigurations(true)).toThrow(); + expect(() => mergeJobConfigurations('foo')).toThrow(); + expect(() => mergeJobConfigurations(123)).toThrow(); + expect(() => mergeJobConfigurations([])).toThrow(); }); it('should return aggregations for one job', () => { - expect(mergeJobConfigurations([jobs[0]])).to.eql({ + expect(mergeJobConfigurations([jobs[0]])).toEqual({ aggs: { terms: { node: { @@ -100,7 +191,7 @@ describe('mergeJobConfigurations', () => { }); it('should return merged aggregations for 2 jobs', () => { - expect(mergeJobConfigurations([jobs[0], jobs[1]])).to.eql({ + expect(mergeJobConfigurations([jobs[0], jobs[1]])).toEqual({ aggs: { terms: { node: { @@ -147,6 +238,6 @@ describe('mergeJobConfigurations', () => { }); it('should throw an error if jobs are not compatible', () => { - expect(mergeJobConfigurations).withArgs([jobs[0], jobs[1], jobs[2]]).to.throwException(); + expect(() => mergeJobConfigurations([jobs[0], jobs[1], jobs[2]])).toThrow(); }); }); diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 8f7ea6f51c7850..e42eaaf86bdf36 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -31,7 +31,6 @@ import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui'; import { EuiGlobalToastListToast } from '@elastic/eui'; import { EventEmitter } from 'events'; -import { ExclusiveUnion } from '@elastic/eui'; import { ExpressionAstExpression } from 'src/plugins/expressions/common'; import { History } from 'history'; import { Href } from 'history'; diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx index 2565f1c6290c8c..0cf1f8e65db07b 100644 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { EuiComboBox } from '@elastic/eui'; import { SavedObjectsClientContract } from '../../../../core/public'; -import { SavedObjectDashboard } from '../../../../plugins/dashboard/public'; +import { DashboardSavedObject } from '../../../../plugins/dashboard/public'; export interface DashboardPickerProps { onChange: (dashboard: { name: string; id: string } | null) => void; @@ -48,7 +48,7 @@ export function DashboardPicker(props: DashboardPickerProps) { setIsLoadingDashboards(true); setDashboards([]); - const { savedObjects } = await savedObjectsClient.find({ + const { savedObjects } = await savedObjectsClient.find({ type: 'dashboard', search: query ? `${query}*` : '', searchFields: ['title'], diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 265f47773dd45a..27a7f585a5cf78 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -17,8 +17,6 @@ * under the License. */ -import { DashboardConstants } from '../../../src/plugins/dashboard/public/dashboard_constants'; - export const PIE_CHART_VIS_NAME = 'Visualization PieChart'; export const AREA_CHART_VIS_NAME = 'Visualization漢字 AreaChart'; export const LINE_CHART_VIS_NAME = 'Visualization漢字 LineChart'; @@ -136,15 +134,23 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide public async clickDashboardBreadcrumbLink() { log.debug('clickDashboardBreadcrumbLink'); - await find.clickByCssSelector(`a[href="#${DashboardConstants.LANDING_PAGE_PATH}"]`); - await this.expectExistsDashboardLandingPage(); + await testSubjects.click('breadcrumb dashboardListingBreadcrumb first'); } - public async gotoDashboardLandingPage() { + public async gotoDashboardLandingPage(ignorePageLeaveWarning = true) { log.debug('gotoDashboardLandingPage'); const onPage = await this.onDashboardLandingPage(); if (!onPage) { await this.clickDashboardBreadcrumbLink(); + await retry.try(async () => { + const warning = await testSubjects.exists('confirmModalTitleText'); + if (warning) { + await testSubjects.click( + ignorePageLeaveWarning ? 'confirmModalConfirmButton' : 'confirmModalCancelButton' + ); + } + }); + await this.expectExistsDashboardLandingPage(); } } diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json new file mode 100644 index 00000000000000..984b96a8bcba1e --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_plugin_helpmenu", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_helpmenu"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/package.json b/test/plugin_functional/plugins/core_plugin_helpmenu/package.json new file mode 100644 index 00000000000000..bfb203d6a4d865 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/package.json @@ -0,0 +1,14 @@ +{ + "name": "core_plugin_helpmenu", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_helpmenu", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx b/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx new file mode 100644 index 00000000000000..d0b024f90c737b --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/application.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountParameters } from 'kibana/public'; + +const App = ({ appName }: { appName: string }) => ( + + + + + +

Welcome to {appName}!

+
+
+
+ + + + +

{appName} home page section title

+
+
+
+ {appName} page content +
+
+
+); + +export const renderApp = (appName: string, { element }: AppMountParameters) => { + render(, element); + return () => unmountComponentAtNode(element); +}; diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts b/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts new file mode 100644 index 00000000000000..eb04224532c5a8 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializer } from 'kibana/public'; +import { CoreHelpMenuPlugin, CoreHelpMenuPluginSetup, CoreHelpMenuPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreHelpMenuPlugin(); diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx b/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx new file mode 100644 index 00000000000000..db22038e602c8b --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, CoreSetup } from 'kibana/public'; + +export class CoreHelpMenuPlugin + implements Plugin { + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'core_help_menu', + title: 'Help Menu Test App', + async mount(context, params) { + const [{ chrome, http }] = await core.getStartServices(); + + chrome.setHelpExtension({ + appName: 'HelpMenuTestApp', + links: [ + { + linkType: 'custom', + href: http.basePath.prepend('/app/management'), + content: 'Go to management', + 'data-test-subj': 'coreHelpMenuInternalLinkTest', + }, + ], + }); + + const { renderApp } = await import('./application'); + return renderApp('Help Menu Test App', params); + }, + }); + + return {}; + } + + public start() {} + public stop() {} +} + +export type CoreHelpMenuPluginSetup = ReturnType; +export type CoreHelpMenuPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json b/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json new file mode 100644 index 00000000000000..f9b0443e0a8bfa --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_helpmenu/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts b/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts new file mode 100644 index 00000000000000..d82a15a0854eaf --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/chrome_help_menu_links.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import url from 'url'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +declare global { + interface Window { + _nonReloadedFlag?: boolean; + } +} + +const getPathWithHash = (absoluteUrl: string) => { + const parsed = url.parse(absoluteUrl); + return `${parsed.path}${parsed.hash ?? ''}`; +}; + +export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + const setNonReloadedFlag = () => { + return browser.executeAsync(async (cb) => { + window._nonReloadedFlag = true; + cb(); + }); + }; + const wasReloaded = () => { + return browser.executeAsync(async (cb) => { + const reloaded = window._nonReloadedFlag !== true; + cb(reloaded); + }); + }; + + describe('chrome helpMenu links', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('core_help_menu'); + await setNonReloadedFlag(); + }); + + it('navigates to internal custom links without performing a full page refresh', async () => { + await testSubjects.click('helpMenuButton'); + await testSubjects.click('coreHelpMenuInternalLinkTest'); + + expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/management'); + expect(await wasReloaded()).to.eql(false); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 3d7cc751175c67..e53323d622d584 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -29,5 +29,6 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./application_leave_confirm')); loadTestFile(require.resolve('./application_status')); loadTestFile(require.resolve('./rendering')); + loadTestFile(require.resolve('./chrome_help_menu_links')); }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 2165ba56428c94..c7d0153daec243 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -111,7 +111,9 @@ describe('Jira service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://siem-kibana.atlassian.net/', projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index b3c5bb4a84de5b..742e68eccbb237 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -48,21 +48,22 @@ export const createExternalService = ( throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/issue`; - const capabilitiesUrl = `${url}/${CAPABILITIES_URL}`; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue`; + const capabilitiesUrl = `${urlWithoutTrailingSlash}/${CAPABILITIES_URL}`; const commentUrl = `${incidentUrl}/{issueId}/comment`; - const getIssueTypesOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; - const getIssueTypeFieldsOldAPIURL = `${url}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; - const getIssueTypesUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; - const getIssueTypeFieldsUrl = `${url}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; - const searchUrl = `${url}/${BASE_URL}/search`; + const getIssueTypesOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&expand=projects.issuetypes.fields`; + const getIssueTypeFieldsOldAPIURL = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta?projectKeys=${projectKey}&issuetypeIds={issueTypeId}&expand=projects.issuetypes.fields`; + const getIssueTypesUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes`; + const getIssueTypeFieldsUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/issue/createmeta/${projectKey}/issuetypes/{issueTypeId}`; + const searchUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/search`; const axiosInstance = axios.create({ auth: { username: email, password: apiToken }, }); const getIncidentViewURL = (key: string) => { - return `${url}/${VIEW_INCIDENT_URL}/${key}`; + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; }; const getCommentsURL = (issueId: string) => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index ecf246cb8fe3c6..9362b0d4d2bada 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -81,7 +81,9 @@ describe('IBM Resilient service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' }, secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 8ec80be1e2b094..3e4873270ad7af 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -34,7 +34,9 @@ describe('ServiceNow service', () => { beforeAll(() => { service = createExternalService( { - config: { apiUrl: 'https://dev102283.service-now.com' }, + // The trailing slash at the end of the url is intended. + // All API calls need to have the trailing slash removed. + config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, logger diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 57f7176e2353c8..29614a4b951e10 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -33,14 +33,15 @@ export const createExternalService = ( throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${INCIDENT_URL}`; - const fieldsUrl = `${url}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${INCIDENT_URL}`; + const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^internal_type=string&active=true&read_only=false&sysparm_fields=max_length,element,column_label`; const axiosInstance = axios.create({ auth: { username, password }, }); const getIncidentViewURL = (id: string) => { - return `${url}/${VIEW_INCIDENT_URL}${id}`; + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}${id}`; }; const getIncident = async (id: string) => { diff --git a/x-pack/plugins/alerts/common/alert.ts b/x-pack/plugins/alerts/common/alert.ts index 88f6090d20737b..e0e73e978f775a 100644 --- a/x-pack/plugins/alerts/common/alert.ts +++ b/x-pack/plugins/alerts/common/alert.ts @@ -5,6 +5,7 @@ */ import { SavedObjectAttribute, SavedObjectAttributes } from 'kibana/server'; +import { AlertNotifyWhenType } from './alert_notify_when_type'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AlertTypeState = Record; @@ -68,6 +69,7 @@ export interface Alert { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; executionStatus: AlertExecutionStatus; diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts new file mode 100644 index 00000000000000..ad0b0430c6c1fb --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.test.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateNotifyWhenType } from './alert_notify_when_type'; + +test('validates valid notify when type', () => { + expect(validateNotifyWhenType('onActionGroupChange')).toBeUndefined(); + expect(validateNotifyWhenType('onActiveAlert')).toBeUndefined(); + expect(validateNotifyWhenType('onThrottleInterval')).toBeUndefined(); +}); +test('returns error string if input is not valid notify when type', () => { + expect(validateNotifyWhenType('randomString')).toEqual( + `string is not a valid AlertNotifyWhenType: randomString` + ); +}); diff --git a/x-pack/plugins/alerts/common/alert_notify_when_type.ts b/x-pack/plugins/alerts/common/alert_notify_when_type.ts new file mode 100644 index 00000000000000..4ae4be0ac20ab9 --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_notify_when_type.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const AlertNotifyWhenTypeValues = [ + 'onActionGroupChange', + 'onActiveAlert', + 'onThrottleInterval', +] as const; +export type AlertNotifyWhenType = typeof AlertNotifyWhenTypeValues[number]; + +export function validateNotifyWhenType(notifyWhen: string) { + if (AlertNotifyWhenTypeValues.includes(notifyWhen as AlertNotifyWhenType)) { + return; + } + return `string is not a valid AlertNotifyWhenType: ${notifyWhen}`; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 3e551facd98a04..cbdfec642fa74b 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -14,6 +14,7 @@ export * from './alert_navigation'; export * from './alert_instance_summary'; export * from './builtin_action_groups'; export * from './disabled_action_groups'; +export * from './alert_notify_when_type'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts index e680f22afad8e7..b428f6c1a91348 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts @@ -72,6 +72,114 @@ describe('isThrottled', () => { }); }); +describe('scheduledActionGroupOrSubgroupHasChanged()', () => { + test('should be false if no last scheduled and nothing scheduled', () => { + const alertInstance = new AlertInstance(); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group and subgroup does not change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from undefined to defined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be false if group does not change and subgroup goes from defined to undefined', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); + }); + + test('should be true if no last scheduled and has scheduled action', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActions('default'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance.scheduleActions('penguin'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('penguin', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); + + test('should be true if group does not change and subgroup does change', () => { + const alertInstance = new AlertInstance({ + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance.scheduleActionsWithSubGroup('default', 'fish'); + expect(alertInstance.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); + }); +}); + describe('getScheduledActionOptions()', () => { test('defaults to undefined', () => { const alertInstance = new AlertInstance(); diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index ba3a2961b96f7e..8841f3115d547d 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -70,6 +70,31 @@ export class AlertInstance< return false; } + scheduledActionGroupOrSubgroupHasChanged(): boolean { + if (!this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // it is considered a change when there are no previous scheduled actions + // and new scheduled actions + return true; + } + + if (this.meta.lastScheduledActions && this.scheduledExecutionOptions) { + // compare previous and new scheduled actions if both exist + return ( + !this.scheduledActionGroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) || + !this.scheduledActionSubgroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) + ); + } + + // no previous and no new scheduled actions + return false; + } + private scheduledActionGroupIsUnchanged( lastScheduledActions: NonNullable, scheduledExecutionOptions: ScheduledExecutionOptions diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index d697817be734b7..b1696696b30444 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -29,8 +29,13 @@ import { AlertTaskState, AlertInstanceSummary, AlertExecutionStatusValues, + AlertNotifyWhenType, } from '../types'; -import { validateAlertTypeParams, alertExecutionStatusFromRaw } from '../lib'; +import { + validateAlertTypeParams, + alertExecutionStatusFromRaw, + getAlertNotifyWhenType, +} from '../lib'; import { GrantAPIKeyResult as SecurityPluginGrantAPIKeyResult, InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, @@ -157,6 +162,7 @@ interface UpdateOptions { actions: NormalizedAlertAction[]; params: Record; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; }; } @@ -251,6 +257,8 @@ export class AlertsClient { const createTime = Date.now(); const { references, actions } = await this.denormalizeActions(data.actions); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); + const rawAlert: RawAlert = { ...data, ...this.apiKeyAsAlertAttributes(createdAPIKey, username), @@ -262,6 +270,7 @@ export class AlertsClient { params: validatedAlertTypeParams as RawAlert['params'], muteAll: false, mutedInstanceIds: [], + notifyWhen, executionStatus: { status: 'pending', lastExecutionDate: new Date().toISOString(), @@ -694,6 +703,7 @@ export class AlertsClient { ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); + const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); let updatedObject: SavedObject; const createAttributes = this.updateMeta({ @@ -702,6 +712,7 @@ export class AlertsClient { ...apiKeyAttributes, params: validatedAlertTypeParams as RawAlert['params'], actions, + notifyWhen, updatedBy: username, updatedAt: new Date().toISOString(), }); @@ -1326,7 +1337,7 @@ export class AlertsClient { private getPartialAlertFromRaw( id: string, - { createdAt, updatedAt, meta, scheduledTaskId, ...rawAlert }: Partial, + { createdAt, updatedAt, meta, notifyWhen, scheduledTaskId, ...rawAlert }: Partial, references: SavedObjectReference[] | undefined ): PartialAlert { // Not the prettiest code here, but if we want to use most of the @@ -1341,6 +1352,7 @@ export class AlertsClient { const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus); return { id, + notifyWhen, ...rawAlertWithoutExecutionStatus, // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index b943a21ba9bb63..4e273ee3a9e449 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -68,6 +68,7 @@ function getMockData(overwrites: Record = {}): CreateOptions['d consumer: 'bar', schedule: { interval: '10s' }, throttle: null, + notifyWhen: null, params: { bar: true, }, @@ -341,6 +342,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": null, "params": Object { "bar": true, }, @@ -389,6 +391,7 @@ describe('create()', () => { "muteAll": false, "mutedInstanceIds": Array [], "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -488,6 +491,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -587,6 +591,7 @@ describe('create()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -626,6 +631,7 @@ describe('create()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -662,6 +668,7 @@ describe('create()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -740,6 +747,426 @@ describe('create()', () => { expect(alertsClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: 123/my alert name'); }); + test('should create alert with given notifyWhen value if notifyWhen is not null', async () => { + const data = getMockData({ notifyWhen: 'onActionGroupChange', throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onActionGroupChange', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onThrottleInterval if notifyWhen is null and throttle is set', async () => { + const data = getMockData({ throttle: '10m' }); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: '10m', + notifyWhen: 'onThrottleInterval', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onThrottleInterval", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": "10m", + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + + test('should create alert with notifyWhen = onActiveAlert if notifyWhen is null and throttle is null', async () => { + const data = getMockData(); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + muteAll: false, + mutedInstanceIds: [], + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: createdAttributes, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + const result = await alertsClient.create({ data }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + actionRef: 'action_0', + group: 'default', + actionTypeId: 'test', + params: { foo: true }, + }, + ], + alertTypeId: '123', + consumer: 'bar', + name: 'abc', + params: { bar: true }, + apiKey: null, + apiKeyOwner: null, + createdBy: 'elastic', + createdAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + updatedAt: '2019-02-12T21:01:22.479Z', + enabled: true, + meta: { + versionApiKeyLastmodified: 'v7.10.0', + }, + schedule: { interval: '10s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + muteAll: false, + mutedInstanceIds: [], + tags: ['foo'], + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + error: null, + }, + }, + { + id: 'mock-saved-object-id', + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "consumer": "bar", + "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, + "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, + "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", + } + `); + }); + test('should validate params', async () => { const data = getMockData(); alertTypeRegistry.get.mockReturnValue({ @@ -1049,6 +1476,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], @@ -1172,6 +1600,7 @@ describe('create()', () => { }, schedule: { interval: '10s' }, throttle: null, + notifyWhen: 'onActiveAlert', muteAll: false, mutedInstanceIds: [], tags: ['foo'], diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index 232d48e258256a..ff64150dc2b795 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -85,6 +85,7 @@ describe('find()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -143,6 +144,7 @@ describe('find()', () => { "alertTypeId": "myType", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -234,6 +236,7 @@ describe('find()', () => { Object { "actions": Array [], "id": "1", + "notifyWhen": undefined, "schedule": undefined, "tags": Array [ "myTag", diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 32ac57459795eb..e3e3630d379ea6 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -72,6 +72,7 @@ describe('get()', () => { }, }, ], + notifyWhen: 'onActiveAlert', }, references: [ { @@ -96,6 +97,7 @@ describe('get()', () => { "alertTypeId": "123", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts index cb878b11548b14..555c316038daa2 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts @@ -80,6 +80,7 @@ const BaseAlertInstanceSummarySavedObject: SavedObject = { apiKey: null, apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index 15fb1e2ec0092c..42cec57b555de2 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -70,6 +70,7 @@ describe('update()', () => { scheduledTaskId: 'task-123', params: {}, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -144,6 +145,7 @@ describe('update()', () => { }, }, ], + notifyWhen: 'onActiveAlert', scheduledTaskId: 'task-123', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -185,6 +187,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: 'onActiveAlert', actions: [ { group: 'default', @@ -241,6 +244,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -295,6 +299,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onActiveAlert", "params": Object { "bar": true, }, @@ -368,6 +373,7 @@ describe('update()', () => { }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -418,6 +424,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -445,6 +452,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": true, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -479,6 +487,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -540,6 +549,7 @@ describe('update()', () => { params: { bar: true, }, + notifyWhen: 'onThrottleInterval', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), actions: [ @@ -583,6 +593,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: 'onThrottleInterval', actions: [ { group: 'default', @@ -611,6 +622,7 @@ describe('update()', () => { "createdAt": 2019-02-12T21:01:22.479Z, "enabled": false, "id": "1", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -645,6 +657,7 @@ describe('update()', () => { "versionApiKeyLastmodified": "v7.10.0", }, "name": "abc", + "notifyWhen": "onThrottleInterval", "params": Object { "bar": true, }, @@ -702,6 +715,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -830,6 +844,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -937,6 +952,7 @@ describe('update()', () => { bar: true, }, throttle: '5m', + notifyWhen: null, actions: [ { group: 'default', @@ -998,6 +1014,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1118,6 +1135,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1149,6 +1167,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1185,6 +1204,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1220,6 +1240,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [ { group: 'default', @@ -1273,6 +1294,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }); @@ -1296,6 +1318,7 @@ describe('update()', () => { bar: true, }, throttle: null, + notifyWhen: null, actions: [], }, }) @@ -1339,6 +1362,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }); @@ -1368,6 +1392,7 @@ describe('update()', () => { }, throttle: null, actions: [], + notifyWhen: null, }, }) ).rejects.toThrow(); diff --git a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts index 60e733b49b0415..aaa70a2594a5ec 100644 --- a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts @@ -105,6 +105,7 @@ async function update(success: boolean) { tags: ['bar'], params: { bar: true }, throttle: '10s', + notifyWhen: null, actions: [], }, }); diff --git a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts index a53a162cc508dc..d6357494546b03 100644 --- a/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerts/server/lib/alert_instance_summary_from_event_log.test.ts @@ -648,6 +648,7 @@ const BaseAlert: SanitizedAlert = { tags: [], consumer: 'alert-consumer', throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], params: { bar: true }, diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts new file mode 100644 index 00000000000000..51eb1277a61c9c --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAlertNotifyWhenType } from './get_alert_notify_when_type'; + +test(`should return 'notifyWhen' value if value is set and throttle is null`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', null)).toEqual('onActionGroupChange'); +}); + +test(`should return 'notifyWhen' value if value is set and throttle is defined`, () => { + expect(getAlertNotifyWhenType('onActionGroupChange', '10m')).toEqual('onActionGroupChange'); +}); + +test(`should return 'onThrottleInterval' value if 'notifyWhen' is null and throttle is defined`, () => { + expect(getAlertNotifyWhenType(null, '10m')).toEqual('onThrottleInterval'); +}); + +test(`should return 'onActiveAlert' value if 'notifyWhen' is null and throttle is null`, () => { + expect(getAlertNotifyWhenType(null, null)).toEqual('onActiveAlert'); +}); diff --git a/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts new file mode 100644 index 00000000000000..c871ba0c6e60aa --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/get_alert_notify_when_type.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertNotifyWhenType } from '../types'; + +export function getAlertNotifyWhenType( + notifyWhen: AlertNotifyWhenType | null, + throttle: string | null +): AlertNotifyWhenType { + // We allow notifyWhen to be null for backwards compatibility. If it is null, determine its + // value based on whether the throttle is set to a value or null + return notifyWhen ? notifyWhen! : throttle ? 'onThrottleInterval' : 'onActiveAlert'; +} diff --git a/x-pack/plugins/alerts/server/lib/index.ts b/x-pack/plugins/alerts/server/lib/index.ts index 32047ae5cbfa83..d4662c02c03174 100644 --- a/x-pack/plugins/alerts/server/lib/index.ts +++ b/x-pack/plugins/alerts/server/lib/index.ts @@ -7,6 +7,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_duration'; export { LicenseState } from './license_state'; export { validateAlertTypeParams } from './validate_alert_type_params'; +export { getAlertNotifyWhenType } from './get_alert_notify_when_type'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; export { executionStatusFromState, diff --git a/x-pack/plugins/alerts/server/routes/create.test.ts b/x-pack/plugins/alerts/server/routes/create.test.ts index 51c5d2525631d6..90c075f129b8c3 100644 --- a/x-pack/plugins/alerts/server/routes/create.test.ts +++ b/x-pack/plugins/alerts/server/routes/create.test.ts @@ -36,6 +36,7 @@ describe('createAlertRoute', () => { bar: true, }, throttle: '30s', + notifyWhen: 'onActionGroupChange', actions: [ { group: 'default', @@ -56,6 +57,7 @@ describe('createAlertRoute', () => { apiKey: '', apiKeyOwner: '', mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', createdAt, updatedAt, id: '123', @@ -110,6 +112,7 @@ describe('createAlertRoute', () => { "alertTypeId": "1", "consumer": "bar", "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "bar": true, }, diff --git a/x-pack/plugins/alerts/server/routes/create.ts b/x-pack/plugins/alerts/server/routes/create.ts index 91a81f6d84b714..f54aec8fe0cf05 100644 --- a/x-pack/plugins/alerts/server/routes/create.ts +++ b/x-pack/plugins/alerts/server/routes/create.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { Alert, BASE_ALERT_API_PATH } from '../types'; +import { Alert, AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../types'; export const bodySchema = schema.object({ name: schema.string(), @@ -38,6 +38,7 @@ export const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -61,7 +62,8 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const alert = req.body; - const alertRes: Alert = await alertsClient.create({ data: alert }); + const notifyWhen = alert?.notifyWhen ? (alert.notifyWhen as AlertNotifyWhenType) : null; + const alertRes: Alert = await alertsClient.create({ data: { ...alert, notifyWhen } }); return res.ok({ body: alertRes, }); diff --git a/x-pack/plugins/alerts/server/routes/get.test.ts b/x-pack/plugins/alerts/server/routes/get.test.ts index c60177e90b79d8..51ac64bbef182e 100644 --- a/x-pack/plugins/alerts/server/routes/get.test.ts +++ b/x-pack/plugins/alerts/server/routes/get.test.ts @@ -46,6 +46,7 @@ describe('getAlertRoute', () => { tags: ['foo'], enabled: true, muteAll: false, + notifyWhen: 'onActionGroupChange', createdBy: '', updatedBy: '', apiKey: '', diff --git a/x-pack/plugins/alerts/server/routes/update.test.ts b/x-pack/plugins/alerts/server/routes/update.test.ts index dedb08a9972c20..89619bd8537071 100644 --- a/x-pack/plugins/alerts/server/routes/update.test.ts +++ b/x-pack/plugins/alerts/server/routes/update.test.ts @@ -10,6 +10,7 @@ import { mockLicenseState } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { alertsClientMock } from '../alerts_client.mock'; +import { AlertNotifyWhenType } from '../../common'; const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ @@ -41,6 +42,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, }; it('updates an alert with proper parameters', async () => { @@ -78,6 +80,7 @@ describe('updateAlertRoute', () => { }, }, ], + notifyWhen: 'onActionGroupChange', }, }, ['ok'] @@ -100,6 +103,7 @@ describe('updateAlertRoute', () => { }, ], "name": "abc", + "notifyWhen": "onActionGroupChange", "params": Object { "otherField": false, }, diff --git a/x-pack/plugins/alerts/server/routes/update.ts b/x-pack/plugins/alerts/server/routes/update.ts index 9b2fe9a43810b2..96b3156525f79f 100644 --- a/x-pack/plugins/alerts/server/routes/update.ts +++ b/x-pack/plugins/alerts/server/routes/update.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; import { handleDisabledApiKeysError } from './lib/error_handler'; -import { BASE_ALERT_API_PATH } from '../../common'; +import { AlertNotifyWhenType, BASE_ALERT_API_PATH, validateNotifyWhenType } from '../../common'; const paramSchema = schema.object({ id: schema.string(), @@ -39,6 +39,7 @@ const bodySchema = schema.object({ }), { defaultValue: [] } ), + notifyWhen: schema.nullable(schema.string({ validate: validateNotifyWhenType })), }); export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => { @@ -62,11 +63,19 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => } const alertsClient = context.alerting.getAlertsClient(); const { id } = req.params; - const { name, actions, params, schedule, tags, throttle } = req.body; + const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; return res.ok({ body: await alertsClient.update({ id, - data: { name, actions, params, schedule, tags, throttle }, + data: { + name, + actions, + params, + schedule, + tags, + throttle, + notifyWhen: notifyWhen as AlertNotifyWhenType, + }, }), }); }) diff --git a/x-pack/plugins/alerts/server/saved_objects/mappings.json b/x-pack/plugins/alerts/server/saved_objects/mappings.json index f40a7d9075eed2..f0c5c28ecaeafc 100644 --- a/x-pack/plugins/alerts/server/saved_objects/mappings.json +++ b/x-pack/plugins/alerts/server/saved_objects/mappings.json @@ -74,6 +74,9 @@ "throttle": { "type": "keyword" }, + "notifyWhen": { + "type": "keyword" + }, "muteAll": { "type": "boolean" }, diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index a4cbc18e13b473..abbce7a009b994 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -277,6 +277,7 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.updated_at, + notifyWhen: 'onActiveAlert', }, }); }); @@ -289,6 +290,33 @@ describe('7.11.0', () => { attributes: { ...alert.attributes, updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is null', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({}); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onActiveAlert', + }, + }); + }); + + test('add notifyWhen=onActiveAlert when throttle is set', () => { + const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; + const alert = getMockData({ throttle: '5m' }); + expect(migration711(alert, { log })).toEqual({ + ...alert, + attributes: { + ...alert.attributes, + updatedAt: alert.attributes.createdAt, + notifyWhen: 'onThrottleInterval', }, }); }); diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.ts index d8ebced03c5a62..1b9c5dac23b88e 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.ts @@ -37,15 +37,18 @@ export function getMigrations( ) ); - const migrationAlertUpdatedAtDate = encryptedSavedObjects.createMigration( - // migrate all documents in 7.11 in order to add the "updatedAt" field + const migrationAlertUpdatedAtAndNotifyWhen = encryptedSavedObjects.createMigration< + RawAlert, + RawAlert + >( + // migrate all documents in 7.11 in order to add the "updatedAt" and "notifyWhen" fields (doc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(setAlertUpdatedAtDate) + pipeMigrations(setAlertUpdatedAtDate, setNotifyWhen) ); return { '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtDate, '7.11.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), }; } @@ -79,6 +82,19 @@ const setAlertUpdatedAtDate = ( }; }; +const setNotifyWhen = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc => { + const notifyWhen = doc.attributes.throttle ? 'onThrottleInterval' : 'onActiveAlert'; + return { + ...doc, + attributes: { + ...doc.attributes, + notifyWhen, + }, + }; +}; + const consumersToChange: Map = new Map( Object.entries({ alerting: 'alerts', diff --git a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts index cf0dd9d135e275..09236ec5e0ad1a 100644 --- a/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/alert_task_instance.test.ts @@ -27,6 +27,7 @@ const alert: SanitizedAlert = { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index d4ea74c008b492..d3d0a54417ee3b 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -92,6 +92,7 @@ describe('Task Runner', () => { updatedAt: new Date('2019-02-12T21:01:22.479Z'), throttle: null, muteAll: false, + notifyWhen: 'onActiveAlert', enabled: true, alertTypeId: alertType.id, apiKey: '', @@ -533,6 +534,188 @@ describe('Task Runner', () => { ); }); + test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert instance state does not change', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { date: '1970-01-01T00:00:00.000Z', group: 'default' }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + }, + ], + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "event": Object { + "action": "execute", + "outcome": "success", + }, + "kibana": Object { + "alerting": Object { + "status": "active", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "alert executed: test:1: 'alert-name'", + }, + ], + ] + `); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { lastScheduledActions: { group: 'newGroup', date: new Date().toISOString() } }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', async () => { + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices + .alertInstanceFactory('1') + .scheduleActionsWithSubGroup('default', 'subgroup1'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + group: 'default', + subgroup: 'newSubgroup', + date: new Date().toISOString(), + }, + }, + state: { bar: false }, + }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + }); + test('includes the apiKey in the request used to initialize the actionsClient', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 6bc6271dd6d5cf..2073528f2c75ef 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -171,7 +171,16 @@ export class TaskRunner { spaceId: string, event: Event ): Promise { - const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; + const { + throttle, + notifyWhen, + muteAll, + mutedInstanceIds, + name, + tags, + createdBy, + updatedBy, + } = alert; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, @@ -257,24 +266,39 @@ export class TaskRunner { alertLabel, }); + const instancesToExecute = + notifyWhen === 'onActionGroupChange' + ? Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const shouldExecuteAction = alertInstance.scheduledActionGroupOrSubgroupHasChanged(); + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is active but action group has not changed` + ); + } + return shouldExecuteAction; + } + ) + : Object.entries(instancesWithScheduledActions).filter( + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => { + const throttled = alertInstance.isThrottled(throttle); + const muted = mutedInstanceIdsSet.has(alertInstanceName); + const shouldExecuteAction = !throttled && !muted; + if (!shouldExecuteAction) { + this.logger.debug( + `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ + muted ? 'muted' : 'throttled' + }` + ); + } + return shouldExecuteAction; + } + ); + await Promise.all( - Object.entries(instancesWithScheduledActions) - .filter(([alertInstanceName, alertInstance]: [string, AlertInstance]) => { - const throttled = alertInstance.isThrottled(throttle); - const muted = mutedInstanceIdsSet.has(alertInstanceName); - const shouldExecuteAction = !throttled && !muted; - if (!shouldExecuteAction) { - this.logger.debug( - `skipping scheduling of actions for '${alertInstanceName}' in alert ${alertLabel}: instance is ${ - muted ? 'muted' : 'throttled' - }` - ); - } - return shouldExecuteAction; - }) - .map(([id, alertInstance]: [string, AlertInstance]) => - this.executeAlertInstance(id, alertInstance, executionHandler) - ) + instancesToExecute.map(([id, alertInstance]: [string, AlertInstance]) => + this.executeAlertInstance(id, alertInstance, executionHandler) + ) ); } else { this.logger.debug(`no scheduling of actions for alert ${alertLabel}: alert is muted.`); diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 8898123506755d..a5aee8dbf3b600 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -28,6 +28,7 @@ import { AlertExecutionStatuses, AlertExecutionStatusErrorReasons, AlertsHealth, + AlertNotifyWhenType, } from '../common'; export type WithoutQueryAndParams = Pick>; @@ -152,6 +153,7 @@ export interface RawAlert extends SavedObjectAttributes { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; + notifyWhen: AlertNotifyWhenType | null; muteAll: boolean; mutedInstanceIds: string[]; meta?: AlertMeta; @@ -162,6 +164,7 @@ export type AlertInfoParams = Pick< RawAlert, | 'params' | 'throttle' + | 'notifyWhen' | 'muteAll' | 'mutedInstanceIds' | 'name' diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index e978b6d55251b1..f243bcc0c694ee 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -235,7 +235,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'Sometimes it is necessary to sanitize, i.e., remove, sensitive data sent to Elastic APM. This config accepts a list of wildcard patterns of field names which should be sanitized. These apply to HTTP headers (including cookies) and `application/x-www-form-urlencoded` data (POST form fields). The query string and the captured request body (such as `application/json` data) will not get sanitized.', } ), - includeAgents: ['java', 'python', 'go'], + includeAgents: ['java', 'python', 'go', 'dotnet', 'nodejs'], }, // Ignore transactions based on URLs @@ -254,6 +254,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Used to restrict requests to certain URLs from being instrumented. This config accepts a comma-separated list of wildcard patterns of URL paths that should be ignored. When an incoming HTTP request is detected, its request path will be tested against each element in this list. For example, adding `/home/index` to this list would match and remove instrumentation from `http://localhost/home/index` as well as `http://whatever.com/home/index?value1=123`', } ), - includeAgents: ['java'], + includeAgents: ['java', 'nodejs'], }, ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index abe353ab8f3a35..ac0820309e77c6 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -102,6 +102,8 @@ describe('filterByAgent', () => { expect(getSettingKeysForAgent('nodejs')).toEqual([ 'capture_body', 'log_level', + 'sanitize_field_names', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -128,6 +130,7 @@ describe('filterByAgent', () => { 'capture_headers', 'log_level', 'recording', + 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', 'transaction_max_spans', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index a289ec78d9e801..dca06988478eab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -14,7 +14,7 @@ import { EuiPageContent, EuiBasicTable } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { DocumentDetail } from '.'; -import { ResultFieldValue } from '../result_field_value'; +import { ResultFieldValue } from '../result'; describe('DocumentDetail', () => { const values = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index 017f5a2f67ad07..1be7e6c53d3431 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { Loading } from '../../../shared/loading'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { FlashMessages } from '../../../shared/flash_messages'; -import { ResultFieldValue } from '../result_field_value'; +import { ResultFieldValue } from '../result'; import { DocumentDetailLogic } from './document_detail_logic'; import { FieldDetails } from './types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index 4afa3f7aee5c88..d9a3de7c078cc9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -28,6 +28,7 @@ jest.mock('../../../shared/flash_messages', () => ({ import { setQueuedSuccessMessage, flashAPIErrors } from '../../../shared/flash_messages'; import { DocumentDetailLogic } from './document_detail_logic'; +import { InternalSchemaTypes } from '../../../shared/types'; describe('DocumentDetailLogic', () => { const DEFAULT_VALUES = { @@ -61,7 +62,7 @@ describe('DocumentDetailLogic', () => { describe('actions', () => { describe('setFields', () => { it('should set fields to the provided value and dataLoading to false', () => { - const fields = [{ name: 'foo', value: ['foo'], type: 'string' }]; + const fields = [{ name: 'foo', value: ['foo'], type: 'string' as InternalSchemaTypes }]; mount({ dataLoading: true, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss index cbc72dbffe57a3..868a561a278739 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience.scss @@ -2,6 +2,10 @@ .sui-results-container { flex-grow: 1; padding: 0; + + > li + li { + margin-top: $euiSize; + } } .documentsSearchExperience__sidebar { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx index 22a63f653a2941..455e237848a4bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.test.tsx @@ -43,6 +43,15 @@ describe('SearchExperienceContent', () => { it('passes engineName to the result view', () => { const props = { result: { + id: { + raw: '1', + }, + _meta: { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }, foo: { raw: 'bar', }, @@ -51,7 +60,7 @@ describe('SearchExperienceContent', () => { const wrapper = shallow(); const resultView: any = wrapper.find(Results).prop('resultView'); - expect(resultView(props)).toEqual(); + expect(resultView(props)).toEqual(); }); it('renders pagination', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx index 938c8930f4dd14..9194a3a1db5e44 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/search_experience_content.tsx @@ -14,24 +14,18 @@ import { useValues } from 'kea'; import { ResultView } from './views'; import { Pagination } from './pagination'; +import { Props as ResultViewProps } from './views/result_view'; import { useSearchContextState } from './hooks'; import { DocumentCreationButton } from '../document_creation_button'; import { AppLogic } from '../../../app_logic'; import { EngineLogic } from '../../engine'; import { DOCS_PREFIX } from '../../../routes'; -// TODO This is temporary until we create real Result type -interface Result { - [key: string]: { - raw: string | string[] | number | number[] | undefined; - }; -} - export const SearchExperienceContent: React.FC = () => { const { resultSearchTerm, totalResults, wasSearched } = useSearchContextState(); const { myRole } = useValues(AppLogic); - const { engineName, isMetaEngine } = useValues(EngineLogic); + const { isMetaEngine } = useValues(EngineLogic); if (!wasSearched) return null; @@ -49,8 +43,8 @@ export const SearchExperienceContent: React.FC = () => { { - return ; + resultView={(props: ResultViewProps) => { + return ; }} /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx index 73ddf16e01074e..049a3ad1bed66f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.test.tsx @@ -9,16 +9,26 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ResultView } from '.'; +import { Result } from '../../../result/result'; describe('ResultView', () => { const result = { id: { raw: '1', }, + title: { + raw: 'A title', + }, + _meta: { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }, }; it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find('div').length).toBe(1); + const wrapper = shallow(); + expect(wrapper.find(Result).exists()).toBe(true); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx index bf472ec3bb21ec..52b845a1aee2d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/search_experience/views/result_view.tsx @@ -5,38 +5,18 @@ */ import React from 'react'; -import { EuiPanel, EuiSpacer } from '@elastic/eui'; -import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { Result as ResultType } from '../../../result/types'; +import { Result } from '../../../result/result'; -// TODO replace this with a real result type when we implement a more sophisticated -// ResultView -interface Result { - [key: string]: { - raw: string | string[] | number | number[] | undefined; - }; +export interface Props { + result: ResultType; } -interface Props { - engineName: string; - result: Result; -} - -export const ResultView: React.FC = ({ engineName, result }) => { - // TODO Replace this entire component when we migrate StuiResult +export const ResultView: React.FC = ({ result }) => { return (
  • - - - {result.id.raw} - - {Object.entries(result).map(([key, value]) => ( -
    - {key}: {value.raw} -
    - ))} -
    - +
  • ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts index 6a7c1cd1d5d2fe..adad8ff4e46832 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/types.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { InternalSchemaTypes } from '../../../shared/types'; + export interface FieldDetails { name: string; value: string | string[]; - type: string; + type: InternalSchemaTypes; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/index.ts new file mode 100644 index 00000000000000..04e5b1b6a401dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Library } from './library'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx new file mode 100644 index 00000000000000..66c0cc165fc056 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiSpacer, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiPageContentBody, + EuiPageContent, +} from '@elastic/eui'; +import React from 'react'; + +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { Result } from '../result/result'; + +export const Library: React.FC = () => { + const props = { + result: { + id: { + raw: '1', + }, + _meta: { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }, + title: { + raw: 'A title', + }, + description: { + raw: 'A description', + }, + states: { + raw: ['Pennsylvania', 'Ohio'], + }, + visitors: { + raw: 1000, + }, + size: { + raw: 200, + }, + length: { + raw: 100, + }, + }, + }; + + return ( + <> + + + + +

    Library

    +
    +
    +
    + + + +

    Result

    +
    + + + +

    5 or more fields

    +
    + + + + + +

    5 or less fields

    +
    + + + + + +

    With just an id

    +
    + + + + + +

    With an id and a score

    +
    + + + + + +

    With an id and a score and an engine

    +
    + + + + + +

    With a long id, a long engine name, a long field key, and a long value

    +
    + + + +
    +
    + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/index.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss new file mode 100644 index 00000000000000..ed8ce512a2eb80 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.scss @@ -0,0 +1,30 @@ +.appSearchResult { + display: flex; + + &__content { + width: 100%; + padding: $euiSize; + overflow: hidden; + } + + &__hiddenFieldsIndicator { + font-size: $euiFontSizeXS; + color: $euiColorDarkShade; + margin-top: $euiSizeS; + } + + &__actionButton { + display: flex; + justify-content: center; + align-items: center; + width: $euiSizeL * 2; + border-left: $euiBorderThin; + + &:hover, + &:focus, + &:active { + background-color: $euiPageBackgroundColor; + cursor: pointer; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx new file mode 100644 index 00000000000000..ade26551039faa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; +import { EuiPanel } from '@elastic/eui'; + +import { ResultField } from './result_field'; +import { ResultHeader } from './result_header'; +import { Result } from './result'; + +describe('Result', () => { + const props = { + result: { + id: { + raw: '1', + }, + title: { + raw: 'A title', + }, + description: { + raw: 'A description', + }, + length: { + raw: 100, + }, + _meta: { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }, + }, + }; + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiPanel).exists()).toBe(true); + }); + + it('should render a ResultField for each field except id and _meta', () => { + const wrapper = shallow(); + expect(wrapper.find(ResultField).map((rf) => rf.prop('field'))).toEqual([ + 'title', + 'description', + 'length', + ]); + }); + + it('passes through showScore and resultMeta to ResultHeader', () => { + const wrapper = shallow(); + expect(wrapper.find(ResultHeader).prop('showScore')).toBe(true); + expect(wrapper.find(ResultHeader).prop('resultMeta')).toEqual({ + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }); + }); + + describe('when there are more than 5 fields', () => { + const propsWithMoreFields = { + result: { + id: { + raw: '1', + }, + title: { + raw: 'A title', + }, + description: { + raw: 'A description', + }, + length: { + raw: 100, + }, + states: { + raw: ['Pennsylvania', 'Ohio'], + }, + visitors: { + raw: 1000, + }, + size: { + raw: 200, + }, + _meta: { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }, + }, + }; + + describe('the initial render', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + wrapper = shallow(); + }); + + it('renders a collapse button', () => { + expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false); + }); + + it('does not render an expand button', () => { + expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true); + }); + + it('renders a hidden fields indicator', () => { + expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual( + '1 more fields' + ); + }); + + it('shows no more than 5 fields', () => { + expect(wrapper.find(ResultField).length).toEqual(5); + }); + }); + + describe('after clicking the expand button', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + wrapper = shallow(); + expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true); + wrapper.find('.appSearchResult__actionButton').simulate('click'); + }); + + it('renders a collapse button', () => { + expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(true); + }); + + it('does not render an expand button', () => { + expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(false); + }); + + it('does not render a hidden fields indicator', () => { + expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').exists()).toBe(false); + }); + + it('shows all fields', () => { + expect(wrapper.find(ResultField).length).toEqual(6); + }); + }); + + describe('after clicking the collapse button', () => { + let wrapper: ShallowWrapper; + + beforeAll(() => { + wrapper = shallow(); + expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true); + wrapper.find('.appSearchResult__actionButton').simulate('click'); + wrapper.find('.appSearchResult__actionButton').simulate('click'); + }); + + it('renders a collapse button', () => { + expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false); + }); + + it('does not render an expand button', () => { + expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true); + }); + + it('renders a hidden fields indicator', () => { + expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual( + '1 more fields' + ); + }); + + it('shows no more than 5 fields', () => { + expect(wrapper.find(ResultField).length).toEqual(5); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx new file mode 100644 index 00000000000000..4f343e64b12ae5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useMemo } from 'react'; + +import './result.scss'; + +import { EuiPanel, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FieldValue, Result as ResultType } from './types'; +import { ResultField } from './result_field'; +import { ResultHeader } from './result_header'; + +interface Props { + result: ResultType; + showScore?: boolean; +} + +const RESULT_CUTOFF = 5; + +export const Result: React.FC = ({ result, showScore }) => { + const [isOpen, setIsOpen] = useState(false); + + const ID = 'id'; + const META = '_meta'; + const resultMeta = result[META]; + const resultFields = useMemo( + () => Object.entries(result).filter(([key]) => key !== META && key !== ID), + [result] + ); + const numResults = resultFields.length; + + return ( + +
    + +
    + {resultFields + .slice(0, isOpen ? resultFields.length : RESULT_CUTOFF) + .map(([field, value]: [string, FieldValue]) => ( + + ))} +
    + {numResults > RESULT_CUTOFF && !isOpen && ( +
    + {i18n.translate('xpack.enterpriseSearch.appSearch.result.numberOfAdditionalFields', { + defaultMessage: '{numberOfAdditionalFields} more fields', + values: { + numberOfAdditionalFields: numResults - RESULT_CUTOFF, + }, + })} +
    + )} +
    + {numResults > RESULT_CUTOFF && ( + + )} +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss new file mode 100644 index 00000000000000..15509b98d465be --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.scss @@ -0,0 +1,25 @@ +.appSearchResultField { + font-size: $euiFontSizeS; + line-height: $euiLineHeight; + display: grid; + grid-template-columns: 0.85fr $euiSizeXL 1fr; + grid-gap: $euiSizeXS; + + &__separator { + text-align: center; + + &:after { + content: '=>'; + color: $euiColorDarkShade; + } + } + + &__value { + padding-left: $euiSize; + overflow: hidden; + + @include euiBreakpoint('xs') { + padding-left: 0; + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx new file mode 100644 index 00000000000000..921e2324d39181 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ResultField } from './result_field'; + +describe('ResultField', () => { + it('renders', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('ResultFieldValue').exists()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx new file mode 100644 index 00000000000000..bc6329aa4fa4ad --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ResultFieldValue } from '.'; +import { FieldType, Raw, Snippet } from './types'; + +import './result_field.scss'; + +interface Props { + field: string; + raw?: Raw; + snippet?: Snippet; + type?: FieldType; +} + +export const ResultField: React.FC = ({ field, raw, snippet, type }) => { + return ( +
    +
    {field}
    +
    +
    + +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss similarity index 97% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss index 13a771d24adc98..996124a725aab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.scss +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.scss @@ -11,6 +11,7 @@ font-family: $euiCodeFontFamily; } + &--geolocation, &--location { color: $euiColorSuccessText; font-family: $euiCodeFontFamily; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.test.tsx similarity index 100% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.test.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.test.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx similarity index 96% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx index 9ee0f1e0ba043b..8e4b264017c0bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_field_value/result_field_value.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_field_value.tsx @@ -8,7 +8,7 @@ import React from 'react'; import classNames from 'classnames'; -import { Raw, Snippet } from '../../types'; +import { FieldType, Raw, Snippet } from './types'; import './result_field_value.scss'; @@ -40,7 +40,7 @@ const isFieldValueEmpty = (type?: string, raw?: Raw, snippet?: Snippet) => { interface Props { raw?: Raw; snippet?: Snippet; - type?: string; + type?: FieldType; className?: string; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss new file mode 100644 index 00000000000000..73372d7c4aca07 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.scss @@ -0,0 +1,28 @@ +.appSearchResultHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: $euiSizeS; + + @include euiBreakpoint('xs') { + flex-direction: column; + } + + &__column { + display: flex; + flex-wrap: wrap; + + @include euiBreakpoint('xs') { + flex-direction: column; + } + + & + &, + .appSearchResultHeaderItem + .appSearchResultHeaderItem { + margin-left: $euiSizeL; + + @include euiBreakpoint('xs') { + margin-left: 0; + } + } + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx new file mode 100644 index 00000000000000..95b77a0aed7bbd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { ResultHeader } from './result_header'; + +describe('ResultHeader', () => { + const resultMeta = { + id: '1', + scopedId: '1', + score: 100, + engine: 'my-engine', + }; + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(false); + }); + + it('always renders an id', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultId"]').prop('value')).toEqual('1'); + }); + + describe('score', () => { + it('renders score if showScore is true ', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultScore"]').prop('value')).toEqual(100); + }); + + it('does not render score if showScore is false', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="ResultScore"]').exists()).toBe(false); + }); + }); + + describe('engine', () => { + it('renders engine name if the ids dont match, which means it is a meta engine', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ResultEngine"]').prop('value')).toBe('my-engine'); + }); + + it('does not render an engine name if the ids match, which means it is not a meta engine', () => { + const wrapper = shallow( + + ); + expect(wrapper.find('[data-test-subj="ResultEngine"]').exists()).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx new file mode 100644 index 00000000000000..9b83014d041ddd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { ResultHeaderItem } from './result_header_item'; +import { ResultMeta } from './types'; + +import './result_header.scss'; + +interface Props { + showScore: boolean; + resultMeta: ResultMeta; +} + +export const ResultHeader: React.FC = ({ showScore, resultMeta }) => { + const showEngineLabel: boolean = resultMeta.id !== resultMeta.scopedId; + + return ( +
    + {showScore && ( +
    + +
    + )} + +
    + {showEngineLabel && ( + + )} + +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss new file mode 100644 index 00000000000000..f1e9343530af38 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.scss @@ -0,0 +1,16 @@ +.appSearchResultHeaderItem { + display: flex; + + &__key, + &__value { + line-height: $euiLineHeight; + font-size: $euiFontSizeXS; + } + + &__key { + text-transform: uppercase; + font-weight: $euiFontWeightLight; + color: $euiColorDarkShade; + margin-right: $euiSizeXS; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx new file mode 100644 index 00000000000000..b4368f83b18336 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { mount } from 'enzyme'; + +import { ResultHeaderItem } from './result_header_item'; + +describe('ResultHeaderItem', () => { + it('renders', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual('id'); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('001'); + }); + + it('will truncate long field names', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__key').text()).toEqual( + 'a-really-really-really-really-…' + ); + }); + + it('will truncate long values', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + 'a-really-really-really-really-…' + ); + }); + + it('will truncate long values from the beginning if the type is "id"', () => { + const wrapper = mount( + + ); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual( + '…lly-really-really-really-value' + ); + }); + + it('will round any numeric values that are passed in to 2 decimals, regardless of the explicit "type" passed', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('5.19'); + }); + + it('if the value passed in is undefined, it will render "-"', () => { + const wrapper = mount(); + expect(wrapper.find('.appSearchResultHeaderItem__value').text()).toEqual('-'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx new file mode 100644 index 00000000000000..d67b3f86b0aa7f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/result_header_item.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import './result_header_item.scss'; + +import { TruncatedContent } from '../../../shared/truncate'; + +interface Props { + field: string; + value?: string | number; + type: 'id' | 'score' | 'string'; +} + +const MAX_CHARACTER_LENGTH = 30; + +export const ResultHeaderItem: React.FC = ({ field, type, value }) => { + let formattedValue = '-'; + if (typeof value === 'string') { + formattedValue = value; + } else if (typeof value === 'number') { + formattedValue = parseFloat((value as number).toFixed(2)).toString(); + } + + return ( +
    +
    + +
    +
    + +
    +
    + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts new file mode 100644 index 00000000000000..7db710eeb213ac --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InternalSchemaTypes, SchemaTypes } from '../../../shared/types'; + +export type FieldType = InternalSchemaTypes | SchemaTypes; + +export type Raw = string | string[] | number | number[]; +export type Snippet = string; +export interface FieldValue { + raw?: Raw; + snippet?: Snippet; +} + +export interface ResultMeta { + id: string; + scopedId: string; + score?: number; + engine: string; +} + +// A search result item +export type Result = { + id: { + raw: string; + }; + _meta: ResultMeta; +} & { + // this should be a FieldType object, but there's no good way to do that in TS: https://github.com/microsoft/TypeScript/issues/17867 + // You'll need to cast it to FieldValue whenever you use it. + [key: string]: object; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 743cf63fb4bc35..769230ccffd22c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -26,6 +26,7 @@ import { ROLE_MAPPINGS_PATH, ENGINES_PATH, ENGINE_PATH, + LIBRARY_PATH, } from './routes'; import { SetupGuide } from './components/setup_guide'; @@ -35,6 +36,7 @@ import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { Credentials, CREDENTIALS_TITLE } from './components/credentials'; import { ROLE_MAPPINGS_TITLE } from './components/role_mappings'; +import { Library } from './components/library'; export const AppSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -66,6 +68,11 @@ export const AppSearchConfigured: React.FC = (props) => { + {process.env.NODE_ENV === 'development' && ( + + + + )} } />} readOnlyMode={readOnlyMode}> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index a7220b89e4410f..ade3c9a9174104 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -12,6 +12,7 @@ export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; +export const LIBRARY_PATH = '/library'; export const SETTINGS_PATH = '/settings/account'; export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts index 9af1ff0293faef..7c22a45757a937 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/types.ts @@ -7,5 +7,3 @@ export * from '../../../common/types/app_search'; export { Role, RoleTypes, AbilityTypes } from './utils/role'; export { Engine } from './components/engine/types'; -export type Raw = string | string[] | number | number[]; -export type Snippet = string; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts index c1737142e482e4..f5833a0ac9f8ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -7,7 +7,8 @@ import { ADD, UPDATE } from './constants/operations'; export type SchemaTypes = 'text' | 'number' | 'geolocation' | 'date'; - +// Certain API endpoints will use these internal type names, which map to the external names above +export type InternalSchemaTypes = 'string' | 'float' | 'location' | 'date'; export interface Schema { [key: string]: SchemaTypes; } diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index 9e2c71ead5b744..4a897d80acd6d5 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -34,6 +34,7 @@ export const createPackagePolicyServiceMock = () => { getByIDs: jest.fn(), list: jest.fn(), update: jest.fn(), + runExternalCallbacks: jest.fn(), } as jest.Mocked; }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 0b58c4aab9d0ba..4a3412954d50cf 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -52,7 +52,12 @@ import { registerSettingsRoutes, registerAppRoutes, } from './routes'; -import { EsAssetReference, FleetConfigType, NewPackagePolicy } from '../common'; +import { + EsAssetReference, + FleetConfigType, + NewPackagePolicy, + UpdatePackagePolicy, +} from '../common'; import { appContextService, licenseService, @@ -119,14 +124,23 @@ const allSavedObjectTypes = [ /** * Callbacks supported by the Fleet plugin */ -export type ExternalCallback = [ - 'packagePolicyCreate', - ( - newPackagePolicy: NewPackagePolicy, - context: RequestHandlerContext, - request: KibanaRequest - ) => Promise -]; +export type ExternalCallback = + | [ + 'packagePolicyCreate', + ( + newPackagePolicy: NewPackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ) => Promise + ] + | [ + 'packagePolicyUpdate', + ( + newPackagePolicy: UpdatePackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ) => Promise + ]; export type ExternalCallbacksStorage = Map>; @@ -302,8 +316,8 @@ export class FleetPlugin getFullAgentPolicy: agentPolicyService.getFullAgentPolicy, }, packagePolicyService, - registerExternalCallback: (...args: ExternalCallback) => { - return appContextService.addExternalCallback(...args); + registerExternalCallback: (type: ExternalCallback[0], callback: ExternalCallback[1]) => { + return appContextService.addExternalCallback(type, callback); }, }; } diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index fee74e39c833a4..f9fd6047baa771 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -5,7 +5,7 @@ */ import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; -import { IRouter, KibanaRequest, Logger, RequestHandler, RouteConfig } from 'kibana/server'; +import { IRouter, KibanaRequest, RequestHandler, RouteConfig } from 'kibana/server'; import { registerRoutes } from './index'; import { PACKAGE_POLICY_API_ROUTES } from '../../../common/constants'; import { xpackMocks } from '../../../../../mocks'; @@ -48,6 +48,9 @@ jest.mock('../../services/package_policy', (): { getByIDs: jest.fn(), list: jest.fn(), update: jest.fn(), + runExternalCallbacks: jest.fn((callbackType, newPackagePolicy, context, request) => + Promise.resolve(newPackagePolicy) + ), }, }; }); @@ -164,50 +167,26 @@ describe('When calling package policy', () => { afterEach(() => (callbackCallingOrder.length = 0)); - it('should call external callbacks in expected order', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackCallingOrder).toEqual(['one', 'two']); - }); - - it('should feed package policy returned by last callback', async () => { + it('should create with data from callback', async () => { const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackOne).toHaveBeenCalledWith( - { - policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', - description: '', - enabled: true, - inputs: [], - name: 'endpoint-1', - namespace: 'default', - output_id: '', - package: { - name: 'endpoint', - title: 'Elastic Endpoint', - version: '0.5.0', - }, - }, - context, - request - ); - expect(callbackTwo).toHaveBeenCalledWith( - { + packagePolicyServiceMock.runExternalCallbacks.mockImplementationOnce(() => + Promise.resolve({ policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', description: '', enabled: true, inputs: [ { - type: 'endpoint', - enabled: true, - streams: [], config: { one: { value: 'inserted by callbackOne', }, + two: { + value: 'inserted by callbackTwo', + }, }, + enabled: true, + streams: [], + type: 'endpoint', }, ], name: 'endpoint-1', @@ -218,14 +197,8 @@ describe('When calling package policy', () => { title: 'Elastic Endpoint', version: '0.5.0', }, - }, - context, - request + }) ); - }); - - it('should create with data from callback', async () => { - const request = getCreateKibanaRequest(); await routeHandler(context, request, response); expect(response.ok).toHaveBeenCalled(); expect(packagePolicyServiceMock.create.mock.calls[0][2]).toEqual({ @@ -257,91 +230,6 @@ describe('When calling package policy', () => { }, }); }); - - describe('and a callback throws an exception', () => { - const callbackThree: ExternalCallback[1] = jest.fn(async (ds) => { - callbackCallingOrder.push('three'); - throw new Error('callbackThree threw error on purpose'); - }); - - const callbackFour: ExternalCallback[1] = jest.fn(async (ds) => { - callbackCallingOrder.push('four'); - return { - ...ds, - inputs: [ - { - ...ds.inputs[0], - config: { - ...ds.inputs[0].config, - four: { - value: 'inserted by callbackFour', - }, - }, - }, - ], - }; - }); - - beforeEach(() => { - appContextService.addExternalCallback('packagePolicyCreate', callbackThree); - appContextService.addExternalCallback('packagePolicyCreate', callbackFour); - }); - - it('should skip over callback exceptions and still execute other callbacks', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(callbackCallingOrder).toEqual(['one', 'two', 'three', 'four']); - }); - - it('should log errors', async () => { - const errorLogger = (appContextService.getLogger() as jest.Mocked).error; - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(errorLogger.mock.calls).toEqual([ - ['An external registered [packagePolicyCreate] callback failed when executed'], - [new Error('callbackThree threw error on purpose')], - ]); - }); - - it('should create package policy with last successful returned package policy', async () => { - const request = getCreateKibanaRequest(); - await routeHandler(context, request, response); - expect(response.ok).toHaveBeenCalled(); - expect(packagePolicyServiceMock.create.mock.calls[0][2]).toEqual({ - policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', - description: '', - enabled: true, - inputs: [ - { - config: { - one: { - value: 'inserted by callbackOne', - }, - two: { - value: 'inserted by callbackTwo', - }, - four: { - value: 'inserted by callbackFour', - }, - }, - enabled: true, - streams: [], - type: 'endpoint', - }, - ], - name: 'endpoint-1', - namespace: 'default', - output_id: '', - package: { - name: 'endpoint', - title: 'Elastic Endpoint', - version: '0.5.0', - }, - }); - }); - }); }); }); }); diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts index b154aa2a2782fd..be14970de3e0f8 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.ts @@ -13,7 +13,6 @@ import { CreatePackagePolicyRequestSchema, UpdatePackagePolicyRequestSchema, DeletePackagePoliciesRequestSchema, - NewPackagePolicy, } from '../../types'; import { CreatePackagePolicyResponse, DeletePackagePoliciesResponse } from '../../../common'; import { defaultIngestErrorHandler } from '../../errors'; @@ -77,31 +76,14 @@ export const createPackagePolicyHandler: RequestHandler< const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - const logger = appContextService.getLogger(); let newData = { ...request.body }; try { - // If we have external callbacks, then process those now before creating the actual package policy - const externalCallbacks = appContextService.getExternalCallbacks('packagePolicyCreate'); - if (externalCallbacks && externalCallbacks.size > 0) { - let updatedNewData: NewPackagePolicy = newData; - - for (const callback of externalCallbacks) { - try { - // ensure that the returned value by the callback passes schema validation - updatedNewData = CreatePackagePolicyRequestSchema.body.validate( - await callback(updatedNewData, context, request) - ); - } catch (error) { - // Log the error, but keep going and process the other callbacks - logger.error( - 'An external registered [packagePolicyCreate] callback failed when executed' - ); - logger.error(error); - } - } - - newData = updatedNewData; - } + newData = await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newData, + context, + request + ); // Create package policy const packagePolicy = await packagePolicyService.create(soClient, callCluster, newData, { @@ -112,6 +94,12 @@ export const createPackagePolicyHandler: RequestHandler< body, }); } catch (error) { + if (error.statusCode) { + return response.customError({ + statusCode: error.statusCode, + body: { message: error.message }, + }); + } return defaultIngestErrorHandler({ error, response }); } }; @@ -123,16 +111,23 @@ export const updatePackagePolicyHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; - try { - const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); + const packagePolicy = await packagePolicyService.get(soClient, request.params.packagePolicyId); - if (!packagePolicy) { - throw Boom.notFound('Package policy not found'); - } + if (!packagePolicy) { + throw Boom.notFound('Package policy not found'); + } + + let newData = { ...request.body }; + const pkg = newData.package || packagePolicy.package; + const inputs = newData.inputs || packagePolicy.inputs; - const newData = { ...request.body }; - const pkg = newData.package || packagePolicy.package; - const inputs = newData.inputs || packagePolicy.inputs; + try { + newData = await packagePolicyService.runExternalCallbacks( + 'packagePolicyUpdate', + newData, + context, + request + ); const updatedPackagePolicy = await packagePolicyService.update( soClient, diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 30a980ab07f706..c6bfb53812c705 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -9,6 +9,12 @@ import { createPackagePolicyMock } from '../../common/mocks'; import { packagePolicyService } from './package_policy'; import { PackageInfo, PackagePolicySOAttributes } from '../types'; import { SavedObjectsUpdateResponse } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; +import { KibanaRequest } from 'kibana/server'; +import { xpackMocks } from '../../../../mocks'; +import { ExternalCallback } from '..'; +import { appContextService } from './app_context'; +import { createAppContextStartContractMock } from '../mocks'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { @@ -318,4 +324,180 @@ describe('Package policy service', () => { ).rejects.toThrow('Saved object [abc/123] conflict'); }); }); + + describe('runExternalCallbacks', () => { + let context: ReturnType; + let request: KibanaRequest; + + const newPackagePolicy = { + policy_id: 'a5ca00c0-b30c-11ea-9732-1bb05811278c', + description: '', + enabled: true, + inputs: [], + name: 'endpoint-1', + namespace: 'default', + output_id: '', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.5.0', + }, + }; + + const callbackCallingOrder: string[] = []; + + // Callback one adds an input that includes a `config` property + const callbackOne: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('one'); + return { + ...ds, + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + one: { + value: 'inserted by callbackOne', + }, + }, + }, + ], + }; + }); + + // Callback two adds an additional `input[0].config` property + const callbackTwo: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('two'); + return { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + two: { + value: 'inserted by callbackTwo', + }, + }, + }, + ], + }; + }); + + beforeEach(() => { + context = xpackMocks.createRequestHandlerContext(); + request = httpServerMock.createKibanaRequest(); + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + appContextService.stop(); + jest.clearAllMocks(); + callbackCallingOrder.length = 0; + }); + + it('should call external callbacks in expected order', async () => { + const callbackA: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('a'); + return ds; + }); + + const callbackB: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('b'); + return ds; + }); + + appContextService.addExternalCallback('packagePolicyCreate', callbackA); + appContextService.addExternalCallback('packagePolicyCreate', callbackB); + + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + expect(callbackCallingOrder).toEqual(['a', 'b']); + }); + + it('should feed package policy returned by last callback', async () => { + appContextService.addExternalCallback('packagePolicyCreate', callbackOne); + appContextService.addExternalCallback('packagePolicyCreate', callbackTwo); + + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + + expect((callbackOne as jest.Mock).mock.calls[0][0].inputs).toHaveLength(0); + expect((callbackTwo as jest.Mock).mock.calls[0][0].inputs).toHaveLength(1); + expect((callbackTwo as jest.Mock).mock.calls[0][0].inputs[0].config.one.value).toEqual( + 'inserted by callbackOne' + ); + }); + + describe('with a callback that throws an exception', () => { + const callbackThree: ExternalCallback[1] = jest.fn(async () => { + callbackCallingOrder.push('three'); + throw new Error('callbackThree threw error on purpose'); + }); + + const callbackFour: ExternalCallback[1] = jest.fn(async (ds) => { + callbackCallingOrder.push('four'); + return { + ...ds, + inputs: [ + { + ...ds.inputs[0], + config: { + ...ds.inputs[0].config, + four: { + value: 'inserted by callbackFour', + }, + }, + }, + ], + }; + }); + + beforeEach(() => { + appContextService.addExternalCallback('packagePolicyCreate', callbackOne); + appContextService.addExternalCallback('packagePolicyCreate', callbackTwo); + appContextService.addExternalCallback('packagePolicyCreate', callbackThree); + appContextService.addExternalCallback('packagePolicyCreate', callbackFour); + }); + + it('should fail to execute remaining callbacks after a callback exception', async () => { + try { + await packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ); + } catch (e) { + // expecting an error + } + + expect(callbackCallingOrder).toEqual(['one', 'two', 'three']); + expect((callbackOne as jest.Mock).mock.calls.length).toBe(1); + expect((callbackTwo as jest.Mock).mock.calls.length).toBe(1); + expect((callbackThree as jest.Mock).mock.calls.length).toBe(1); + expect((callbackFour as jest.Mock).mock.calls.length).toBe(0); + }); + + it('should fail to return the package policy', async () => { + expect( + packagePolicyService.runExternalCallbacks( + 'packagePolicyCreate', + newPackagePolicy, + context, + request + ) + ).rejects.toThrow('callbackThree threw error on purpose'); + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 7b8952bdea2cd8..5a6b48b952836c 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'src/core/server'; +import { KibanaRequest, RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server'; import uuid from 'uuid'; import { AuthenticatedUser } from '../../../security/server'; import { @@ -25,6 +25,8 @@ import { PackagePolicySOAttributes, RegistryPackage, CallESAsCurrentUser, + NewPackagePolicySchema, + UpdatePackagePolicySchema, } from '../types'; import { agentPolicyService } from './agent_policy'; import { outputService } from './output'; @@ -33,6 +35,8 @@ import { getPackageInfo, getInstallation, ensureInstalledPackage } from './epm/p import { getAssetsData } from './epm/packages/assets'; import { compileTemplate } from './epm/agent/agent'; import { normalizeKuery } from './saved_object'; +import { appContextService } from '.'; +import { ExternalCallback } from '..'; const SAVED_OBJECT_TYPE = PACKAGE_POLICY_SAVED_OBJECT_TYPE; @@ -391,6 +395,32 @@ class PackagePolicyService { return Promise.all(inputsPromises); } + + public async runExternalCallbacks( + externalCallbackType: ExternalCallback[0], + newPackagePolicy: NewPackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ): Promise { + let newData = newPackagePolicy; + + const externalCallbacks = appContextService.getExternalCallbacks(externalCallbackType); + if (externalCallbacks && externalCallbacks.size > 0) { + let updatedNewData: NewPackagePolicy = newData; + + for (const callback of externalCallbacks) { + const result = await callback(updatedNewData, context, request); + if (externalCallbackType === 'packagePolicyCreate') { + updatedNewData = NewPackagePolicySchema.validate(result); + } else if (externalCallbackType === 'packagePolicyUpdate') { + updatedNewData = UpdatePackagePolicySchema.validate(result); + } + } + + newData = updatedNewData; + } + return newData; + } } function assignStreamIdToInput(packagePolicyId: string, input: NewPackagePolicyInput) { diff --git a/x-pack/plugins/infra/.storybook/main.js b/x-pack/plugins/infra/.storybook/main.js index 1818aa44a93993..95e8ab8535a4fd 100644 --- a/x-pack/plugins/infra/.storybook/main.js +++ b/x-pack/plugins/infra/.storybook/main.js @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -module.exports = require('@kbn/storybook').defaultConfig; +const defaultConfig = require('@kbn/storybook').defaultConfig; + +module.exports = { + ...defaultConfig, + stories: ['../**/*.stories.mdx', ...defaultConfig.stories], +}; diff --git a/x-pack/plugins/infra/common/search_strategies/common/errors.ts b/x-pack/plugins/infra/common/search_strategies/common/errors.ts index 4f7954c09c48b1..3a08564f34941b 100644 --- a/x-pack/plugins/infra/common/search_strategies/common/errors.ts +++ b/x-pack/plugins/infra/common/search_strategies/common/errors.ts @@ -6,12 +6,22 @@ import * as rt from 'io-ts'; -const genericErrorRT = rt.type({ +const abortedRequestSearchStrategyErrorRT = rt.type({ + type: rt.literal('aborted'), +}); + +export type AbortedRequestSearchStrategyError = rt.TypeOf< + typeof abortedRequestSearchStrategyErrorRT +>; + +const genericSearchStrategyErrorRT = rt.type({ type: rt.literal('generic'), message: rt.string, }); -const shardFailureErrorRT = rt.type({ +export type GenericSearchStrategyError = rt.TypeOf; + +const shardFailureSearchStrategyErrorRT = rt.type({ type: rt.literal('shardFailure'), shardInfo: rt.type({ shard: rt.number, @@ -21,6 +31,12 @@ const shardFailureErrorRT = rt.type({ message: rt.string, }); -export const searchStrategyErrorRT = rt.union([genericErrorRT, shardFailureErrorRT]); +export type ShardFailureSearchStrategyError = rt.TypeOf; + +export const searchStrategyErrorRT = rt.union([ + abortedRequestSearchStrategyErrorRT, + genericSearchStrategyErrorRT, + shardFailureSearchStrategyErrorRT, +]); export type SearchStrategyError = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/components/centered_flyout_body.tsx b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx new file mode 100644 index 00000000000000..ec762610f36c4e --- /dev/null +++ b/x-pack/plugins/infra/public/components/centered_flyout_body.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlyoutBody } from '@elastic/eui'; +import { euiStyled } from '../../../observability/public'; + +export const CenteredEuiFlyoutBody = euiStyled(EuiFlyoutBody)` + & .euiFlyoutBody__overflow { + display: flex; + flex-direction: column; + } + + & .euiFlyoutBody__overflowContent { + align-items: center; + align-self: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: center; + overflow: hidden; + } +`; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx new file mode 100644 index 00000000000000..4e46e5fdd3f455 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.stories.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchErrorCallout } from './data_search_error_callout'; + +export default { + title: 'infra/dataSearch/DataSearchErrorCallout', + decorators: [ + (wrappedStory) => ( + +
    {wrappedStory()}
    +
    + ), + ], + parameters: { + layout: 'padded', + }, + argTypes: { + errors: { + control: { + type: 'object', + }, + }, + }, +} as Meta; + +type DataSearchErrorCalloutProps = PropsOf; + +const DataSearchErrorCalloutTemplate: Story = (args) => ( + +); + +const commonArgs = { + title: 'Failed to load data', + errors: [ + { + type: 'generic' as const, + message: 'A generic error message', + }, + { + type: 'shardFailure' as const, + shardInfo: { + index: 'filebeat-7.9.3-2020.12.01-000003', + node: 'a45hJUm3Tba4U8MkvkCU_g', + shard: 0, + }, + message: 'No mapping found for [@timestamp] in order to sort on', + }, + ], +}; + +export const ErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCallout.args = { + ...commonArgs, +}; + +export const ErrorCalloutWithRetry = DataSearchErrorCalloutTemplate.bind({}); + +ErrorCalloutWithRetry.args = { + ...commonArgs, +}; +ErrorCalloutWithRetry.argTypes = { + onRetry: { action: 'retrying' }, +}; + +export const AbortedErrorCallout = DataSearchErrorCalloutTemplate.bind({}); + +AbortedErrorCallout.args = { + ...commonArgs, + errors: [ + { + type: 'aborted', + }, + ], +}; +AbortedErrorCallout.argTypes = { + onRetry: { action: 'retrying' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_error_callout.tsx b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx new file mode 100644 index 00000000000000..a0ed3bed950781 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_error_callout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { + AbortedRequestSearchStrategyError, + GenericSearchStrategyError, + SearchStrategyError, + ShardFailureSearchStrategyError, +} from '../../common/search_strategies/common/errors'; + +export const DataSearchErrorCallout: React.FC<{ + title: React.ReactNode; + errors: SearchStrategyError[]; + onRetry?: () => void; +}> = ({ errors, onRetry, title }) => { + const calloutColor = errors.some((error) => error.type !== 'aborted') ? 'danger' : 'warning'; + + return ( + + {errors?.map((error, errorIndex) => ( + + ))} + {onRetry ? ( + + + + ) : null} + + ); +}; + +const DataSearchErrorMessage: React.FC<{ error: SearchStrategyError }> = ({ error }) => { + if (error.type === 'aborted') { + return ; + } else if (error.type === 'shardFailure') { + return ; + } else { + return ; + } +}; + +const AbortedRequestErrorMessage: React.FC<{ + error?: AbortedRequestSearchStrategyError; +}> = ({}) => ( + +); + +const GenericErrorMessage: React.FC<{ error: GenericSearchStrategyError }> = ({ error }) => ( +

    {error.message ?? `${error}`}

    +); + +const ShardFailureErrorMessage: React.FC<{ error: ShardFailureSearchStrategyError }> = ({ + error, +}) => ( + +); diff --git a/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx new file mode 100644 index 00000000000000..d5293a72823050 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.stories.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PropsOf } from '@elastic/eui'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import React from 'react'; +import { EuiThemeProvider } from '../../../observability/public'; +import { DataSearchProgress } from './data_search_progress'; + +export default { + title: 'infra/dataSearch/DataSearchProgress', + decorators: [ + (wrappedStory) => ( + +
    {wrappedStory()}
    +
    + ), + ], + parameters: { + layout: 'padded', + }, +} as Meta; + +type DataSearchProgressProps = PropsOf; + +const DataSearchProgressTemplate: Story = (args) => ( + +); + +export const UndeterminedProgress = DataSearchProgressTemplate.bind({}); + +export const DeterminedProgress = DataSearchProgressTemplate.bind({}); + +DeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; + +export const CancelableDeterminedProgress = DataSearchProgressTemplate.bind({}); + +CancelableDeterminedProgress.args = { + label: 'Searching', + maxValue: 10, + value: 3, +}; +CancelableDeterminedProgress.argTypes = { + onCancel: { action: 'canceled' }, +}; diff --git a/x-pack/plugins/infra/public/components/data_search_progress.tsx b/x-pack/plugins/infra/public/components/data_search_progress.tsx new file mode 100644 index 00000000000000..bf699ac9762327 --- /dev/null +++ b/x-pack/plugins/infra/public/components/data_search_progress.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +export const DataSearchProgress: React.FC<{ + label?: React.ReactNode; + maxValue?: number; + onCancel?: () => void; + value?: number; +}> = ({ label, maxValue, onCancel, value }) => { + const valueText = useMemo( + () => + Number.isFinite(maxValue) && Number.isFinite(value) ? `${value} / ${maxValue}` : undefined, + [value, maxValue] + ); + + return ( + + + + + {onCancel ? ( + + + + ) : null} + + ); +}; + +const cancelButtonLabel = i18n.translate('xpack.infra.dataSearch.cancelButtonLabel', { + defaultMessage: 'Cancel request', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx new file mode 100644 index 00000000000000..44e9902e0413f1 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_fields_table.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBasicTableColumn, EuiInMemoryTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { + LogEntry, + LogEntryField, +} from '../../../../common/search_strategies/log_entries/log_entry'; +import { TimeKey } from '../../../../common/time'; +import { FieldValue } from '../log_text_stream/field_value'; + +export const LogEntryFieldsTable: React.FC<{ + logEntry: LogEntry; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; +}> = ({ logEntry, onSetFieldFilter }) => { + const createSetFilterHandler = useMemo( + () => + onSetFieldFilter + ? (field: LogEntryField) => () => { + onSetFieldFilter?.(`${field.field}:"${field.value}"`, logEntry.id, logEntry.key); + } + : undefined, + [logEntry, onSetFieldFilter] + ); + + const columns = useMemo>>( + () => [ + { + field: 'field', + name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { + defaultMessage: 'Field', + }), + sortable: true, + }, + { + actions: [ + { + type: 'icon', + icon: 'filter', + name: setFilterButtonLabel, + description: setFilterButtonDescription, + available: () => !!createSetFilterHandler, + onClick: (item) => createSetFilterHandler?.(item)(), + }, + ], + }, + { + field: 'value', + name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { + defaultMessage: 'Value', + }), + render: (_name: string, item: LogEntryField) => ( + + ), + }, + ], + [createSetFilterHandler] + ); + + return ( + + columns={columns} + items={logEntry.fields} + search={searchOptions} + sorting={initialSortingOptions} + /> + ); +}; + +const emptyHighlightTerms: string[] = []; + +const initialSortingOptions = { + sort: { + field: 'field', + direction: 'asc' as const, + }, +}; + +const searchOptions = { + box: { + incremental: true, + schema: true, + }, +}; + +const setFilterButtonLabel = i18n.translate('xpack.infra.logFlyout.filterAriaLabel', { + defaultMessage: 'Filter', +}); + +const setFilterButtonDescription = i18n.translate('xpack.infra.logFlyout.setFilterTooltip', { + defaultMessage: 'View event with filter', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index bc0f6dc97017a6..5684d4068f3be1 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -5,132 +5,60 @@ */ import { - EuiBasicTableColumn, - EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, - EuiInMemoryTable, EuiSpacer, EuiTextColor, EuiTitle, - EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import React, { useCallback, useMemo } from 'react'; -import { euiStyled } from '../../../../../observability/public'; -import { - LogEntry, - LogEntryField, -} from '../../../../common/search_strategies/log_entries/log_entry'; +import React, { useEffect } from 'react'; import { TimeKey } from '../../../../common/time'; -import { InfraLoadingPanel } from '../../loading'; -import { FieldValue } from '../log_text_stream/field_value'; +import { useLogEntry } from '../../../containers/logs/log_entry'; +import { CenteredEuiFlyoutBody } from '../../centered_flyout_body'; +import { DataSearchErrorCallout } from '../../data_search_error_callout'; +import { DataSearchProgress } from '../../data_search_progress'; import { LogEntryActionsMenu } from './log_entry_actions_menu'; +import { LogEntryFieldsTable } from './log_entry_fields_table'; export interface LogEntryFlyoutProps { - flyoutError: string | null; - flyoutItem: LogEntry | null; - setFlyoutVisibility: (visible: boolean) => void; - setFilter: (filter: string, flyoutItemId: string, timeKey?: TimeKey) => void; - loading: boolean; + logEntryId: string | null | undefined; + onCloseFlyout: () => void; + onSetFieldFilter?: (filter: string, logEntryId: string, timeKey?: TimeKey) => void; + sourceId: string | null | undefined; } -const emptyHighlightTerms: string[] = []; - -const initialSortingOptions = { - sort: { - field: 'field', - direction: 'asc' as const, - }, -}; - -const searchOptions = { - box: { - incremental: true, - schema: true, - }, -}; - export const LogEntryFlyout = ({ - flyoutError, - flyoutItem, - loading, - setFlyoutVisibility, - setFilter, + logEntryId, + onCloseFlyout, + onSetFieldFilter, + sourceId, }: LogEntryFlyoutProps) => { - const createFilterHandler = useCallback( - (field: LogEntryField) => () => { - if (!flyoutItem) { - return; - } - - const filter = `${field.field}:"${field.value}"`; - const timestampMoment = moment(flyoutItem.key.time); - let target; + const { + cancelRequest: cancelLogEntryRequest, + errors: logEntryErrors, + fetchLogEntry, + isRequestRunning, + loaded: logEntryRequestProgress, + logEntry, + total: logEntryRequestTotal, + } = useLogEntry({ + sourceId, + logEntryId, + }); - if (timestampMoment.isValid()) { - target = { - time: timestampMoment.valueOf(), - tiebreaker: flyoutItem.key.tiebreaker, - }; - } - - setFilter(filter, flyoutItem.id, target); - }, - [flyoutItem, setFilter] - ); - - const closeFlyout = useCallback(() => setFlyoutVisibility(false), [setFlyoutVisibility]); - - const columns = useMemo>>( - () => [ - { - field: 'field', - name: i18n.translate('xpack.infra.logFlyout.fieldColumnLabel', { - defaultMessage: 'Field', - }), - sortable: true, - }, - { - field: 'value', - name: i18n.translate('xpack.infra.logFlyout.valueColumnLabel', { - defaultMessage: 'Value', - }), - render: (_name: string, item: LogEntryField) => ( - - - - - - - ), - }, - ], - [createFilterHandler] - ); + useEffect(() => { + if (sourceId && logEntryId) { + fetchLogEntry(); + } + }, [fetchLogEntry, sourceId, logEntryId]); return ( - + @@ -140,12 +68,12 @@ export const LogEntryFlyout = ({ defaultMessage="Details for log entry {logEntryId}" id="xpack.infra.logFlyout.flyoutTitle" values={{ - logEntryId: flyoutItem ? {flyoutItem.id} : '', + logEntryId: logEntryId ? {logEntryId} : '', }} /> - {flyoutItem ? ( + {logEntry ? ( <> @@ -153,7 +81,7 @@ export const LogEntryFlyout = ({ id="xpack.infra.logFlyout.flyoutSubTitle" defaultMessage="From index {indexName}" values={{ - indexName: {flyoutItem.index}, + indexName: {logEntry.index}, }} /> @@ -161,40 +89,54 @@ export const LogEntryFlyout = ({ ) : null} - {flyoutItem !== null ? : null} + {logEntry ? : null} - - {loading ? ( - - +
    + +
    + + ) : logEntry ? ( + 0 ? ( + + ) : undefined + } + > + + + ) : ( + +
    + - - ) : flyoutItem ? ( - - columns={columns} - items={flyoutItem.fields} - search={searchOptions} - sorting={initialSortingOptions} - /> - ) : ( - {flyoutError} - )} - +
    +
    + )}
    ); }; -export const InfraFlyoutLoadingPanel = euiStyled.div` - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -`; +const loadingProgressMessage = i18n.translate('xpack.infra.logFlyout.loadingMessage', { + defaultMessage: 'Searching log entry in shards', +}); + +const loadingErrorCalloutTitle = i18n.translate('xpack.infra.logFlyout.loadingErrorCalloutTitle', { + defaultMessage: 'Error while searching the log entry', +}); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index ab0f0ac78529e4..3c86ce3e32526f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -51,8 +51,7 @@ interface ScrollableLogTextStreamViewProps { }) => any; loadNewerItems: () => void; reloadItems: () => void; - setFlyoutItem?: (id: string) => void; - setFlyoutVisibility?: (visible: boolean) => void; + onOpenLogEntryFlyout?: (logEntryId?: string) => void; setContextEntry?: (entry: LogEntry) => void; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; @@ -143,15 +142,14 @@ export class ScrollableLogTextStreamView extends React.PureComponent< lastLoadedTime, updateDateRange, startLiveStreaming, - setFlyoutItem, - setFlyoutVisibility, + onOpenLogEntryFlyout, setContextEntry, } = this.props; const hideScrollbar = this.props.hideScrollbar ?? true; const { targetId, items, isScrollLocked } = this.state; const hasItems = items.length > 0; - const hasFlyoutAction = !!(setFlyoutItem && setFlyoutVisibility); + const hasFlyoutAction = !!onOpenLogEntryFlyout; const hasContextAction = !!setContextEntry; return ( @@ -305,12 +303,7 @@ export class ScrollableLogTextStreamView extends React.PureComponent< } private handleOpenFlyout = (id: string) => { - const { setFlyoutItem, setFlyoutVisibility } = this.props; - - if (setFlyoutItem && setFlyoutVisibility) { - setFlyoutItem(id); - setFlyoutVisibility(true); - } + this.props.onOpenLogEntryFlyout?.(id); }; private handleOpenViewLogInContext = (entry: LogEntry) => { diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts deleted file mode 100644 index 764de1d34a3bf8..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/api/fetch_log_entry.ts +++ /dev/null @@ -1,31 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ISearchStart } from '../../../../../../../../src/plugins/data/public'; -import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { - LogEntry, - LogEntrySearchRequestParams, - logEntrySearchRequestParamsRT, - logEntrySearchResponsePayloadRT, - LOG_ENTRY_SEARCH_STRATEGY, -} from '../../../../../common/search_strategies/log_entries/log_entry'; - -export { LogEntry }; - -export const fetchLogEntry = async ( - requestArgs: LogEntrySearchRequestParams, - search: ISearchStart -) => { - const response = await search - .search( - { params: logEntrySearchRequestParamsRT.encode(requestArgs) }, - { strategy: LOG_ENTRY_SEARCH_STRATEGY } - ) - .toPromise(); - - return decodeOrThrow(logEntrySearchResponsePayloadRT)(response.rawResponse); -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_entry.ts b/x-pack/plugins/infra/public/containers/logs/log_entry.ts new file mode 100644 index 00000000000000..af8618b8be5657 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_entry.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + logEntrySearchRequestParamsRT, + logEntrySearchResponsePayloadRT, + LOG_ENTRY_SEARCH_STRATEGY, +} from '../../../common/search_strategies/log_entries/log_entry'; +import { useDataSearch, useLatestPartialDataSearchResponse } from '../../utils/data_search'; + +export const useLogEntry = ({ + sourceId, + logEntryId, +}: { + sourceId: string | null | undefined; + logEntryId: string | null | undefined; +}) => { + const { search: fetchLogEntry, requests$: logEntrySearchRequests$ } = useDataSearch({ + getRequest: useCallback(() => { + return !!logEntryId && !!sourceId + ? { + request: { + params: logEntrySearchRequestParamsRT.encode({ sourceId, logEntryId }), + }, + options: { strategy: LOG_ENTRY_SEARCH_STRATEGY }, + } + : null; + }, [sourceId, logEntryId]), + }); + + const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, + } = useLatestPartialDataSearchResponse( + logEntrySearchRequests$, + null, + decodeLogEntrySearchResponse + ); + + return { + cancelRequest, + errors: latestResponseErrors, + fetchLogEntry, + isRequestRunning, + isResponsePartial, + loaded, + logEntry: latestResponseData ?? null, + total, + }; +}; + +const decodeLogEntrySearchResponse = decodeOrThrow(logEntrySearchResponsePayloadRT); diff --git a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx index 121f0e6b651dc0..7f35af58005186 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_flyout.tsx @@ -6,12 +6,8 @@ import createContainer from 'constate'; import { isString } from 'lodash'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import React, { useCallback, useState } from 'react'; import { UrlStateContainer } from '../../utils/url_state'; -import { useTrackedPromise } from '../../utils/use_tracked_promise'; -import { fetchLogEntry } from './log_entries/api/fetch_log_entry'; -import { useLogSourceContext } from './log_source'; export enum FlyoutVisibility { hidden = 'hidden', @@ -25,97 +21,78 @@ export interface FlyoutOptionsUrlState { } export const useLogFlyout = () => { - const { services } = useKibanaContextForPlugin(); - const { sourceId } = useLogSourceContext(); - const [flyoutVisible, setFlyoutVisibility] = useState(false); - const [flyoutId, setFlyoutId] = useState(null); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [logEntryId, setLogEntryId] = useState(null); const [surroundingLogsId, setSurroundingLogsId] = useState(null); - const [loadFlyoutItemRequest, loadFlyoutItem] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async () => { - if (!flyoutId) { - throw new Error('Failed to load log entry: Id not specified.'); - } - return await fetchLogEntry({ sourceId, logEntryId: flyoutId }, services.data.search); - }, - }, - [sourceId, flyoutId] - ); - - const isLoading = useMemo(() => { - return loadFlyoutItemRequest.state === 'pending'; - }, [loadFlyoutItemRequest.state]); - - useEffect(() => { - if (flyoutId) { - loadFlyoutItem(); + const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + const openFlyout = useCallback((newLogEntryId?: string) => { + if (newLogEntryId) { + setLogEntryId(newLogEntryId); } - }, [loadFlyoutItem, flyoutId]); + setIsFlyoutOpen(true); + }, []); return { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + closeFlyout, + openFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - isLoading, - flyoutItem: - loadFlyoutItemRequest.state === 'resolved' ? loadFlyoutItemRequest.value.data : null, - flyoutError: - loadFlyoutItemRequest.state === 'rejected' ? `${loadFlyoutItemRequest.value}` : null, }; }; export const LogFlyout = createContainer(useLogFlyout); +export const [LogEntryFlyoutProvider, useLogEntryFlyoutContext] = LogFlyout; export const WithFlyoutOptionsUrlState = () => { const { - flyoutVisible, - setFlyoutVisibility, - flyoutId, - setFlyoutId, + isFlyoutOpen, + openFlyout, + closeFlyout, + logEntryId, + setLogEntryId, surroundingLogsId, setSurroundingLogsId, - } = useContext(LogFlyout.Context); + } = useLogEntryFlyoutContext(); return ( { if (newUrlState && newUrlState.flyoutId) { - setFlyoutId(newUrlState.flyoutId); + setLogEntryId(newUrlState.flyoutId); } if (newUrlState && newUrlState.surroundingLogsId) { setSurroundingLogsId(newUrlState.surroundingLogsId); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (newUrlState && newUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} onInitialize={(initialUrlState) => { if (initialUrlState && initialUrlState.flyoutId) { - setFlyoutId(initialUrlState.flyoutId); + setLogEntryId(initialUrlState.flyoutId); } if (initialUrlState && initialUrlState.surroundingLogsId) { setSurroundingLogsId(initialUrlState.surroundingLogsId); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.visible) { - setFlyoutVisibility(true); + openFlyout(); } if (initialUrlState && initialUrlState.flyoutVisibility === FlyoutVisibility.hidden) { - setFlyoutVisibility(false); + closeFlyout(); } }} /> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index bb0c9196fb0ccd..c4a464a4cffad9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -7,21 +7,25 @@ import datemath from '@elastic/datemath'; import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; -import { encode, RisonValue } from 'rison-node'; import { stringify } from 'query-string'; -import React, { useCallback, useEffect, useMemo, useState, useContext } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { encode, RisonValue } from 'rison-node'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; +import { TimeKey } from '../../../../common/time'; import { CategoryJobNoticesSection, LogAnalysisJobProblemIndicator, } from '../../../components/logging/log_analysis_job_status'; import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { LogEntryFlyout } from '../../../components/logging/log_entry_flyout'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis/log_analysis_capabilities'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { useLogEntryFlyoutContext } from '../../../containers/logs/log_flyout'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useInterval } from '../../../hooks/use_interval'; import { AnomaliesResults } from './sections/anomalies'; @@ -31,9 +35,6 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; -import { LogEntryFlyout, LogEntryFlyoutProps } from '../../../components/logging/log_entry_flyout'; -import { LogFlyout } from '../../../containers/logs/log_flyout'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -77,6 +78,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { setAutoRefresh, } = useLogAnalysisResultsUrlState(); + const { + closeFlyout: closeLogEntryFlyout, + isFlyoutOpen: isLogEntryFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); + const [queryTimeRange, setQueryTimeRange] = useState<{ value: TimeRange; lastChangedTime: number; @@ -85,8 +92,8 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { lastChangedTime: Date.now(), })); - const linkToLogStream = useCallback( - (filter, id, timeKey) => { + const linkToLogStream = useCallback( + (filter: string, id: string, timeKey?: TimeKey) => { const params = { logPosition: encode({ end: moment(queryTimeRange.value.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), @@ -144,14 +151,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { filteredDatasets: selectedDatasets, }); - const { - flyoutVisible, - setFlyoutVisibility, - flyoutError, - flyoutItem, - isLoading: isFlyoutLoading, - } = useContext(LogFlyout.Context); - const handleQueryTimeRangeChange = useCallback( ({ start: startTime, end: endTime }: { start: string; end: string }) => { setQueryTimeRange({ @@ -305,14 +304,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - - {flyoutVisible ? ( + {isLogEntryFlyoutOpen ? ( ) : null} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index a226486666095d..b639cecf676ade 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useCallback, useState, useContext } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; @@ -37,7 +37,7 @@ import { } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; import { LogEntryAnomaly } from '../../../../../../common/http_api'; -import { LogFlyout } from '../../../../../containers/logs/log_flyout'; +import { useLogEntryFlyoutContext } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -88,7 +88,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ const setItemIsHovered = useCallback(() => setIsHovered(true), []); const setItemIsNotHovered = useCallback(() => setIsHovered(false), []); - const { setFlyoutVisibility, setFlyoutId } = useContext(LogFlyout.Context); + const { openFlyout: openLogEntryFlyout } = useLogEntryFlyoutContext(); // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -129,8 +129,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ { label: VIEW_DETAILS_LABEL, onClick: () => { - setFlyoutId(id); - setFlyoutVisibility(true); + openLogEntryFlyout(id); }, }, { @@ -144,13 +143,7 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ href: viewAnomalyInMachineLearningLinkProps.href, }, ]; - }, [ - id, - setFlyoutId, - setFlyoutVisibility, - viewInStreamLinkProps, - viewAnomalyInMachineLearningLinkProps, - ]); + }, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); return ( { const { sourceConfiguration, sourceId } = useLogSourceContext(); const { textScale, textWrap } = useContext(LogViewConfiguration.Context); const { - setFlyoutVisibility, - flyoutVisible, - setFlyoutId, surroundingLogsId, setSurroundingLogsId, - flyoutItem, - flyoutError, - isLoading, - } = useContext(LogFlyoutState.Context); + closeFlyout: closeLogEntryFlyout, + openFlyout: openLogEntryFlyout, + isFlyoutOpen, + logEntryId: flyoutLogEntryId, + } = useLogEntryFlyoutContext(); const { logSummaryHighlights } = useContext(LogHighlightsState.Context); const { applyLogFilterQuery } = useContext(LogFilterState.Context); const { @@ -76,13 +74,12 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { - {flyoutVisible ? ( + {isFlyoutOpen ? ( ) : null} @@ -116,8 +113,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { scale={textScale} target={targetPosition} wrap={textWrap} - setFlyoutItem={setFlyoutId} - setFlyoutVisibility={setFlyoutVisibility} + onOpenLogEntryFlyout={openLogEntryFlyout} setContextEntry={setContextEntry} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} diff --git a/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx new file mode 100644 index 00000000000000..a698b806b4cd70 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/data_search.stories.mdx @@ -0,0 +1,140 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; + + + +# The `data` plugin and `SearchStrategies` + +The search functionality abstraction provided by the `search` service of the +`data` plugin is pretty powerful: + +- The execution of the request is delegated to a search strategy, which is + executed on the Kibana server side. +- Any plugin can register custom search strategies with custom parameters and + response shapes. +- Search requests can be cancelled via an `AbortSignal`. +- Search requests are decoupled from the transport layer. The service will poll + for new results transparently. +- Partial responses can be returned as they become available if the search + takes longer. + +# Working with `data.search.search()` in the Browser + +The following chapters describe a set of React components and hooks that aim to +make it easy to take advantage of these characteristics from client-side React +code. They implement a producer/consumer pattern that decouples the craeation +of search requests from the consumption of the responses. This keeps each +code-path small and encourages the use of reactive processing, which in turn +reduces the risk of race conditions and incorrect assumptions about the +response timing. + +## Issuing new requests + +The main API to issue new requests is the `data.search.search()` function. It +returns an `Observable` representing the stream of partial and final results +without the consumer having to know the underlying transport mechanisms. +Besides receiving a search-strategy-specific parameter object, it supports +selection of the search strategy as well an `AbortSignal` used for request +cancellation. + +The hook `useDataSearch()` is designed to ease the integration between the +`Observable` world and the React world. It uses the function it is given to +derive the parameters to use for the next search request. The request can then +be issued by calling the returned `search()` function. For each new request the +hook emits an object describing the request and its state in the `requests$` +`Observable`. + +```typescript +const { search, requests$ } = useDataSearch({ + getRequest: useCallback((searchTerm: string) => ({ + request: { + params: { + searchTerm + } + } + }), []); +}); +``` + +## Executing requests and consuming the responses + +The response `Observable`s emitted by `data.search.search()` is "cold", so it +won't be executed unless a subscriber subscribes to it. And in order to cleanly +cancel and garbage collect the subscription it should be integrated with the +React component life-cycle. + +The `useLatestPartialDataSearchResponse()` does that in such a way that the +newest response observable is subscribed to and that any previous response +observables are unsubscribed from for proper cancellation if a new request has +been created. This uses RxJS's `switchMap()` operator under the hood. The hook +also makes sure that all observables are unsubscribed from on unmount. + +Since the specific response shape depends on the data strategy used, the hook +takes a projection function, that is responsible for decoding the response in +an appropriate way. + +A request can fail due to various reasons that include servers-side errors, +Elasticsearch shard failures and network failures. The intention is to map all +of them to a common `SearchStrategyError` interface. While the +`useLatestPartialDataSearchResponse()` hook does that for errors emitted +natively by the response `Observable`, it's the responsibility of the +projection function to handle errors that are encoded in the response body, +which includes most server-side errors. Note that errors and partial results in +a response are not mutually exclusive. + +The request status (running, partial, etc), the response +and the errors are turned in to React component state so they can be used in +the usual rendering cycle: + +```typescript +const { + cancelRequest, + isRequestRunning, + isResponsePartial, + latestResponseData, + latestResponseErrors, + loaded, + total, +} = useLatestPartialDataSearchResponse( + requests$, + 'initialValue', + useMemo(() => decodeOrThrow(mySearchStrategyResponsePayloadRT), []), +); +``` + +## Representing the request state to the user + +After the values have been made available to the React rendering process using +the `useLatestPartialDataSearchResponse()` hook, normal component hierarchies +can be used to make the request state and result available to the user. The +following utility components can make that even easier. + +### Undetermined progress + +If `total` and `loaded` are not (yet) known, we can show an undetermined +progress bar. + + + + + +### Known progress + +If `total` and `loaded` are returned by the search strategy, they can be used +to show a progress bar with the option to cancel the request if it takes too +long. + + + + + +### Failed requests + +Assuming the errors are represented as an array of `SearchStrategyError`s in +the `latestResponseErrors` return value, they can be rendered as appropriate +for the respective part of the UI. For many cases a `EuiCallout` is suitable, +so the `DataSearchErrorCallout` can serve as a starting point: + + + + + diff --git a/x-pack/plugins/infra/public/utils/data_search/index.ts b/x-pack/plugins/infra/public/utils/data_search/index.ts new file mode 100644 index 00000000000000..c08ab0727fd904 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './types'; +export * from './use_data_search_request'; +export * from './use_latest_partial_data_search_response'; diff --git a/x-pack/plugins/infra/public/utils/data_search/types.ts b/x-pack/plugins/infra/public/utils/data_search/types.ts new file mode 100644 index 00000000000000..ba0a4c639dae4a --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; + +export interface DataSearchRequestDescriptor { + request: Request; + options: ISearchOptions; + response$: Observable>; + abortController: AbortController; +} + +export interface NormalizedKibanaSearchResponse { + total?: number; + loaded?: number; + isRunning: boolean; + isPartial: boolean; + data: ResponseData; + errors: SearchStrategyError[]; +} + +export interface DataSearchResponseDescriptor { + request: Request; + options: ISearchOptions; + response: NormalizedKibanaSearchResponse; + abortController: AbortController; +} diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx new file mode 100644 index 00000000000000..87c091f12ad90a --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { Observable, of, Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { + DataPublicPluginStart, + IKibanaSearchResponse, + ISearchGeneric, + ISearchStart, +} from '../../../../../../src/plugins/data/public'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { PluginKibanaContextValue } from '../../hooks/use_kibana'; +import { useDataSearch } from './use_data_search_request'; + +describe('useDataSearch hook', () => { + it('forwards the search function arguments to the getRequest function', async () => { + const dataMock = createDataPluginMock(); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((_firstArgument: string, _secondArgument: string) => null); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.search('first', 'second'); + }); + + expect(getRequest).toHaveBeenLastCalledWith('first', 'second'); + expect(dataMock.search.search).not.toHaveBeenCalled(); + }); + + it('creates search requests with the given params and options', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = of({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + expect(dataMock.search.search).toHaveBeenLastCalledWith( + { + params: { firstArgument: 'first', secondArgument: 'second' }, + }, + { + abortSignal: expect.any(Object), + strategy: 'test-search-strategy', + } + ); + expect(firstRequest).toHaveProperty('abortController', expect.any(Object)); + expect(firstRequest).toHaveProperty('request.params', { + firstArgument: 'first', + secondArgument: 'second', + }); + expect(firstRequest).toHaveProperty('options.strategy', 'test-search-strategy'); + expect(firstRequest).toHaveProperty('response$', expect.any(Observable)); + await expect(firstRequest.response$.toPromise()).resolves.toEqual({ + rawResponse: { + firstKey: 'firstValue', + }, + }); + }); + + it('aborts the request when the response observable looses the last subscriber', async () => { + const dataMock = createDataPluginMock(); + const searchResponseMock$ = new Subject(); + dataMock.search.search.mockReturnValue(searchResponseMock$); + const { Provider: KibanaContextProvider } = createKibanaReactContext< + Partial + >({ + data: dataMock, + }); + + const getRequest = jest.fn((firstArgument: string, secondArgument: string) => ({ + request: { + params: { + firstArgument, + secondArgument, + }, + }, + options: { + strategy: 'test-search-strategy', + }, + })); + + const { result } = renderHook( + () => + useDataSearch({ + getRequest, + }), + { + wrapper: ({ children }) => {children}, + } + ); + + // the request execution is lazy + expect(dataMock.search.search).not.toHaveBeenCalled(); + + // execute requests$ observable + const firstRequestPromise = result.current.requests$.pipe(take(1)).toPromise(); + + act(() => { + result.current.search('first', 'second'); + }); + + const firstRequest = await firstRequestPromise; + + // execute requests$ observable + const firstResponseSubscription = firstRequest.response$.subscribe({ + next: jest.fn(), + }); + + // get the abort signal + const [, firstRequestOptions] = dataMock.search.search.mock.calls[0]; + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(false); + + // unsubscribe + firstResponseSubscription.unsubscribe(); + + expect(firstRequestOptions?.abortSignal?.aborted).toBe(true); + }); +}); + +const createDataPluginMock = () => { + const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { + search: ISearchStart & { search: jest.MockedFunction }; + }; + return dataMock; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts new file mode 100644 index 00000000000000..a23f06adc0353c --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_data_search_request.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Subject } from 'rxjs'; +import { map, share, switchMap, tap } from 'rxjs/operators'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchOptions, +} from '../../../../../../src/plugins/data/public'; +import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; +import { tapUnsubscribe, useObservable } from '../use_observable'; + +export type DataSearchRequestFactory = ( + ...args: Args +) => + | { + request: Request; + options: ISearchOptions; + } + | null + | undefined; + +export const useDataSearch = < + RequestFactoryArgs extends any[], + Request extends IKibanaSearchRequest, + RawResponse +>({ + getRequest, +}: { + getRequest: DataSearchRequestFactory; +}) => { + const { services } = useKibanaContextForPlugin(); + const request$ = useObservable( + () => new Subject<{ request: Request; options: ISearchOptions }>(), + [] + ); + const requests$ = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequest$]) => currentRequest$), + map(({ request, options }) => { + const abortController = new AbortController(); + let isAbortable = true; + + return { + abortController, + request, + options, + response$: services.data.search + .search>(request, { + abortSignal: abortController.signal, + ...options, + }) + .pipe( + // avoid aborting failed or completed requests + tap({ + error: () => { + isAbortable = false; + }, + complete: () => { + isAbortable = false; + }, + }), + tapUnsubscribe(() => { + if (isAbortable) { + abortController.abort(); + } + }), + share() + ), + }; + }) + ), + [request$] + ); + + const search = useCallback( + (...args: RequestFactoryArgs) => { + const request = getRequest(...args); + + if (request) { + request$.next(request); + } + }, + [getRequest, request$] + ); + + return { + requests$, + search, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx new file mode 100644 index 00000000000000..4c336aa1107a22 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { Observable, of, Subject } from 'rxjs'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, +} from '../../../../../../src/plugins/data/public'; +import { DataSearchRequestDescriptor } from './types'; +import { useLatestPartialDataSearchResponse } from './use_latest_partial_data_search_response'; + +describe('useLatestPartialDataSearchResponse hook', () => { + it("subscribes to the latest request's response observable", () => { + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Subject>(), + }; + + const secondRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'secondRequestParam' }, + response$: new Subject>(), + }; + + const requests$ = new Subject< + DataSearchRequestDescriptor, string> + >(); + + const { result } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty('current.latestResponseData', undefined); + + // first request is started + act(() => { + requests$.next(firstRequest); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty('current.latestResponseData', 'initial'); + + // first response of the first request arrives + act(() => { + firstRequest.response$.next({ rawResponse: 'request-1-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-1-response-1' + ); + + // second request is started before the second response of the first request arrives + act(() => { + requests$.next(secondRequest); + secondRequest.response$.next({ rawResponse: 'request-2-response-1', isRunning: true }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', true); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-1' + ); + + // second response of the second request arrives + act(() => { + secondRequest.response$.next({ rawResponse: 'request-2-response-2', isRunning: false }); + }); + + expect(result).toHaveProperty('current.isRequestRunning', false); + expect(result).toHaveProperty( + 'current.latestResponseData', + 'projection of request-2-response-2' + ); + }); + + it("unsubscribes from the latest request's response observable on unmount", () => { + const onUnsubscribe = jest.fn(); + + const firstRequest = { + abortController: new AbortController(), + options: {}, + request: { params: 'firstRequestParam' }, + response$: new Observable>(() => { + return onUnsubscribe; + }), + }; + + const requests$ = of, string>>( + firstRequest + ); + + const { unmount } = renderHook(() => + useLatestPartialDataSearchResponse(requests$, 'initial', (response) => ({ + data: `projection of ${response}`, + })) + ); + + expect(onUnsubscribe).not.toHaveBeenCalled(); + + unmount(); + + expect(onUnsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts new file mode 100644 index 00000000000000..71fd96283d0ef9 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/data_search/use_latest_partial_data_search_response.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { Observable, of } from 'rxjs'; +import { catchError, map, startWith, switchMap } from 'rxjs/operators'; +import { IKibanaSearchRequest } from '../../../../../../src/plugins/data/public'; +import { AbortError } from '../../../../../../src/plugins/kibana_utils/public'; +import { SearchStrategyError } from '../../../common/search_strategies/common/errors'; +import { useLatest, useObservable, useObservableState } from '../use_observable'; +import { DataSearchRequestDescriptor, DataSearchResponseDescriptor } from './types'; + +export const useLatestPartialDataSearchResponse = < + Request extends IKibanaSearchRequest, + RawResponse, + Response, + InitialResponse +>( + requests$: Observable>, + initialResponse: InitialResponse, + projectResponse: (rawResponse: RawResponse) => { data: Response; errors?: SearchStrategyError[] } +) => { + const latestInitialResponse = useLatest(initialResponse); + const latestProjectResponse = useLatest(projectResponse); + + const latestResponse$: Observable< + DataSearchResponseDescriptor + > = useObservable( + (inputs$) => + inputs$.pipe( + switchMap(([currentRequests$]) => + currentRequests$.pipe( + switchMap(({ abortController, options, request, response$ }) => + response$.pipe( + map((response) => { + const { data, errors = [] } = latestProjectResponse.current(response.rawResponse); + return { + abortController, + options, + request, + response: { + data, + errors, + isPartial: response.isPartial ?? false, + isRunning: response.isRunning ?? false, + loaded: response.loaded, + total: response.total, + }, + }; + }), + startWith({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [], + isPartial: true, + isRunning: true, + loaded: 0, + total: undefined, + }, + }), + catchError((error) => + of({ + abortController, + options, + request, + response: { + data: latestInitialResponse.current, + errors: [ + error instanceof AbortError + ? { + type: 'aborted' as const, + } + : { + type: 'generic' as const, + message: `${error.message ?? error}`, + }, + ], + isPartial: true, + isRunning: false, + loaded: 0, + total: undefined, + }, + }) + ) + ) + ) + ) + ) + ), + [requests$] as const + ); + + const { latestValue } = useObservableState(latestResponse$, undefined); + + const cancelRequest = useCallback(() => { + latestValue?.abortController.abort(); + }, [latestValue]); + + return { + cancelRequest, + isRequestRunning: latestValue?.response.isRunning ?? false, + isResponsePartial: latestValue?.response.isPartial ?? false, + latestResponseData: latestValue?.response.data, + latestResponseErrors: latestValue?.response.errors, + loaded: latestValue?.response.loaded, + total: latestValue?.response.total, + }; +}; diff --git a/x-pack/plugins/infra/public/utils/use_observable.ts b/x-pack/plugins/infra/public/utils/use_observable.ts new file mode 100644 index 00000000000000..342aa5aa797b13 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/use_observable.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { BehaviorSubject, Observable, PartialObserver, Subscription } from 'rxjs'; + +export const useLatest = (value: Value) => { + const valueRef = useRef(value); + valueRef.current = value; + return valueRef; +}; + +export const useObservable = < + OutputValue, + OutputObservable extends Observable, + InputValues extends Readonly +>( + createObservableOnce: (inputValues: Observable) => OutputObservable, + inputValues: InputValues +) => { + const [inputValues$] = useState(() => new BehaviorSubject(inputValues)); + const [output$] = useState(() => createObservableOnce(inputValues$)); + + useEffect(() => { + inputValues$.next(inputValues); + // `inputValues` can't be statically analyzed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, inputValues); + + return output$; +}; + +export const useObservableState = ( + state$: Observable, + initialState: InitialState | (() => InitialState) +) => { + const [latestValue, setLatestValue] = useState(initialState); + const [latestError, setLatestError] = useState(); + + useSubscription(state$, { + next: setLatestValue, + error: setLatestError, + }); + + return { latestValue, latestError }; +}; + +export const useSubscription = ( + input$: Observable, + { next, error, complete, unsubscribe }: PartialObserver & { unsubscribe?: () => void } +) => { + const latestSubscription = useRef(); + const latestNext = useLatest(next); + const latestError = useLatest(error); + const latestComplete = useLatest(complete); + const latestUnsubscribe = useLatest(unsubscribe); + + useEffect(() => { + const fixedUnsubscribe = latestUnsubscribe.current; + + const subscription = input$.subscribe({ + next: (value) => latestNext.current?.(value), + error: (value) => latestError.current?.(value), + complete: () => latestComplete.current?.(), + }); + + latestSubscription.current = subscription; + + return () => { + subscription.unsubscribe(); + fixedUnsubscribe?.(); + }; + }, [input$, latestNext, latestError, latestComplete, latestUnsubscribe]); + + return latestSubscription.current; +}; + +export const tapUnsubscribe = (onUnsubscribe: () => void) => (source$: Observable) => { + return new Observable((subscriber) => { + const subscription = source$.subscribe({ + next: (value) => subscriber.next(value), + error: (error) => subscriber.error(error), + complete: () => subscriber.complete(), + }); + + return () => { + onUnsubscribe(); + subscription.unsubscribe(); + }; + }); +}; diff --git a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts index 044cea3899baf8..38626675f5ae7b 100644 --- a/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts +++ b/x-pack/plugins/infra/server/services/log_entries/log_entry_search_strategy.test.ts @@ -164,6 +164,35 @@ describe('LogEntry search strategy', () => { await expect(response.toPromise()).rejects.toThrowError(ResponseError); }); + + it('forwards cancellation to the underlying search strategy', async () => { + const esSearchStrategyMock = createEsSearchStrategyMock({ + id: 'ASYNC_REQUEST_ID', + isRunning: false, + rawResponse: { + took: 1, + _shards: { total: 1, failed: 0, skipped: 0, successful: 1 }, + timed_out: false, + hits: { total: 0, max_score: 0, hits: [] }, + }, + }); + const dataMock = createDataPluginMock(esSearchStrategyMock); + const sourcesMock = createInfraSourcesMock(); + sourcesMock.getSourceConfiguration.mockResolvedValue(createSourceConfigurationMock()); + const mockDependencies = createSearchStrategyDependenciesMock(); + + const logEntrySearchStrategy = logEntrySearchStrategyProvider({ + data: dataMock, + sources: sourcesMock, + }); + const requestId = logEntrySearchRequestStateRT.encode({ + esRequestId: 'ASYNC_REQUEST_ID', + }); + + await logEntrySearchStrategy.cancel?.(requestId, {}, mockDependencies); + + expect(esSearchStrategyMock.cancel).toHaveBeenCalled(); + }); }); const createSourceConfigurationMock = () => ({ @@ -208,6 +237,7 @@ const createEsSearchStrategyMock = (esSearchResponse: IEsSearchResponse) => ({ return of(esSearchResponse); } }), + cancel: jest.fn().mockResolvedValue(undefined), }); const createSearchStrategyDependenciesMock = (): SearchStrategyDependencies => ({ diff --git a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts index 880a48fd5b8f7c..dac97479d4b04e 100644 --- a/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts +++ b/x-pack/plugins/infra/server/services/log_entries/queries/log_entry.ts @@ -17,7 +17,7 @@ export const createGetLogEntryQuery = ( logEntryId: string, timestampField: string, tiebreakerField: string -): RequestParams.Search> => ({ +): RequestParams.AsyncSearchSubmit> => ({ index: logEntryIndex, terminate_after: 1, track_scores: false, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx index f663832702b1cf..ab4d36104d7dea 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx @@ -81,7 +81,7 @@ export const AddProcessorForm: FunctionComponent = ({
    - +

    {getFlyoutTitle(isOnFailure)}

    diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx index 7f4caa09b6df05..fa35c1e7e7c3e6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx @@ -46,6 +46,7 @@ export const EmptyList: FunctionComponent = () => { } actions={ _score: 0, _source: getSearchEsListItemMock(), _type: '', + matched_queries: ['0.0'], }, ], max_score: 0, diff --git a/x-pack/plugins/lists/common/schemas/response/index.ts b/x-pack/plugins/lists/common/schemas/response/index.ts index deca06ad99feac..5e739ccf3a0a0c 100644 --- a/x-pack/plugins/lists/common/schemas/response/index.ts +++ b/x-pack/plugins/lists/common/schemas/response/index.ts @@ -15,3 +15,4 @@ export * from './found_list_schema'; export * from './list_item_schema'; export * from './list_schema'; export * from './list_item_index_exist_schema'; +export * from './search_list_item_schema'; diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts new file mode 100644 index 00000000000000..1ad241ffca0775 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchListItemSchema } from '../../../common/schemas'; +import { VALUE } from '../../../common/constants.mock'; + +import { getListItemResponseMock } from './list_item_schema.mock'; + +export const getSearchListItemResponseMock = (): SearchListItemSchema => ({ + items: [getListItemResponseMock()], + value: VALUE, +}); diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.test.ts new file mode 100644 index 00000000000000..132c3f16688f03 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { left } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { exactCheck, foldLeftRight, getPaths } from '../../shared_imports'; + +import { getSearchListItemResponseMock } from './search_list_item_schema.mock'; +import { SearchListItemSchema, searchListItemSchema } from './search_list_item_schema'; + +describe('search_list_item_schema', () => { + test('it should validate a typical search list item response', () => { + const payload = getSearchListItemResponseMock(); + const decoded = searchListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate with an "undefined" for "items"', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { items, ...noItems } = getSearchListItemResponseMock(); + const decoded = searchListItemSchema.decode(noItems); + const checked = exactCheck(noItems, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "items"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not allow an extra key to be sent in', () => { + const payload: SearchListItemSchema & { extraKey?: string } = getSearchListItemResponseMock(); + payload.extraKey = 'some new value'; + const decoded = searchListItemSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extraKey"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.ts new file mode 100644 index 00000000000000..5177098a6f67f9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/search_list_item_schema.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { listItemArraySchema } from './list_item_schema'; + +/** + * NOTE: Although this is defined within "response" this does not expose a REST API + * endpoint right now for this particular response. Instead this is only used internally + * for the plugins at this moment. If this changes, please remove this message. + */ +export const searchListItemSchema = t.exact( + t.type({ + items: listItemArraySchema, + value: t.unknown, + }) +); + +export type SearchListItemSchema = t.TypeOf; + +export const searchListItemArraySchema = t.array(searchListItemSchema); +export type SearchListItemArraySchema = t.TypeOf; diff --git a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts index f658a51730d97f..1120f99bf917a9 100644 --- a/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts +++ b/x-pack/plugins/lists/server/services/items/delete_list_item_by_value.test.ts @@ -47,7 +47,15 @@ describe('delete_list_item_by_value', () => { body: { query: { bool: { - filter: [{ term: { list_id: 'some-list-id' } }, { terms: { ip: ['127.0.0.1'] } }], + filter: [ + { term: { list_id: 'some-list-id' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ], }, }, }, diff --git a/x-pack/plugins/lists/server/services/items/index.ts b/x-pack/plugins/lists/server/services/items/index.ts index bc04ba88b943e2..31003771679a9a 100644 --- a/x-pack/plugins/lists/server/services/items/index.ts +++ b/x-pack/plugins/lists/server/services/items/index.ts @@ -11,6 +11,7 @@ export * from './delete_list_item_by_value'; export * from './delete_list_item'; export * from './find_list_item'; export * from './get_list_item_by_value'; +export * from './get_list_item_by_values'; export * from './get_list_item'; export * from './get_list_item_by_values'; export * from './get_list_item_template'; @@ -18,3 +19,4 @@ export * from './get_list_item_index'; export * from './update_list_item'; export * from './write_lines_to_bulk_list_items'; export * from './write_list_items_to_stream'; +export * from './search_list_item_by_values'; diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts new file mode 100644 index 00000000000000..40b5fbb3ab8faa --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.mock.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +import { SearchListItemByValuesOptions } from '../items'; +import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; + +export const searchListItemByValuesOptionsMocks = (): SearchListItemByValuesOptions => ({ + callCluster: getCallClusterMock(), + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], +}); diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts new file mode 100644 index 00000000000000..b2a89dfe321add --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchListItemArraySchema } from '../../../common/schemas'; +import { getSearchListItemMock } from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; +import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; +import { LIST_ID, LIST_ITEM_INDEX, TYPE, VALUE, VALUE_2 } from '../../../common/constants.mock'; + +import { searchListItemByValues } from './search_list_item_by_values'; + +describe('search_list_item_by_values', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Returns a an empty array of items if the value is empty', async () => { + const data = getSearchListItemMock(); + data.hits.hits = []; + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [], + }); + + expect(listItem).toEqual([]); + }); + + test('Returns a an empty array of items if the ES query is also empty', async () => { + const data = getSearchListItemMock(); + data.hits.hits = []; + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], + }); + + const expected: SearchListItemArraySchema = [ + { items: [], value: VALUE }, + { items: [], value: VALUE_2 }, + ]; + expect(listItem).toEqual(expected); + }); + + test('Returns transformed list item if the data exists within ES', async () => { + const data = getSearchListItemMock(); + const callCluster = getCallClusterMock(data); + const listItem = await searchListItemByValues({ + callCluster, + listId: LIST_ID, + listItemIndex: LIST_ITEM_INDEX, + type: TYPE, + value: [VALUE, VALUE_2], + }); + + const expected: SearchListItemArraySchema = [ + { + items: [ + { + _version: undefined, + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'some user', + deserializer: undefined, + id: 'some-list-item-id', + list_id: 'some-list-id', + meta: {}, + serializer: undefined, + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: '2020-04-20T15:25:31.830Z', + updated_by: 'some user', + value: '127.0.0.1', + }, + ], + value: '127.0.0.1', + }, + { + items: [], + value: VALUE_2, + }, + ]; + expect(listItem).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts new file mode 100644 index 00000000000000..33025a6a177ff4 --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/search_list_item_by_values.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LegacyAPICaller } from 'kibana/server'; + +import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; +import { getQueryFilterFromTypeValue, transformElasticNamedSearchToListItem } from '../utils'; + +export interface SearchListItemByValuesOptions { + listId: string; + callCluster: LegacyAPICaller; + listItemIndex: string; + type: Type; + value: unknown[]; +} + +export const searchListItemByValues = async ({ + listId, + callCluster, + listItemIndex, + type, + value, +}: SearchListItemByValuesOptions): Promise => { + const response = await callCluster('search', { + body: { + query: { + bool: { + filter: getQueryFilterFromTypeValue({ listId, type, value }), + }, + }, + }, + ignoreUnavailable: true, + index: listItemIndex, + size: 10000, // TODO: This has a limit on the number which is 10,000 the default of Elastic but we might want to provide a way to increase that number + }); + return transformElasticNamedSearchToListItem({ response, type, value }); +}; diff --git a/x-pack/plugins/lists/server/services/lists/list_client.ts b/x-pack/plugins/lists/server/services/lists/list_client.ts index 590bfef6625f5e..b0640ac8d6ba99 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.ts @@ -12,6 +12,7 @@ import { ListItemArraySchema, ListItemSchema, ListSchema, + SearchListItemArraySchema, } from '../../../common/schemas'; import { ConfigType } from '../../config'; import { @@ -35,6 +36,7 @@ import { getListItemIndex, getListItemTemplate, importListItemsToStream, + searchListItemByValues, updateListItem, } from '../../services/items'; import { @@ -67,6 +69,7 @@ import { GetListItemsByValueOptions, GetListOptions, ImportListItemsToStreamOptions, + SearchListItemByValuesOptions, UpdateListItemOptions, UpdateListOptions, } from './list_client_types'; @@ -472,6 +475,22 @@ export class ListClient { }); }; + public searchListItemByValues = async ({ + type, + listId, + value, + }: SearchListItemByValuesOptions): Promise => { + const { callCluster } = this; + const listItemIndex = this.getListItemIndex(); + return searchListItemByValues({ + callCluster, + listId, + listItemIndex, + type, + value, + }); + }; + public findList = async ({ filter, currentIndexPosition, diff --git a/x-pack/plugins/lists/server/services/lists/list_client_types.ts b/x-pack/plugins/lists/server/services/lists/list_client_types.ts index ea983b38c7e5dd..fd9066cfe24099 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client_types.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client_types.ts @@ -160,3 +160,9 @@ export interface FindListItemOptions { sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; } + +export interface SearchListItemByValuesOptions { + type: Type; + listId: string; + value: unknown[]; +} diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts index 3d48e44e26eaa3..aec9ef629788c8 100644 --- a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.test.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { QueryFilterType, getQueryFilterFromTypeValue } from './get_query_filter_from_type_value'; +import { + QueryFilterType, + getEmptyQuery, + getQueryFilterFromTypeValue, + getShouldQuery, + getTermsQuery, + getTextQuery, +} from './get_query_filter_from_type_value'; describe('get_query_filter_from_type_value', () => { beforeEach(() => { @@ -15,78 +22,813 @@ describe('get_query_filter_from_type_value', () => { jest.clearAllMocks(); }); - test('it returns an ip if given an ip', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: ['127.0.0.1'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { ip: ['127.0.0.1'] } }, - ]; - expect(queryFilter).toEqual(expected); - }); + describe('getQueryFilterFromTypeValue', () => { + test('it returns an ip if given an ip', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two ip if given two ip', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: '127.0.0.1' } } }, + { term: { ip: { _name: '1.0', value: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); - test('it returns two ip if given two ip', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: ['127.0.0.1', '127.0.0.2'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { ip: ['127.0.0.1', '127.0.0.2'] } }, - ]; - expect(queryFilter).toEqual(expected); + test('it returns a keyword if given a keyword', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: ['host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { keyword: { _name: '0.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two keywords if given two values', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: ['host-name-1', 'host-name-2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { keyword: { _name: '0.0', value: 'host-name-1' } } }, + { term: { keyword: { _name: '1.0', value: 'host-name-2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query given an empty value', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'keyword', + value: [], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query object given an empty array', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns an empty query object given an array with only null values', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [null, null], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value for non-text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value for non-text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value for text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'text', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { text: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out object values if mixed with a string value for text based query', () => { + const query = getQueryFilterFromTypeValue({ + listId: 'list-123', + type: 'text', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { text: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); }); - test('it returns a keyword if given a keyword', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: ['host-name-1'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: ['host-name-1'] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getEmptyQuery', () => { + test('it returns an empty query given a list_id', () => { + const emptyQuery = getEmptyQuery({ listId: 'list-123' }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { bool: { minimum_should_match: 1, should: [{ match_none: { _name: 'empty' } }] } }, + ]; + expect(emptyQuery).toEqual(expected); + }); }); - test('it returns two keywords if given two values', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: ['host-name-1', 'host-name-2'], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: ['host-name-1', 'host-name-2'] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getTermsQuery', () => { + describe('scalar values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '0.0', value: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: '127.0.0.1' } } }, + { term: { ip: { _name: '1.0', value: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [5, 3], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: 5 } } }, + { term: { ip: { _name: '1.0', value: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [5, '3'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { term: { ip: { _name: '0.0', value: 5 } } }, + { term: { ip: { _name: '1.0', value: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ term: { ip: { _name: '1.0', value: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); + + describe('array values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1'], ['127.0.0.2']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { terms: { _name: '0.0', ip: ['127.0.0.1'] } }, + { terms: { _name: '1.0', ip: ['127.0.0.2'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], [3]], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: [5] } }, { terms: { _name: '1.0', ip: [3] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], ['3']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { terms: { _name: '0.0', ip: [5] } }, + { terms: { _name: '1.0', ip: ['3'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[null], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '1.0', ip: ['host-name-1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [[{}], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '1.0', ip: ['host-name-1'] } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it flattens and removes null values correctly in a deeply nested set of arrays', () => { + const query = getTermsQuery({ + listId: 'list-123', + type: 'ip', + value: [ + [null], + [ + 'host-name-1', + ['host-name-2', [null], ['host-name-3'], ['host-name-4', null, 'host-name-5']], + ], + ['host-name-6'], + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + terms: { + _name: '1.0', + ip: ['host-name-1', 'host-name-2', 'host-name-3', 'host-name-4', 'host-name-5'], + }, + }, + { terms: { _name: '2.0', ip: ['host-name-6'] } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); }); - test('it returns an empty keyword given an empty value', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'keyword', - value: [], - }); - const expected: QueryFilterType = [ - { term: { list_id: 'list-123' } }, - { terms: { keyword: [] } }, - ]; - expect(queryFilter).toEqual(expected); + describe('getTextQuery', () => { + describe('scalar values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: ['127.0.0.1', '127.0.0.2'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [5, 3], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [5, '3'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [null, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out an object value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [{}, 'host-name-1'], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); + + describe('array values', () => { + test('it returns a expected terms query give a single string value, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected terms query given two string values, listId, and type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [['127.0.0.1'], ['127.0.0.2']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: '127.0.0.1' } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '127.0.0.2' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns two expected numeric terms without converting them into strings', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], [3]], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: 3 } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it returns a string and a numeric without converting them into a homogenous type', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[5], ['3']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '0.0', operator: 'and', query: 5 } } }, + { match: { ip: { _name: '1.0', operator: 'and', query: '3' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a null value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[null], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it filters out a object value if mixed with a string value', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [[{}], ['host-name-1']], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [{ match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }], + }, + }, + ]; + expect(query).toEqual(expected); + }); + + test('it flattens and removes null values correctly in a deeply nested set of arrays', () => { + const query = getTextQuery({ + listId: 'list-123', + type: 'ip', + value: [ + [null], + [ + 'host-name-1', + ['host-name-2', [null], ['host-name-3'], ['host-name-4', null, 'host-name-5']], + ], + ['host-name-6'], + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { match: { ip: { _name: '1.0', operator: 'and', query: 'host-name-1' } } }, + { match: { ip: { _name: '1.1', operator: 'and', query: 'host-name-2' } } }, + { match: { ip: { _name: '1.2', operator: 'and', query: 'host-name-3' } } }, + { match: { ip: { _name: '1.3', operator: 'and', query: 'host-name-4' } } }, + { match: { ip: { _name: '1.4', operator: 'and', query: 'host-name-5' } } }, + { match: { ip: { _name: '2.0', operator: 'and', query: 'host-name-6' } } }, + ], + }, + }, + ]; + expect(query).toEqual(expected); + }); + }); }); - test('it returns an empty ip given an empty value', () => { - const queryFilter = getQueryFilterFromTypeValue({ - listId: 'list-123', - type: 'ip', - value: [], + describe('getShouldQuery', () => { + test('it returns a should as-is when passed one', () => { + const query = getShouldQuery({ + listId: 'list-123', + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ], + }); + const expected: QueryFilterType = [ + { term: { list_id: 'list-123' } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ terms: { _name: '0.0', ip: ['127.0.0.1'] } }], + }, + }, + ], + }, + }, + ]; + expect(query).toEqual(expected); }); - const expected: QueryFilterType = [{ term: { list_id: 'list-123' } }, { terms: { ip: [] } }]; - expect(queryFilter).toEqual(expected); }); }); diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts index 3baba07aa98850..cf332cd6dd9577 100644 --- a/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter_from_type_value.ts @@ -4,19 +4,170 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty, isObject } from 'lodash/fp'; + import { Type } from '../../../common/schemas'; export type QueryFilterType = [ - { term: Record }, - { terms: Record } + { term: Record }, + { terms: Record } | { bool: {} } ]; +/** + * Given a type, value, and listId, this will return a valid query. If the type is + * "text" it will return a "text" match, otherwise it returns a terms query. If an + * array or array of arrays is passed, this will flatten, remove any "null" values, + * and then the result. + * @param type The type of list + * @param value The unknown value + * @param listId The list id + */ export const getQueryFilterFromTypeValue = ({ type, value, listId, }: { type: Type; - value: string[]; + value: unknown[]; + listId: string; +}): QueryFilterType => { + const valueFlattened = value + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (isEmpty(valueFlattened)) { + return getEmptyQuery({ listId }); + } else if (type === 'text') { + return getTextQuery({ listId, type, value }); + } else { + return getTermsQuery({ listId, type, value }); + } +}; + +/** + * Returns an empty named query that should not match anything + * @param listId The list id to associate with the empty query + */ +export const getEmptyQuery = ({ listId }: { listId: string }): QueryFilterType => [ + { term: { list_id: listId } }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_none: { + _name: 'empty', + }, + }, + ], + }, + }, +]; + +/** + * Returns a terms query against a large value based list. If it detects that an array or item has a "null" + * value it will filter that value out. If it has arrays within arrays it will flatten those out as well. + * @param value The value which can be unknown + * @param type The list type type + * @param listId The list id + */ +export const getTermsQuery = ({ + value, + type, + listId, +}: { + value: unknown[]; + type: Type; + listId: string; +}): QueryFilterType => { + const should = value.reduce((accum, item, index) => { + if (Array.isArray(item)) { + const itemFlattened = item + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (itemFlattened.length === 0) { + return accum; + } else { + return [...accum, { terms: { _name: `${index}.0`, [type]: itemFlattened } }]; + } + } else { + if (item == null || isObject(item)) { + return accum; + } else { + return [...accum, { term: { [type]: { _name: `${index}.0`, value: item } } }]; + } + } + }, []); + return getShouldQuery({ listId, should }); +}; + +/** + * Returns a text query against a large value based list. If it detects that an array or item has a "null" + * value it will filter that value out. If it has arrays within arrays it will flatten those out as well. + * @param value The value which can be unknown + * @param type The list type type + * @param listId The list id + */ +export const getTextQuery = ({ + value, + type, + listId, +}: { + value: unknown[]; + type: Type; + listId: string; +}): QueryFilterType => { + const should = value.reduce((accum, item, index) => { + if (Array.isArray(item)) { + const itemFlattened = item + .flat(Infinity) + .filter((singleValue) => singleValue != null && !isObject(singleValue)); + if (itemFlattened.length === 0) { + return accum; + } else { + return [ + ...accum, + ...itemFlattened.map((flatItem, secondIndex) => ({ + match: { + [type]: { _name: `${index}.${secondIndex}`, operator: 'and', query: flatItem }, + }, + })), + ]; + } + } else { + if (item == null || isObject(item)) { + return accum; + } else { + return [ + ...accum, + { match: { [type]: { _name: `${index}.0`, operator: 'and', query: item } } }, + ]; + } + } + }, []); + + return getShouldQuery({ listId, should }); +}; + +/** + * Given an unknown should this constructs a simple bool and terms with the should + * clause/query. + * @param listId The list id to query against + * @param should The unknown should to construct the query against + */ +export const getShouldQuery = ({ + listId, + should, +}: { listId: string; -}): QueryFilterType => [{ term: { list_id: listId } }, { terms: { [type]: value } }]; + should: unknown; +}): QueryFilterType => { + return [ + { term: { list_id: listId } }, + { + bool: { + minimum_should_match: 1, + should, + }, + }, + ]; +}; diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts index f7ed118ea58578..57f37a1d6bfca7 100644 --- a/x-pack/plugins/lists/server/services/utils/index.ts +++ b/x-pack/plugins/lists/server/services/utils/index.ts @@ -15,6 +15,7 @@ export * from './get_search_after_with_tie_breaker'; export * from './get_sort_with_tie_breaker'; export * from './get_source_with_tie_breaker'; export * from './scroll_to_start_page'; +export * from './transform_elastic_named_search_to_list_item'; export * from './transform_elastic_to_list'; export * from './transform_elastic_to_list_item'; export * from './transform_list_item_to_elastic_query'; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts new file mode 100644 index 00000000000000..83a486b5d1544b --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSearchListItemResponseMock } from '../../../common/schemas/response/search_list_item_schema.mock'; +import { LIST_INDEX, LIST_ITEM_ID, TYPE, VALUE } from '../../../common/constants.mock'; +import { + getSearchEsListItemMock, + getSearchListItemMock, +} from '../../../common/schemas/elastic_response/search_es_list_item_schema.mock'; +import { SearchListItemArraySchema } from '../../../common/schemas'; + +import { transformElasticNamedSearchToListItem } from './transform_elastic_named_search_to_list_item'; + +describe('transform_elastic_named_search_to_list_item', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('if given an empty array for values, it returns an empty array', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [], + }); + const expected: SearchListItemArraySchema = []; + expect(queryFilter).toEqual(expected); + }); + + test('if given an empty array for hits, it returns an empty match', () => { + const response = getSearchListItemMock(); + response.hits.hits = []; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const expected: SearchListItemArraySchema = [{ items: [], value: VALUE }]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms a single elastic type to a search list item type', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const expected: SearchListItemArraySchema = [getSearchListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms two elastic types to a search list item type', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['1.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE, VALUE], + }); + const expected: SearchListItemArraySchema = [ + getSearchListItemResponseMock(), + getSearchListItemResponseMock(), + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms only 1 elastic type to a search list item type if only 1 is found as a value', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE, '127.0.0.2'], + }); + const expected: SearchListItemArraySchema = [ + getSearchListItemResponseMock(), + { items: [], value: '127.0.0.2' }, + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it attaches two found results if the value is found in two hits from Elastic Search', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['0.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [VALUE], + }); + const { + items: [firstItem], + value, + } = getSearchListItemResponseMock(); + const expected: SearchListItemArraySchema = [ + { + items: [firstItem, firstItem], + value, + }, + ]; + expect(queryFilter).toEqual(expected); + }); + + test('it will return an empty array if no values are passed in', () => { + const response = getSearchListItemMock(); + response.hits.hits = [ + ...response.hits.hits, + { + _id: LIST_ITEM_ID, + _index: LIST_INDEX, + _score: 0, + _source: getSearchEsListItemMock(), + _type: '', + matched_queries: ['1.0'], + }, + ]; + const queryFilter = transformElasticNamedSearchToListItem({ + response, + type: TYPE, + value: [], + }); + expect(queryFilter).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts new file mode 100644 index 00000000000000..0326d22aa8436a --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_named_search_to_list_item.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +import { SearchEsListItemSchema, SearchListItemArraySchema, Type } from '../../../common/schemas'; + +import { transformElasticHitsToListItem } from './transform_elastic_to_list_item'; + +export interface TransformElasticMSearchToListItemOptions { + response: SearchResponse; + type: Type; + value: unknown[]; +} + +/** + * Given an Elasticsearch response this will look to see if the named query matches the + * index found. The named query will have to be in the format of, "1.0", "1.1", "2.0" where the + * major number "1,2,n" will match with the index. + * Ref: https://www.elastic.co/guide/en/elasticsearch//reference/7.9/query-dsl-bool-query.html#named-queries + * @param response The elastic response + * @param type The list type + * @param value The values to check against the named queries. + */ +export const transformElasticNamedSearchToListItem = ({ + response, + type, + value, +}: TransformElasticMSearchToListItemOptions): SearchListItemArraySchema => { + return value.map((singleValue, index) => { + const matchingHits = response.hits.hits.filter((hit) => { + if (hit.matched_queries != null) { + return hit.matched_queries.some((matchedQuery) => { + const [matchedQueryIndex] = matchedQuery.split('.'); + return matchedQueryIndex === `${index}`; + }); + } else { + return false; + } + }); + const items = transformElasticHitsToListItem({ hits: matchingHits, type }); + return { + items, + value: singleValue, + }; + }); +}; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts index 8a5554c3865c56..09e5ecd74b0dea 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.test.ts @@ -8,7 +8,10 @@ import { getSearchListItemMock } from '../../../common/schemas/elastic_response/ import { getListItemResponseMock } from '../../../common/schemas/response/list_item_schema.mock'; import { ListItemArraySchema } from '../../../common/schemas'; -import { transformElasticToListItem } from './transform_elastic_to_list_item'; +import { + transformElasticHitsToListItem, + transformElasticToListItem, +} from './transform_elastic_to_list_item'; describe('transform_elastic_to_list_item', () => { beforeEach(() => { @@ -19,28 +22,61 @@ describe('transform_elastic_to_list_item', () => { jest.clearAllMocks(); }); - test('it transforms an elastic type to a list item type', () => { - const response = getSearchListItemMock(); - const queryFilter = transformElasticToListItem({ - response, - type: 'ip', + describe('transformElasticToListItem', () => { + test('it transforms an elastic type to a list item type', () => { + const response = getSearchListItemMock(); + const queryFilter = transformElasticToListItem({ + response, + type: 'ip', + }); + const expected: ListItemArraySchema = [getListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms an elastic keyword type to a list item type', () => { + const response = getSearchListItemMock(); + response.hits.hits[0]._source.ip = undefined; + response.hits.hits[0]._source.keyword = 'host-name-example'; + const queryFilter = transformElasticToListItem({ + response, + type: 'keyword', + }); + const listItemResponse = getListItemResponseMock(); + listItemResponse.type = 'keyword'; + listItemResponse.value = 'host-name-example'; + const expected: ListItemArraySchema = [listItemResponse]; + expect(queryFilter).toEqual(expected); }); - const expected: ListItemArraySchema = [getListItemResponseMock()]; - expect(queryFilter).toEqual(expected); }); - test('it transforms an elastic keyword type to a list item type', () => { - const response = getSearchListItemMock(); - response.hits.hits[0]._source.ip = undefined; - response.hits.hits[0]._source.keyword = 'host-name-example'; - const queryFilter = transformElasticToListItem({ - response, - type: 'keyword', + describe('transformElasticHitsToListItem', () => { + test('it transforms an elastic type to a list item type', () => { + const { + hits: { hits }, + } = getSearchListItemMock(); + const queryFilter = transformElasticHitsToListItem({ + hits, + type: 'ip', + }); + const expected: ListItemArraySchema = [getListItemResponseMock()]; + expect(queryFilter).toEqual(expected); + }); + + test('it transforms an elastic keyword type to a list item type', () => { + const { + hits: { hits }, + } = getSearchListItemMock(); + hits[0]._source.ip = undefined; + hits[0]._source.keyword = 'host-name-example'; + const queryFilter = transformElasticHitsToListItem({ + hits, + type: 'keyword', + }); + const listItemResponse = getListItemResponseMock(); + listItemResponse.type = 'keyword'; + listItemResponse.value = 'host-name-example'; + const expected: ListItemArraySchema = [listItemResponse]; + expect(queryFilter).toEqual(expected); }); - const listItemResponse = getListItemResponseMock(); - listItemResponse.type = 'keyword'; - listItemResponse.value = 'host-name-example'; - const expected: ListItemArraySchema = [listItemResponse]; - expect(queryFilter).toEqual(expected); }); }); diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts index 14794870bf67ab..db16f213adec8c 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts @@ -17,11 +17,23 @@ export interface TransformElasticToListItemOptions { type: Type; } +export interface TransformElasticHitToListItemOptions { + hits: SearchResponse['hits']['hits']; + type: Type; +} + export const transformElasticToListItem = ({ response, type, }: TransformElasticToListItemOptions): ListItemArraySchema => { - return response.hits.hits.map((hit) => { + return transformElasticHitsToListItem({ hits: response.hits.hits, type }); +}; + +export const transformElasticHitsToListItem = ({ + hits, + type, +}: TransformElasticHitToListItemOptions): ListItemArraySchema => { + return hits.map((hit) => { const { _id, _source: { diff --git a/x-pack/plugins/ml/common/constants/field_types.ts b/x-pack/plugins/ml/common/constants/field_types.ts index 93641fd45c499b..24b099d176c643 100644 --- a/x-pack/plugins/ml/common/constants/field_types.ts +++ b/x-pack/plugins/ml/common/constants/field_types.ts @@ -16,7 +16,17 @@ export enum ML_JOB_FIELD_TYPES { } export const MLCATEGORY = 'mlcategory'; + +/** + * For use as summary_count_field_name in datafeeds which use aggregations. + */ export const DOC_COUNT = 'doc_count'; +/** + * Elasticsearch field showing number of documents aggregated in a single summary field for + * pre-aggregated data. For use as summary_count_field_name in datafeeds which do not use aggregations. + */ +export const _DOC_COUNT = '_doc_count'; + // List of system fields we don't want to display. export const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score']; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 04ce7f79e1c029..69c554b413655b 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -24,7 +24,7 @@ import { EuiModalBody } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../contexts/kibana'; -import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public'; +import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public'; import { getDefaultPanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { useDashboardService } from '../services/dashboard_service'; import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; @@ -35,7 +35,7 @@ export interface DashboardItem { id: string; title: string; description: string | undefined; - attributes: SavedObjectDashboard; + attributes: DashboardSavedObject; } export type EuiTableProps = EuiInMemoryTableProps; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 440585dcf2a19d..23eff26b41c321 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -13,7 +13,11 @@ import { ML_JOB_AGGREGATION, SPARSE_DATA_AGGREGATIONS, } from '../../../../../../../common/constants/aggregation_types'; -import { MLCATEGORY, DOC_COUNT } from '../../../../../../../common/constants/field_types'; +import { + MLCATEGORY, + DOC_COUNT, + _DOC_COUNT, +} from '../../../../../../../common/constants/field_types'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { EVENT_RATE_FIELD_ID, @@ -113,7 +117,11 @@ export function createDocCountFieldOption(usingAggregations: boolean) { label: DOC_COUNT, }, ] - : []; + : [ + { + label: _DOC_COUNT, + }, + ]; } function getDetectorsAdvanced(job: Job, datafeed: Datafeed) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx index 8136008dce11b0..e54a48817c4aee 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx @@ -23,8 +23,8 @@ interface Props { export const SummaryCountFieldSelect: FC = ({ fields, changeHandler, selectedField }) => { const { jobCreator } = useContext(JobCreatorContext); const options: EuiComboBoxOptionOption[] = [ - ...createFieldOptions(fields, jobCreator.additionalFields), ...createDocCountFieldOption(jobCreator.aggregationFields.length > 0), + ...createFieldOptions(fields, jobCreator.additionalFields), ]; const selection: EuiComboBoxOptionOption[] = []; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts index 00adb2d3258339..41412cf6e378fd 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -6,7 +6,7 @@ import { dashboardServiceProvider } from './dashboard_service'; import { savedObjectsServiceMock } from '../../../../../../src/core/public/mocks'; -import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; +import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; import { DashboardUrlGenerator, SavedDashboardPanel, @@ -91,7 +91,7 @@ describe('DashboardService', () => { kibanaSavedObjectMeta: { searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', }, - } as unknown) as SavedObjectDashboard, + } as unknown) as DashboardSavedObject, [{ title: 'Test title', type: 'test-panel', embeddableConfig: { testConfig: '' } }] ); // assert diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts index d6ccfc2f203e9b..27f2bd366b8806 100644 --- a/x-pack/plugins/ml/public/application/services/dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -11,7 +11,7 @@ import { DASHBOARD_APP_URL_GENERATOR, DashboardUrlGenerator, SavedDashboardPanel, - SavedObjectDashboard, + DashboardSavedObject, } from '../../../../../../src/plugins/dashboard/public'; import { useMlKibana } from '../contexts/kibana'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; @@ -32,7 +32,7 @@ export function dashboardServiceProvider( * Fetches dashboards */ async fetchDashboards(query?: string) { - return await savedObjectClient.find({ + return await savedObjectClient.find({ type: 'dashboard', perPage: 1000, search: query ? `${query}*` : '', @@ -60,7 +60,7 @@ export function dashboardServiceProvider( */ async attachPanels( dashboardId: string, - dashboardAttributes: SavedObjectDashboard, + dashboardAttributes: DashboardSavedObject, panelsData: Array> ) { const panels = JSON.parse(dashboardAttributes.panelsJSON) as SavedDashboardPanel[]; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index 62e64b3d4092e5..2fa869b058aa2b 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -11,6 +11,8 @@ import { get } from 'lodash'; +import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { DOC_COUNT, _DOC_COUNT } from '../../../common/constants/field_types'; import { mlFunctionToESAggregation } from '../../../common/util/job_utils'; // Builds the basic configuration to plot a chart of the source data @@ -35,9 +37,10 @@ export function buildConfigFromDetector(job, detectorIndex) { // Extra checks if the job config uses a summary count field. const summaryCountFieldName = analysisConfig.summary_count_field_name; if ( - config.metricFunction === 'count' && + config.metricFunction === ES_AGGREGATION.COUNT && summaryCountFieldName !== undefined && - summaryCountFieldName !== 'doc_count' + summaryCountFieldName !== DOC_COUNT && + summaryCountFieldName !== _DOC_COUNT ) { // Check for a detector looking at cardinality (distinct count) using an aggregation. // The cardinality field will be in: @@ -50,18 +53,23 @@ export function buildConfigFromDetector(job, detectorIndex) { get(Object.values(topAgg)[0], [ 'aggregations', summaryCountFieldName, - 'cardinality', + ES_AGGREGATION.CARDINALITY, 'field', ]) || - get(Object.values(topAgg)[0], ['aggs', summaryCountFieldName, 'cardinality', 'field']); + get(Object.values(topAgg)[0], [ + 'aggs', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]); } - if (detector.function === 'non_zero_count' && cardinalityField !== undefined) { - config.metricFunction = 'cardinality'; + if (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT && cardinalityField !== undefined) { + config.metricFunction = ES_AGGREGATION.CARDINALITY; config.metricFieldName = cardinalityField; } else { // For count detectors using summary_count_field, plot sum(summary_count_field_name) - config.metricFunction = 'sum'; + config.metricFunction = ES_AGGREGATION.SUM; config.metricFieldName = summaryCountFieldName; } } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts index 7559111d012d0d..73d35efd66c8ba 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -5,6 +5,7 @@ */ import { IScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; +import { _DOC_COUNT } from '../../../../common/constants/field_types'; import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; import { fieldServiceProvider } from './field_service'; @@ -22,10 +23,12 @@ export function newJobCapsProvider(client: IScopedClusterClient) { const { aggs, fields } = await fieldService.getData(); convertForStringify(aggs, fields); + // Remove the _doc_count field as we don't want to display this in the fields lists in the UI + const fieldsWithoutDocCount = fields.filter(({ id }) => id !== _DOC_COUNT); return { [indexPattern]: { aggs, - fields, + fields: fieldsWithoutDocCount, }, }; } diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index e4ee805c4b48f2..369f03ab8cb115 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -156,6 +156,10 @@ describe('alert_form', () => { }); it('should update throttle value', async () => { + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onThrottleInterval"]').simulate('click'); + wrapper.update(); const newThrottle = 17; const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); expect(throttleField.exists()).toBeTruthy(); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts index d23d6c8b32f14e..8cba1537965f4e 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.test.ts @@ -64,6 +64,7 @@ describe('BaseAlert', () => { }, tags: [], throttle: '1d', + notifyWhen: null, }, }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index 179721d08d1e40..4f989b37421ef1 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -178,6 +178,7 @@ export class BaseAlert { name, alertTypeId, throttle, + notifyWhen: null, schedule: { interval }, actions: alertActions, }, diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index 9fa7f96599debb..0346f3bb9439b1 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -9,6 +9,7 @@ import { Inspect, Maybe } from '../../../common'; import { TimelineRequestOptionsPaginated } from '../..'; export interface TimelineEventsDetailsItem { + category?: string; field: string; values?: Maybe; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index e447a004fb51c0..aa114ff0748980 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -411,7 +411,6 @@ export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom'; export interface TimelineExpandedEventType { eventId: string; indexName: string; - loading: boolean; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts index ab0a53f4cd014f..4d013ee7d72321 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts @@ -83,7 +83,9 @@ import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; import { DETECTIONS_URL } from '../urls/navigation'; -describe('Detection rules, override', () => { +// FLAKY: https://github.com/elastic/kibana/issues/85671 +// FLAKY: https://github.com/elastic/kibana/issues/84020 +describe.skip('Detection rules, override', () => { const expectedUrls = newOverrideRule.referenceUrls.join(''); const expectedFalsePositives = newOverrideRule.falsePositivesExamples.join(''); const expectedTags = newOverrideRule.tags.join(''); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts index 1cece57c2fea58..2bfe72033135b2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_timeline.spec.ts @@ -4,14 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ALERT_ID } from '../screens/alerts'; import { PROVIDER_BADGE } from '../screens/timeline'; -import { - expandFirstAlert, - investigateFirstAlertInTimeline, - waitForAlertsPanelToBeLoaded, -} from '../tasks/alerts'; +import { investigateFirstAlertInTimeline, waitForAlertsPanelToBeLoaded } from '../tasks/alerts'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPage } from '../tasks/login'; @@ -29,13 +24,13 @@ describe('Alerts timeline', () => { it('Investigate alert in default timeline', () => { waitForAlertsPanelToBeLoaded(); - expandFirstAlert(); - cy.get(ALERT_ID) + investigateFirstAlertInTimeline(); + cy.get(PROVIDER_BADGE) .first() .invoke('text') .then((eventId) => { investigateFirstAlertInTimeline(); - cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', `_id: "${eventId}"`); + cy.get(PROVIDER_BADGE).filter(':visible').should('have.text', eventId); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts index 831fa8fbbf9fa2..4009ac13ab1204 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts @@ -45,7 +45,8 @@ import { openTimeline } from '../tasks/timelines'; import { OVERVIEW_URL } from '../urls/navigation'; -describe('Timelines', () => { +// FLAKY: https://github.com/elastic/kibana/issues/79389 +describe.skip('Timelines', () => { let timelineId: string; after(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index 288e2178d71ae3..abf6ca38844e22 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -24,7 +24,9 @@ import { closeTimeline, createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; -describe('timeline data providers', () => { +// FLAKY: https://github.com/elastic/kibana/issues/85098 +// FLAKY: https://github.com/elastic/kibana/issues/62060 +describe.skip('timeline data providers', () => { before(() => { loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index f85e6f683cba54..b911fc5b81f98b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -48,7 +48,8 @@ const ABSOLUTE_DATE = { startTimeTimeline: '2019-08-02T20:03:29.186Z', }; -describe('url state', () => { +// FLAKY: https://github.com/elastic/kibana/issues/61612 +describe.skip('url state', () => { it('sets the global start and end dates from the url', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON).should( diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index e5a673b03449fa..0e6226f69fce73 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -352,7 +352,6 @@ export const CaseComponent = React.memo( event: { eventId: alertId, indexName: index, - loading: false, }, }) ); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 7466d34a9938fe..8ec5133ef48b01 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -134,7 +134,7 @@ const AddToCaseActionComponent: React.FC = ({ ecsRowData, return ( <> - + name, []); - const optionsMemo = useMemo(() => { - if ( - selectedField != null && - selectedField.esTypes != null && - selectedField.esTypes.length > 0 - ) { - return lists.filter(({ type }) => selectedField.esTypes?.includes(type)); - } else { - return []; - } - }, [lists, selectedField]); + const optionsMemo = useMemo(() => filterFieldToList(lists, selectedField), [ + lists, + selectedField, + ]); const selectedOptionsMemo = useMemo(() => { if (selectedValue != null) { const list = lists.filter(({ id }) => id === selectedValue); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index f78740f7642023..abbeec2b64d729 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -6,6 +6,7 @@ import moment from 'moment'; import '../../../common/mock/match_media'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; import * as i18n from './translations'; import { @@ -15,7 +16,16 @@ import { existsOperator, doesNotExistOperator, } from './operators'; -import { getOperators, checkEmptyValue, paramIsValid, getGenericComboBoxProps } from './helpers'; +import { + getOperators, + checkEmptyValue, + paramIsValid, + getGenericComboBoxProps, + typeMatch, + filterFieldToList, +} from './helpers'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../../lists/common'; describe('helpers', () => { // @ts-ignore @@ -260,4 +270,117 @@ describe('helpers', () => { }); }); }); + + describe('#typeMatch', () => { + test('ip -> ip is true', () => { + expect(typeMatch('ip', 'ip')).toEqual(true); + }); + + test('keyword -> keyword is true', () => { + expect(typeMatch('keyword', 'keyword')).toEqual(true); + }); + + test('text -> text is true', () => { + expect(typeMatch('text', 'text')).toEqual(true); + }); + + test('ip_range -> ip is true', () => { + expect(typeMatch('ip_range', 'ip')).toEqual(true); + }); + + test('date_range -> date is true', () => { + expect(typeMatch('date_range', 'date')).toEqual(true); + }); + + test('double_range -> double is true', () => { + expect(typeMatch('double_range', 'double')).toEqual(true); + }); + + test('float_range -> float is true', () => { + expect(typeMatch('float_range', 'float')).toEqual(true); + }); + + test('integer_range -> integer is true', () => { + expect(typeMatch('integer_range', 'integer')).toEqual(true); + }); + + test('long_range -> long is true', () => { + expect(typeMatch('long_range', 'long')).toEqual(true); + }); + + test('ip -> date is false', () => { + expect(typeMatch('ip', 'date')).toEqual(false); + }); + + test('long -> float is false', () => { + expect(typeMatch('long', 'float')).toEqual(false); + }); + + test('integer -> long is false', () => { + expect(typeMatch('integer', 'long')).toEqual(false); + }); + }); + + describe('#filterFieldToList', () => { + test('it returns empty array if given a undefined for field', () => { + const filter = filterFieldToList([], undefined); + expect(filter).toEqual([]); + }); + + test('it returns empty array if filed does not contain esTypes', () => { + const field: IFieldType = { name: 'some-name', type: 'some-type' }; + const filter = filterFieldToList([], field); + expect(filter).toEqual([]); + }); + + test('it returns single filtered list of ip_range -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of ip -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of keyword -> keyword', () => { + const field: IFieldType = { name: 'some-name', type: 'keyword', esTypes: ['keyword'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of text -> text', () => { + const field: IFieldType = { name: 'some-name', type: 'text', esTypes: ['text'] }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns 2 filtered lists of ip_range -> ip', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1, listItem2]; + expect(filter).toEqual(expected); + }); + + test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { + const field: IFieldType = { name: 'some-name', type: 'ip', esTypes: ['ip'] }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1]; + expect(filter).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index 1ad296e0299b1b..44e5adde656507 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -18,6 +18,7 @@ import { } from './operators'; import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; import * as i18n from './translations'; +import { ListSchema, Type } from '../../../lists_plugin_deps'; /** * Returns the appropriate operators given a field type @@ -138,3 +139,36 @@ export function getGenericComboBoxProps({ selectedComboOptions: newSelectedComboOptions, }; } + +/** + * Given an array of lists and optionally a field this will return all + * the lists that match against the field based on the types from the field + * @param lists The lists to match against the field + * @param field The field to check against the list to see if they are compatible + */ +export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { + if (field != null) { + const { esTypes = [] } = field; + return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType))); + } else { + return []; + } +}; + +/** + * Given an input list type and a string based ES type this will match + * if they're exact or if they are compatible with a range + * @param type The type to match against the esType + * @param esType The ES type to match with + */ +export const typeMatch = (type: Type, esType: string): boolean => { + return ( + type === esType || + (type === 'ip_range' && esType === 'ip') || + (type === 'date_range' && esType === 'date') || + (type === 'double_range' && esType === 'double') || + (type === 'float_range' && esType === 'float') || + (type === 'integer_range' && esType === 'integer') || + (type === 'long_range' && esType === 'long') + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx index 5a50442f8dd5f6..71da294fb1844a 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.tsx @@ -11,6 +11,7 @@ import { getOr, get, isNumber } from 'lodash/fp'; import deepmerge from 'deepmerge'; import uuid from 'uuid'; import styled from 'styled-components'; +import deepEqual from 'fast-deep-equal'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { useTimeZone } from '../../lib/kibana'; @@ -193,4 +194,11 @@ export const BarChartComponent: React.FC = ({ ); }; -export const BarChart = React.memo(BarChartComponent); +export const BarChart = React.memo( + BarChartComponent, + (prevProps, nextProps) => + prevProps.stackByField === nextProps.stackByField && + prevProps.timelineId === nextProps.timelineId && + deepEqual(prevProps.configs, nextProps.configs) && + deepEqual(prevProps.barChart, nextProps.barChart) +); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts new file mode 100644 index 00000000000000..b4561e6d5bffdf --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__mocks__/index.ts @@ -0,0 +1,657 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockAlertDetailsData = [ + { category: 'process', field: 'process.name', values: ['-'], originalValue: '-' }, + { category: 'process', field: 'process.pid', values: [0], originalValue: 0 }, + { category: 'process', field: 'process.executable', values: ['-'], originalValue: '-' }, + { + category: 'agent', + field: 'agent.hostname', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'agent', + field: 'agent.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'agent', + field: 'agent.id', + values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + originalValue: 'abfe4a35-d5b4-42a0-a539-bd054c791769', + }, + { category: 'agent', field: 'agent.type', values: ['winlogbeat'], originalValue: 'winlogbeat' }, + { + category: 'agent', + field: 'agent.ephemeral_id', + values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + originalValue: 'b9850845-c000-4ddd-bd51-9978a07b7e7d', + }, + { category: 'agent', field: 'agent.version', values: ['7.10.0'], originalValue: '7.10.0' }, + { + category: 'winlog', + field: 'winlog.computer_name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { category: 'winlog', field: 'winlog.process.pid', values: [624], originalValue: 624 }, + { category: 'winlog', field: 'winlog.process.thread.id', values: [1896], originalValue: 1896 }, + { + category: 'winlog', + field: 'winlog.keywords', + values: ['Audit Failure'], + originalValue: ['Audit Failure'], + }, + { + category: 'winlog', + field: 'winlog.logon.failure.reason', + values: ['Unknown user name or bad password.'], + originalValue: 'Unknown user name or bad password.', + }, + { + category: 'winlog', + field: 'winlog.logon.failure.sub_status', + values: ['User logon with misspelled or bad password'], + originalValue: 'User logon with misspelled or bad password', + }, + { + category: 'winlog', + field: 'winlog.logon.failure.status', + values: ['This is either due to a bad username or authentication information'], + originalValue: 'This is either due to a bad username or authentication information', + }, + { category: 'winlog', field: 'winlog.logon.id', values: ['0x0'], originalValue: '0x0' }, + { category: 'winlog', field: 'winlog.logon.type', values: ['Network'], originalValue: 'Network' }, + { category: 'winlog', field: 'winlog.channel', values: ['Security'], originalValue: 'Security' }, + { + category: 'winlog', + field: 'winlog.event_data.Status', + values: ['0xc000006d'], + originalValue: '0xc000006d', + }, + { category: 'winlog', field: 'winlog.event_data.LogonType', values: ['3'], originalValue: '3' }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectLogonId', + values: ['0x0'], + originalValue: '0x0', + }, + { + category: 'winlog', + field: 'winlog.event_data.TransmittedServices', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.LmPackageName', + values: ['-'], + originalValue: '-', + }, + { category: 'winlog', field: 'winlog.event_data.KeyLength', values: ['0'], originalValue: '0' }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectUserName', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.FailureReason', + values: ['%%2313'], + originalValue: '%%2313', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectDomainName', + values: ['-'], + originalValue: '-', + }, + { + category: 'winlog', + field: 'winlog.event_data.TargetUserName', + values: ['administrator'], + originalValue: 'administrator', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubStatus', + values: ['0xc000006a'], + originalValue: '0xc000006a', + }, + { + category: 'winlog', + field: 'winlog.event_data.LogonProcessName', + values: ['NtLmSsp '], + originalValue: 'NtLmSsp ', + }, + { + category: 'winlog', + field: 'winlog.event_data.SubjectUserSid', + values: ['S-1-0-0'], + originalValue: 'S-1-0-0', + }, + { + category: 'winlog', + field: 'winlog.event_data.AuthenticationPackageName', + values: ['NTLM'], + originalValue: 'NTLM', + }, + { + category: 'winlog', + field: 'winlog.event_data.TargetUserSid', + values: ['S-1-0-0'], + originalValue: 'S-1-0-0', + }, + { category: 'winlog', field: 'winlog.opcode', values: ['Info'], originalValue: 'Info' }, + { category: 'winlog', field: 'winlog.record_id', values: [890770], originalValue: 890770 }, + { category: 'winlog', field: 'winlog.task', values: ['Logon'], originalValue: 'Logon' }, + { category: 'winlog', field: 'winlog.event_id', values: [4625], originalValue: 4625 }, + { + category: 'winlog', + field: 'winlog.provider_guid', + values: ['{54849625-5478-4994-a5ba-3e3b0328c30d}'], + originalValue: '{54849625-5478-4994-a5ba-3e3b0328c30d}', + }, + { + category: 'winlog', + field: 'winlog.activity_id', + values: ['{e148a943-f9c4-0001-5a39-81b88bbed601}'], + originalValue: '{e148a943-f9c4-0001-5a39-81b88bbed601}', + }, + { + category: 'winlog', + field: 'winlog.api', + values: ['wineventlog'], + originalValue: 'wineventlog', + }, + { + category: 'winlog', + field: 'winlog.provider_name', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { category: 'log', field: 'log.level', values: ['information'], originalValue: 'information' }, + { category: 'source', field: 'source.port', values: [0], originalValue: 0 }, + { category: 'source', field: 'source.domain', values: ['-'], originalValue: '-' }, + { + category: 'source', + field: 'source.ip', + values: ['185.156.74.3'], + originalValue: '185.156.74.3', + }, + { + category: 'base', + field: 'message', + values: [ + 'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.', + ], + originalValue: + 'An account failed to log on.\n\nSubject:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\t-\n\tAccount Domain:\t\t-\n\tLogon ID:\t\t0x0\n\nLogon Type:\t\t\t3\n\nAccount For Which Logon Failed:\n\tSecurity ID:\t\tS-1-0-0\n\tAccount Name:\t\tadministrator\n\tAccount Domain:\t\t\n\nFailure Information:\n\tFailure Reason:\t\tUnknown user name or bad password.\n\tStatus:\t\t\t0xC000006D\n\tSub Status:\t\t0xC000006A\n\nProcess Information:\n\tCaller Process ID:\t0x0\n\tCaller Process Name:\t-\n\nNetwork Information:\n\tWorkstation Name:\t-\n\tSource Network Address:\t185.156.74.3\n\tSource Port:\t\t0\n\nDetailed Authentication Information:\n\tLogon Process:\t\tNtLmSsp \n\tAuthentication Package:\tNTLM\n\tTransited Services:\t-\n\tPackage Name (NTLM only):\t-\n\tKey Length:\t\t0\n\nThis event is generated when a logon request fails. It is generated on the computer where access was attempted.\n\nThe Subject fields indicate the account on the local system which requested the logon. This is most commonly a service such as the Server service, or a local process such as Winlogon.exe or Services.exe.\n\nThe Logon Type field indicates the kind of logon that was requested. The most common types are 2 (interactive) and 3 (network).\n\nThe Process Information fields indicate which account and process on the system requested the logon.\n\nThe Network Information fields indicate where a remote logon request originated. Workstation name is not always available and may be left blank in some cases.\n\nThe authentication information fields provide detailed information about this specific logon request.\n\t- Transited services indicate which intermediate services have participated in this logon request.\n\t- Package name indicates which sub-protocol was used among the NTLM protocols.\n\t- Key length indicates the length of the generated session key. This will be 0 if no session key was requested.', + }, + { + category: 'cloud', + field: 'cloud.availability_zone', + values: ['us-central1-a'], + originalValue: 'us-central1-a', + }, + { + category: 'cloud', + field: 'cloud.instance.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'cloud', + field: 'cloud.instance.id', + values: ['5896613765949631815'], + originalValue: '5896613765949631815', + }, + { category: 'cloud', field: 'cloud.provider', values: ['gcp'], originalValue: 'gcp' }, + { + category: 'cloud', + field: 'cloud.machine.type', + values: ['e2-medium'], + originalValue: 'e2-medium', + }, + { + category: 'cloud', + field: 'cloud.project.id', + values: ['elastic-siem'], + originalValue: 'elastic-siem', + }, + { + category: 'base', + field: '@timestamp', + values: ['2020-11-25T15:42:39.417Z'], + originalValue: '2020-11-25T15:42:39.417Z', + }, + { + category: 'related', + field: 'related.user', + values: ['administrator'], + originalValue: 'administrator', + }, + { category: 'ecs', field: 'ecs.version', values: ['1.5.0'], originalValue: '1.5.0' }, + { + category: 'host', + field: 'host.hostname', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { category: 'host', field: 'host.os.build', values: ['17763.1577'], originalValue: '17763.1577' }, + { + category: 'host', + field: 'host.os.kernel', + values: ['10.0.17763.1577 (WinBuild.160101.0800)'], + originalValue: '10.0.17763.1577 (WinBuild.160101.0800)', + }, + { + category: 'host', + field: 'host.os.name', + values: ['Windows Server 2019 Datacenter'], + originalValue: 'Windows Server 2019 Datacenter', + }, + { category: 'host', field: 'host.os.family', values: ['windows'], originalValue: 'windows' }, + { category: 'host', field: 'host.os.version', values: ['10.0'], originalValue: '10.0' }, + { category: 'host', field: 'host.os.platform', values: ['windows'], originalValue: 'windows' }, + { + category: 'host', + field: 'host.ip', + values: ['fe80::406c:d205:5b46:767f', '10.128.15.228'], + originalValue: ['fe80::406c:d205:5b46:767f', '10.128.15.228'], + }, + { + category: 'host', + field: 'host.name', + values: ['windows-native'], + originalValue: 'windows-native', + }, + { + category: 'host', + field: 'host.id', + values: ['08f50e68-847a-4fae-a8eb-c7dc886447bb'], + originalValue: '08f50e68-847a-4fae-a8eb-c7dc886447bb', + }, + { + category: 'host', + field: 'host.mac', + values: ['42:01:0a:80:0f:e4'], + originalValue: ['42:01:0a:80:0f:e4'], + }, + { category: 'host', field: 'host.architecture', values: ['x86_64'], originalValue: 'x86_64' }, + { + category: 'event', + field: 'event.ingested', + values: ['2020-11-25T15:36:40.924914552Z'], + originalValue: '2020-11-25T15:36:40.924914552Z', + }, + { category: 'event', field: 'event.code', values: [4625], originalValue: 4625 }, + { category: 'event', field: 'event.lag.total', values: [2077], originalValue: 2077 }, + { category: 'event', field: 'event.lag.read', values: [1075], originalValue: 1075 }, + { category: 'event', field: 'event.lag.ingest', values: [1002], originalValue: 1002 }, + { + category: 'event', + field: 'event.provider', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { + category: 'event', + field: 'event.created', + values: ['2020-11-25T15:36:39.922Z'], + originalValue: '2020-11-25T15:36:39.922Z', + }, + { category: 'event', field: 'event.kind', values: ['signal'], originalValue: 'signal' }, + { category: 'event', field: 'event.module', values: ['security'], originalValue: 'security' }, + { + category: 'event', + field: 'event.action', + values: ['logon-failed'], + originalValue: 'logon-failed', + }, + { category: 'event', field: 'event.type', values: ['start'], originalValue: 'start' }, + { + category: 'event', + field: 'event.category', + values: ['authentication'], + originalValue: 'authentication', + }, + { category: 'event', field: 'event.outcome', values: ['failure'], originalValue: 'failure' }, + { + category: 'user', + field: 'user.name', + values: ['administrator'], + originalValue: 'administrator', + }, + { category: 'user', field: 'user.id', values: ['S-1-0-0'], originalValue: 'S-1-0-0' }, + { + category: 'signal', + field: 'signal.parents', + values: [ + '{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}', + ], + originalValue: [ + { + id: '688MAHYB7WTwW_Glsi_d', + type: 'event', + index: 'winlogbeat-7.10.0-2020.11.12-000001', + depth: 0, + }, + ], + }, + { + category: 'signal', + field: 'signal.ancestors', + values: [ + '{"id":"688MAHYB7WTwW_Glsi_d","type":"event","index":"winlogbeat-7.10.0-2020.11.12-000001","depth":0}', + ], + originalValue: [ + { + id: '688MAHYB7WTwW_Glsi_d', + type: 'event', + index: 'winlogbeat-7.10.0-2020.11.12-000001', + depth: 0, + }, + ], + }, + { category: 'signal', field: 'signal.status', values: ['open'], originalValue: 'open' }, + { + category: 'signal', + field: 'signal.rule.id', + values: ['b69d086c-325a-4f46-b17b-fb6d227006ba'], + originalValue: 'b69d086c-325a-4f46-b17b-fb6d227006ba', + }, + { + category: 'signal', + field: 'signal.rule.rule_id', + values: ['e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5'], + originalValue: 'e7cd9a53-ac62-44b5-bdec-9c94d85bb1a5', + }, + { category: 'signal', field: 'signal.rule.actions', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.author', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.false_positives', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.meta.from', values: ['1m'], originalValue: '1m' }, + { + category: 'signal', + field: 'signal.rule.meta.kibana_siem_app_url', + values: ['http://localhost:5601/app/security'], + originalValue: 'http://localhost:5601/app/security', + }, + { category: 'signal', field: 'signal.rule.max_signals', values: [100], originalValue: 100 }, + { category: 'signal', field: 'signal.rule.risk_score', values: [21], originalValue: 21 }, + { category: 'signal', field: 'signal.rule.risk_score_mapping', values: [], originalValue: [] }, + { + category: 'signal', + field: 'signal.rule.output_index', + values: ['.siem-signals-angelachuang-default'], + originalValue: '.siem-signals-angelachuang-default', + }, + { category: 'signal', field: 'signal.rule.description', values: ['xxx'], originalValue: 'xxx' }, + { + category: 'signal', + field: 'signal.rule.from', + values: ['now-360s'], + originalValue: 'now-360s', + }, + { + category: 'signal', + field: 'signal.rule.index', + values: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + originalValue: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + }, + { category: 'signal', field: 'signal.rule.interval', values: ['5m'], originalValue: '5m' }, + { category: 'signal', field: 'signal.rule.language', values: ['kuery'], originalValue: 'kuery' }, + { category: 'signal', field: 'signal.rule.license', values: [''], originalValue: '' }, + { category: 'signal', field: 'signal.rule.name', values: ['xxx'], originalValue: 'xxx' }, + { + category: 'signal', + field: 'signal.rule.query', + values: ['@timestamp : * '], + originalValue: '@timestamp : * ', + }, + { category: 'signal', field: 'signal.rule.references', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.severity', values: ['low'], originalValue: 'low' }, + { category: 'signal', field: 'signal.rule.severity_mapping', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.tags', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.type', values: ['query'], originalValue: 'query' }, + { category: 'signal', field: 'signal.rule.to', values: ['now'], originalValue: 'now' }, + { + category: 'signal', + field: 'signal.rule.filters', + values: [ + '{"meta":{"alias":null,"negate":false,"disabled":false,"type":"exists","key":"message","value":"exists"},"exists":{"field":"message"},"$state":{"store":"appState"}}', + ], + originalValue: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'message', + value: 'exists', + }, + exists: { field: 'message' }, + $state: { store: 'appState' }, + }, + ], + }, + { + category: 'signal', + field: 'signal.rule.created_by', + values: ['angela'], + originalValue: 'angela', + }, + { + category: 'signal', + field: 'signal.rule.updated_by', + values: ['angela'], + originalValue: 'angela', + }, + { category: 'signal', field: 'signal.rule.threat', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.rule.version', values: [2], originalValue: 2 }, + { + category: 'signal', + field: 'signal.rule.created_at', + values: ['2020-11-24T10:30:33.660Z'], + originalValue: '2020-11-24T10:30:33.660Z', + }, + { + category: 'signal', + field: 'signal.rule.updated_at', + values: ['2020-11-25T15:37:40.939Z'], + originalValue: '2020-11-25T15:37:40.939Z', + }, + { category: 'signal', field: 'signal.rule.exceptions_list', values: [], originalValue: [] }, + { category: 'signal', field: 'signal.depth', values: [1], originalValue: 1 }, + { + category: 'signal', + field: 'signal.parent.id', + values: ['688MAHYB7WTwW_Glsi_d'], + originalValue: '688MAHYB7WTwW_Glsi_d', + }, + { category: 'signal', field: 'signal.parent.type', values: ['event'], originalValue: 'event' }, + { + category: 'signal', + field: 'signal.parent.index', + values: ['winlogbeat-7.10.0-2020.11.12-000001'], + originalValue: 'winlogbeat-7.10.0-2020.11.12-000001', + }, + { category: 'signal', field: 'signal.parent.depth', values: [0], originalValue: 0 }, + { + category: 'signal', + field: 'signal.original_time', + values: ['2020-11-25T15:36:38.847Z'], + originalValue: '2020-11-25T15:36:38.847Z', + }, + { + category: 'signal', + field: 'signal.original_event.ingested', + values: ['2020-11-25T15:36:40.924914552Z'], + originalValue: '2020-11-25T15:36:40.924914552Z', + }, + { category: 'signal', field: 'signal.original_event.code', values: [4625], originalValue: 4625 }, + { + category: 'signal', + field: 'signal.original_event.lag.total', + values: [2077], + originalValue: 2077, + }, + { + category: 'signal', + field: 'signal.original_event.lag.read', + values: [1075], + originalValue: 1075, + }, + { + category: 'signal', + field: 'signal.original_event.lag.ingest', + values: [1002], + originalValue: 1002, + }, + { + category: 'signal', + field: 'signal.original_event.provider', + values: ['Microsoft-Windows-Security-Auditing'], + originalValue: 'Microsoft-Windows-Security-Auditing', + }, + { + category: 'signal', + field: 'signal.original_event.created', + values: ['2020-11-25T15:36:39.922Z'], + originalValue: '2020-11-25T15:36:39.922Z', + }, + { + category: 'signal', + field: 'signal.original_event.kind', + values: ['event'], + originalValue: 'event', + }, + { + category: 'signal', + field: 'signal.original_event.module', + values: ['security'], + originalValue: 'security', + }, + { + category: 'signal', + field: 'signal.original_event.action', + values: ['logon-failed'], + originalValue: 'logon-failed', + }, + { + category: 'signal', + field: 'signal.original_event.type', + values: ['start'], + originalValue: 'start', + }, + { + category: 'signal', + field: 'signal.original_event.category', + values: ['authentication'], + originalValue: 'authentication', + }, + { + category: 'signal', + field: 'signal.original_event.outcome', + values: ['failure'], + originalValue: 'failure', + }, + { + category: '_index', + field: '_index', + values: ['.siem-signals-angelachuang-default-000004'], + originalValue: '.siem-signals-angelachuang-default-000004', + }, + { + category: '_id', + field: '_id', + values: ['5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31'], + originalValue: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + }, + { category: '_score', field: '_score', values: [1], originalValue: 1 }, + { + category: 'fields', + field: 'fields.agent.name', + values: ['windows-native'], + originalValue: ['windows-native'], + }, + { + category: 'fields', + field: 'fields.cloud.machine.type', + values: ['e2-medium'], + originalValue: ['e2-medium'], + }, + { category: 'fields', field: 'fields.cloud.provider', values: ['gcp'], originalValue: ['gcp'] }, + { + category: 'fields', + field: 'fields.agent.id', + values: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + originalValue: ['abfe4a35-d5b4-42a0-a539-bd054c791769'], + }, + { + category: 'fields', + field: 'fields.cloud.instance.id', + values: ['5896613765949631815'], + originalValue: ['5896613765949631815'], + }, + { + category: 'fields', + field: 'fields.agent.type', + values: ['winlogbeat'], + originalValue: ['winlogbeat'], + }, + { + category: 'fields', + field: 'fields.@timestamp', + values: ['2020-11-25T15:42:39.417Z'], + originalValue: ['2020-11-25T15:42:39.417Z'], + }, + { + category: 'fields', + field: 'fields.agent.ephemeral_id', + values: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + originalValue: ['b9850845-c000-4ddd-bd51-9978a07b7e7d'], + }, + { + category: 'fields', + field: 'fields.cloud.instance.name', + values: ['windows-native'], + originalValue: ['windows-native'], + }, + { + category: 'fields', + field: 'fields.cloud.availability_zone', + values: ['us-central1-a'], + originalValue: ['us-central1-a'], + }, + { + category: 'fields', + field: 'fields.agent.version', + values: ['7.10.0'], + originalValue: ['7.10.0'], + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index 8d807825c246a7..973d067d9e3794 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -566,6 +566,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } eventId="Y-6TfmcB0WOhS6qyMv3s" @@ -1139,6 +1146,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } eventId="Y-6TfmcB0WOhS6qyMv3s" @@ -1296,6 +1310,13 @@ exports[`EventDetails rendering should match snapshot 1`] = ` "902", ], }, + Object { + "field": "event.kind", + "originalValue": "event", + "values": Array [ + "event", + ], + }, ] } /> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index af9fc61b9585c6..2b681870e92fe4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -1,17 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`JSON View rendering should match snapshot 1`] = ` - + + width="100%" + /> + `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 1a492eee4ae7a0..0b2fbcf703d77f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -58,6 +58,7 @@ export const getColumns = ({ onUpdateColumns, contextId, toggleColumn, + getLinkValue, }: { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; @@ -65,6 +66,7 @@ export const getColumns = ({ onUpdateColumns: OnUpdateColumns; contextId: string; toggleColumn: (column: ColumnHeaderOptions) => void; + getLinkValue: (field: string) => string | null; }) => [ { field: 'field', @@ -187,6 +189,7 @@ export const getColumns = ({ fieldName={data.field} fieldType={data.type} value={value} + linkValue={getLinkValue(data.field)} /> )}
    diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index bafe3df1a9cc7e..20fa6e54e044d5 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -9,37 +9,45 @@ import React from 'react'; import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; -import { - defaultHeaders, - mockDetailItemData, - mockDetailItemDataId, - TestProviders, -} from '../../mock'; +import { mockDetailItemData, mockDetailItemDataId, TestProviders } from '../../mock'; -import { EventDetails, View } from './event_details'; +import { EventDetails, EventsViewType } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; jest.mock('../link_to'); describe('EventDetails', () => { const mount = useMountAppended(); const defaultProps = { browserFields: mockBrowserFields, - columnHeaders: defaultHeaders, data: mockDetailItemData, id: mockDetailItemDataId, - view: 'table-view' as View, - onUpdateColumns: jest.fn(), + isAlert: false, onViewSelected: jest.fn(), timelineId: 'test', - toggleColumn: jest.fn(), + view: EventsViewType.summaryView, }; + + const alertsProps = { + ...defaultProps, + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + isAlert: true, + }; + const wrapper = mount( ); + const alertsWrapper = mount( + + + + ); + describe('rendering', () => { test('should match snapshot', () => { const shallowWrap = shallow(); @@ -65,4 +73,27 @@ describe('EventDetails', () => { ).toEqual('Table'); }); }); + + describe('alerts tabs', () => { + ['Summary', 'Table', 'JSON View'].forEach((tab) => { + test(`it renders the ${tab} tab`, () => { + expect( + alertsWrapper + .find('[data-test-subj="eventDetails"]') + .find('[role="tablist"]') + .containsMatchingElement({tab}) + ).toBeTruthy(); + }); + }); + + test('the Summary tab is selected by default', () => { + expect( + alertsWrapper + .find('[data-test-subj="eventDetails"]') + .find('.euiTab-isSelected') + .first() + .text() + ).toEqual('Summary'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 92c3ff9b9fa97b..291893fe682b43 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; +import { EuiTabbedContent, EuiTabbedContentTab, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -13,17 +13,20 @@ import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/ti import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; +import { SummaryView } from './summary_view'; -export type View = EventsViewType.tableView | EventsViewType.jsonView; +export type View = EventsViewType.tableView | EventsViewType.jsonView | EventsViewType.summaryView; export enum EventsViewType { tableView = 'table-view', jsonView = 'json-view', + summaryView = 'summary-view', } interface Props { browserFields: BrowserFields; data: TimelineEventsDetailsItem[]; id: string; + isAlert: boolean; view: EventsViewType; onViewSelected: (selected: EventsViewType) => void; timelineId: string; @@ -50,13 +53,33 @@ const EventDetailsComponent: React.FC = ({ view, onViewSelected, timelineId, + isAlert, }) => { - const handleTabClick = useCallback((e) => onViewSelected(e.id as EventsViewType), [ - onViewSelected, - ]); + const handleTabClick = useCallback((e) => onViewSelected(e.id), [onViewSelected]); + const alerts = useMemo( + () => [ + { + id: EventsViewType.summaryView, + name: i18n.SUMMARY, + content: ( + <> + + + + ), + }, + ], + [data, id, browserFields, timelineId] + ); const tabs: EuiTabbedContentTab[] = useMemo( () => [ + ...(isAlert ? alerts : []), { id: EventsViewType.tableView, name: i18n.TABLE, @@ -83,10 +106,10 @@ const EventDetailsComponent: React.FC = ({ ), }, ], - [browserFields, data, id, timelineId] + [alerts, browserFields, data, id, isAlert, timelineId] ); - const selectedTab = view === EventsViewType.tableView ? tabs[0] : tabs[1]; + const selectedTab = useMemo(() => tabs.find((t) => t.id === view) ?? tabs[0], [tabs, view]); return ( ( const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const items = useMemo( () => - sortBy(data, ['field']).map((item) => ({ + sortBy(['field'], data).map((item) => ({ ...item, ...fieldsByName[item.field], valuesConcatenated: item.values != null ? item.values.join() : '', @@ -90,6 +90,19 @@ export const EventFieldsBrowser = React.memo( return getColumnHeaders(columns, browserFields); }); + const getLinkValue = useCallback( + (field: string) => { + const linkField = (columnHeaders.find((col) => col.id === field) ?? {}).linkField; + if (!linkField) { + return null; + } + const linkFieldData = (data ?? []).find((d) => d.field === linkField); + const linkFieldValue = getOr(null, 'originalValue', linkFieldData); + return Array.isArray(linkFieldValue) ? linkFieldValue[0] : linkFieldValue; + }, + [data, columnHeaders] + ); + const toggleColumn = useCallback( (column: ColumnHeaderOptions) => { if (columnHeaders.some((c) => c.id === column.id)) { @@ -126,8 +139,17 @@ export const EventFieldsBrowser = React.memo( onUpdateColumns, contextId: timelineId, toggleColumn, + getLinkValue, }), - [browserFields, columnHeaders, eventId, onUpdateColumns, timelineId, toggleColumn] + [ + browserFields, + columnHeaders, + eventId, + onUpdateColumns, + timelineId, + toggleColumn, + getLinkValue, + ] ); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx index 0cf158c8ea90bf..da93670d647a84 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.test.tsx @@ -54,6 +54,9 @@ describe('JSON View', () => { packets: 4, port: 902, }, + event: { + kind: 'event', + }, }; expect(buildJsonView(mockDetailItemData)).toEqual(expectedData); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index bf548d04e780bd..2944a15cbeb93d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -16,8 +16,10 @@ interface Props { data: TimelineEventsDetailsItem[]; } -const StyledEuiCodeEditor = styled(EuiCodeEditor)` - flex: 1; +const EuiCodeEditorContainer = styled.div` + .euiCodeEditorWrapper { + position: absolute; + } `; const EDITOR_SET_OPTIONS = { fontSize: '12px' }; @@ -34,19 +36,29 @@ export const JsonView = React.memo(({ data }) => { ); return ( - + + + ); }); JsonView.displayName = 'JsonView'; export const buildJsonView = (data: TimelineEventsDetailsItem[]) => - data.reduce((accumulator, item) => set(item.field, item.originalValue, accumulator), {}); + data.reduce( + (accumulator, item) => + set( + item.field, + Array.isArray(item.originalValue) ? item.originalValue.join() : item.originalValue, + accumulator + ), + {} + ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx new file mode 100644 index 00000000000000..dec1bd9f3ac694 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { waitFor } from '@testing-library/react'; + +import { SummaryViewComponent } from './summary_view'; +import { mockAlertDetailsData } from './__mocks__'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; + +import { TestProviders } from '../../mock'; +import { mockBrowserFields } from '../../containers/source/mock'; +import { useMountAppended } from '../../utils/use_mount_appended'; + +jest.mock('../../../detections/containers/detection_engine/rules/use_rule_async', () => { + return { + useRuleAsync: jest.fn(), + }; +}); + +const props = { + data: mockAlertDetailsData as TimelineEventsDetailsItem[], + browserFields: mockBrowserFields, + eventId: '5d1d53da502f56aacc14c3cb5c669363d102b31f99822e5d369d4804ed370a31', + timelineId: 'detections-page', +}; + +describe('SummaryViewComponent', () => { + const mount = useMountAppended(); + + beforeEach(() => { + jest.clearAllMocks(); + (useRuleAsync as jest.Mock).mockReturnValue({ + rule: { + note: 'investigation guide', + }, + }); + }); + test('render correct items', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="summary-view"]').exists()).toEqual(true); + }); + + test('render investigation guide', async () => { + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(true); + }); + }); + + test("render no investigation guide if it doesn't exist", async () => { + (useRuleAsync as jest.Mock).mockReturnValue({ + rule: { + note: null, + }, + }); + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="summary-view-guide"]').exists()).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx new file mode 100644 index 00000000000000..860bf13908855a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, getOr } from 'lodash/fp'; +import { + EuiTitle, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiInMemoryTable, + EuiBasicTableColumn, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; +import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; +import * as i18n from './translations'; +import { BrowserFields } from '../../../../common/search_strategy/index_fields'; +import { + ALERTS_HEADERS_RISK_SCORE, + ALERTS_HEADERS_RULE, + ALERTS_HEADERS_SEVERITY, +} from '../../../detections/components/alerts_table/translations'; +import { + IP_FIELD_TYPE, + SIGNAL_RULE_NAME_FIELD_NAME, +} from '../../../timelines/components/timeline/body/renderers/constants'; +import { DESTINATION_IP_FIELD_NAME, SOURCE_IP_FIELD_NAME } from '../../../network/components/ip'; +import { LineClamp } from '../line_clamp'; +import { useRuleAsync } from '../../../detections/containers/detection_engine/rules/use_rule_async'; + +interface SummaryRow { + title: string; + description: { + contextId: string; + eventId: string; + fieldName: string; + value: string; + fieldType: string; + linkValue: string | undefined; + }; +} +type Summary = SummaryRow[]; + +const fields = [ + { id: 'signal.status' }, + { id: '@timestamp' }, + { + id: SIGNAL_RULE_NAME_FIELD_NAME, + linkField: 'signal.rule.id', + label: ALERTS_HEADERS_RULE, + }, + { id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY }, + { id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, + { id: 'host.name' }, + { id: 'user.name' }, + { id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, + { id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` + .euiTableHeaderCell { + border: none; + } + .euiTableRowCell { + border: none; + } +`; + +const StyledEuiDescriptionList = styled(EuiDescriptionList)` + padding: 24px 4px 4px; +`; + +const getTitle = (title: SummaryRow['title']) => ( + +
    {title}
    +
    +); + +getTitle.displayName = 'getTitle'; + +const getDescription = ({ + contextId, + eventId, + fieldName, + value, + fieldType = '', + linkValue, +}: SummaryRow['description']) => ( + +); + +const getSummary = ({ + data, + browserFields, + timelineId, + eventId, +}: { + data: TimelineEventsDetailsItem[]; + browserFields: BrowserFields; + timelineId: string; + eventId: string; +}) => { + return data != null + ? fields.reduce((acc, item) => { + const field = data.find((d) => d.field === item.id); + if (!field) { + return acc; + } + const linkValueField = + item.linkField != null && data.find((d) => d.field === item.linkField); + const linkValue = getOr(null, 'originalValue.0', linkValueField); + const value = getOr(null, 'originalValue.0', field); + const category = field.category; + const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string; + const description = { + contextId: timelineId, + eventId, + fieldName: item.id, + value, + fieldType: item.fieldType ?? fieldType, + linkValue: linkValue ?? undefined, + }; + + return [ + ...acc, + { + title: item.label ?? item.id, + description, + }, + ]; + }, []) + : []; +}; + +const summaryColumns: Array> = [ + { + field: 'title', + truncateText: false, + render: getTitle, + width: '120px', + name: '', + }, + { + field: 'description', + truncateText: false, + render: getDescription, + name: '', + }, +]; + +export const SummaryViewComponent: React.FC<{ + browserFields: BrowserFields; + data: TimelineEventsDetailsItem[]; + eventId: string; + timelineId: string; +}> = ({ data, eventId, timelineId, browserFields }) => { + const ruleId = useMemo(() => { + const item = data.find((d) => d.field === 'signal.rule.id'); + return Array.isArray(item?.originalValue) + ? item?.originalValue[0] + : item?.originalValue ?? null; + }, [data]); + const { rule: maybeRule } = useRuleAsync(ruleId); + const summaryList = useMemo(() => getSummary({ browserFields, data, eventId, timelineId }), [ + browserFields, + data, + eventId, + timelineId, + ]); + + return ( + <> + + {maybeRule?.note && ( + + {i18n.INVESTIGATION_GUIDE} + + + + + )} + + ); +}; + +export const SummaryView = React.memo(SummaryViewComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 19e71e0f37da64..76ae2cd4a88a88 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -6,6 +6,17 @@ import { i18n } from '@kbn/i18n'; +export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summary', { + defaultMessage: 'Summary', +}); + +export const INVESTIGATION_GUIDE = i18n.translate( + 'xpack.securitySolution.alertDetails.summary.investigationGuide', + { + defaultMessage: 'Investigation guide', + } +); + export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', { defaultMessage: 'Table', }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx index b3a838ab088dfd..48bdebbc0aa4ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/event_details_flyout.tsx @@ -4,19 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import { some } from 'lodash/fp'; +import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; -import { timelineActions } from '../../../timelines/store/timeline'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { ExpandableEvent, ExpandableEventTitle, } from '../../../timelines/components/timeline/expandable_event'; import { useDeepEqualSelector } from '../../hooks/use_selector'; +import { useTimelineEventsDetails } from '../../../timelines/containers/details'; +import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; const StyledEuiFlyout = styled(EuiFlyout)` z-index: ${({ theme }) => theme.eui.euiZLevel7}; @@ -28,27 +31,33 @@ interface EventDetailsFlyoutProps { timelineId: string; } -const emptyExpandedEvent = {}; - const EventDetailsFlyoutComponent: React.FC = ({ browserFields, docValueFields, timelineId, }) => { const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent ?? emptyExpandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedEvent ?? {} ); const handleClearSelection = useCallback(() => { - dispatch( - timelineActions.toggleExpandedEvent({ - timelineId, - event: emptyExpandedEvent, - }) - ); + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); }, [dispatch, timelineId]); + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent?.indexName ?? '', + eventId: expandedEvent?.eventId ?? '', + skip: !expandedEvent.eventId, + }); + + const isAlert = useMemo( + () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), + [detailsData] + ); + if (!expandedEvent.eventId) { return null; } @@ -56,13 +65,15 @@ const EventDetailsFlyoutComponent: React.FC = ({ return ( - + diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 8710503924d841..5e5bdebffa1828 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -65,6 +65,7 @@ const eventsViewerDefaultProps = { deletedEventIds: [], docValueFields: [], end: to, + expandedEvent: {}, filters: [], id: TimelineId.detectionsPage, indexNames: mockIndexNames, @@ -78,6 +79,7 @@ const eventsViewerDefaultProps = { query: '', language: 'kql', }, + handleCloseExpandedEvent: jest.fn(), start: from, sort: [ { diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index c578e017c4d954..d6b2efbe430532 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -6,13 +6,15 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useRef } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; import { Direction } from '../../../../common/search_strategy'; import { BrowserFields, DocValueFields } from '../../containers/source'; import { useTimelineEvents } from '../../../timelines/containers'; +import { timelineActions } from '../../../timelines/store/timeline'; import { useKibana } from '../../lib/kibana'; import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; import { HeaderSection } from '../header_section'; @@ -35,7 +37,7 @@ import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; import { useFullScreen } from '../../containers/use_full_screen'; -import { TimelineId } from '../../../../common/types/timeline'; +import { TimelineExpandedEvent, TimelineId } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px @@ -101,6 +103,7 @@ interface Props { deletedEventIds: Readonly; docValueFields: DocValueFields[]; end: string; + expandedEvent: TimelineExpandedEvent; filters: Filter[]; headerFilterGroup?: React.ReactNode; height?: number; @@ -128,6 +131,7 @@ const EventsViewerComponent: React.FC = ({ deletedEventIds, docValueFields, end, + expandedEvent, filters, headerFilterGroup, id, @@ -145,6 +149,7 @@ const EventsViewerComponent: React.FC = ({ utilityBar, graphEventId, }) => { + const dispatch = useDispatch(); const { globalFullScreen, timelineFullScreen } = useFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); @@ -175,6 +180,9 @@ const EventsViewerComponent: React.FC = ({ [justTitle] ); + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -226,6 +234,13 @@ const EventsViewerComponent: React.FC = ({ skip: !canQueryTimeline, }); + useEffect(() => { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; + dispatch(timelineActions.toggleExpandedEvent({ timelineId: id })); + } + }, [combinedQueries, dispatch, id]); + const totalCountMinusDeleted = useMemo( () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), [deletedEventIds.length, totalCount] diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index ec3cbbdef98ad8..2570a2b6d1f370 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -51,6 +51,7 @@ const StatefulEventsViewerComponent: React.FC = ({ deletedEventIds, deleteEventQuery, end, + expandedEvent, excludedRowRendererIds, filters, headerFilterGroup, @@ -111,6 +112,7 @@ const StatefulEventsViewerComponent: React.FC = ({ dataProviders={dataProviders!} deletedEventIds={deletedEventIds} end={end} + expandedEvent={expandedEvent} isLoadingIndexPattern={isLoadingIndexPattern} filters={globalFilters} headerFilterGroup={headerFilterGroup} @@ -142,27 +144,29 @@ const makeMapStateToProps = () => { const getInputsTimeline = inputsSelectors.getTimelineSelector(); const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const getEvents = timelineSelectors.getEventsByIdSelector(); const getTimeline = timelineSelectors.getTimelineByIdSelector(); const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => { const input: inputsModel.InputsRange = getInputsTimeline(state); - const events: TimelineModel = getEvents(state, id) ?? defaultModel; + const timeline: TimelineModel = getTimeline(state, id) ?? defaultModel; const { columns, dataProviders, deletedEventIds, excludedRowRendererIds, + expandedEvent, + graphEventId, itemsPerPage, itemsPerPageOptions, kqlMode, sort, showCheckboxes, - } = events; + } = timeline; return { columns, dataProviders, deletedEventIds, + expandedEvent, excludedRowRendererIds, filters: getGlobalFiltersQuerySelector(state), id, @@ -175,7 +179,7 @@ const makeMapStateToProps = () => { showCheckboxes, // Used to determine whether the footer should show (since it is hidden if the graph is showing.) // `getTimeline` actually returns `TimelineModel | undefined` - graphEventId: (getTimeline(state, id) as TimelineModel | undefined)?.graphEventId, + graphEventId, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx index 8b5e0555b57b40..badb29e165573e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx @@ -7,7 +7,7 @@ import React, { useCallback } from 'react'; import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { isEqlRule } from '../../../../../common/detection_engine/utils'; +import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils'; import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { FieldComponent } from '../../autocomplete/field'; @@ -149,7 +149,7 @@ export const BuilderEntryItem: React.FC = ({ entry, listType, entry.field != null && entry.field.type === 'boolean', - isFirst && !isEqlRule(ruleType) + isFirst && !isEqlRule(ruleType) && !isThresholdRule(ruleType) ); const comboBox = ( = ({ content }) => { + const [isOverflow, setIsOverflow] = useState(null); + const [isExpanded, setIsExpanded] = useState(null); + const descriptionRef = useRef(null); + const toggleReadMore = useCallback(() => { + setIsExpanded((prevState) => !prevState); + }, []); + + useEffect(() => { + if (content != null && descriptionRef?.current?.clientHeight != null) { + if ( + (descriptionRef?.current?.scrollHeight ?? 0) > (descriptionRef?.current?.clientHeight ?? 0) + ) { + setIsOverflow(true); + } + + if ( + ((content == null || descriptionRef?.current?.scrollHeight) ?? 0) <= + (descriptionRef?.current?.clientHeight ?? 0) + ) { + setIsOverflow(false); + } + } + }, [content]); + + if (!content) { + return null; + } + + return ( + <> + {isExpanded ? ( +

    {content}

    + ) : isOverflow == null || isOverflow === true ? ( + {content} + ) : ( + {content} + )} + {isOverflow && ( + + {isExpanded ? i18n.READ_LESS : i18n.READ_MORE} + + )} + + ); +}; + +export const LineClamp = React.memo(LineClampComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts b/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts new file mode 100644 index 00000000000000..e332d1a2d2b5c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/line_clamp/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const READ_MORE = i18n.translate('xpack.securitySolution.alertDetails.summary.readMore', { + defaultMessage: 'Read More', +}); + +export const READ_LESS = i18n.translate('xpack.securitySolution.alertDetails.summary.readLess', { + defaultMessage: 'Read Less', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index d6cbd31e86ddba..3964acbc9b7669 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -327,6 +327,20 @@ const ReputationLinkComponent: React.FC<{ [ipReputationLinksSetting, domain, defaultNameMapping, allItemsLimit] ); + const renderCallback = useCallback( + (rowItem) => + isReputationLink(rowItem) && ( + + <>{rowItem.name ?? domain} + + ), + [allItemsLimit, domain, overflowIndexStart] + ); + return ipReputationLinks?.length > 0 ? (
    { - return ( - isReputationLink(rowItem) && ( - - <>{rowItem.name ?? domain} - - ) - ); - }} + render={renderCallback} moreMaxHeight={DEFAULT_MORE_MAX_HEIGHT} overflowIndexStart={overflowIndexStart} /> diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx index ac03e6c5c0018f..fb4cd95ae36f23 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.tsx @@ -58,20 +58,17 @@ export interface Props extends Pick = ({ combinedQueries, defaultView, deleteQuery, - filters = NO_FILTERS, + filters, field, from, indexPattern, indexNames, options, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, timelineId, @@ -132,7 +129,6 @@ const TopNComponent: React.FC = ({ filters={filters} from={from} headerChildren={headerChildren} - indexPattern={indexPattern} onlyField={field} query={query} setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget} diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx new file mode 100644 index 00000000000000..393c844bf50988 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { createPortalNode, OutPortal } from 'react-reverse-portal'; + +/** + * A singleton portal for rendering content in the global header + */ +const timelineEventsCountPortalNodeSingleton = createPortalNode(); + +export const useTimelineEventsCountPortal = () => { + const [timelineEventsCountPortalNode] = useState(timelineEventsCountPortalNodeSingleton); + + return { timelineEventsCountPortalNode }; +}; + +export const TimelineEventsCountBadge = React.memo(() => { + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + + return ; +}); + +TimelineEventsCountBadge.displayName = 'TimelineEventsCountBadge'; diff --git a/x-pack/plugins/security_solution/public/common/lib/note/index.ts b/x-pack/plugins/security_solution/public/common/lib/note/index.ts index b803cade326ad6..19821753a6cdcd 100644 --- a/x-pack/plugins/security_solution/public/common/lib/note/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/note/index.ts @@ -8,6 +8,7 @@ export interface Note { /** When the note was created */ created: Date; + eventId?: string | null; /** Uniquely identifies the note */ id: string; /** When not `null`, this represents the last edit */ @@ -18,5 +19,6 @@ export interface Note { user: string; /** SaveObjectID for note */ saveObjectId: string | null | undefined; + timelineId?: string | null; version: string | null | undefined; } diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts index c5d881c540eecf..f074495e65b640 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_detail_item.ts @@ -109,4 +109,9 @@ export const mockDetailItemData: TimelineEventsDetailsItem[] = [ originalValue: 902, values: ['902'], }, + { + field: 'event.kind', + originalValue: 'event', + values: ['event'], + }, ]; diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 59d783107e5870..9808bbb1faed30 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keys } from 'lodash/fp'; +import { keys, values } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import { createSelector } from 'reselect'; import { Note } from '../../lib/note'; import { ErrorModel, NotesById } from './model'; import { State } from '../types'; +import { TimelineResultNote } from '../../../timelines/components/open_timeline/types'; const selectNotesById = (state: State): NotesById => state.app.notesById; @@ -25,6 +26,16 @@ export const getNotes = memoizeOne((notesById: NotesById, noteIds: string[]): No }, []) ); +export const getNotesAsCommentsList = (notesById: NotesById): TimelineResultNote[] => + values(notesById).map((note) => ({ + eventId: note.eventId, + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })); + export const selectNotesByIdSelector = createSelector( selectNotesById, (notesById: NotesById) => notesById @@ -33,4 +44,7 @@ export const selectNotesByIdSelector = createSelector( export const notesByIdsSelector = () => createSelector(selectNotesById, (notesById: NotesById) => notesById); +export const selectNotesAsCommentsListSelector = () => + createSelector(selectNotesById, getNotesAsCommentsList); + export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts index 9feb2f87d7e087..47a63ec843073c 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { State } from '../types'; import { InputsModel, InputsRange, GlobalQuery } from './model'; @@ -64,21 +65,18 @@ export const timelineQueryByIdSelector = () => export const globalSelector = () => createSelector(selectGlobal, (global) => global); +const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; + export const globalQuerySelector = () => - createSelector( - selectGlobal, - (global) => - global.query || { - query: '', - language: 'kuery', - } - ); + createSelector(selectGlobal, (global) => global.query || DEFAULT_QUERY); export const globalSavedQuerySelector = () => createSelector(selectGlobal, (global) => global.savedQuery || null); +const NO_FILTERS: Filter[] = []; + export const globalFiltersQuerySelector = () => - createSelector(selectGlobal, (global) => global.filters || []); + createSelector(selectGlobal, (global) => global.filters || NO_FILTERS); export const getTimelineSelector = () => createSelector(selectTimeline, (timeline) => timeline); diff --git a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts index 599cddb605148c..88694c66bf960d 100644 --- a/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/sourcerer/selectors.ts @@ -91,16 +91,23 @@ export const getSourcererScopeSelector = () => { : selectedPatterns; }); + const getIndexPattern = memoizeOne( + (indexPattern, title) => ({ + ...indexPattern, + title, + }), + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] && newArgs[1].length === lastArgs[1].length + ); + const mapStateToProps = (state: State, scopeId: SourcererScopeName): ManageScope => { const scope = getScopeIdSelector(state, scopeId); const selectedPatterns = getSelectedPatterns(scope.selectedPatterns.sort().join()); + const indexPattern = getIndexPattern(scope.indexPattern, selectedPatterns.join()); + return { ...scope, selectedPatterns, - indexPattern: { - ...scope.indexPattern, - title: selectedPatterns.join(), - }, + indexPattern, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx index bb8cc2267249f6..e2ab339fbaa83f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/helpers.tsx @@ -10,6 +10,8 @@ import { HistogramData, AlertsAggregation, AlertsBucket, AlertsGroupBucket } fro import { AlertSearchResponse } from '../../containers/detection_engine/alerts/types'; import * as i18n from './translations'; +const EMPTY_ALERTS_DATA: HistogramData[] = []; + export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggregation> | null) => { const groupBuckets: AlertsGroupBucket[] = alertsData?.aggregations?.alertsByGrouping?.buckets ?? []; @@ -25,7 +27,7 @@ export const formatAlertsData = (alertsData: AlertSearchResponse<{}, AlertsAggre g: group, })), ]; - }, []); + }, EMPTY_ALERTS_DATA); }; export const getAlertsHistogramQuery = ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 9eb0a97a1c9a21..f044eb3799c117 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -19,8 +19,7 @@ import { getOr } from 'lodash/fp'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; -import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { isThresholdRule } from '../../../../../common/detection_engine/utils'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; import { EventsTd, EventsTdContent } from '../../../../timelines/components/timeline/styles'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers'; @@ -323,12 +322,6 @@ const AlertContextMenuComponent: React.FC = ({ setOpenAddExceptionModal('detection'); }, [closePopover]); - const areExceptionsAllowed = useMemo((): boolean => { - const ruleTypes = getOr([], 'signal.rule.type', ecsRowData); - const [ruleType] = ruleTypes as Type[]; - return !isThresholdRule(ruleType); - }, [ecsRowData]); - // eslint-disable-next-line react-hooks/exhaustive-deps const addExceptionComponent = ( = ({ data-test-subj="add-exception-menu-item" id="addException" onClick={handleAddExceptionClick} - disabled={!canUserCRUD || !hasIndexWrite || !areExceptionsAllowed} + disabled={!canUserCRUD || !hasIndexWrite} > {i18n.ACTION_ADD_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index 8960b7a76660b1..d7306e26d3cfef 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -89,7 +89,6 @@ const InvestigateInTimelineActionComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg deleted file mode 100644 index 716fff726293c9..00000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/eql_search_icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index aa1db1e31170e5..d184ed8d06f11d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -19,7 +19,6 @@ import { FieldHook } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; import { MlCardDescription } from './ml_card_description'; -import EqlSearchIcon from './eql_search_icon.svg'; interface SelectRuleTypeProps { describedByIds: string[]; @@ -156,7 +155,7 @@ export const SelectRuleType: React.FC = ({ title={i18n.EQL_TYPE_TITLE} titleSize="xs" description={i18n.EQL_TYPE_DESCRIPTION} - icon={} + icon={} selectable={eqlSelectableConfig} layout="horizontal" textAlign="left" diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 65993902d4c285..6fa93f9fb41392 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -8,7 +8,6 @@ import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; -import { isThresholdRule } from '../../../../../common/detection_engine/utils'; import { RuleStepProps, RuleStep, @@ -75,8 +74,6 @@ const StepAboutRuleComponent: FC = ({ const [severityValue, setSeverityValue] = useState(initialState.severity.value); const [indexPatternLoading, { indexPatterns }] = useFetchIndex(defineRuleData?.index ?? []); - const canUseExceptions = defineRuleData?.ruleType && !isThresholdRule(defineRuleData.ruleType); - const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, @@ -282,7 +279,7 @@ const StepAboutRuleComponent: FC = ({ idAria: 'detectionEngineStepAboutRuleAssociatedToEndpointList', 'data-test-subj': 'detectionEngineStepAboutRuleAssociatedToEndpointList', euiFieldProps: { - disabled: isLoading || !canUseExceptions, + disabled: isLoading, }, }} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 62f0d12fd67b15..28c7805e968d69 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -82,7 +82,6 @@ import { DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; import { useFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports'; -import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../../../timelines/store/timeline'; @@ -104,7 +103,6 @@ enum RuleDetailTabs { } const getRuleDetailsTabs = (rule: Rule | null) => { - const canUseExceptions = rule && !isThresholdRule(rule.type); return [ { id: RuleDetailTabs.alerts, @@ -115,7 +113,7 @@ const getRuleDetailsTabs = (rule: Rule | null) => { { id: RuleDetailTabs.exceptions, name: i18n.EXCEPTIONS_TAB, - disabled: !canUseExceptions, + disabled: false, dataTestSubj: 'exceptionsTab', }, { diff --git a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx index 701094cee88a28..4905fdc2e1f578 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip/index.tsx @@ -11,7 +11,7 @@ import { FormattedFieldValue } from '../../../timelines/components/timeline/body export const SOURCE_IP_FIELD_NAME = 'source.ip'; export const DESTINATION_IP_FIELD_NAME = 'destination.ip'; -const IP_FIELD_TYPE = 'ip'; +export const IP_FIELD_TYPE = 'ip'; /** * Renders text containing a draggable IP address (e.g. `source.ip`, diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 704506d9813d98..50b5ae9388fe57 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -37,6 +37,10 @@ describe('Alerts by category', () => { indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; describe('before loading data', () => { beforeAll(async () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx index 4ab72afc3fb455..a58b5cf315ec17 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.tsx @@ -35,26 +35,24 @@ import { LinkButton } from '../../../common/components/links'; const ID = 'alertsByCategoryOverview'; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; const DEFAULT_STACK_BY = 'event.module'; interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; hideHeaderChildren?: boolean; indexPattern: IIndexPattern; indexNames: string[]; - query?: Query; + query: Query; } const AlertsByCategoryComponent: React.FC = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, hideHeaderChildren = false, indexPattern, indexNames, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx index 44cb7a65dbc5e9..7e96ab8779304e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx @@ -21,11 +21,16 @@ describe('EventCounts', () => { const to = '2020-01-21T20:49:57.080Z'; const testProps = { + filters: [], from, indexNames: [], indexPattern: mockIndexPattern, setQuery: jest.fn(), to, + query: { + query: '', + language: 'kuery', + }, }; test('it filters the `Host events` widget with a `host.name` `exists` filter', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx index 6e47de68221c79..af3c7ecf1f36d9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { OverviewHost } from '../overview_host'; @@ -26,38 +26,52 @@ const HorizontalSpacer = styled(EuiFlexItem)` width: 24px; `; -const NO_FILTERS: Filter[] = []; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; - interface Props extends Pick { - filters?: Filter[]; + filters: Filter[]; indexNames: string[]; indexPattern: IIndexPattern; - query?: Query; + query: Query; } const EventCountsComponent: React.FC = ({ - filters = NO_FILTERS, + filters, from, indexNames, indexPattern, - query = DEFAULT_QUERY, + query, setQuery, to, }) => { - const kibana = useKibana(); + const { uiSettings } = useKibana().services; + + const hostFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterHostData], + }), + [filters, indexPattern, query, uiSettings] + ); + + const networkFilterQuery = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: [...filters, ...filterNetworkData], + }), + [filters, indexPattern, uiSettings, query] + ); return ( = ({ { combinedQueries?: string; - filters?: Filter[]; + filters: Filter[]; headerChildren?: React.ReactNode; indexPattern: IIndexPattern; indexNames: string[]; onlyField?: string; - query?: Query; + query: Query; setAbsoluteRangeDatePickerTarget?: InputsModelId; showSpacer?: boolean; timelineId?: string; @@ -63,13 +61,13 @@ const getHistogramOption = (fieldName: string): MatrixHistogramOption => ({ const EventsByDatasetComponent: React.FC = ({ combinedQueries, deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, indexPattern, indexNames, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget, setQuery, showSpacer = true, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx index f92f004bd2448b..a74d7af7140b7d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.tsx @@ -84,33 +84,38 @@ const OverviewHostComponent: React.FC = ({ [goToHost, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewHost) ? ( + + ) : ( + <>{''} + ), + [formattedHostEventsCount, hostEventsCount, overviewHost] + ); + return ( - - ) : ( - <>{''} - ) - } - title={ - - } - > + <>{hostPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 178a752d1286f1..fd4b7bbd386ba0 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -89,34 +89,39 @@ const OverviewNetworkComponent: React.FC = ({ [goToNetwork, formatUrl] ); + const title = useMemo( + () => ( + + ), + [] + ); + + const subtitle = useMemo( + () => + !isEmpty(overviewNetwork) ? ( + + ) : ( + <>{''} + ), + [formattedNetworkEventsCount, networkEventsCount, overviewNetwork] + ); + return ( <> - - ) : ( - <>{''} - ) - } - title={ - - } - > + {networkPageButton} diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 34722fd147a993..432ad0642be9d8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -11,19 +11,15 @@ import { AlertsHistogramPanel } from '../../../detections/components/alerts_hist import { alertsHistogramOptions } from '../../../detections/components/alerts_histogram_panel/config'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { Filter, IIndexPattern, Query } from '../../../../../../../src/plugins/data/public'; +import { Filter, Query } from '../../../../../../../src/plugins/data/public'; import { InputsModelId } from '../../../common/store/inputs/constants'; import * as i18n from '../../pages/translations'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - interface Props extends Pick { filters?: Filter[]; headerChildren?: React.ReactNode; - indexPattern: IIndexPattern; /** Override all defaults, and only display this field */ onlyField?: string; query?: Query; @@ -33,11 +29,11 @@ interface Props extends Pick = ({ deleteQuery, - filters = NO_FILTERS, + filters, from, headerChildren, onlyField, - query = DEFAULT_QUERY, + query, setAbsoluteRangeDatePickerTarget = 'global', setQuery, timelineId, diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 0f34734ebf861c..2e1a8d3a6e3768 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -6,7 +6,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useState, useMemo } from 'react'; -import { Query, Filter } from 'src/plugins/data/public'; import styled from 'styled-components'; import { AlertsByCategory } from '../components/alerts_by_category'; @@ -33,9 +32,6 @@ import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; -const DEFAULT_QUERY: Query = { query: '', language: 'kuery' }; -const NO_FILTERS: Filter[] = []; - const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; `; @@ -46,10 +42,8 @@ const OverviewComponent = () => { [] ); const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); - const query = useDeepEqualSelector((state) => getGlobalQuerySelector(state) ?? DEFAULT_QUERY); - const filters = useDeepEqualSelector( - (state) => getGlobalFiltersQuerySelector(state) ?? NO_FILTERS - ); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { from, deleteQuery, setQuery, to } = useGlobalTime(); const { indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); @@ -97,7 +91,6 @@ const OverviewComponent = () => { { }); }); + test('it returns the expected signal column settings', async () => { + const mockSelectedCategoryId = 'signal'; + const mockBrowserFieldsWithSignal = { + ...mockBrowserFields, + signal: { + fields: { + 'signal.rule.name': { + aggregatable: true, + category: 'signal', + description: 'rule name', + example: '', + format: '', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'signal.rule.name', + searchable: true, + type: 'string', + }, + }, + }, + }; + const toggleColumn = jest.fn(); + const wrapper = mount( + + + + ); + wrapper + .find(`[data-test-subj="field-signal.rule.name-checkbox"]`) + .last() + .simulate('change', { + target: { checked: true }, + }); + + await waitFor(() => { + expect(toggleColumn).toBeCalledWith({ + columnHeaderType: 'not-filtered', + id: 'signal.rule.name', + width: 180, + }); + }); + }); + test('it renders the expected icon for a field', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 0b086610da82af..749bc4b1f010de 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -11,6 +11,7 @@ import { isEmpty } from 'lodash/fp'; import styled from 'styled-components'; import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; import { timelineActions } from '../../../store/timeline'; @@ -26,6 +27,17 @@ interface ActiveTimelinesProps { isOpen: boolean; } +const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + > span { + padding: 0; + + > span { + display: flex; + flex-direction: row; + } + } +`; + const ActiveTimelinesComponent: React.FC = ({ timelineId, timelineType, @@ -46,16 +58,17 @@ const ActiveTimelinesComponent: React.FC = ({ : UNTITLED_TIMELINE; return ( - + - {title} - + {!isOpen && } + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index e09eedcd34dd19..368cb53eccc344 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -175,7 +175,7 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } return ( - {'Unsaved'} + {i18n.UNSAVED} ); @@ -198,7 +198,7 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts index ef9b88d65c551b..2633faf4e3e436 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/translations.ts @@ -13,6 +13,10 @@ export const CLOSE_TIMELINE = i18n.translate( } ); +export const UNSAVED = i18n.translate('xpack.securitySolution.timeline.properties.unsavedLabel', { + defaultMessage: 'Unsaved', +}); + export const AUTOSAVED = i18n.translate( 'xpack.securitySolution.timeline.properties.autosavedLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index b53c11868998f4..069f46c40e6af9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiHorizontalRule, EuiToolTip, + EuiLoadingSpinner, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; @@ -72,7 +73,7 @@ const NavigationComponent: React.FC = ({ timelineFullScreen, toggleFullScreen, }) => ( - + {i18n.CLOSE_ANALYZER} @@ -167,15 +168,17 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } /> - - - {graphEventId !== undefined && indices !== null && ( + {graphEventId !== undefined && indices !== null ? ( + ) : ( + + + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx deleted file mode 100644 index 32e10ac3eb77d1..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/columns.tsx +++ /dev/null @@ -1,44 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable react/display-name */ - -import React from 'react'; - -import { EuiTableDataType } from '@elastic/eui'; -import { NoteCard } from './note_card'; -import * as i18n from './translations'; - -const Column = React.memo<{ text: string }>(({ text }) => {text}); -Column.displayName = 'Column'; - -interface Item { - created: Date; - note: string; - user: string; -} - -interface Column { - field: string; - dataType?: EuiTableDataType; - name: string; - sortable: boolean; - truncateText: boolean; - render: (value: string, item: Item) => JSX.Element; -} - -export const columns: Column[] = [ - { - field: 'note', - dataType: 'string', - name: i18n.NOTE, - sortable: true, - truncateText: false, - render: (_, { created, note, user }) => ( - - ), - }, -]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx deleted file mode 100644 index 1ba573c0ac6c3e..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ /dev/null @@ -1,119 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiInMemoryTable, - EuiInMemoryTableProps, - EuiModalBody, - EuiModalHeader, - EuiSpacer, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; - -import { Note } from '../../../common/lib/note'; - -import { AddNote } from './add_note'; -import { columns } from './columns'; -import { AssociateNote, NotesCount, search } from './helpers'; -import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; -import { timelineActions } from '../../store/timeline'; -import { appSelectors } from '../../../common/store/app'; -import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; - -interface Props { - associateNote: AssociateNote; - noteIds: string[]; - status: TimelineStatusLiteral; -} - -export const InMemoryTable: typeof EuiInMemoryTable & { displayName: string } = styled( - EuiInMemoryTable as React.ComponentType> -)` - & thead { - display: none; - } -` as any; // eslint-disable-line @typescript-eslint/no-explicit-any - -InMemoryTable.displayName = 'InMemoryTable'; - -/** A view for entering and reviewing notes */ -export const Notes = React.memo(({ associateNote, noteIds, status }) => { - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = status === TimelineStatus.immutable; - - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - return ( - <> - - - - - - {!isImmutable && ( - - )} - - - - - ); -}); - -Notes.displayName = 'Notes'; - -interface NotesTabContentPros { - noteIds: string[]; - timelineId: string; - timelineStatus: TimelineStatusLiteral; -} - -/** A view for entering and reviewing notes */ -export const NotesTabContent = React.memo( - ({ noteIds, timelineStatus, timelineId }) => { - const dispatch = useDispatch(); - const getNotesByIds = appSelectors.notesByIdsSelector(); - const [newNote, setNewNote] = useState(''); - const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); - - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); - - const associateNote = useCallback( - (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - [dispatch, timelineId] - ); - - return ( - <> - - - {!isImmutable && ( - - )} - - ); - } -); - -NotesTabContent.displayName = 'NotesTabContent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap deleted file mode 100644 index 58cf0ae1e9f8f1..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/__snapshots__/note_card_body.test.tsx.snap +++ /dev/null @@ -1,759 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NoteCardBody renders correctly against snapshot 1`] = ` - - - -`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx deleted file mode 100644 index 161671ed730f32..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.test.tsx +++ /dev/null @@ -1,40 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCard } from '.'; - -describe('NoteCard', () => { - const created = new Date(); - const rawNote = 'noteworthy'; - const user = 'elastic'; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('it renders a note card header', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-header"]').exists()).toEqual(true); - }); - - test('it renders a note card body', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="note-card-body"]').exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx deleted file mode 100644 index e02ebc2a25fd0c..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/index.tsx +++ /dev/null @@ -1,29 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import { NoteCardBody } from './note_card_body'; -import { NoteCardHeader } from './note_card_header'; - -const NoteCardContainer = styled(EuiPanel)` - width: 100%; -`; - -NoteCardContainer.displayName = 'NoteCardContainer'; - -export const NoteCard = React.memo<{ created: Date; rawNote: string; user: string }>( - ({ created, rawNote, user }) => ( - - - - - ) -); - -NoteCard.displayName = 'NoteCard'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx deleted file mode 100644 index 77f1375b7a3c0e..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, shallow } from 'enzyme'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; - -import { NoteCardBody } from './note_card_body'; - -describe('NoteCardBody', () => { - const markdownHeaderPrefix = '# '; // translates to an h1 in markdown - const noteText = 'This is a note'; - const rawNote = `${markdownHeaderPrefix} ${noteText}`; - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - - - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('it renders the text of the note in an h1', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('h1').first().text()).toEqual(noteText); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx deleted file mode 100644 index efda3737cd1772..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_body.tsx +++ /dev/null @@ -1,41 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { WithCopyToClipboard } from '../../../../common/lib/clipboard/with_copy_to_clipboard'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { WithHoverActions } from '../../../../common/components/with_hover_actions'; -import * as i18n from '../translations'; - -const BodyContainer = styled(EuiPanel)` - border: none; -`; - -BodyContainer.displayName = 'BodyContainer'; - -export const NoteCardBody = React.memo<{ rawNote: string }>(({ rawNote }) => { - const hoverContent = useMemo( - () => ( - - - - ), - [rawNote] - ); - - const render = useCallback(() => {rawNote}, [rawNote]); - - return ( - - - - ); -}); - -NoteCardBody.displayName = 'NoteCardBody'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx deleted file mode 100644 index 4fbb7ce3f46ebd..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.test.tsx +++ /dev/null @@ -1,53 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import * as i18n from '../translations'; - -import { NoteCardHeader } from './note_card_header'; - -describe('NoteCardHeader', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - - const date = moment('2019-02-19 06:21:00'); - - const user = 'elastic'; - - test('it renders an avatar containing the first letter of the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual(user[0]); - }); - - test('it renders the username', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="user"]').first().text()).toEqual(user); - }); - - test('it renders the expected action', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="action"]').first().text()).toEqual(i18n.ADDED_A_NOTE); - }); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx deleted file mode 100644 index e6aa0542df4b35..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_card_header.tsx +++ /dev/null @@ -1,51 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiAvatar, EuiPanel } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from '../translations'; - -import { NoteCreated } from './note_created'; - -const Action = styled.span` - margin-right: 5px; -`; - -Action.displayName = 'Action'; - -const Avatar = styled(EuiAvatar)` - margin-right: 5px; -`; - -Avatar.displayName = 'Avatar'; - -const HeaderContainer = styled.div` - align-items: center; - display: flex; - user-select: none; -`; - -HeaderContainer.displayName = 'HeaderContainer'; - -const User = styled.span` - font-weight: 700; - margin: 5px; -`; - -export const NoteCardHeader = React.memo<{ created: Date; user: string }>(({ created, user }) => ( - - - - {user} - {i18n.ADDED_A_NOTE} - - - -)); - -NoteCardHeader.displayName = 'NoteCardHeader'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx deleted file mode 100644 index 92d334a059ae9f..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import moment from 'moment-timezone'; -import React from 'react'; -import { mountWithIntl } from '@kbn/test/jest'; -import '../../../../common/mock/formatted_relative'; - -import { NoteCreated } from './note_created'; - -describe('NoteCreated', () => { - beforeEach(() => { - moment.tz.setDefault('UTC'); - }); - afterEach(() => { - moment.tz.setDefault('Browser'); - }); - - moment.locale('en'); - const date = moment('2019-02-19 06:21:00'); - - test('it renders a humanized date when the note was created', () => { - const wrapper = mountWithIntl(); - - expect(wrapper.find('[data-test-subj="note-created"]').first().exists()).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx deleted file mode 100644 index dc97373660bd1c..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_card/note_created.tsx +++ /dev/null @@ -1,27 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { LocalizedDateTooltip } from '../../../../common/components/localized_date_tooltip'; - -const NoteCreatedContainer = styled.span` - user-select: none; -`; - -NoteCreatedContainer.displayName = 'NoteCreatedContainer'; - -export const NoteCreated = React.memo<{ created: Date }>(({ created }) => ( - - - - - -)); - -NoteCreated.displayName = 'NoteCreated'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx index 8fd95feba60317..724f49e9bd481d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.test.tsx @@ -77,14 +77,9 @@ describe('NoteCards', () => { ); - expect( - wrapper - .find('[data-test-subj="note-card"]') - .find('[data-test-subj="note-card-body"]') - .find('.euiMarkdownFormat') - .first() - .text() - ).toEqual(getNotesByIds().abc.note); + expect(wrapper.find('.euiCommentEvent__body .euiMarkdownFormat').first().text()).toEqual( + getNotesByIds().abc.note + ); }); test('it shows controls for adding notes when showAddNote is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 4ce4de18518635..6c3fd2b50ae6a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -12,7 +12,8 @@ import { appSelectors } from '../../../../common/store'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { AddNote } from '../add_note'; import { AssociateNote } from '../helpers'; -import { NoteCard } from '../note_card'; +import { NotePreviews, NotePreviewsContainer } from '../../open_timeline/note_previews'; +import { TimelineResultNote } from '../../open_timeline/types'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -22,23 +23,17 @@ const NoteContainer = styled.div` `; NoteContainer.displayName = 'NoteContainer'; -interface NoteCardsCompProps { - children: React.ReactNode; -} const NoteCardsCompContainer = styled(EuiPanel)` border: none; background-color: transparent; box-shadow: none; + + &.euiPanel--plain { + background-color: transparent; + } `; NoteCardsCompContainer.displayName = 'NoteCardsCompContainer'; -const NoteCardsComp = React.memo(({ children }) => ( - - {children} - -)); -NoteCardsComp.displayName = 'NoteCardsComp'; - const NotesContainer = styled(EuiFlexGroup)` margin-bottom: 5px; `; @@ -56,7 +51,6 @@ export const NoteCards = React.memo( ({ associateNote, noteIds, showAddNote, toggleShowAddNote }) => { const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); const notesById = useDeepEqualSelector(getNotesByIds); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); const [newNote, setNewNote] = useState(''); const associateNoteAndToggleShow = useCallback( @@ -67,16 +61,26 @@ export const NoteCards = React.memo( [associateNote, toggleShowAddNote] ); + const notes: TimelineResultNote[] = useMemo( + () => + appSelectors.getNotes(notesById, noteIds).map((note) => ({ + savedObjectId: note.saveObjectId, + note: note.note, + noteId: note.id, + updated: (note.lastEdit ?? note.created).getTime(), + updatedBy: note.user, + })), + [notesById, noteIds] + ); + return ( - - {noteIds.length ? ( - - {items.map((note) => ( - - - - ))} - + + {notes.length ? ( + + + + + ) : null} {showAddNote ? ( @@ -89,7 +93,7 @@ export const NoteCards = React.memo( /> ) : null} - + ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts index 4827481c7c5f38..e92b918a525d05 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/translations.ts @@ -50,3 +50,7 @@ export const COPY_TO_CLIPBOARD = i18n.translate( defaultMessage: 'Copy to Clipboard', } ); + +export const CREATED_BY = i18n.translate('xpack.securitySolution.notes.createdByLabel', { + defaultMessage: 'Created by', +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 6c76da44c85578..61b0c004dcb9dc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -1502,11 +1502,13 @@ describe('helpers', () => { notes: [ { created: new Date('2020-03-26T14:35:56.356Z'), + eventId: null, id: 'note-id', lastEdit: new Date('2020-03-26T14:35:56.356Z'), note: 'I am a note', user: 'unknown', saveObjectId: 'note-id', + timelineId: null, version: undefined, }, ], diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 76eb9196e8c5c6..df12194e264de9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -462,6 +462,8 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli user: note.updatedBy || 'unknown', saveObjectId: note.noteId, version: note.version, + eventId: note.eventId ?? null, + timelineId: note.timelineId ?? null, })) : [], }) diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index 9ca5d0c7b438ae..ffff6af3f13517 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -568,16 +568,8 @@ describe('StatefulOpenTimeline', () => { wrapper.update(); wrapper.find('[data-test-subj="expand-notes"]').first().simulate('click'); - expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true); - expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true); - - expect( - wrapper - .find('[data-test-subj="note-previews-container"]') - .find('[data-test-subj="updated-by"]') - .first() - .text() - ).toEqual('elastic'); + expect(wrapper.find('.euiCommentEvent__headerUsername').exists()).toEqual(true); + expect(wrapper.find('.euiCommentEvent__headerUsername').first().text()).toEqual('elastic'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index d791e6ebe43663..18e276a0914b45 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -104,7 +104,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find('.euiCommentEvent__headerUsername').at(1).text()).toEqual('bob'); }); test('it filters-out null savedObjectIds', () => { @@ -135,7 +135,7 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); test('it filters-out undefined savedObjectIds', () => { @@ -165,6 +165,6 @@ describe('NotePreviews', () => { ); - expect(wrapper.find(`[data-test-subj="updated-by"]`).at(2).text()).toEqual('bob'); + expect(wrapper.find(`.euiCommentEvent__headerUsername`).at(2).text()).toEqual('bob'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx index 8c804dbe4b70d0..7efa16d8168e7d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.tsx @@ -5,46 +5,101 @@ */ import { uniqBy } from 'lodash/fp'; -import React from 'react'; +import { EuiAvatar, EuiButtonIcon, EuiCommentList } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useDispatch } from 'react-redux'; -import { NotePreview } from './note_preview'; import { TimelineResultNote } from '../types'; +import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; +import { timelineActions } from '../../../store/timeline'; +import * as i18n from './translations'; -const NotePreviewsContainer = styled.section` - padding: ${(props) => - `${props.theme.eui.euiSizeS} 0 ${props.theme.eui.euiSizeS} ${props.theme.eui.euiSizeXXL}`}; +export const NotePreviewsContainer = styled.section` + padding-top: ${({ theme }) => `${theme.eui.euiSizeS}`}; `; NotePreviewsContainer.displayName = 'NotePreviewsContainer'; +interface ToggleEventDetailsButtonProps { + eventId: string; + timelineId: string; +} + +const ToggleEventDetailsButtonComponent: React.FC = ({ + eventId, + timelineId, +}) => { + const dispatch = useDispatch(); + const handleClick = useCallback(() => { + dispatch( + timelineActions.toggleExpandedEvent({ + timelineId, + event: { + eventId, + // we don't store yet info about event index name in note + indexName: '', + }, + }) + ); + }, [dispatch, eventId, timelineId]); + + return ( + + ); +}; + +const ToggleEventDetailsButton = React.memo(ToggleEventDetailsButtonComponent); /** * Renders a preview of a note in the All / Open Timelines table */ -export const NotePreviews = React.memo<{ + +interface NotePreviewsProps { notes?: TimelineResultNote[] | null; -}>(({ notes }) => { + timelineId?: string; +} + +export const NotePreviews = React.memo(({ notes, timelineId }) => { + const notesList = useMemo( + () => + uniqBy('savedObjectId', notes).map((note) => ({ + 'data-test-subj': `note-preview-${note.savedObjectId}`, + username: defaultToEmptyTag(note.updatedBy), + event: 'added a comment', + timestamp: note.updated ? ( + + ) : ( + getEmptyValue() + ), + children: {note.note ?? ''}, + actions: + note.eventId && timelineId ? ( + + ) : null, + timelineIcon: ( + + ), + })), + [notes, timelineId] + ); + if (notes == null || notes.length === 0) { return null; } - const uniqueNotes = uniqBy('savedObjectId', notes); - - return ( - - {uniqueNotes.map(({ note, savedObjectId, updated, updatedBy }) => - savedObjectId != null ? ( - - ) : null - )} - - ); + return ; }); NotePreviews.displayName = 'NotePreviews'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx deleted file mode 100644 index 484b3e5a600154..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.test.tsx +++ /dev/null @@ -1,154 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import { mountWithIntl } from '@kbn/test/jest'; -import React from 'react'; -import { ThemeProvider } from 'styled-components'; -import '../../../../common/mock/formatted_relative'; - -import { getEmptyValue } from '../../../../common/components/empty_value'; -import { NotePreview } from './note_preview'; - -import * as i18n from '../translations'; - -describe('NotePreview', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); - - describe('Avatar', () => { - test('it renders an avatar with the expected initials when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('a'); - }); - - test('it renders an avatar with a "?" when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - - test('it renders an avatar with a "?" when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="avatar"]').first().text()).toEqual('?'); - }); - }); - - describe('UpdatedBy', () => { - test('it renders the username when updatedBy is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual('admin'); - }); - - test('it renders placeholder text when updatedBy is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - - test('it renders placeholder text when updatedBy is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated-by"]').first().text()).toEqual(getEmptyValue()); - }); - }); - - describe('Updated', () => { - const updated = 1553300753 * 1000; - - test('it is always prefixed by "Posted:"', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text().startsWith(i18n.POSTED)).toBe( - true - ); - }); - - test('it renders the relative date when updated is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(true); - }); - - test('it does NOT render the relative date when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it does NOT render the relative date when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="updated"]').first().exists()).toBe(false); - }); - - test('it renders placeholder text when updated is undefined', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - - test('it renders placeholder text when updated is null', () => { - const wrapper = mountWithIntl( - - - - ); - - expect(wrapper.find('[data-test-subj="posted"]').first().text()).toEqual( - `Posted: ${getEmptyValue()}` - ); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx deleted file mode 100644 index a8e7a2c465e0c4..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/note_preview.tsx +++ /dev/null @@ -1,69 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiAvatar, EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import React from 'react'; -import styled from 'styled-components'; - -import { getEmptyValue, defaultToEmptyTag } from '../../../../common/components/empty_value'; -import { FormattedDate } from '../../../../common/components/formatted_date'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import * as i18n from '../translations'; -import { TimelineResultNote } from '../types'; - -const NotePreviewGroup = styled.article` - & + & { - margin-top: ${(props) => props.theme.eui.euiSizeL}; - } -`; - -NotePreviewGroup.displayName = 'NotePreviewGroup'; - -const NotePreviewHeader = styled.header` - margin-bottom: ${(props) => props.theme.eui.euiSizeS}; -`; - -NotePreviewHeader.displayName = 'NotePreviewHeader'; - -/** - * Renders a preview of a note in the All / Open Timelines table - */ -export const NotePreview = React.memo>( - ({ note, updated, updatedBy }) => ( - - - - - - - - - -
    {defaultToEmptyTag(updatedBy)}
    -
    - - -

    - {i18n.POSTED}{' '} - {updated != null ? ( - }> - - - ) : ( - getEmptyValue() - )} -

    -
    -
    - {note ?? ''} -
    -
    -
    - ) -); - -NotePreview.displayName = 'NotePreview'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts new file mode 100644 index 00000000000000..9857e55e365700 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const TOGGLE_EXPAND_EVENT_DETAILS = i18n.translate( + 'xpack.securitySolution.timeline.toggleEventDetailsTitle', + { + defaultMessage: 'Expand event details', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index 3f391714bb0583..268c874de7d503 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -6,13 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const ALL_ACTIONS = i18n.translate( - 'xpack.securitySolution.open.timeline.allActionsTooltip', - { - defaultMessage: 'All actions', - } -); - export const BATCH_ACTIONS = i18n.translate( 'xpack.securitySolution.open.timeline.batchActionsTitle', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 4e7e99a5d3e493..1556cd65ddd0a7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -25,9 +25,11 @@ export interface FavoriteTimelineResult { } export interface TimelineResultNote { + eventId?: string | null; savedObjectId?: string | null; note?: string | null; noteId?: string | null; + timelineId?: string | null; updated?: number | null; updatedBy?: string | null; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 00cd5453e96699..2ded93377de934 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -19,16 +19,16 @@ import { EuiFlexItem, EuiInMemoryTable, } from '@elastic/eui'; -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { RowRendererId } from '../../../../common/types/timeline'; import { State } from '../../../common/store'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; - +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../store/timeline/actions'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; import { renderers } from './catalog'; -import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../../timelines/store/timeline/actions'; import { RowRenderersBrowser } from './row_renderers_browser'; import * as i18n from './translations'; @@ -78,16 +78,14 @@ interface StatefulRowRenderersBrowserProps { timelineId: string; } -const emptyExcludedRowRendererIds: RowRendererId[] = []; - const StatefulRowRenderersBrowserComponent: React.FC = ({ timelineId, }) => { const tableRef = useRef>(); const dispatch = useDispatch(); - const excludedRowRendererIds = useShallowEqualSelector( - (state: State) => - state.timeline.timelineById[timelineId]?.excludedRowRendererIds || emptyExcludedRowRendererIds + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const excludedRowRendererIds = useDeepEqualSelector( + (state: State) => (getTimeline(state, timelineId) ?? timelineDefaults).excludedRowRendererIds ); const [show, setShow] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx index 12cfbbc04222f0..fd4a7e91ddb795 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -12,7 +12,6 @@ import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; interface ActionIconItemProps { ariaLabel?: string; - id: string; width?: number; dataTestSubj?: string; content?: string; @@ -23,7 +22,6 @@ interface ActionIconItemProps { } const ActionIconItemComponent: React.FC = ({ - id, width = DEFAULT_ICON_BUTTON_WIDTH, dataTestSubj, content, @@ -33,7 +31,7 @@ const ActionIconItemComponent: React.FC = ({ onClick, children, }) => ( - + {children ?? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx index af8045bf624c37..3f9f680ee19138 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/add_note_icon_item.tsx @@ -6,38 +6,26 @@ import React from 'react'; -import { TimelineType, TimelineStatus } from '../../../../../../common/types/timeline'; -import { AssociateNote } from '../../../notes/helpers'; +import { TimelineType } from '../../../../../../common/types/timeline'; import * as i18n from '../translations'; import { NotesButton } from '../../properties/helpers'; import { ActionIconItem } from './action_icon_item'; interface AddEventNoteActionProps { - associateNote: AssociateNote; - noteIds: string[]; showNotes: boolean; - status: TimelineStatus; timelineType: TimelineType; toggleShowNotes: () => void; } const AddEventNoteActionComponent: React.FC = ({ - associateNote, - noteIds, showNotes, - status, timelineType, toggleShowNotes, }) => ( - + = ({ )} - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 66856f3bd6284c..54cb4d5d14462a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -278,6 +278,7 @@ export const ColumnHeadersComponent = ({ width={FIELD_BROWSER_WIDTH} /> + { - const origin = jest.requireActual('react-redux'); - return { - ...origin, - useSelector: jest.fn(), - }; -}); +jest.mock('../../../../../common/hooks/use_selector'); describe('EventColumnView', () => { - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); const props = { id: 'event-id', @@ -82,17 +76,14 @@ describe('EventColumnView', () => { }); test('it renders correct tooltip for NotesButton - timeline template', () => { - (useSelector as jest.Mock).mockReturnValue({ - ...mockTimelineModel, - timelineType: TimelineType.template, - }); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.template); const wrapper = mount(, { wrappingComponent: TestProviders }); expect(wrapper.find('[data-test-subj="add-note"]').prop('toolTip')).toEqual( i18n.NOTES_DISABLE_TOOLTIP ); - (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + (useShallowEqualSelector as jest.Mock).mockReturnValue(TimelineType.default); }); test('it does NOT render a pin button when isEventViewer is true', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index 584350f9f7b660..cbb7bb9d0c6a16 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -4,14 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pick } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; -import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector'; +import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; -import { AssociateNote } from '../../../notes/helpers'; import { OnPinEvent, OnRowSelected, OnUnPinEvent } from '../../events'; import { EventsTrData } from '../../styles'; import { Actions } from '../actions'; @@ -29,12 +27,13 @@ import { AddEventNoteAction } from '../actions/add_note_icon_item'; import { PinEventAction } from '../actions/pin_event_action'; import { inputsModel } from '../../../../../common/store'; import { TimelineId } from '../../../../../../common/types/timeline'; +import { timelineSelectors } from '../../../../store/timeline'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; import { AddToCaseAction } from '../../../../../cases/components/timeline_actions/add_to_case_action'; interface Props { id: string; actionsColumnWidth: number; - associateNote: AssociateNote; columnHeaders: ColumnHeaderOptions[]; columnRenderers: ColumnRenderer[]; data: TimelineNonEcsData[]; @@ -63,7 +62,6 @@ export const EventColumnView = React.memo( ({ id, actionsColumnWidth, - associateNote, columnHeaders, columnRenderers, data, @@ -85,8 +83,9 @@ export const EventColumnView = React.memo( timelineId, toggleShowNotes, }) => { - const { timelineType, status } = useDeepEqualSelector((state) => - pick(['timelineType', 'status'], state.timeline.timelineById[timelineId]) + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const timelineType = useShallowEqualSelector( + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).timelineType ); const handlePinClicked = useCallback( @@ -123,11 +122,8 @@ export const EventColumnView = React.memo( ? [ , ( />, ], [ - associateNote, data, ecsData, eventIdToNoteIds, @@ -174,7 +169,6 @@ export const EventColumnView = React.memo( refetch, onRuleChange, showNotes, - status, timelineId, timelineType, toggleShowNotes, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 3d3c87be42824f..917b4a4e7a7624 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -26,8 +26,9 @@ import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; import { inputsModel } from '../../../../../common/store'; -import { timelineActions } from '../../../../store/timeline'; +import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { activeTimeline } from '../../../../containers/active_timeline_context'; +import { timelineDefaults } from '../../../../store/timeline/defaults'; interface Props { actionsColumnWidth: number; @@ -77,8 +78,9 @@ const StatefulEventComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId].expandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent ); const divElement = useRef(null); @@ -112,13 +114,12 @@ const StatefulEventComponent: React.FC = ({ event: { eventId, indexName, - loading: false, }, }) ); if (timelineId === TimelineId.active) { - activeTimeline.toggleExpandedEvent({ eventId, indexName, loading: false }); + activeTimeline.toggleExpandedEvent({ eventId, indexName }); } }, [dispatch, event._id, event._index, timelineId]); @@ -155,7 +156,6 @@ const StatefulEventComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index 10518141ebb257..7f3d86af7ca8ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -14,3 +14,4 @@ export const RULE_REFERENCE_FIELD_NAME = 'rule.reference'; export const REFERENCE_URL_FIELD_NAME = 'reference.url'; export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name'; +export const SIGNAL_STATUS_FIELD_NAME = 'signal.status'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 5bd928021fa0bf..9c1169608ccaeb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -5,19 +5,15 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { isNumber, isString, isEmpty } from 'lodash/fp'; +import { isNumber, isEmpty } from 'lodash/fp'; import React from 'react'; import { DefaultDraggable } from '../../../../../common/components/draggables'; import { Bytes, BYTES_FORMAT } from './bytes'; import { Duration, EVENT_DURATION_FIELD_NAME } from '../../../duration'; -import { - getOrEmptyTagFromValue, - getEmptyTagValue, -} from '../../../../../common/components/empty_value'; +import { getOrEmptyTagFromValue } from '../../../../../common/components/empty_value'; import { FormattedDate } from '../../../../../common/components/formatted_date'; import { FormattedIp } from '../../../../components/formatted_ip'; -import { HostDetailsLink } from '../../../../../common/components/links'; import { Port, PORT_NAMES } from '../../../../../network/components/port'; import { TruncatableText } from '../../../../../common/components/truncatable_text'; @@ -31,9 +27,12 @@ import { SIGNAL_RULE_NAME_FIELD_NAME, REFERENCE_URL_FIELD_NAME, EVENT_URL_FIELD_NAME, + SIGNAL_STATUS_FIELD_NAME, GEO_FIELD_TYPE, } from './constants'; import { RenderRuleName, renderEventModule, renderUrl } from './formatted_field_helpers'; +import { RuleStatus } from './rule_status'; +import { HostName } from './host_name'; // simple black-list to prevent dragging and dropping fields such as message name const columnNamesNotDraggable = [MESSAGE_FIELD_NAME]; @@ -80,22 +79,7 @@ const FormattedFieldValueComponent: React.FC<{ ); } else if (fieldName === HOST_NAME_FIELD_NAME) { - const hostname = `${value}`; - - return isString(value) && hostname.length > 0 ? ( - - - {value} - - - ) : ( - getEmptyTagValue() - ); + return ; } else if (fieldFormat === BYTES_FORMAT) { return ( @@ -113,6 +97,10 @@ const FormattedFieldValueComponent: React.FC<{ ); } else if (fieldName === EVENT_MODULE_FIELD_NAME) { return renderEventModule({ contextId, eventId, fieldName, linkValue, truncate, value }); + } else if (fieldName === SIGNAL_STATUS_FIELD_NAME) { + return ( + + ); } else if ( [RULE_REFERENCE_FIELD_NAME, REFERENCE_URL_FIELD_NAME, EVENT_URL_FIELD_NAME].includes(fieldName) ) { @@ -142,7 +130,6 @@ const FormattedFieldValueComponent: React.FC<{ } else { const contentValue = getOrEmptyTagFromValue(value); const content = truncate ? {contentValue} : contentValue; - return ( = ({ {content} + ) : value != null ? ( + + {value} + ) : ( getEmptyTagValue() ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx new file mode 100644 index 00000000000000..fbac27095d4f18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { isString } from 'lodash/fp'; + +import { DefaultDraggable } from '../../../../../common/components/draggables'; +import { getEmptyTagValue } from '../../../../../common/components/empty_value'; +import { HostDetailsLink } from '../../../../../common/components/links'; +import { TruncatableText } from '../../../../../common/components/truncatable_text'; + +interface Props { + contextId: string; + eventId: string; + fieldName: string; + value: string | number | undefined | null; +} + +const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { + const hostname = `${value}`; + + return isString(value) && hostname.length > 0 ? ( + + + {value} + + + ) : ( + getEmptyTagValue() + ); +}; + +export const HostName = React.memo(HostNameComponent); +HostName.displayName = 'HostName'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx new file mode 100644 index 00000000000000..4dc6d3b2e8e8d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { getOr } from 'lodash/fp'; + +import styled from 'styled-components'; +import { DefaultDraggable } from '../../../../../common/components/draggables'; + +const mapping = { + open: 'primary', + 'in-progress': 'warning', + closed: 'default', +}; + +const StyledEuiBadge = styled(EuiBadge)` + text-transform: capitalize; +`; + +interface Props { + contextId: string; + eventId: string; + fieldName: string; + value: string | number | undefined | null; +} + +const RuleStatusComponent: React.FC = ({ contextId, eventId, fieldName, value }) => { + const color = useMemo(() => getOr('default', `${value}`, mapping), [value]); + return ( + + {value} + + ); +}; + +export const RuleStatus = React.memo(RuleStatusComponent); +RuleStatus.displayName = 'RuleStatus'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index c57002023b79de..c934f50ba0aec9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -73,17 +73,17 @@ export const EXPAND = i18n.translate( } ); -export const COLLAPSE = i18n.translate( - 'xpack.securitySolution.timeline.body.actions.collapseAriaLabel', +export const EXPAND_EVENT = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.expandEventTooltip', { - defaultMessage: 'Collapse', + defaultMessage: 'Expand event', } ); -export const COLLAPSE_EVENT = i18n.translate( - 'xpack.securitySolution.timeline.body.actions.collapseEventTooltip', +export const COLLAPSE = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.collapseAriaLabel', { - defaultMessage: 'Collapse event', + defaultMessage: 'Collapse', } ); @@ -93,3 +93,10 @@ export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( defaultMessage: 'Analyze event', } ); + +export const INVESTIGATE_IN_RESOLVER_DISABLED = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.investigateInResolverDisabledTooltip', + { + defaultMessage: 'This event cannot be analyzed since it has incompatible field mappings', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts index 58729f69402e12..ecd06faed7253a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/date_picker_lock/translations.ts @@ -10,7 +10,7 @@ export const LOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerTooltip', { defaultMessage: - 'Disable syncing of date/time range between the currently viewed page and your timeline', + 'Disable syncing of date/time range bteween the currently viewed page and your timeline', } ); @@ -25,27 +25,27 @@ export const UNLOCK_SYNC_MAIN_DATE_PICKER_TOOL_TIP = i18n.translate( export const LOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.lockedDatePickerLabel', { - defaultMessage: 'Date picker is locked to global date picker', + defaultMessage: 'Global date picker is locked to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_LABEL = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockedDatePickerLabel', { - defaultMessage: 'Date picker is NOT locked to global date picker', + defaultMessage: 'Global date picker NOT locked to timeline date picker', } ); export const LOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.lockDatePickerDescription', { - defaultMessage: 'Lock date picker to global date picker', + defaultMessage: 'Lock global date picker to timeline date picker', } ); export const UNLOCK_SYNC_MAIN_DATE_PICKER_ARIA = i18n.translate( 'xpack.securitySolution.timeline.properties.unlockDatePickerDescription', { - defaultMessage: 'Unlock date picker to global date picker', + defaultMessage: 'Unlock global date picker from timeline date picker', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx index ed9b20f7a5e2db..9895f4eda0e6cc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/event_details.tsx @@ -10,40 +10,66 @@ * you may not use this file except in compliance with the Elastic License. */ +import { some } from 'lodash/fp'; import { EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import React, { useMemo } from 'react'; import deepEqual from 'fast-deep-equal'; import { BrowserFields, DocValueFields } from '../../../common/containers/source'; import { ExpandableEvent, ExpandableEventTitle, + HandleOnEventClosed, } from '../../../timelines/components/timeline/expandable_event'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { useTimelineEventsDetails } from '../../containers/details'; +import { timelineSelectors } from '../../store/timeline'; +import { timelineDefaults } from '../../store/timeline/defaults'; interface EventDetailsProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; timelineId: string; + handleOnEventClosed?: HandleOnEventClosed; } const EventDetailsComponent: React.FC = ({ browserFields, docValueFields, timelineId, + handleOnEventClosed, }) => { + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const expandedEvent = useDeepEqualSelector( - (state) => state.timeline.timelineById[timelineId]?.expandedEvent + (state) => (getTimeline(state, timelineId) ?? timelineDefaults).expandedEvent + ); + + const [loading, detailsData] = useTimelineEventsDetails({ + docValueFields, + indexName: expandedEvent.indexName!, + eventId: expandedEvent.eventId!, + skip: !expandedEvent.eventId, + }); + + const isAlert = useMemo( + () => some({ category: 'signal', field: 'signal.rule.id' }, detailsData), + [detailsData] ); return ( <> - + @@ -55,5 +81,6 @@ export const EventDetails = React.memo( (prevProps, nextProps) => deepEqual(prevProps.browserFields, nextProps.browserFields) && deepEqual(prevProps.docValueFields, nextProps.docValueFields) && - prevProps.timelineId === nextProps.timelineId + prevProps.timelineId === nextProps.timelineId && + prevProps.handleOnEventClosed === nextProps.handleOnEventClosed ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index 77a37d8b9a9298..df8e84b4e2a789 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -5,45 +5,74 @@ */ import { find } from 'lodash/fp'; -import { EuiTextColor, EuiLoadingContent, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiTextColor, + EuiLoadingContent, + EuiTitle, + EuiSpacer, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; import { TimelineExpandedEvent } from '../../../../../common/types/timeline'; -import { BrowserFields, DocValueFields } from '../../../../common/containers/source'; +import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails, EventsViewType, View, } from '../../../../common/components/event_details/event_details'; import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; -import { useTimelineEventsDetails } from '../../../containers/details'; +import { LineClamp } from '../../../../common/components/line_clamp'; import * as i18n from './translations'; +export type HandleOnEventClosed = () => void; interface Props { browserFields: BrowserFields; - docValueFields: DocValueFields[]; + detailsData: TimelineEventsDetailsItem[] | null; event: TimelineExpandedEvent; + isAlert: boolean; + loading: boolean; timelineId: string; } -export const ExpandableEventTitle = React.memo(() => ( - -

    {i18n.EVENT_DETAILS}

    -
    -)); +interface ExpandableEventTitleProps { + isAlert: boolean; + loading: boolean; + handleOnEventClosed?: HandleOnEventClosed; +} + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +export const ExpandableEventTitle = React.memo( + ({ isAlert, loading, handleOnEventClosed }) => ( + + + + {!loading ?

    {isAlert ? i18n.ALERT_DETAILS : i18n.EVENT_DETAILS}

    : <>} +
    +
    + {handleOnEventClosed && ( + + + + )} +
    + ) +); ExpandableEventTitle.displayName = 'ExpandableEventTitle'; export const ExpandableEvent = React.memo( - ({ browserFields, docValueFields, event, timelineId }) => { - const [view, setView] = useState(EventsViewType.tableView); - - const [loading, detailsData] = useTimelineEventsDetails({ - docValueFields, - indexName: event.indexName!, - eventId: event.eventId!, - skip: !event.eventId, - }); + ({ browserFields, event, timelineId, isAlert, loading, detailsData }) => { + const [view, setView] = useState(EventsViewType.summaryView); const message = useMemo(() => { if (detailsData) { @@ -52,7 +81,9 @@ export const ExpandableEvent = React.memo( | undefined; if (messageField?.originalValue) { - return messageField?.originalValue; + return Array.isArray(messageField?.originalValue) + ? messageField?.originalValue.join() + : messageField?.originalValue; } } return null; @@ -68,12 +99,22 @@ export const ExpandableEvent = React.memo( return ( <> - {message} - + {message && ( + <> + + {i18n.MESSAGE} + + + + + + + )} = ({ username }) => ( + + + + + + {username} + + +); + +const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); + +interface ParticipantsProps { + users: TimelineResultNote[]; +} + +const ParticipantsComponent: React.FC = ({ users }) => { + const List = useMemo( + () => + users.map((user) => ( + + + + + )), + [users] + ); + + if (!users.length) { + return null; + } + + return ( + <> + +

    {PARTICIPANTS}

    +
    + + {List} + + ); +}; + +ParticipantsComponent.displayName = 'ParticipantsComponent'; + +const Participants = React.memo(ParticipantsComponent); + interface NotesTabContentProps { timelineId: string; } @@ -48,37 +122,77 @@ interface NotesTabContentProps { const NotesTabContentComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { status: timelineStatus, noteIds } = useDeepEqualSelector((state) => - pick(['noteIds', 'status'], getTimeline(state, timelineId) ?? timelineDefaults) + const { createdBy, expandedEvent, status: timelineStatus } = useDeepEqualSelector((state) => + pick( + ['createdBy', 'expandedEvent', 'status'], + getTimeline(state, timelineId) ?? timelineDefaults + ) ); - const getNotesByIds = useMemo(() => appSelectors.notesByIdsSelector(), []); + const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.timeline); + + const getNotesAsCommentsList = useMemo( + () => appSelectors.selectNotesAsCommentsListSelector(), + [] + ); const [newNote, setNewNote] = useState(''); const isImmutable = timelineStatus === TimelineStatus.immutable; - const notesById = useDeepEqualSelector(getNotesByIds); + const notes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); - const items = useMemo(() => appSelectors.getNotes(notesById, noteIds), [notesById, noteIds]); + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); const associateNote = useCallback( (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), [dispatch, timelineId] ); + const handleOnEventClosed = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + }, [dispatch, timelineId]); + + const EventDetailsContent = useMemo( + () => + expandedEvent.eventId ? ( + + ) : null, + [browserFields, docValueFields, expandedEvent.eventId, handleOnEventClosed, timelineId] + ); + + const SidebarContent = useMemo( + () => ( + <> + {createdBy && ( + <> + + +

    {CREATED_BY}

    +
    + + + + + )} + + + ), + [createdBy, participants] + ); + return ( - + -

    {'Notes'}

    +

    {NOTES}

    - + {!isImmutable && ( @@ -86,7 +200,7 @@ const NotesTabContentComponent: React.FC = ({ timelineId }
    - {/* SIDEBAR PLACEHOLDER */} + {EventDetailsContent ?? SidebarContent}
    ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 494b3cefba6f13..673efa1857cb83 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -9,11 +9,6 @@ import { EuiButton, EuiButtonIcon, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiModal, - EuiOverlayMask, EuiToolTip, EuiTextArea, } from '@elastic/eui'; @@ -22,22 +17,14 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; -import { - TimelineTypeLiteral, - TimelineType, - TimelineStatusLiteral, -} from '../../../../../common/types/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { useDeepEqualSelector, useShallowEqualSelector, } from '../../../../common/hooks/use_selector'; -import { Notes } from '../../notes'; -import { AssociateNote } from '../../notes/helpers'; - -import { NOTES_PANEL_WIDTH } from './notes_size'; -import { ButtonContainer, DescriptionContainer, LabelText, NameField, NameWrapper } from './styles'; +import { DescriptionContainer, NameField, NameWrapper } from './styles'; import * as i18n from './translations'; import { TimelineInput } from '../../../store/timeline/actions'; import { useCreateTimelineButton } from './use_create_timeline'; @@ -259,43 +246,12 @@ export const NewTimeline = React.memo( NewTimeline.displayName = 'NewTimeline'; interface NotesButtonProps { - animate?: boolean; - associateNote: AssociateNote; - noteIds: string[]; - size: 's' | 'l'; - status: TimelineStatusLiteral; showNotes: boolean; toggleShowNotes: () => void; - text?: string; toolTip?: string; timelineType: TimelineTypeLiteral; } -interface LargeNotesButtonProps { - noteIds: string[]; - text?: string; - toggleShowNotes: () => void; -} - -const LargeNotesButton = React.memo(({ noteIds, text, toggleShowNotes }) => ( - - - - - - - {text && text.length ? {text} : null} - - - - {noteIds.length} - - - - -)); -LargeNotesButton.displayName = 'LargeNotesButton'; - interface SmallNotesButtonProps { toggleShowNotes: () => void; timelineType: TimelineTypeLiteral; @@ -316,83 +272,13 @@ const SmallNotesButton = React.memo(({ toggleShowNotes, t }); SmallNotesButton.displayName = 'SmallNotesButton'; -/** - * The internal implementation of the `NotesButton` - */ -const NotesButtonComponent = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - toggleShowNotes, - text, - timelineType, - }) => ( - - <> - {size === 'l' ? ( - - ) : ( - - )} - {size === 'l' && showNotes ? ( - - - - - - ) : null} - - - ) -); -NotesButtonComponent.displayName = 'NotesButtonComponent'; - export const NotesButton = React.memo( - ({ - animate = true, - associateNote, - noteIds, - showNotes, - size, - status, - timelineType, - toggleShowNotes, - toolTip, - text, - }) => + ({ showNotes, timelineType, toggleShowNotes, toolTip }) => showNotes ? ( - + ) : ( - + ) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx index 7dc5b8601955ae..c1f9b18f05c609 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/styles.tsx @@ -5,12 +5,7 @@ */ import { EuiFieldText } from '@elastic/eui'; -import styled, { keyframes } from 'styled-components'; - -const fadeInEffect = keyframes` - from { opacity: 0; } - to { opacity: 1; } -`; +import styled from 'styled-components'; export const NameField = styled(EuiFieldText)` .euiToolTipAnchor { @@ -33,11 +28,6 @@ export const DescriptionContainer = styled.div` `; DescriptionContainer.displayName = 'DescriptionContainer'; -export const ButtonContainer = styled.div<{ animate: boolean }>` - animation: ${fadeInEffect} ${({ animate }) => (animate ? '0.3s' : '0s')}; -`; -ButtonContainer.displayName = 'ButtonContainer'; - export const LabelText = styled.div` margin-left: 10px; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx index 10b505da5c76fd..ddf180f7d2286b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx @@ -145,6 +145,9 @@ describe('useCreateTimelineButton', () => { 'x-pack/security_solution/local/inputs/ADD_TIMELINE_LINK_TO' ); expect(mockDispatch.mock.calls[4][0].type).toEqual( + 'x-pack/security_solution/local/app/ADD_NOTE' + ); + expect(mockDispatch.mock.calls[5][0].type).toEqual( 'x-pack/security_solution/local/inputs/SET_RELATIVE_RANGE_DATE_PICKER' ); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index 4043ceeb85b7e3..7fab0374d791d1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -19,6 +19,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { inputsActions, inputsSelectors } from '../../../../common/store/inputs'; import { sourcererActions, sourcererSelectors } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; +import { appActions } from '../../../../common/store/app'; interface Props { timelineId?: string; @@ -57,6 +58,7 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P ); dispatch(inputsActions.addGlobalLinkTo({ linkToId: 'timeline' })); dispatch(inputsActions.addTimelineLinkTo({ linkToId: 'global' })); + dispatch(appActions.addNotes({ notes: [] })); if (globalTimeRange.kind === 'absolute') { dispatch( inputsActions.setAbsoluteRangeDatePicker({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index c9355797193a09..20f5b61457e9f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -261,6 +261,7 @@ In other use cases the message field can be used to concatenate different values } end="2018-03-24T03:33:52.253Z" eventType="all" + expandedEvent={Object {}} filters={Array []} isLive={false} itemsPerPage={5} @@ -273,6 +274,7 @@ In other use cases the message field can be used to concatenate different values } kqlMode="search" kqlQueryExpression="" + onEventClosed={[MockFunction]} showCallOutUnauthorizedMsg={false} showEventDetails={false} sort={ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 7e60461a015744..962e09d1a6237e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -94,6 +94,7 @@ describe('Timeline', () => { columns: defaultHeaders, dataProviders: mockDataProviders, end: endDate, + expandedEvent: {}, eventType: 'all', showEventDetails: false, filters: [], @@ -103,6 +104,7 @@ describe('Timeline', () => { itemsPerPageOptions: [5, 10, 20], kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], kqlQueryExpression: '', + onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, sort, start: startDate, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 69a7299b9833de..8da3c257a5db8c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -12,13 +12,15 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiSpacer, + EuiBadge, } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import styled from 'styled-components'; import { Dispatch } from 'redux'; import { connect, ConnectedProps } from 'react-redux'; import deepEqual from 'fast-deep-equal'; +import { InPortal } from 'react-reverse-portal'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { Direction } from '../../../../../common/search_strategy'; @@ -32,7 +34,7 @@ import { combineQueries } from '../helpers'; import { TimelineRefetch } from '../refetch_timeline'; import { esQuery, FilterManager } from '../../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../../manage_timeline'; -import { TimelineEventsType } from '../../../../../common/types/timeline'; +import { TimelineEventsType, TimelineId } from '../../../../../common/types/timeline'; import { requiredFieldsForActions } from '../../../../detections/components/alerts_table/default_config'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; @@ -42,9 +44,12 @@ import { sourcererActions } from '../../../../common/store/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { timelineDefaults } from '../../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../../common/containers/sourcerer'; +import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timeline_events_count'; import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; +import { activeTimeline } from '../../../containers/active_timeline_context'; +import { ToggleExpandedEvent } from '../../../store/timeline/actions'; const TimelineHeaderContainer = styled.div` margin-top: 6px; @@ -125,6 +130,10 @@ const StyledEuiTabbedContent = styled(EuiTabbedContent)` StyledEuiTabbedContent.displayName = 'StyledEuiTabbedContent'; +const EventsCountBadge = styled(EuiBadge)` + margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; +`; + const isTimerangeSame = (prevProps: Props, nextProps: Props) => prevProps.end === nextProps.end && prevProps.start === nextProps.start && @@ -141,6 +150,7 @@ export const QueryTabContentComponent: React.FC = ({ dataProviders, end, eventType, + expandedEvent, filters, timelineId, isLive, @@ -148,6 +158,7 @@ export const QueryTabContentComponent: React.FC = ({ itemsPerPageOptions, kqlMode, kqlQueryExpression, + onEventClosed, showCallOutUnauthorizedMsg, showEventDetails, start, @@ -156,18 +167,7 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, updateEventTypeAndIndexesName, }) => { - const [showEventDetailsColumn, setShowEventDetailsColumn] = useState(false); - - useEffect(() => { - // it should changed only once to true and then stay visible till the component umount - setShowEventDetailsColumn((current) => { - if (showEventDetails && !current) { - return true; - } - return current; - }); - }, [showEventDetails]); - + const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); const { browserFields, docValueFields, @@ -182,6 +182,10 @@ export const QueryTabContentComponent: React.FC = ({ const kqlQuery = useMemo(() => ({ query: kqlQueryExpression, language: 'kuery' }), [ kqlQueryExpression, ]); + + const prevCombinedQueries = useRef<{ + filterQuery: string; + } | null>(null); const combinedQueries = useMemo( () => combineQueries({ @@ -247,12 +251,33 @@ export const QueryTabContentComponent: React.FC = ({ timerangeKind, }); + const handleOnEventClosed = useCallback(() => { + onEventClosed({ timelineId }); + + if (timelineId === TimelineId.active) { + activeTimeline.toggleExpandedEvent({ + eventId: expandedEvent.eventId!, + indexName: expandedEvent.indexName!, + }); + } + }, [timelineId, onEventClosed, expandedEvent.eventId, expandedEvent.indexName]); + useEffect(() => { setIsTimelineLoading({ id: timelineId, isLoading: isQueryLoading || loadingSourcerer }); }, [loadingSourcerer, timelineId, isQueryLoading, setIsTimelineLoading]); + useEffect(() => { + if (!deepEqual(prevCombinedQueries.current, combinedQueries)) { + prevCombinedQueries.current = combinedQueries; + handleOnEventClosed(); + } + }, [combinedQueries, handleOnEventClosed]); + return ( <> + + {totalCount >= 0 ? {totalCount} : null} + = ({ ) : null} - {showEventDetailsColumn && ( + {showEventDetails && ( <> @@ -332,6 +357,7 @@ export const QueryTabContentComponent: React.FC = ({ browserFields={browserFields} docValueFields={docValueFields} timelineId={timelineId} + handleOnEventClosed={handleOnEventClosed} /> @@ -375,6 +401,7 @@ const makeMapStateToProps = () => { dataProviders, eventType: eventType ?? 'raw', end: input.timerange.to, + expandedEvent, filters: timelineFilter, timelineId, isLive: input.policy.kind === 'interval', @@ -404,6 +431,9 @@ const mapDispatchToProps = (dispatch: Dispatch, { timelineId }: OwnProps) => ({ }) ); }, + onEventClosed: (args: ToggleExpandedEvent) => { + dispatch(timelineActions.toggleExpandedEvent(args)); + }, }); const connector = connect(makeMapStateToProps, mapDispatchToProps); @@ -420,6 +450,7 @@ const QueryTabContent = connector( prevProps.itemsPerPage === nextProps.itemsPerPage && prevProps.kqlMode === nextProps.kqlMode && prevProps.kqlQueryExpression === nextProps.kqlQueryExpression && + prevProps.onEventClosed === nextProps.onEventClosed && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && prevProps.showEventDetails === nextProps.showEventDetails && prevProps.status === nextProps.status && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index ef7c821bd652d3..d65458046de45a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -243,7 +243,7 @@ export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({ }))<{ className: string }>` font-size: ${({ theme }) => theme.eui.euiFontSizeXS}; line-height: ${({ theme }) => theme.eui.euiLineHeight}; - padding: 0 ${({ theme }) => theme.eui.paddingSizes.m}; + padding-left: ${({ theme }) => theme.eui.paddingSizes.m}; .euiAccordion + div { background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; padding: 0 ${({ theme }) => theme.eui.paddingSizes.s}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx index c9c2b1b1c2af92..7ffe661e785177 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs_content/index.tsx @@ -10,9 +10,10 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { useShallowEqualSelector } from '../../../../common/hooks/use_selector'; +import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { timelineActions } from '../../../store/timeline'; import { TimelineTabs } from '../../../store/timeline/model'; -import { getActiveTabSelector } from './selectors'; +import { getActiveTabSelector, getShowTimelineSelector } from './selectors'; import * as i18n from './translations'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean }>(({ $isVisible = false }) => ({ @@ -99,18 +100,35 @@ const ActiveTimelineTab = memo(({ activeTimelineTab, tim ActiveTimelineTab.displayName = 'ActiveTimelineTab'; +const StyledEuiTab = styled(EuiTab)` + > span { + display: flex; + flex-direction: row; + white-space: pre; + } + + :focus { + text-decoration: none; + + > span > span { + text-decoration: underline; + } + } +`; + const TabsContentComponent: React.FC = ({ timelineId, graphEventId }) => { const dispatch = useDispatch(); const getActiveTab = useMemo(() => getActiveTabSelector(), []); + const getShowTimeline = useMemo(() => getShowTimelineSelector(), []); const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); + const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); - const setQueryAsActiveTab = useCallback( - () => - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) - ), - [dispatch, timelineId] - ); + const setQueryAsActiveTab = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.query }) + ); + }, [dispatch, timelineId]); const setGraphAsActiveTab = useCallback( () => @@ -120,13 +138,12 @@ const TabsContentComponent: React.FC = ({ timelineId, graphEve [dispatch, timelineId] ); - const setNotesAsActiveTab = useCallback( - () => - dispatch( - timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) - ), - [dispatch, timelineId] - ); + const setNotesAsActiveTab = useCallback(() => { + dispatch(timelineActions.toggleExpandedEvent({ timelineId })); + dispatch( + timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: TimelineTabs.notes }) + ); + }, [dispatch, timelineId]); const setPinnedAsActiveTab = useCallback( () => @@ -145,15 +162,16 @@ const TabsContentComponent: React.FC = ({ timelineId, graphEve return ( <> - - {i18n.QUERY_TAB} - + {i18n.QUERY_TAB} + {showTimeline && } + createSelector(selectTimeline, (timeline) => timeline?.activeTab ?? TimelineTabs.query); + +export const getShowTimelineSelector = () => + createSelector(selectTimeline, (timeline) => timeline?.show ?? false); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index 3baab2024558f3..9f5aeea695beba 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -5,7 +5,7 @@ */ import deepEqual from 'fast-deep-equal'; -import { noop } from 'lodash/fp'; +import { isEmpty, noop } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; @@ -72,14 +72,6 @@ export const initSortDefault = [ }, ]; -function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) { - const ref = useRef(value); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - export const useTimelineEvents = ({ docValueFields, endDate, @@ -105,7 +97,7 @@ export const useTimelineEvents = ({ const [timelineRequest, setTimelineRequest] = useState( null ); - const prevTimelineRequest = usePreviousRequest(timelineRequest); + const prevTimelineRequest = useRef(null); const clearSignalsState = useCallback(() => { if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) { @@ -159,6 +151,7 @@ export const useTimelineEvents = ({ } let didCancel = false; const asyncSearch = async () => { + prevTimelineRequest.current = request; abortCtrl.current = new AbortController(); setLoading(true); const searchSubscription$ = data.search @@ -223,6 +216,7 @@ export const useTimelineEvents = ({ abortCtrl.current.abort(); setLoading(false); + prevTimelineRequest.current = activeTimeline.getRequest(); refetch.current = asyncSearch.bind(null, activeTimeline.getRequest()); setTimelineResponse((prevResp) => { const resp = activeTimeline.getResponse(); @@ -331,9 +325,35 @@ export const useTimelineEvents = ({ id !== TimelineId.active || timerangeKind === 'absolute' || !deepEqual(prevTimelineRequest, timelineRequest) - ) + ) { timelineSearch(timelineRequest); + } }, [id, prevTimelineRequest, timelineRequest, timelineSearch, timerangeKind]); + /* + cleanup timeline events response when the filters were removed completely + to avoid displaying previous query results + */ + useEffect(() => { + if (isEmpty(filterQuery)) { + setTimelineResponse({ + id, + inspect: { + dsl: [], + response: [], + }, + refetch: refetchGrid, + totalCount: -1, + pageInfo: { + activePage: 0, + querySize: 0, + }, + events: [], + loadPage: wrappedLoadPage, + updatedAt: 0, + }); + } + }, [filterQuery, id, refetchGrid, wrappedLoadPage]); + return [loading, timelineResponse]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 479c289cdd21d3..b0a0a7e6abe343 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -35,9 +35,9 @@ export const addNoteToEvent = actionCreator<{ id: string; noteId: string; eventI 'ADD_NOTE_TO_EVENT' ); -interface ToggleExpandedEvent { +export interface ToggleExpandedEvent { timelineId: string; - event: TimelineExpandedEvent; + event?: TimelineExpandedEvent; } export const toggleExpandedEvent = actionCreator('TOGGLE_EXPANDED_EVENT'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 5fcbcf434d3eef..95a916a6858ef2 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -80,12 +80,14 @@ describe('epicLocalStorage', () => { dataProviders: mockDataProviders, end: endDate, eventType: 'all', + expandedEvent: {}, filters: [], isLive: false, itemsPerPage: 5, itemsPerPageOptions: [5, 10, 20], kqlMode: 'search' as QueryTabContentComponentProps['kqlMode'], kqlQueryExpression: '', + onEventClosed: jest.fn(), showCallOutUnauthorizedMsg: false, showEventDetails: false, start: startDate, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index 9c71fabfffac58..79d7460c7e1175 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -55,6 +55,8 @@ export interface TimelineModel { activeTab: TimelineTabs; /** The columns displayed in the timeline */ columns: ColumnHeaderOptions[]; + /** Timeline saved object owner */ + createdBy?: string; /** The sources of the event data shown in the timeline */ dataProviders: DataProvider[]; /** Events to not be rendered **/ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index daf57505b6bafe..a92a976697eaaf 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -177,7 +177,7 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) ...state, timelineById: addTimelineNoteToEvent({ id, noteId, eventId, timelineById: state.timelineById }), })) - .case(toggleExpandedEvent, (state, { timelineId, event }) => ({ + .case(toggleExpandedEvent, (state, { timelineId, event = {} }) => ({ ...state, timelineById: { ...state.timelineById, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts index e379caba323cae..f6386b30d112eb 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/selectors.ts @@ -41,8 +41,6 @@ export const getTimelines = () => timelineByIdSelector; export const getTimelineByIdSelector = () => createSelector(selectTimeline, (timeline) => timeline); -export const getEventsByIdSelector = () => createSelector(selectTimeline, (timeline) => timeline); - export const getKqlFilterQuerySelector = () => createSelector(selectTimeline, (timeline) => timeline && diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 58e2ea6111a38e..69d986707adb85 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -18,7 +18,10 @@ import { PackagePolicyServiceInterface, } from '../../../fleet/server'; import { PluginStartContract as AlertsPluginStartContract } from '../../../alerts/server'; -import { getPackagePolicyCreateCallback } from './ingest_integration'; +import { + getPackagePolicyCreateCallback, + getPackagePolicyUpdateCallback, +} from './ingest_integration'; import { ManifestManager } from './services/artifacts'; import { MetadataQueryStrategy } from './types'; import { MetadataQueryStrategyVersions } from '../../common/endpoint/types'; @@ -30,6 +33,7 @@ import { ElasticsearchAssetType } from '../../../fleet/common/types/models'; import { metadataTransformPrefix } from '../../common/endpoint/constants'; import { AppClientFactory } from '../client'; import { ConfigType } from '../config'; +import { LicenseService } from '../../common/license/license'; export interface MetadataService { queryStrategy( @@ -85,6 +89,7 @@ export type EndpointAppContextServiceStartContract = Partial< config: ConfigType; registerIngestCallback?: FleetStartContract['registerExternalCallback']; savedObjectsStart: SavedObjectsServiceStart; + licenseService: LicenseService; }; /** @@ -119,6 +124,11 @@ export class EndpointAppContextService { dependencies.alerts ) ); + + dependencies.registerIngestCallback( + 'packagePolicyUpdate', + getPackagePolicyUpdateCallback(dependencies.logger, dependencies.licenseService) + ); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts index 321d67a441f6dc..c0f95b5bce61fa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts @@ -11,22 +11,43 @@ import { getManifestManagerMock, ManifestManagerMockType, } from './services/artifacts/manifest_manager/manifest_manager.mock'; -import { getPackagePolicyCreateCallback } from './ingest_integration'; +import { + getPackagePolicyCreateCallback, + getPackagePolicyUpdateCallback, +} from './ingest_integration'; import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { createMockConfig, requestContextMock } from '../lib/detection_engine/routes/__mocks__'; import { EndpointAppContextServiceStartContract } from './endpoint_app_context_services'; import { createMockEndpointAppContextServiceStartContract } from './mocks'; +import { licenseMock } from '../../../licensing/common/licensing.mock'; +import { LicenseService } from '../../common/license/license'; +import { Subject } from 'rxjs'; +import { ILicense } from '../../../licensing/common/types'; +import { EndpointDocGenerator } from '../../common/endpoint/generate_data'; +import { ProtectionModes } from '../../common/endpoint/types'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; let req: KibanaRequest; let ctx: RequestHandlerContext; const maxTimelineImportExportSize = createMockConfig().maxTimelineImportExportSize; + let licenseEmitter: Subject; + let licenseService: LicenseService; + const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); + const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } }); + const generator = new EndpointDocGenerator(); beforeEach(() => { endpointAppContextMock = createMockEndpointAppContextServiceStartContract(); ctx = requestContextMock.createTools().context; req = httpServerMock.createKibanaRequest(); + licenseEmitter = new Subject(); + licenseService = new LicenseService(); + licenseService.start(licenseEmitter); + }); + afterEach(() => { + licenseService.stop(); + licenseEmitter.complete(); }); describe('ingest_integration sanity checks', () => { @@ -179,4 +200,45 @@ describe('ingest_integration tests ', () => { ); }); }); + describe('when the license is below platinum', () => { + beforeEach(() => { + licenseEmitter.next(Gold); // set license level to gold + }); + it('returns an error if paid features are turned on in the policy', async () => { + const mockPolicy = policyConfigFactory(); + mockPolicy.windows.popup.malware.message = 'paid feature'; + const logger = loggingSystemMock.create().get('ingest_integration.test'); + const callback = getPackagePolicyUpdateCallback(logger, licenseService); + const policyConfig = generator.generatePolicyPackagePolicy(); + policyConfig.inputs[0]!.config!.policy.value = mockPolicy; + await expect(() => callback(policyConfig, ctx, req)).rejects.toThrow( + 'Requires Platinum license' + ); + }); + it('updates successfully if no paid features are turned on in the policy', async () => { + const mockPolicy = policyConfigFactory(); + mockPolicy.windows.malware.mode = ProtectionModes.detect; + const logger = loggingSystemMock.create().get('ingest_integration.test'); + const callback = getPackagePolicyUpdateCallback(logger, licenseService); + const policyConfig = generator.generatePolicyPackagePolicy(); + policyConfig.inputs[0]!.config!.policy.value = mockPolicy; + const updatedPolicyConfig = await callback(policyConfig, ctx, req); + expect(updatedPolicyConfig.inputs[0]!.config!.policy.value).toEqual(mockPolicy); + }); + }); + describe('when the license is at least platinum', () => { + beforeEach(() => { + licenseEmitter.next(Platinum); // set license level to platinum + }); + it('updates successfully when paid features are turned on', async () => { + const mockPolicy = policyConfigFactory(); + mockPolicy.windows.popup.malware.message = 'paid feature'; + const logger = loggingSystemMock.create().get('ingest_integration.test'); + const callback = getPackagePolicyUpdateCallback(logger, licenseService); + const policyConfig = generator.generatePolicyPackagePolicy(); + policyConfig.inputs[0]!.config!.policy.value = mockPolicy; + const updatedPolicyConfig = await callback(policyConfig, ctx, req); + expect(updatedPolicyConfig.inputs[0]!.config!.policy.value).toEqual(mockPolicy); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 3c94bdc63b0fb8..124f3005d38235 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -8,7 +8,7 @@ import { PluginStartContract as AlertsStartContract } from '../../../alerts/serv import { SecurityPluginSetup } from '../../../security/server'; import { ExternalCallback } from '../../../fleet/server'; import { KibanaRequest, Logger, RequestHandlerContext } from '../../../../../src/core/server'; -import { NewPackagePolicy } from '../../../fleet/common/types/models'; +import { NewPackagePolicy, UpdatePackagePolicy } from '../../../fleet/common/types/models'; import { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; import { NewPolicyData } from '../../common/endpoint/types'; import { ManifestManager } from './services/artifacts'; @@ -20,6 +20,8 @@ import { AppClientFactory } from '../client'; import { createDetectionIndex } from '../lib/detection_engine/routes/index/create_index_route'; import { createPrepackagedRules } from '../lib/detection_engine/routes/rules/add_prepackaged_rules_route'; import { buildFrameworkRequest } from '../lib/timeline/routes/utils/common'; +import { isEndpointPolicyValidForLicense } from '../../common/license/policy_config'; +import { LicenseService } from '../../common/license/license'; const getManifest = async (logger: Logger, manifestManager: ManifestManager): Promise => { let manifest: Manifest | null = null; @@ -164,3 +166,32 @@ export const getPackagePolicyCreateCallback = ( return handlePackagePolicyCreate; }; + +export const getPackagePolicyUpdateCallback = ( + logger: Logger, + licenseService: LicenseService +): ExternalCallback[1] => { + const handlePackagePolicyUpdate = async ( + newPackagePolicy: NewPackagePolicy, + context: RequestHandlerContext, + request: KibanaRequest + ): Promise => { + if (newPackagePolicy.package?.name !== 'endpoint') { + return newPackagePolicy; + } + + if ( + !isEndpointPolicyValidForLicense( + newPackagePolicy.inputs[0].config?.policy?.value, + licenseService.getLicenseInformation() + ) + ) { + logger.warn('Incorrect license tier for paid policy fields'); + const licenseError: Error & { statusCode?: number } = new Error('Requires Platinum license'); + licenseError.statusCode = 403; + throw licenseError; + } + return newPackagePolicy; + }; + return handlePackagePolicyUpdate; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 61264a8848f61a..c771187bc22346 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -25,6 +25,8 @@ import { ManifestManager } from './services/artifacts/manifest_manager/manifest_ import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { EndpointAppContext } from './types'; import { MetadataRequestContext } from './routes/metadata/handlers'; +// import { licenseMock } from '../../../licensing/common/licensing.mock'; +import { LicenseService } from '../../common/license/license'; /** * Creates a mocked EndpointAppContext. @@ -72,6 +74,7 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< security: securityMock.createSetup(), alerts: alertsMock.createStart(), config, + licenseService: new LicenseService(), registerIngestCallback: jest.fn< ReturnType, Parameters diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts index 8f6826cec5365f..5731a51aeabc19 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts @@ -31,5 +31,6 @@ export const createNotifications = async ({ enabled, actions: actions.map(transformRuleToAlertAction), throttle: null, + notifyWhen: null, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts index 17024c7c0d75fe..d6c8973215117e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/update_notifications.ts @@ -35,6 +35,7 @@ export const updateNotifications = async ({ ruleAlertId, }, throttle: null, + notifyWhen: null, }, }); } else if (interval && !notification) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index ea95c4fa788425..3f8dcefd01e23e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -404,6 +404,7 @@ export const getResult = (): RuleAlertType => ({ enabled: true, actions: [], throttle: null, + notifyWhen: null, createdBy: 'elastic', updatedBy: 'elastic', apiKey: null, @@ -629,6 +630,7 @@ export const getNotificationResult = (): RuleNotificationAlertType => ({ }, ], throttle: null, + notifyWhen: null, apiKey: null, apiKeyOwner: 'elastic', createdBy: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index 3c814ce7e66067..0519a98df1fae8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -114,6 +114,7 @@ export const createRules = async ({ enabled, actions: actions.map(transformRuleToAlertAction), throttle: null, + notifyWhen: null, }, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index f01ea3c8555011..b2303d48b0517a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -105,6 +105,7 @@ const rule: SanitizedAlert = { enabled: true, actions: [], throttle: null, + notifyWhen: null, createdBy: 'elastic', updatedBy: 'elastic', apiKeyOwner: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 8e10fc21f040c2..c86526cee9302f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -172,6 +172,7 @@ export const patchRules = async ({ const newRule = { tags: addTags(tags ?? rule.tags, rule.params.ruleId, rule.params.immutable), throttle: null, + notifyWhen: null, name: calculateName({ updatedName: name, originalName: rule.name }), schedule: { interval: calculateInterval(interval, rule.schedule.interval), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/application_added_to_google_workspace_domain.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/application_added_to_google_workspace_domain.json new file mode 100644 index 00000000000000..b5b2b1994a511a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/application_added_to_google_workspace_domain.json @@ -0,0 +1,35 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a Google marketplace application is added to the Google Workspace domain. An adversary may add a malicious application to an organization\u2019s Google Workspace domain in order to maintain a presence in their target\u2019s organization and steal data.", + "false_positives": [ + "Applications can be added to a Google Workspace domain by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Application Added to Google Workspace Domain", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:ADD_APPLICATION", + "references": [ + "https://support.google.com/a/answer/6328701?hl=en#" + ], + "risk_score": 47, + "rule_id": "785a404b-75aa-4ffd-8be5-3334a5a544dd", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_deactivate_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_deactivate_okta_network_zone.json new file mode 100644 index 00000000000000..9ccd80d65d542c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_deactivate_okta_network_zone.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to deactivate an Okta network zone. Okta network zones can be configured to limit or restrict access to a network based on IP addresses or geolocations. An adversary may attempt to modify, delete, or deactivate an Okta network zone in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if your organization's Okta network zones are regularly modified." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate an Okta Network Zone", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:zone.deactivate", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/network/network-zones.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "8a5c1e5f-ad63-481e-b53a-ef959230f7f1", + "severity": "medium", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Network Security" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_delete_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_delete_okta_network_zone.json new file mode 100644 index 00000000000000..541aaea36f6910 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/attempt_to_delete_okta_network_zone.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to delete an Okta network zone. Okta network zones can be configured to limit or restrict access to a network based on IP addresses or geolocations. An adversary may attempt to modify, delete, or deactivate an Okta network zone in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Oyour organization's Okta network zones are regularly deleted." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Delete an Okta Network Zone", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:zone.delete", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/network/network-zones.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "c749e367-a069-4a73-b1f2-43a3798153ad", + "severity": "medium", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Network Security" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_default_teamserver_cert.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_default_teamserver_cert.json new file mode 100644 index 00000000000000..b7a36ff30bffba --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_cobalt_strike_default_teamserver_cert.json @@ -0,0 +1,58 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule detects the use of the default Cobalt Strike Team Server TLS certificate. Cobalt Strike is software for Adversary Simulations and Red Team Operations which are security assessments that replicate the tactics and techniques of an advanced adversary in a network. If using Filebeat, this rule requires the Suricata or Zeek modules. Modifications to the Packetbeat configuration can be made to include MD5 and SHA256 hashing algorithms (the default is SHA1) - see the Reference section for additional information on module configuration.", + "index": [ + "filebeat-*", + "packetbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Default Cobalt Strike Team Server Certificate", + "note": "While Cobalt Strike is intended to be used for penetration tests and IR training, it is frequently used by actual threat actors (TA) such as APT19, APT29, APT32, APT41, FIN6, DarkHydrus, CopyKittens, Cobalt Group, Leviathan, and many other unnamed criminal TAs. This rule uses high-confidence atomic indicators, alerts should be investigated rapidly.", + "query": "event.category:(network or network_traffic) and (tls.server.hash.md5:950098276A495286EB2A2556FBAB6D83 or tls.server.hash.sha1:6ECE5ECE4192683D2D84E25B0BA7E04F9CB7EB7C or tls.server.hash.sha256:87F2085C32B6A2CC709B365F55873E207A9CAA10BFFECF2FD16D3CF9D94D390C)", + "references": [ + "https://attack.mitre.org/software/S0154/", + "https://www.cobaltstrike.com/help-setup-collaboration", + "https://www.elastic.co/guide/en/beats/packetbeat/current/configuration-tls.html", + "https://www.elastic.co/guide/en/beats/filebeat/7.9/filebeat-module-suricata.html", + "https://www.elastic.co/guide/en/beats/filebeat/7.9/filebeat-module-zeek.html" + ], + "risk_score": 100, + "rule_id": "e7075e8d-a966-458e-a183-85cd331af255", + "severity": "critical", + "tags": [ + "Command and Control", + "Post-Execution", + "Threat Detection, Prevention and Hunting", + "Elastic", + "Network" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/", + "subtechnique": [ + { + "id": "T1071.001", + "name": "Web Protocols", + "reference": "https://attack.mitre.org/techniques/T1071/001/" + } + ] + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_common_webservices.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_common_webservices.json new file mode 100644 index 00000000000000..30bdaba20b0d0c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_common_webservices.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Adversaries may implement command and control communications that use common web services in order to hide their activity. This attack technique is typically targeted to an organization and uses web services common to the victim network which allows the adversary to blend into legitimate traffic. activity. These popular services are typically targeted since they have most likely been used before a compromise and allow adversaries to blend in the network.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Connection to Commonly Abused Web Services", + "query": "network where network.protocol == \"dns\" and\n /* Add new WebSvc domains here */\n wildcard(dns.question.name, \"*.githubusercontent.*\",\n \"*.pastebin.*\",\n \"*drive.google.*\",\n \"*docs.live.*\",\n \"*api.dropboxapi.*\",\n \"*dropboxusercontent.*\",\n \"*onedrive.*\",\n \"*4shared.*\",\n \"*.file.io\",\n \"*filebin.net\",\n \"*slack-files.com\",\n \"*ghostbin.*\",\n \"*ngrok.*\",\n \"*portmap.*\",\n \"*serveo.net\",\n \"*localtunnel.me\",\n \"*pagekite.me\",\n \"*localxpose.io\",\n \"*notabug.org\"\n ) and\n /* Insert noisy false positives here */\n not process.name in (\"MicrosoftEdgeCP.exe\",\n \"MicrosoftEdge.exe\",\n \"iexplore.exe\",\n \"chrome.exe\",\n \"msedge.exe\",\n \"opera.exe\",\n \"firefox.exe\",\n \"Dropbox.exe\",\n \"slack.exe\",\n \"svchost.exe\",\n \"thunderbird.exe\",\n \"outlook.exe\",\n \"OneDrive.exe\")\n", + "risk_score": 21, + "rule_id": "66883649-f908-4a5b-a1e0-54090a1d3a32", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1102", + "name": "Web Service", + "reference": "https://attack.mitre.org/techniques/T1102/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json index 3df567b09055aa..ca9ef1b26f5b16 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json @@ -13,7 +13,7 @@ "language": "kuery", "license": "Elastic License", "name": "DNS Activity to the Internet", - "query": "event.category:(network or network_traffic) and (event.type:connection or type:dns) and (destination.port:53 or event.dataset:zeek.dns) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 169.254.169.254/32 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.251 or 224.0.0.252 or 255.255.255.255 or \"::1\" or \"ff02::fb\")", + "query": "event.category:(network or network_traffic) and (event.type:connection or type:dns) and (destination.port:53 or event.dataset:zeek.dns) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or 255.255.255.255 or \"::1\" or \"FE80::/10\" or \"FF00::/8\")", "references": [ "https://www.us-cert.gov/ncas/alerts/TA15-240A", "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-81-2.pdf" @@ -45,5 +45,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_tunneling_nslookup.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_tunneling_nslookup.json new file mode 100644 index 00000000000000..8e9822cc610a7b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_tunneling_nslookup.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule identifies a large number (15) of nslookup.exe executions with an explicit query type from the same host. This may indicate command and control activity utilizing the DNS protocol.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Potential DNS Tunneling via NsLookup", + "query": "event.category:process and event.type:start and process.name:nslookup.exe and process.args:(-querytype=* or -qt=* or -q=* or -type=*)", + "references": [ + "https://unit42.paloaltonetworks.com/dns-tunneling-in-the-wild-overview-of-oilrigs-dns-tunneling/" + ], + "risk_score": 47, + "rule_id": "3a59fc81-99d3-47ea-8cd6-d48d561fca20", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/" + } + ] + } + ], + "threshold": { + "field": "host.id", + "value": 15 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_encrypted_channel_freesslcert.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_encrypted_channel_freesslcert.json new file mode 100644 index 00000000000000..63d8f155a194f2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_encrypted_channel_freesslcert.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies unusual processes connecting to domains using known free SSL certificates. Adversaries may employ a known encryption algorithm to conceal command and control traffic.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Connection to Commonly Abused Free SSL Certificate Providers", + "query": "network where network.protocol == \"dns\" and\n /* Add new free SSL certificate provider domains here */\n dns.question.name : (\"*letsencrypt.org\", \"*.sslforfree.com\", \"*.zerossl.com\", \"*.freessl.org\") and\n \n /* Native Windows process paths that are unlikely to have network connections to domains secured using free SSL certificates */\n process.executable : (\"C:\\\\Windows\\\\System32\\\\*.exe\",\n \"C:\\\\Windows\\\\System\\\\*.exe\",\n\t \"C:\\\\Windows\\\\SysWOW64\\\\*.exe\",\n\t\t \"C:\\\\Windows\\\\Microsoft.NET\\\\Framework*\\\\*.exe\",\n\t\t \"C:\\\\Windows\\\\explorer.exe\",\n\t\t \"C:\\\\Windows\\\\notepad.exe\") and\n \n /* Insert noisy false positives here */\n not process.name : (\"svchost.exe\", \"MicrosoftEdge*.exe\", \"msedge.exe\")\n", + "risk_score": 21, + "rule_id": "e3cf38fa-d5b8-46cc-87f9-4a7513e4281d", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1573", + "name": "Encrypted Channel", + "reference": "https://attack.mitre.org/techniques/T1573/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json index c73fdf1bded9d7..37b95be7a0c419 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "FTP (File Transfer Protocol) Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(20 or 21) or event.dataset:zeek.ftp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(20 or 21) or event.dataset:zeek.ftp) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 21, "rule_id": "87ec6396-9ac4-4706-bcf0-2ebb22002f43", "severity": "low", @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_iexplore_via_com.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_iexplore_via_com.json new file mode 100644 index 00000000000000..b0718fc2418be8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_iexplore_via_com.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies instances of Internet Explorer (iexplore.exe) being started via the Component Object Model (COM) making unusual network connections. Adversaries could abuse Internet Explorer via COM to avoid suspicious processes making network connections and bypass host-based firewall restrictions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Potential Command and Control via Internet Explorer", + "query": "sequence by host.id, process.entity_id with maxspan = 1s\n [process where event.type:\"start\" and process.parent.name:\"iexplore.exe\" and process.parent.args:\"-Embedding\"]\n /* IE started via COM in normal conditions makes few connections, mainly to Microsoft and OCSP related domains, add FPs here */\n [network where network.protocol : \"dns\" and process.name:\"iexplore.exe\" and\n not wildcard(dns.question.name, \"*.microsoft.com\", \n \"*.digicert.com\", \n \"*.msocsp.com\", \n \"*.windowsupdate.com\", \n \"*.bing.com\",\n \"*.identrust.com\")\n ]\n", + "risk_score": 43, + "rule_id": "acd611f3-2b93-47b3-a0a3-7723bcc46f6d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1071", + "name": "Application Layer Protocol", + "reference": "https://attack.mitre.org/techniques/T1071/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json index f1901fa70def26..c29ec8c70f78f7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "IRC (Internet Relay Chat) Protocol Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(6667 or 6697) or event.dataset:zeek.irc) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(6667 or 6697) or event.dataset:zeek.irc) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 47, "rule_id": "c6474c34-4953-447a-903e-9fcb7b6661aa", "severity": "medium", @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json index 0c35bd5e23ed53..fba51f8c0f3c0a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "TCP Port 8000 Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:8000 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:8000 and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 21, "rule_id": "08d5d7e2-740f-44d8-aeda-e41f4263efaf", "severity": "low", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json index 8535a9591b88f2..3a7bf829b53644 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "Proxy Port Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1080 or 3128 or 8080) or event.dataset:zeek.socks) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1080 or 3128 or 8080) or event.dataset:zeek.socks) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 47, "rule_id": "ad0e5e75-dd89-4875-8d0a-dfdc1828b5f3", "severity": "medium", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json index 4a3fd026f54a76..2e94a13f717954 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "RDP (Remote Desktop Protocol) from the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and not source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and not source.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" ) and destination.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 )", "risk_score": 47, "rule_id": "8c1bdde8-4204-45c0-9e0c-c85ca3902488", "severity": "medium", @@ -73,5 +73,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json index 596c4bbac57bab..d55e2b7cc44715 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_desktopimgdownldr.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Remote File Download via Desktopimgdownldr Utility", - "query": "event.category:process and event.type:(start or process_started) and (process.name:desktopimgdownldr.exe or process.pe.original_file_name:desktopimgdownldr.exe or winlog.event_data.OriginalFileName:desktopimgdownldr.exe) and process.args:/lockscreenurl\\:http*", + "query": "event.category:process and event.type:(start or process_started) and (process.name:desktopimgdownldr.exe or process.pe.original_file_name:desktopimgdownldr.exe) and process.args:/lockscreenurl\\:http*", "references": [ "https://labs.sentinelone.com/living-off-windows-land-a-new-native-file-downldr/" ], @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json index 9eef2fbbc62a6a..8c27bbf85d567e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_mpcmdrun.json @@ -12,7 +12,7 @@ "license": "Elastic License", "name": "Remote File Download via MpCmdRun", "note": "### Investigating Remote File Download via MpCmdRun\nVerify details such as the parent process, URL reputation, and downloaded file details. Additionally, `MpCmdRun` logs this information in the Appdata Temp folder in `MpCmdRun.log`.", - "query": "event.category:process and event.type:(start or process_started) and (process.name:MpCmdRun.exe or process.pe.original_file_name:MpCmdRun.exe or winlog.event_data.OriginalFileName:MpCmdRun.exe) and process.args:((\"-DownloadFile\" or \"-downloadfile\") and \"-url\" and \"-path\")", + "query": "event.category:process and event.type:(start or process_started) and (process.name:MpCmdRun.exe or process.pe.original_file_name:MpCmdRun.exe) and process.args:((\"-DownloadFile\" or \"-downloadfile\") and \"-url\" and \"-path\")", "references": [ "https://twitter.com/mohammadaskar2/status/1301263551638761477", "https://www.bleepingcomputer.com/news/microsoft/microsoft-defender-can-ironically-be-used-to-download-malware/" @@ -45,5 +45,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_powershell.json new file mode 100644 index 00000000000000..cb9d215acfda35 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_powershell.json @@ -0,0 +1,59 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies powershell.exe being used to download an executable file from an untrusted remote destination.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote File Download via PowerShell", + "query": "sequence by host.id, process.entity_id with maxspan=30s\n [network where process.name : \"powershell.exe\" and network.protocol == \"dns\" and\n not dns.question.name : (\"localhost\", \"*.microsoft.com\", \"*.azureedge.net\", \"*.powershellgallery.com\", \"*.windowsupdate.com\", \"metadata.google.internal\") and \n not user.domain : \"NT AUTHORITY\"]\n [file where process.name : \"powershell.exe\" and event.type == \"creation\" and file.extension : (\"exe\", \"dll\", \"ps1\", \"bat\") and \n not file.name : \"__PSScriptPolicy*.ps1\"]\n", + "risk_score": 47, + "rule_id": "33f306e8-417c-411b-965c-c2812d6d3f4d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1086", + "name": "PowerShell", + "reference": "https://attack.mitre.org/techniques/T1086/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_scripts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_scripts.json new file mode 100644 index 00000000000000..37f1364c5f61fe --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_remote_file_copy_scripts.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies built-in Windows script interpreters (cscript.exe or wscript.exe) being used to download an executable file from a remote destination.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote File Download via Script Interpreter", + "query": "sequence by host.id, process.entity_id\n [network where process.name : (\"wscript.exe\", \"cscript.exe\") and network.protocol != \"dns\" and\n network.direction == \"outgoing\" and network.type == \"ipv4\" and destination.ip != \"127.0.0.1\"\n ]\n [file where event.type == \"creation\" and file.extension : (\"exe\", \"dll\")]\n", + "risk_score": 43, + "rule_id": "1d276579-3380-4095-ad38-e596a01bc64f", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Command and Control" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json index f041255374f12c..21c4d22e2af8c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "SMTP to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(25 or 465 or 587) or event.dataset:zeek.smtp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(25 or 465 or 587) or event.dataset:zeek.smtp) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 21, "rule_id": "67a9beba-830d-4035-bfe8-40b7e28f8ac4", "severity": "low", @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json index 7e4f3907fc31ea..45cfc2bc5fc3bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "SQL Traffic to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1433 or 1521 or 3306 or 5432) or event.dataset:zeek.mysql) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1433 or 1521 or 3306 or 5432) or event.dataset:zeek.mysql) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 47, "rule_id": "139c7458-566a-410c-a5cd-f80238d6a5cd", "severity": "medium", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json index 08ab14aeb5c7ca..95e564ff7af2c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "SSH (Secure Shell) from the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and not source.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" ) and destination.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 )", "risk_score": 47, "rule_id": "ea0784f0-a4d7-4fea-ae86-4baaf27a6f17", "severity": "medium", @@ -73,5 +73,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json index 4bc48ebe0c3160..d5e0a29dd2a018 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "SSH (Secure Shell) to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 21, "rule_id": "6f1500bc-62d7-4eb9-8601-7485e87da2f4", "severity": "low", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json index e82106a87bc2e1..014c46a09e448c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "Tor Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:(9001 or 9030) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:(9001 or 9030) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\")", "risk_score": 47, "rule_id": "7d2c38d7-ede7-4bdf-b140-445906e6c540", "severity": "medium", @@ -38,25 +38,22 @@ "id": "T1043", "name": "Commonly Used Port", "reference": "https://attack.mitre.org/techniques/T1043/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0011", - "name": "Command and Control", - "reference": "https://attack.mitre.org/tactics/TA0011/" - }, - "technique": [ + }, { - "id": "T1188", - "name": "Multi-hop Proxy", - "reference": "https://attack.mitre.org/techniques/T1188/" + "id": "T1090", + "name": "Proxy", + "reference": "https://attack.mitre.org/techniques/T1090/", + "subtechnique": [ + { + "id": "T1090.003", + "name": "Multi-hop Proxy", + "reference": "https://attack.mitre.org/techniques/T1090/003/" + } + ] } ] } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json index 9321d2a2103de5..0eba8b5a091533 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "VNC (Virtual Network Computing) from the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and not source.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" ) and destination.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 )", "risk_score": 73, "rule_id": "5700cb81-df44-46aa-a5d7-337798f53eb8", "severity": "high", @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json index 38f38e97626453..7152d91518dd85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "VNC (Virtual Network Computing) to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 47, "rule_id": "3ad49c61-7adc-42c1-b788-732eda2f5abf", "severity": "medium", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json index fb8256bf2509c4..621a70d11b065e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to bypass the Okta multi-factor authentication (MFA) policies configured for an organization in order to obtain unauthorized access to an application. This rule detects when an Okta MFA bypass attempt occurs.", + "description": "Detects attempts to bypass Okta multi-factor authentication (MFA). An adversary may attempt to bypass the Okta MFA policies configured for an organization in order to obtain unauthorized access to an application.", "index": [ "filebeat-*", "logs-okta*" @@ -10,7 +10,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempted Bypass of Okta MFA", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:user.mfa.attempt_bypass", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -45,5 +45,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json index d8d5b5305aaaab..335a393e8c2267 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempts_to_brute_force_okta_user_account.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempts to Brute Force an Okta User Account", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:user.account.lock", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -50,5 +50,5 @@ "value": 3 }, "type": "threshold", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_cmdline_dump_tool.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_cmdline_dump_tool.json new file mode 100644 index 00000000000000..a557809877b531 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_cmdline_dump_tool.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the execution of known Windows utilities often abused to dump LSASS memory or the Active Directory database (NTDS.dit) in preparation for credential access.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Potential Credential Access via Windows Utilities", + "query": "process where event.type in (\"start\", \"process_started\") and\n/* update here with any new lolbas with dump capability */\n(process.pe.original_file_name == \"procdump\" and process.args : \"-ma\") or\n(process.name : \"ProcessDump.exe\" and not process.parent.executable : \"C:\\\\Program Files*\\\\Cisco Systems\\\\*.exe\") or\n(process.pe.original_file_name == \"WriteMiniDump.exe\" and not process.parent.executable : \"C:\\\\Program Files*\\\\Steam\\\\*.exe\") or\n(process.pe.original_file_name == \"RUNDLL32.EXE\" and (process.args : \"MiniDump*\" or process.command_line : \"*comsvcs.dll*#24*\")) or\n(process.pe.original_file_name == \"RdrLeakDiag.exe\" and process.args : \"/fullmemdmp\") or\n(process.pe.original_file_name == \"SqlDumper.exe\" and process.args : \"0x01100*\") or\n(process.pe.original_file_name == \"TTTracer.exe\" and process.args : \"-dumpFull\" and process.args : \"-attach\") or\n(process.pe.original_file_name == \"ntdsutil.exe\" and process.args : \"create*full*\") or\n(process.pe.original_file_name == \"diskshadow.exe\" and process.args : \"/s\")\n", + "references": [ + "https://lolbas-project.github.io/" + ], + "risk_score": 73, + "rule_id": "00140285-b827-4aee-aa09-8113f58a08f3", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_copy_ntds_sam_volshadowcp_cmdline.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_copy_ntds_sam_volshadowcp_cmdline.json new file mode 100644 index 00000000000000..ccd4079f22289e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_copy_ntds_sam_volshadowcp_cmdline.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a copy operation of the Active Directory Domain Database (ntds.dit) or Security Account Manager (SAM) files. Those files contain sensitive information including hashed domain and/or local credentials.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "max_signals": 33, + "name": "NTDS or SAM Database File Copied", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.pe.original_file_name in (\"Cmd.Exe\", \"PowerShell.EXE\", \"XCOPY.EXE\") and\n process.args : (\"copy\", \"xcopy\", \"Copy-Item\", \"move\", \"cp\", \"mv\") and\n process.args : (\"*\\\\ntds.dit\", \"*\\\\config\\\\SAM\", \"\\\\*\\\\GLOBALROOT\\\\Device\\\\HarddiskVolumeShadowCopy*\\\\*\")\n", + "references": [ + "https://thedfirreport.com/2020/11/23/pysa-mespinoza-ransomware/" + ], + "risk_score": 73, + "rule_id": "3bc6deaa-fbd4-433a-ae21-3e892f95624f", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json index 0761ba515d9b1d..9aba46f783dda1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "Microsoft Build Engine Loading Windows Credential Libraries", - "query": "event.category:process and event.type:change and (winlog.event_data.OriginalFileName:(vaultcli.dll or SAMLib.DLL) or dll.name:(vaultcli.dll or SAMLib.DLL)) and process.name: MSBuild.exe", + "query": "event.category:process and event.type:change and (process.pe.original_file_name:(vaultcli.dll or SAMLib.DLL) or dll.name:(vaultcli.dll or SAMLib.DLL)) and process.name: MSBuild.exe", "risk_score": 73, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae5", "severity": "high", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_dump_registry_hives.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_dump_registry_hives.json new file mode 100644 index 00000000000000..a98fc46d405f5d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_dump_registry_hives.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to export a registry hive which may contain credentials using the Windows reg.exe tool.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Credential Acquisition via Registry Hive Dumping", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.pe.original_file_name == \"reg.exe\" and\n process.args : (\"save\", \"export\") and\n process.args : (\"hklm\\\\sam\", \"hklm\\\\security\") and\n not process.parent.executable : \"C:\\\\Program Files*\\\\Rapid7\\\\Insight Agent\\\\components\\\\insight_agent\\\\*\\\\ir_agent.exe\"\n \n", + "references": [ + "https://medium.com/threatpunter/detecting-attempts-to-steal-passwords-from-the-registry-7512674487f8" + ], + "risk_score": 73, + "rule_id": "a7e7bfa3-088e-4f13-b29e-3986e0e756b8", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json index 6a182617945f1b..cde3713732edef 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_apppoolsa_pwd_appcmd.json @@ -8,11 +8,11 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "lucene", + "language": "eql", "license": "Elastic License", "max_signals": 33, "name": "Microsoft IIS Service Account Password Dumped", - "query": "event.category:process AND event.type:(start OR process_started) AND (process.name:appcmd.exe OR process.pe.original_file_name:appcmd.exe or winlog.event_data.OriginalFileName:appcmd.exe) AND process.args:(/[lL][iI][sS][tT]/ AND /\\/[tT][eE][xX][tT]\\:[pP][aA][sS][sS][wW][oO][rR][dD]/)", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"appcmd.exe\" or process.pe.original_file_name == \"appcmd.exe\") and \n process.args : \"/list\" and process.args : \"/text*password\"\n", "references": [ "https://blog.netspi.com/decrypting-iis-passwords-to-break-out-of-the-dmz-part-1/" ], @@ -43,6 +43,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json index f750a0f5594b47..e7c1154f5296e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iis_connectionstrings_dumping.json @@ -8,11 +8,11 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "max_signals": 33, "name": "Microsoft IIS Connection Strings Decryption", - "query": "event.category:process and event.type:(start or process_started) and (process.name:aspnet_regiis.exe or process.pe.original_file_name:aspnet_regiis.exe or winlog.event_data.OriginalFileName:aspnet_regiis.exe) and process.args:(connectionStrings and \"-pdf\")", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"aspnet_regiis.exe\" or process.pe.original_file_name == \"aspnet_regiis.exe\") and\n process.args : \"connectionStrings\" and process.args : \"-pdf\"\n", "references": [ "https://blog.netspi.com/decrypting-iis-passwords-to-break-out-of-the-dmz-part-1/", "https://symantec-enterprise-blogs.security.com/blogs/threat-intelligence/greenbug-espionage-telco-south-asia" @@ -44,6 +44,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberoasting_unusual_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberoasting_unusual_process.json new file mode 100644 index 00000000000000..bc518ad202d8af --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_kerberoasting_unusual_process.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies network connections to the standard Kerberos port from an unusual process. On Windows, the only process that normally performs Kerberos traffic from a domain joined host is lsass.exe.", + "false_positives": [ + "HTTP traffic on a non standard port. Verify that the destination IP address is not related to a Domain Controller." + ], + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Kerberos Traffic from Unusual Process", + "query": "network where event.type == \"start\" and network.direction == \"outgoing\" and\n destination.port == 88 and source.port >= 49152 and\n process.executable != \"C:\\\\Windows\\\\System32\\\\lsass.exe\" and destination.address !=\"127.0.0.1\" and destination.address !=\"::1\" and\n /* insert False Positives here */\n not process.name in (\"swi_fc.exe\", \"fsIPcam.exe\", \"IPCamera.exe\", \"MicrosoftEdgeCP.exe\", \"MicrosoftEdge.exe\", \"iexplore.exe\", \"chrome.exe\", \"msedge.exe\", \"opera.exe\", \"firefox.exe\")\n", + "risk_score": 43, + "rule_id": "897dc6b5-b39f-432a-8d75-d3730d50c782", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1558", + "name": "Steal or Forge Kerberos Tickets", + "reference": "https://attack.mitre.org/techniques/T1558/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_lsass_memdump_file_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_lsass_memdump_file_created.json new file mode 100644 index 00000000000000..759146a5d73a2a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_lsass_memdump_file_created.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a Local Security Authority Subsystem Service (lsass.exe) default memory dump. This may indicate a credential access attempt via trusted system utilities such as Task Manager (taskmgr.exe) and SQL Dumper (sqldumper.exe) or known pentesting tools such as Dumpert and AndrewSpecial.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "LSASS Memory Dump Creation", + "query": "event.category:file and file.name:(lsass.DMP or lsass*.dmp or dumpert.dmp or Andrew.dmp or SQLDmpr*.mdmp or Coredump.dmp)", + "references": [ + "https://github.com/outflanknl/Dumpert", + "https://github.com/hoangprod/AndrewSpecial" + ], + "risk_score": 73, + "rule_id": "f2f46686-6f3c-4724-bd7d-24e31c70f98f", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1003", + "name": "OS Credential Dumping", + "reference": "https://attack.mitre.org/techniques/T1003/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_brute_force_user_account_attempt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_brute_force_user_account_attempt.json new file mode 100644 index 00000000000000..8bd1d60f6dcaa7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_brute_force_user_account_attempt.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to brute force a Microsoft 365 user account. An adversary may attempt a brute force attack to obtain unauthorized access to user accounts.", + "false_positives": [ + "Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false positives." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempts to Brute Force a Microsoft 365 User Account", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:AzureActiveDirectory and event.category:authentication and event.action:UserLoginFailed and event.outcome:failure", + "risk_score": 73, + "rule_id": "26f68dba-ce29-497b-8e13-b4fde1db5a2d", + "severity": "high", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "user.id", + "value": 10 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_potential_password_spraying_attack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_potential_password_spraying_attack.json new file mode 100644 index 00000000000000..348c5506d55f6e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_microsoft_365_potential_password_spraying_attack.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number (25) of failed Microsoft 365 user authentication attempts from a single IP address within 30 minutes, which could be indicative of a password spraying attack. An adversary may attempt a password spraying attack to obtain unauthorized access to user accounts.", + "false_positives": [ + "Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false positives." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Potential Password Spraying of Microsoft 365 User Accounts", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:AzureActiveDirectory and event.category:authentication and event.action:UserLoginFailed and event.outcome:failure", + "risk_score": 73, + "rule_id": "3efee4f0-182a-40a8-a835-102c68a4175d", + "severity": "high", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "source.ip", + "value": 25 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json index 9e10dd6dae522b..188c5bfbff8d15 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json @@ -13,7 +13,7 @@ "language": "kuery", "license": "Elastic License", "name": "Okta Brute Force or Password Spraying Attack", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.category:authentication and event.outcome:failure", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -52,5 +52,5 @@ "value": 25 }, "type": "threshold", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_potential_ssh_bruteforce.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_potential_ssh_bruteforce.json new file mode 100644 index 00000000000000..3a436516622460 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_potential_ssh_bruteforce.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number (20) of macOS SSH KeyGen process executions from the same host. An adversary may attempt a brute force attack to obtain unauthorized access to user accounts.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Potential SSH Brute Force Detected", + "query": "event.category:process and event.type:start and process.name:\"sshd-keygen-wrapper\" and process.parent.name:launchd", + "references": [ + "https://themittenmac.com/detecting-ssh-activity-via-process-monitoring/" + ], + "risk_score": 47, + "rule_id": "ace1e989-a541-44df-93a8-a8b0591b63c0", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "host.id", + "value": 20 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_promt_for_pwd_via_osascript.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_promt_for_pwd_via_osascript.json new file mode 100644 index 00000000000000..8de7a0293fac72 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_promt_for_pwd_via_osascript.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of osascript to execute scripts via standard input that may prompt a user with a rogue dialog for credentials.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Prompt for Credentials with OSASCRIPT", + "query": "process where event.type in (\"start\", \"process_started\") and process.name:\"osascript\" and process.args:\"-e\" and process.args:\"password\"\n", + "references": [ + "https://github.com/EmpireProject/EmPyre/blob/master/lib/modules/collection/osx/prompt.py", + "https://ss64.com/osx/osascript.html" + ], + "risk_score": 73, + "rule_id": "38948d29-3d5d-42e3-8aec-be832aaaf8eb", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Credential Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1056", + "name": "Input Capture", + "reference": "https://attack.mitre.org/techniques/T1056/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_del_quarantine_attrib.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_del_quarantine_attrib.json new file mode 100644 index 00000000000000..0c662efe2b310b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_del_quarantine_attrib.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a potential Gatekeeper bypass. In macOS, when applications or programs are downloaded from the internet, there is a quarantine flag set on the file. This attribute is read by Apple's Gatekeeper defense program at execution time. An adversary may disable this attribute to evade defenses.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Attempt to Remove File Quarantine Attribute", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name == \"xattr\" and process.args == \"com.apple.quarantine\" and process.args == \"-d\"\n", + "references": [ + "https://www.trendmicro.com/en_us/research/20/k/new-macos-backdoor-connected-to-oceanlotus-surfaces.html" + ], + "risk_score": 43, + "rule_id": "f0b48bbc-549e-4bcf-8ee0-a7a72586c6a7", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json index 140e1ccd8e8905..446029f8cbbcb9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json @@ -38,17 +38,7 @@ "id": "T1140", "name": "Deobfuscate/Decode Files or Information", "reference": "https://attack.mitre.org/techniques/T1140/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ + }, { "id": "T1027", "name": "Obfuscated Files or Information", @@ -58,5 +48,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json index fa322fca5db8ad..d65483a69ec1d7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json @@ -38,17 +38,7 @@ "id": "T1140", "name": "Deobfuscate/Decode Files or Information", "reference": "https://attack.mitre.org/techniques/T1140/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ + }, { "id": "T1027", "name": "Obfuscated Files or Information", @@ -58,5 +48,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json index 11d57b855f974a..ba4d1ba6b88306 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies attempts to clear Windows event log stores. This is often done by attackers in an attempt to evade detection or destroy forensic evidence on a system.", + "description": "Identifies attempts to clear or disable Windows event log stores using Windows wevetutil command. This is often done by attackers in an attempt to evade detection or destroy forensic evidence on a system.", "from": "now-9m", "index": [ "winlogbeat-*", @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Clearing Windows Event Logs", - "query": "event.category:process and event.type:(start or process_started) and process.name:wevtutil.exe and process.args:cl or process.name:powershell.exe and process.args:Clear-EventLog", + "query": "event.category:process and event.type:(process_started or start) and (process.name:\"wevtutil.exe\" or process.pe.original_file_name:\"wevtutil.exe\") and process.args:(\"/e:false\" or cl or \"clear-log\") or process.name:\"powershell.exe\" and process.args:\"Clear-EventLog\"", "risk_score": 21, "rule_id": "d331bbe2-6db4-4941-80a5-8270db72eb61", "severity": "low", @@ -40,5 +40,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_websvr_access_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_websvr_access_logs.json new file mode 100644 index 00000000000000..4b8ca550087c21 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_websvr_access_logs.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of WebServer access logs. This may indicate an attempt to evade detection or destroy forensic evidence on a system.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "WebServer Access Logs Deleted", + "query": "file where event.type == \"deletion\" and\n file.path : (\"C:\\\\inetpub\\\\logs\\\\LogFiles\\\\*.log\", \n \"/var/log/apache*/access.log\",\n \"/etc/httpd/logs/access_log\", \n \"/var/log/httpd/access_log\", \n \"/var/www/*/logs/access.log\")\n", + "risk_score": 47, + "rule_id": "665e7a4f-c58e-4fc6-bc83-87a7572670ac", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Linux", + "Windows", + "macOS", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1070", + "name": "Indicator Removal on Host", + "reference": "https://attack.mitre.org/techniques/T1070/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_dotnet_compiler_parent_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_dotnet_compiler_parent_process.json index 08cbb33710b264..6b601f98458158 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_dotnet_compiler_parent_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_dotnet_compiler_parent_process.json @@ -8,10 +8,10 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Suspicious .NET Code Compilation", - "query": "event.category:process and event.type:(start or process_started) and process.name:(csc.exe or vbc.exe) and process.parent.name:(wscript.exe or mshta.exe or wscript.exe or wmic.exe or svchost.exe or rundll32.exe or cmstp.exe or regsvr32.exe)", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name : (\"csc.exe\", \"vbc.exe\") and\n process.parent.name : (\"wscript.exe\", \"mshta.exe\", \"cscript.exe\", \"wmic.exe\", \"svchost.exe\", \"rundll32.exe\", \"cmstp.exe\", \"regsvr32.exe\")\n", "risk_score": 47, "rule_id": "201200f1-a99b-43fb-88ed-f65a45c4972c", "severity": "medium", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_inbound_rdp_with_netsh.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_inbound_rdp_with_netsh.json new file mode 100644 index 00000000000000..1785d826ce89cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_enable_inbound_rdp_with_netsh.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the network shell utility (netsh.exe) to enable inbound Remote Desktop Protocol (RDP) connections in the Windows Firewall.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote Desktop Enabled in Windows Firewall", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"netsh.exe\" or process.pe.original_file_name == \"netsh.exe\") and\n process.args : (\"localport=3389\", \"RemoteDesktop\", \"group=\\\"remote desktop\\\"\") and\n process.args : (\"action=allow\", \"enable=Yes\", \"enable\")\n", + "risk_score": 47, + "rule_id": "074464f9-f30d-4029-8c03-0ed237fffec7", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_lolbas_wuauclt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_lolbas_wuauclt.json new file mode 100644 index 00000000000000..741f575fd31861 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_lolbas_wuauclt.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies abuse of the Windows Update Auto Update Client (wuauclt.exe) to load an arbitrary DLL. This behavior is used as a defense evasion technique to blend-in malicious activity with legitimate Windows software.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "ImageLoad via Windows Update Auto Update Client", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.pe.original_file_name == \"wuauclt.exe\" or process.name : \"wuauclt.exe\") and\n /* necessary windows update client args to load a dll */\n process.args : \"/RunHandlerComServer\" and process.args : \"/UpdateDeploymentProvider\" and\n /* common paths writeable by a standard user where the target DLL can be placed */\n process.args : (\"C:\\\\Users\\\\*.dll\", \"C:\\\\ProgramData\\\\*.dll\", \"C:\\\\Windows\\\\Temp\\\\*.dll\", \"C:\\\\Windows\\\\Tasks\\\\*.dll\")\n", + "references": [ + "https://dtm.uk/wuauclt/" + ], + "risk_score": 47, + "rule_id": "edf8ee23-5ea7-4123-ba19-56b41e424ae3", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1218", + "name": "Signed Binary Proxy Execution", + "reference": "https://attack.mitre.org/techniques/T1218/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json index 5daab573db5bd0..63fa323292092f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "Microsoft Build Engine Using an Alternate Name", - "query": "event.category:process and event.type:(start or process_started) and (process.pe.original_file_name:MSBuild.exe or winlog.event_data.OriginalFileName:MSBuild.exe) and not process.name: MSBuild.exe", + "query": "event.category:process and event.type:(start or process_started) and process.pe.original_file_name:MSBuild.exe and not process.name: MSBuild.exe", "risk_score": 21, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae4", "severity": "low", @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json index 7d9f190ba7be2f..19ff6f2e921482 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_suspicious_explorer_winword.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Potential DLL SideLoading via Trusted Microsoft Programs", - "query": "event.category:process and event.type:(start or process_started) and (process.pe.original_file_name:(WinWord.exe or EXPLORER.EXE or w3wp.exe or DISM.EXE) or winlog.event_data.OriginalFileName:(WinWord.exe or EXPLORER.EXE or w3wp.exe or DISM.EXE)) and not (process.name:(winword.exe or WINWORD.EXE or explorer.exe or w3wp.exe or Dism.exe) or process.executable:(\"C:\\Windows\\explorer.exe\" or C\\:\\\\Program?Files\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or C\\:\\\\Program?Files?\\(x86\\)\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or \"C:\\Windows\\System32\\Dism.exe\" or \"C:\\Windows\\SysWOW64\\Dism.exe\" or \"C:\\Windows\\System32\\inetsrv\\w3wp.exe\"))", + "query": "event.category:process and event.type:(start or process_started) and process.pe.original_file_name:(WinWord.exe or EXPLORER.EXE or w3wp.exe or DISM.EXE) and not (process.name:(winword.exe or WINWORD.EXE or explorer.exe or w3wp.exe or Dism.exe) or process.executable:(\"C:\\Windows\\explorer.exe\" or C\\:\\\\Program?Files\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or C\\:\\\\Program?Files?\\(x86\\)\\\\Microsoft?Office\\\\root\\\\Office*\\\\WINWORD.EXE or \"C:\\Windows\\System32\\Dism.exe\" or \"C:\\Windows\\SysWOW64\\Dism.exe\" or \"C:\\Windows\\System32\\inetsrv\\w3wp.exe\"))", "risk_score": 73, "rule_id": "1160dcdb-0a0a-4a79-91d8-9b84616edebd", "severity": "high", @@ -40,5 +40,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json index 6d3d6f456da4cc..d7c89b4e0c471e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json @@ -38,17 +38,7 @@ "id": "T1140", "name": "Deobfuscate/Decode Files or Information", "reference": "https://attack.mitre.org/techniques/T1140/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ + }, { "id": "T1027", "name": "Obfuscated Files or Information", @@ -58,5 +48,5 @@ } ], "type": "query", - "version": 4 + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hide_encoded_executable_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hide_encoded_executable_registry.json new file mode 100644 index 00000000000000..825918605bd02e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hide_encoded_executable_registry.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies registry write modifications to hide an encoded portable executable. This could be indicative of adversary defense evasion by avoiding the storing of malicious content directly on disk.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Encoded Executable Stored in the Registry", + "query": "registry where\n/* update here with encoding combinations */\n registry.data.strings : \"TVqQAAMAAAAEAAAA*\"\n", + "risk_score": 47, + "rule_id": "93c1ce76-494c-4f01-8167-35edfb52f7b1", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1140", + "name": "Deobfuscate/Decode Files or Information", + "reference": "https://attack.mitre.org/techniques/T1140/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json index 7d75f508561253..cd0e88826adc91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_iis_httplogging_disabled.json @@ -12,7 +12,7 @@ "license": "Elastic License", "max_signals": 33, "name": "IIS HTTP Logging Disabled", - "query": "event.category:process and event.type:(start or process_started) and (process.name:appcmd.exe or process.pe.original_file_name:appcmd.exe or winlog.event_data.OriginalFileName:appcmd.exe) and process.args:/dontLog\\:\\\"True\\\" and not process.parent.name:iissetup.exe", + "query": "event.category:process and event.type:(start or process_started) and (process.name:appcmd.exe or process.pe.original_file_name:appcmd.exe) and process.args:/dontLog\\:\\\"True\\\" and not process.parent.name:iissetup.exe", "risk_score": 73, "rule_id": "ebf1adea-ccf2-4943-8b96-7ab11ca173a5", "severity": "high", @@ -41,5 +41,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_log_files_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_log_files_deleted.json new file mode 100644 index 00000000000000..af9ca14e6d01e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_log_files_deleted.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of sensitive Linux system logs. This may indicate an attempt to evade detection or destroy forensic evidence on a system.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "System Log File Deletion", + "query": "file where event.type == \"deletion\" and \n file.path : \n (\n \"/var/run/utmp\", \n \"/var/log/wtmp\", \n \"/var/log/btmp\", \n \"/var/log/lastlog\", \n \"/var/log/faillog\",\n \"/var/log/syslog\", \n \"/var/log/messages\", \n \"/var/log/secure\", \n \"/var/log/auth.log\"\n )\n", + "references": [ + "https://www.fireeye.com/blog/threat-research/2020/11/live-off-the-land-an-overview-of-unc1945.html" + ], + "risk_score": 47, + "rule_id": "aa895aea-b69c-4411-b110-8d7599634b30", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Linux", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1070", + "name": "Indicator Removal on Host", + "reference": "https://attack.mitre.org/techniques/T1070/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json index 163c7e834ba342..bc8609f0a180a1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_as_elastic_endpoint_process.json @@ -8,10 +8,10 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Suspicious Endpoint Security Parent Process", - "query": "event.category:process and event.type:(start or process_started) and process.name:(esensor.exe or \"elastic-endpoint.exe\" or \"elastic-agent.exe\") and not process.parent.executable:\"C:\\Windows\\System32\\services.exe\"", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.name : (\"esensor.exe\", \"elastic-endpoint.exe\") and\n process.parent.executable != null and\n /* add FPs here */\n not process.parent.executable : (\"C:\\\\Program Files\\\\Elastic\\\\*\", \n \"C:\\\\Windows\\\\System32\\\\services.exe\", \n \"C:\\\\Windows\\\\System32\\\\WerFault*.exe\", \n \"C:\\\\Windows\\\\System32\\\\wermgr.exe\")\n", "risk_score": 47, "rule_id": "b41a13c6-ba45-4bab-a534-df53d0cfed6a", "severity": "medium", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json index be83f8c41a2ea3..ed326a798ad31c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_renamed_autoit.json @@ -2,16 +2,16 @@ "author": [ "Elastic" ], - "description": "Identifies a suspicious AutoIt process execution. Malware written as AutoIt scripts tend to rename the AutoIt executable to avoid detection.", + "description": "Identifies a suspicious AutoIt process execution. Malware written as an AutoIt script tends to rename the AutoIt executable to avoid detection.", "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "lucene", + "language": "eql", "license": "Elastic License", "name": "Renamed AutoIt Scripts Interpreter", - "query": "event.category:process AND event.type:(start OR process_started) AND (process.pe.original_file_name:/[aA][uU][tT][oO][iI][tT]\\d\\.[eE][xX][eE]/ OR winlog.event_data.OriginalFileName:/[aA][uU][tT][oO][iI][tT]\\d\\.[eE][xX][eE]/) AND NOT process.name:/[aA][uU][tT][oO][iI][tT]\\d{1,3}\\.[eE][xX][eE]/", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.pe.original_file_name : \"AutoIt*.exe\" and not process.name : \"AutoIt*.exe\"\n", "risk_score": 47, "rule_id": "2e1e835d-01e5-48ca-b9fc-7a61f7f11902", "severity": "medium", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_trusted_directory.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_trusted_directory.json new file mode 100644 index 00000000000000..693a9a77326f85 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_trusted_directory.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution from a directory masquerading as the Windows Program Files directories. These paths are trusted and usually host trusted third party programs. An adversary may leverage masquerading, along with low privileges to bypass detections whitelisting those folders.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Program Files Directory Masquerading", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n /* capture both fake program files directory in process executable as well as if passed in process args as a dll*/\n process.args : (\"C:\\\\*Program*Files*\\\\*\", \"C:\\\\*Program*Files*\\\\*\") and\n not process.args : (\"C:\\\\Program Files\\\\*\", \"C:\\\\Program Files (x86)\\\\*\")\n", + "risk_score": 43, + "rule_id": "32c5cf9c-2ef8-4e87-819e-5ccb7cd18b14", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json index 9f5615d4663745..39cf9860dadcdf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_masquerading_werfault.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies a suspicious WerFault command line parameter, which may indicate an attempt to run unnoticed.", + "description": "Identifies suspicious instances of the Windows Error Reporting process (WerFault.exe or Wermgr.exe) with matching command-line and process executable values performing outgoing network connections. This may be indicative of a masquerading attempt to evade suspicious child process behavior detections.", "false_positives": [ "Legit Application Crash with rare Werfault commandline value" ], @@ -11,13 +11,14 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", - "name": "Process Potentially Masquerading as WerFault", - "query": "event.category:process and event.type:(start or process_started) and process.name:WerFault.exe and not process.args:(((\"-u\" or \"-pss\") and \"-p\" and \"-s\") or (\"/h\" and \"/shared\") or (\"-k\" and \"-lcq\"))", + "name": "Potential Windows Error Manager Masquerading", + "query": "sequence by host.id, process.entity_id with maxspan = 5s\n [process where event.type:\"start\" and process.name : (\"wermgr.exe\", \"WerFault.exe\") and process.args_count == 1]\n [network where process.name : (\"wermgr.exe\", \"WerFault.exe\") and network.protocol != \"dns\" and\n network.direction == \"outgoing\" and destination.ip !=\"::1\" and destination.ip !=\"127.0.0.1\"\n ]\n", "references": [ "https://twitter.com/SBousseaden/status/1235533224337641473", - "https://www.hexacorn.com/blog/2019/09/20/werfault-command-line-switches-v0-1/" + "https://www.hexacorn.com/blog/2019/09/20/werfault-command-line-switches-v0-1/", + "https://app.any.run/tasks/26051d84-b68e-4afb-8a9a-76921a271b81/" ], "risk_score": 47, "rule_id": "6ea41894-66c3-4df7-ad6b-2c5074eb3df8", @@ -46,6 +47,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_dlp_policy_removed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_dlp_policy_removed.json new file mode 100644 index 00000000000000..0166512e7361b0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_dlp_policy_removed.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Data Loss Prevention (DLP) policy is removed in Microsoft 365. An adversary may remove a DLP policy to evade existing DLP monitoring.", + "false_positives": [ + "A DLP policy may be removed by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange DLP Policy Removed", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Remove-DlpPolicy\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-dlppolicy?view=exchange-ps", + "https://docs.microsoft.com/en-us/microsoft-365/compliance/data-loss-prevention-policies?view=o365-worldwide" + ], + "risk_score": 47, + "rule_id": "60f3adec-1df9-4104-9c75-b97d9f078b25", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_policy_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_policy_deletion.json new file mode 100644 index 00000000000000..7f087c1db21c8b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_policy_deletion.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a malware filter policy has been deleted in Microsoft 365. A malware filter policy is used to alert administrators that an internal user sent a message that contained malware. This may indicate an account or machine compromise that would need to be investigated. Deletion of a malware filter policy may be done to evade detection.", + "false_positives": [ + "A malware filter policy may be deleted by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Malware Filter Policy Deletion", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Remove-MalwareFilterPolicy\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-malwarefilterpolicy?view=exchange-ps" + ], + "risk_score": 47, + "rule_id": "d743ff2a-203e-4a46-a3e3-40512cfe8fbb", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_rule_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_rule_mod.json new file mode 100644 index 00000000000000..5475d0ee04d361 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_malware_filter_rule_mod.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a malware filter rule has been deleted or disabled in Microsoft 365. An adversary or insider threat may want to modify a malware filter rule to evade detection.", + "false_positives": [ + "A malware filter rule may be deleted by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Malware Filter Rule Modification", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:(\"Remove-MalwareFilterRule\" or \"Disable-MalwareFilterRule\") and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-malwarefilterrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/powershell/module/exchange/disable-malwarefilterrule?view=exchange-ps" + ], + "risk_score": 47, + "rule_id": "ca79768e-40e1-4e45-a097-0e5fbc876ac2", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_safe_attach_rule_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_safe_attach_rule_disabled.json new file mode 100644 index 00000000000000..7b5af375da4fe4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_microsoft_365_exchange_safe_attach_rule_disabled.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a safe attachment rule is disabled in Microsoft 365. Safe attachment rules can extend malware protections to include routing all messages and attachments without a known malware signature to a special hypervisor environment. An adversary or insider threat may disable a safe attachment rule to exfiltrate data or evade defenses.", + "false_positives": [ + "A safe attachment rule may be disabled by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Safe Attachment Rule Disabled", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Disable-SafeAttachmentRule\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/disable-safeattachmentrule?view=exchange-ps" + ], + "risk_score": 21, + "rule_id": "03024bd9-d23f-4ec1-8674-3cf1a21e130b", + "severity": "low", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1562", + "name": "Impair Defenses", + "reference": "https://attack.mitre.org/techniques/T1562/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_port_forwarding_added_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_port_forwarding_added_registry.json new file mode 100644 index 00000000000000..b694298622967a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_port_forwarding_added_registry.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a new port forwarding rule. An adversary may abuse this technique to bypass network segmentation restrictions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Port Forwarding Rule Addition", + "query": "registry where registry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Services\\\\PortProxy\\\\v4tov4\\\\*\"\n", + "references": [ + "https://www.fireeye.com/blog/threat-research/2019/01/bypassing-network-restrictions-through-rdp-tunneling.html" + ], + "risk_score": 47, + "rule_id": "3535c8bb-3bd5-40f4-ae32-b7cd589d5372", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_potential_processherpaderping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_potential_processherpaderping.json new file mode 100644 index 00000000000000..5f8975c41cf183 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_potential_processherpaderping.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies process execution followed by a file overwrite of an executable by the same parent process. This may indicate an evasion attempt to execute malicious code in a stealthy way.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Potential Process Herpaderping Attempt", + "query": "sequence with maxspan=5s\n [process where event.type == \"start\" and not process.parent.executable : \"C:\\\\Windows\\\\SoftwareDistribution\\\\*.exe\"] by host.id, process.executable, process.parent.entity_id\n [file where event.type == \"change\" and event.action == \"overwrite\" and file.extension == \"exe\"] by host.id, file.path, process.entity_id\n", + "references": [ + "https://github.com/jxy-s/herpaderping" + ], + "risk_score": 73, + "rule_id": "ccc55af4-9882-4c67-87b4-449a7ae8079c", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1036", + "name": "Masquerading", + "reference": "https://attack.mitre.org/techniques/T1036/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_process_termination_followed_by_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_process_termination_followed_by_deletion.json new file mode 100644 index 00000000000000..69819be8c538cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_process_termination_followed_by_deletion.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a process termination event quickly followed by the deletion of its executable file. Malware tools and other non-native files dropped or created on a system by an adversary may leave traces to indicate to what occurred. Removal of these files can occur during an intrusion, or as part of a post-intrusion process to minimize the adversary's footprint.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Process Termination followed by Deletion", + "query": "sequence by host.id with maxspan=5s\n [process where event.type == \"end\" and \n process.code_signature.trusted == false and\n not process.executable : (\"C:\\\\Windows\\\\SoftwareDistribution\\\\*.exe\", \"C:\\\\Windows\\\\WinSxS\\\\*.exe\")\n ] by process.executable\n [file where event.type == \"deletion\" and file.extension : (\"exe\", \"scr\", \"com\")] by file.path\n", + "risk_score": 47, + "rule_id": "09443c92-46b3-45a4-8f25-383b028b258d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1070", + "name": "Indicator Removal on Host", + "reference": "https://attack.mitre.org/techniques/T1070/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_rundll32_no_arguments.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_rundll32_no_arguments.json index c2712f1574a7cb..b518ad072e6947 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_rundll32_no_arguments.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_rundll32_no_arguments.json @@ -11,7 +11,7 @@ "language": "eql", "license": "Elastic License", "name": "Unusual Child Processes of RunDLL32", - "query": "sequence with maxspan=1h\n [process where event.type in (\"start\", \"process_started\") and\n /* uncomment once in winlogbeat */\n (process.name : \"rundll32.exe\" /* or process.pe.original_file_name == \"RUNDLL32.EXE\" */ ) and\n process.args_count < 2\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"rundll32.exe\"\n ] by process.parent.entity_id\n", + "query": "sequence with maxspan=1h\n [process where event.type in (\"start\", \"process_started\") and\n (process.name : \"rundll32.exe\" or process.pe.original_file_name == \"RUNDLL32.EXE\") and\n process.args_count == 1\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"rundll32.exe\"\n ] by process.parent.entity_id\n", "risk_score": 21, "rule_id": "f036953a-4615-4707-a1ca-dc53bf69dcd5", "severity": "high", @@ -40,5 +40,5 @@ } ], "type": "eql", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_scheduledjobs_at_protocol_enabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_scheduledjobs_at_protocol_enabled.json new file mode 100644 index 00000000000000..6fa2c70520f686 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_scheduledjobs_at_protocol_enabled.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to enable the Windows scheduled tasks AT command via the registry. Attackers may use this method to move laterally or persist locally. The AT command has been deprecated since Windows 8 and Windows Server 2012, but still exists for backwards compatibility.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Scheduled Tasks AT Command Enabled", + "query": "registry where \n registry.path : \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Schedule\\\\Configuration\\\\EnableAt\" and registry.data.strings == \"1\"\n", + "references": [ + "https://docs.microsoft.com/en-us/windows/win32/cimwin32prov/win32-scheduledjob" + ], + "risk_score": 47, + "rule_id": "9aa0e1f6-52ce-42e1-abb3-09657cee2698", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json index 6fea9a3c789456..47df5a750c8291 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_sdelete_like_filename_rename.json @@ -8,11 +8,11 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "lucene", + "language": "eql", "license": "Elastic License", "name": "Potential Secure File Deletion via SDelete Utility", "note": "Verify process details such as command line and hash to confirm this activity legitimacy.", - "query": "event.category:file AND event.type:change AND file.name:/.+A+\\.AAA/", + "query": "file where event.type == \"change\" and wildcard(file.name,\"*AAA.AAA\")\n", "risk_score": 21, "rule_id": "5aee924b-6ceb-4633-980e-1bde8cdb40c5", "severity": "low", @@ -40,6 +40,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_stop_process_service_threshold.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_stop_process_service_threshold.json new file mode 100644 index 00000000000000..b638f05a732af0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_stop_process_service_threshold.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule identifies a high number (10) of process terminations (stop, delete, or suspend) from the same host within a short time period. This may indicate a defense evasion attempt.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "High Number of Process and/or Service Terminations", + "query": "event.category:process and event.type:start and process.name:(net.exe or sc.exe or taskkill.exe) and process.args:(stop or pause or delete or \"/PID\" or \"/IM\" or \"/T\" or \"/F\" or \"/t\" or \"/f\" or \"/im\" or \"/pid\")", + "risk_score": 47, + "rule_id": "035889c4-2686-4583-a7df-67f89c292f2c", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "threshold": { + "field": "host.id", + "value": 10 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json index fedeaca68ab643..728f2cee894340 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "High Number of Okta User Password Reset or Unlock Attempts", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:(system.email.account_unlock.sent_message or system.email.password_reset.sent_message or system.sms.send_account_unlock_message or system.sms.send_password_reset_message or system.voice.send_account_unlock_call or system.voice.send_password_reset_call or user.account.unlock_token)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -83,5 +83,5 @@ "value": 5 }, "type": "threshold", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_powershell_imgload.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_powershell_imgload.json new file mode 100644 index 00000000000000..9d0d327bc9a77e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_powershell_imgload.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the PowerShell engine being invoked by unexpected processes. Rather than executing PowerShell functionality with powershell.exe, some attackers do this to operate more stealthily.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious PowerShell Engine ImageLoad", + "query": "library where file.name : (\"System.Management.Automation.ni.dll\", \"System.Management.Automation.dll\") and\n/* add false positives relevant to your environment here */\nnot process.executable : (\"C:\\\\Windows\\\\System32\\\\RemoteFXvGPUDisablement.exe\", \"C:\\\\Windows\\\\System32\\\\sdiagnhost.exe\", \"C:\\\\Program Files*\\\\*.exe\") and\n not process.name : (\n \"Altaro.SubAgent.exe\",\n \"AppV_Manage.exe\",\n \"azureadconnect.exe\",\n \"CcmExec.exe\",\n \"configsyncrun.exe\",\n \"choco.exe\",\n \"ctxappvservice.exe\",\n \"DVLS.Console.exe\",\n \"edgetransport.exe\",\n \"exsetup.exe\",\n \"forefrontactivedirectoryconnector.exe\",\n \"InstallUtil.exe\",\n \"JenkinsOnDesktop.exe\",\n \"Microsoft.EnterpriseManagement.ServiceManager.UI.Console.exe\",\n \"mmc.exe\",\n \"mscorsvw.exe\",\n \"msexchangedelivery.exe\",\n \"msexchangefrontendtransport.exe\",\n \"msexchangehmworker.exe\",\n \"msexchangesubmission.exe\",\n \"msiexec.exe\",\n \"MsiExec.exe\",\n \"noderunner.exe\",\n \"NServiceBus.Host.exe\",\n \"NServiceBus.Host32.exe\",\n \"NServiceBus.Hosting.Azure.HostProcess.exe\",\n \"OuiGui.WPF.exe\",\n \"powershell.exe\",\n \"powershell_ise.exe\",\n \"pwsh.exe\",\n \"SCCMCliCtrWPF.exe\",\n \"ScriptEditor.exe\",\n \"ScriptRunner.exe\",\n \"sdiagnhost.exe\",\n \"servermanager.exe\",\n \"setup100.exe\",\n \"ServiceHub.VSDetouredHost.exe\",\n \"SPCAF.Client.exe\",\n \"SPCAF.SettingsEditor.exe\",\n \"SQLPS.exe\",\n \"telemetryservice.exe\",\n \"UMWorkerProcess.exe\",\n \"w3wp.exe\",\n \"wsmprovhost.exe\"\n )\n", + "risk_score": 47, + "rule_id": "852c1f19-68e8-43a6-9dce-340771fe1be3", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1086", + "name": "PowerShell", + "reference": "https://attack.mitre.org/techniques/T1086/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json index f3c20e5251184e..5ce48d1526466b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_suspicious_zoom_child_process.json @@ -3,18 +3,15 @@ "Elastic" ], "description": "A suspicious Zoom child process was detected, which may indicate an attempt to run unnoticed. Verify process details such as command line, network connections, file writes and associated file signature details as well.", - "false_positives": [ - "New Zoom Executable" - ], "from": "now-9m", "index": [ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Suspicious Zoom Child Process", - "query": "event.category:process and event.type:(start or process_started) and process.parent.name:Zoom.exe and not process.name:(Zoom.exe or WerFault.exe or airhost.exe or CptControl.exe or CptHost.exe or cpthost.exe or CptInstall.exe or CptService.exe or Installer.exe or zCrashReport.exe or Zoom_launcher.exe or zTscoder.exe or plugin_Launcher.exe or mDNSResponder.exe or zDevHelper.exe or APcptControl.exe or CrashSender*.exe or aomhost64.exe or Magnify.exe or m_plugin_launcher.exe or com.zoom.us.zTranscode.exe or RoomConnector.exe or tabtip.exe or Explorer.exe or chrome.exe or firefox.exe or iexplore.exe or outlook.exe or lync.exe or ApplicationFrameHost.exe or ZoomAirhostInstaller.exe or narrator.exe or NVDA.exe or Magnify.exe or Outlook.exe or m_plugin_launcher.exe or mphost.exe or APcptControl.exe or winword.exe or excel.exe or powerpnt.exe or ONENOTE.EXE or wpp.exe or debug_message.exe or zAssistant.exe or msiexec.exe or msedge.exe or dwm.exe or vcredist_x86.exe or Controller.exe or Installer.exe or CptInstall.exe or Zoom_launcher.exe or ShellExperienceHost.exe or wps.exe)", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.parent.name : \"Zoom.exe\" and process.name : (\"cmd.exe\", \"powershell.exe\", \"pwsh.exe\")\n", "risk_score": 47, "rule_id": "97aba1ef-6034-4bd3-8c1a-1e0996b27afa", "severity": "medium", @@ -38,17 +35,7 @@ "id": "T1036", "name": "Masquerading", "reference": "https://attack.mitre.org/techniques/T1036/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ + }, { "id": "T1055", "name": "Process Injection", @@ -57,6 +44,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_timestomp_touch.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_timestomp_touch.json new file mode 100644 index 00000000000000..d14103889a35e9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_timestomp_touch.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Timestomping is an anti-forensics technique which is used to modify the timestamps of a file, often to mimic files that are in the same folder.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "max_signals": 33, + "name": "Timestomping using Touch Command", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name == \"touch\" and wildcard(process.args, \"-r\", \"-t\", \"-a*\",\"-m*\")\n", + "risk_score": 47, + "rule_id": "b0046934-486e-462f-9487-0d4cf9e429c6", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Linux", + "macOS", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1099", + "name": "Timestomp", + "reference": "https://attack.mitre.org/techniques/T1099/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_dir_ads.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_dir_ads.json new file mode 100644 index 00000000000000..659a9333e694e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_unusual_dir_ads.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies processes running from an Alternate Data Stream. This is uncommon for legitimate processes and sometimes done by adversaries to hide malware.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Process Execution Path - Alternate Data Stream", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.args : \"C:\\\\*:*\"\n", + "risk_score": 47, + "rule_id": "4bd1c1af-79d4-4d37-9efa-6e0240640242", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1564", + "name": "Hide Artifacts", + "reference": "https://attack.mitre.org/techniques/T1564/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_adfind_command_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_adfind_command_activity.json new file mode 100644 index 00000000000000..23eae7093f0fa1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_adfind_command_activity.json @@ -0,0 +1,77 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule detects the Active Directory query tool, AdFind.exe. AdFind has legitimate purposes, but it is frequently leveraged by threat actors to perform post-exploitation Active Directory reconnaissance. The AdFind tool has been observed in Trickbot, Ryuk, Maze, and FIN6 campaigns. For Winlogbeat, this rule requires Sysmon.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "AdFind Command Activity", + "note": "`AdFind.exe` is a legitimate domain query tool. Rule alerts should be investigated to identify if the user has a role that would explain using this tool and that it is being run from an expected directory and endpoint. Leverage the exception workflow in the Kibana Security App or Elasticsearch API to tune this rule to your environment.", + "query": "process where event.type in (\"start\", \"process_started\") and \n (process.name : \"AdFind.exe\" or process.pe.original_file_name == \"AdFind.exe\") and \n process.args : (\"objectcategory=computer\", \"(objectcategory=computer)\", \n \"objectcategory=person\", \"(objectcategory=person)\",\n \"objectcategory=subnet\", \"(objectcategory=subnet)\",\n \"objectcategory=group\", \"(objectcategory=group)\", \n \"objectcategory=organizationalunit\", \"(objectcategory=organizationalunit)\",\n \"objectcategory=attributeschema\", \"(objectcategory=attributeschema)\",\n \"domainlist\", \"dcmodes\", \"adinfo\", \"dclist\", \"computers_pwnotreqd\", \"trustdmp\")\n", + "references": [ + "http://www.joeware.net/freetools/tools/adfind/", + "https://thedfirreport.com/2020/05/08/adfind-recon/", + "https://www.fireeye.com/blog/threat-research/2020/05/tactics-techniques-procedures-associated-with-maze-ransomware-incidents.html", + "https://www.cybereason.com/blog/dropping-anchor-from-a-trickbot-infection-to-the-discovery-of-the-anchor-malware", + "https://www.fireeye.com/blog/threat-research/2019/04/pick-six-intercepting-a-fin6-intrusion.html", + "https://usa.visa.com/dam/VCOM/global/support-legal/documents/fin6-cybercrime-group-expands-threat-To-ecommerce-merchants.pdf" + ], + "risk_score": 21, + "rule_id": "eda499b8-a073-4e35-9733-22ec71f57f3a", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1069", + "name": "Permission Groups Discovery", + "reference": "https://attack.mitre.org/techniques/T1069/", + "subtechnique": [ + { + "id": "T1069.002", + "name": "Domain Groups", + "reference": "https://attack.mitre.org/techniques/T1069/002/" + } + ] + }, + { + "id": "T1087", + "name": "Account Discovery", + "reference": "https://attack.mitre.org/techniques/T1087/", + "subtechnique": [ + { + "id": "T1087.002", + "name": "Domain Account", + "reference": "https://attack.mitre.org/techniques/T1087/002/" + } + ] + }, + { + "id": "T1482", + "name": "Domain Trust Discovery", + "reference": "https://attack.mitre.org/techniques/T1482/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_admin_recon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_admin_recon.json new file mode 100644 index 00000000000000..803cd6704d424e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_admin_recon.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies instances of lower privilege accounts enumerating Administrator accounts or groups using built-in Windows tools.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Enumeration of Administrator Accounts", + "query": "process where event.type in (\"start\", \"process_started\") and\n (((process.name : \"net.exe\" or process.pe.original_file_name == \"net.exe\") or\n ((process.name : \"net1.exe\" or process.pe.original_file_name == \"net1.exe\") and\n not process.parent.name : \"net.exe\")) and\n process.args : (\"group\", \"user\", \"localgroup\") and\n process.args : (\"admin\", \"Domain Admins\", \"Remote Desktop Users\", \"Enterprise Admins\", \"Organization Management\") and\n not process.args : \"/add\")\n\n or\n\n ((process.name : \"wmic.exe\" or process.pe.original_file_name == \"wmic.exe\") and\n process.args : (\"group\", \"useraccount\"))\n", + "risk_score": 21, + "rule_id": "871ea072-1b71-4def-b016-6278b505138d", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1069", + "name": "Permission Groups Discovery", + "reference": "https://attack.mitre.org/techniques/T1069/" + }, + { + "id": "T1087", + "name": "Account Discovery", + "reference": "https://attack.mitre.org/techniques/T1087/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_file_dir_discovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_file_dir_discovery.json new file mode 100644 index 00000000000000..0881e0b0843948 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_file_dir_discovery.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Enumeration of files and directories using built-in tools. Adversaries may use the information discovered to plan follow-on activity.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "File and Directory Discovery", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"cmd.exe\" or process.pe.original_file_name == \"Cmd.Exe\") and\n process.args : (\"dir\", \"tree\")\n\n", + "risk_score": 21, + "rule_id": "7b08314d-47a0-4b71-ae4e-16544176924f", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1083", + "name": "File and Directory Discovery", + "reference": "https://attack.mitre.org/techniques/T1083/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_view.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_view.json new file mode 100644 index 00000000000000..072472e9484223 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_view.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to enumerate hosts in a network using the built-in Windows net.exe tool.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Windows Network Enumeration", + "query": "process where event.type in (\"start\", \"process_started\") and\n ((process.name : \"net.exe\" or process.pe.original_file_name == \"net.exe\") or\n ((process.name : \"net1.exe\" or process.pe.original_file_name == \"net1.exe\") and\n not process.parent.name : \"net.exe\")) and\n (process.args : \"view\" or (process.args : \"time\" and process.args : \"\\\\\\\\*\"))\n\n\n /* expand when ancestory is available\n and not descendant of [process where event.type == (\"start\", \"process_started\") and process.name : \"cmd.exe\" and\n ((process.parent.name : \"userinit.exe\") or\n (process.parent.name : \"gpscript.exe\") or\n (process.parent.name : \"explorer.exe\" and\n process.args : \"C:\\\\*\\\\Start Menu\\\\Programs\\\\Startup\\\\*.bat*\"))]\n */\n", + "risk_score": 47, + "rule_id": "7b8bfc26-81d2-435e-965c-d722ee397ef1", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1018", + "name": "Remote System Discovery", + "reference": "https://attack.mitre.org/techniques/T1018/" + }, + { + "id": "T1135", + "name": "Network Share Discovery", + "reference": "https://attack.mitre.org/techniques/T1135/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_peripheral_device.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_peripheral_device.json new file mode 100644 index 00000000000000..8b0dc1d1c1ead4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_peripheral_device.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the Windows file system utility (fsutil.exe ) to gather information about attached peripheral devices and components connected to a computer system.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Peripheral Device Discovery", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"fsutil.exe\" or process.pe.original_file_name == \"fsutil.exe\") and \n process.args : \"fsinfo\" and process.args : \"drives\"\n", + "risk_score": 21, + "rule_id": "0c7ca5c2-728d-4ad9-b1c5-bbba83ecb1f4", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1120", + "name": "Peripheral Device Discovery", + "reference": "https://attack.mitre.org/techniques/T1120/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_query_registry_via_reg.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_query_registry_via_reg.json new file mode 100644 index 00000000000000..23e0cf236ffd4b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_query_registry_via_reg.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Enumeration or discovery of the Windows registry using reg.exe. This information can be used to perform follow-on activities.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Query Registry via reg.exe", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"reg.exe\" or process.pe.original_file_name == \"reg.exe\") and\n process.args == \"query\"\n", + "risk_score": 21, + "rule_id": "68113fdc-3105-4cdd-85bb-e643c416ef0b", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1012", + "name": "Query Registry", + "reference": "https://attack.mitre.org/techniques/T1012/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_remote_system_discovery_commands_windows.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_remote_system_discovery_commands_windows.json new file mode 100644 index 00000000000000..fa879cc9301dcf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_remote_system_discovery_commands_windows.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Discovery of remote system information using built-in commands, which may be used to mover laterally.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote System Discovery Commands", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"nbtstat.exe\" and process.args : (\"-n\", \"-s\")) or\n (process.name : \"arp.exe\" and process.args : \"-a\")\n", + "risk_score": 21, + "rule_id": "0635c542-1b96-4335-9b47-126582d2c19a", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1018", + "name": "Remote System Discovery", + "reference": "https://attack.mitre.org/techniques/T1018/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_security_software_wmic.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_security_software_wmic.json new file mode 100644 index 00000000000000..32880a5342ffed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_security_software_wmic.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of Windows Management Instrumentation Command (WMIC) to discover certain System Security Settings such as AntiVirus or Host Firewall details.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Security Software Discovery using WMIC", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name:\"wmic.exe\" or process.pe.original_file_name:\"wmic.exe\") and\n process.args:\"/namespace:\\\\\\\\root\\\\SecurityCenter2\" and process.args:\"Get\"\n", + "risk_score": 47, + "rule_id": "6ea55c81-e2ba-42f2-a134-bccf857ba922", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Discovery" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0007", + "name": "Discovery", + "reference": "https://attack.mitre.org/tactics/TA0007/" + }, + "technique": [ + { + "id": "T1518", + "name": "Software Discovery", + "reference": "https://attack.mitre.org/techniques/T1518/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/domain_added_to_google_workspace_trusted_domains.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/domain_added_to_google_workspace_trusted_domains.json new file mode 100644 index 00000000000000..e3c06cae1c2295 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/domain_added_to_google_workspace_trusted_domains.json @@ -0,0 +1,35 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a domain is added to the list of trusted Google Workspace domains. An adversary may add a trusted domain in order to collect and exfiltrate data from their target\u2019s organization with less restrictive security controls.", + "false_positives": [ + "Trusted domains may be added by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Domain Added to Google Workspace Trusted Domains", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:ADD_TRUSTED_DOMAINS", + "references": [ + "https://support.google.com/a/answer/6160020?hl=en" + ], + "risk_score": 73, + "rule_id": "cf549724-c577-4fd6-8f9b-d1b8ec519ec0", + "severity": "high", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json index 220a7f94dce9a1..df561f4c0ee1ce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json @@ -35,17 +35,7 @@ "id": "T1059", "name": "Command and Scripting Interpreter", "reference": "https://attack.mitre.org/techniques/T1059/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ + }, { "id": "T1086", "name": "PowerShell", @@ -55,5 +45,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_via_rundll32.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_via_rundll32.json new file mode 100644 index 00000000000000..f4808c7e12670e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_via_rundll32.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies command shell activity started via RunDLL32, which is commonly abused by attackers to host malicious code.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Command Shell Activity Started via RunDLL32", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name : (\"cmd.exe\", \"powershell.exe\") and\n process.parent.name : \"rundll32.exe\" and \n /* common FPs can be added here */\n not process.parent.args : \"C:\\\\Windows\\\\System32\\\\SHELL32.dll,RunAsNewUser_RunDLL\"\n", + "risk_score": 21, + "rule_id": "9ccf3ce0-0057-440a-91f5-870c6ad39093", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + }, + { + "id": "T1086", + "name": "PowerShell", + "reference": "https://attack.mitre.org/techniques/T1086/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_directory.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_directory.json new file mode 100644 index 00000000000000..5df91110a8dfe5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_directory.json @@ -0,0 +1,27 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies process execution from suspicious default Windows directories. This is sometimes done by adversaries to hide malware in trusted paths.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Process Execution from an Unusual Directory", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n /* add suspicious execution paths here */\nprocess.executable : (\"C:\\\\PerfLogs\\\\*.exe\",\"C:\\\\Users\\\\Public\\\\*.exe\",\"C:\\\\Users\\\\Default\\\\*.exe\",\"C:\\\\Windows\\\\Tasks\\\\*.exe\",\"C:\\\\Intel\\\\*.exe\",\"C:\\\\AMD\\\\Temp\\\\*.exe\",\"C:\\\\Windows\\\\AppReadiness\\\\*.exe\",\n\"C:\\\\Windows\\\\ServiceState\\\\*.exe\",\"C:\\\\Windows\\\\security\\\\*.exe\",\"C:\\\\Windows\\\\IdentityCRL\\\\*.exe\",\"C:\\\\Windows\\\\Branding\\\\*.exe\",\"C:\\\\Windows\\\\csc\\\\*.exe\",\n \"C:\\\\Windows\\\\DigitalLocker\\\\*.exe\",\"C:\\\\Windows\\\\en-US\\\\*.exe\",\"C:\\\\Windows\\\\wlansvc\\\\*.exe\",\"C:\\\\Windows\\\\Prefetch\\\\*.exe\",\"C:\\\\Windows\\\\Fonts\\\\*.exe\",\n \"C:\\\\Windows\\\\diagnostics\\\\*.exe\",\"C:\\\\Windows\\\\TAPI\\\\*.exe\",\"C:\\\\Windows\\\\INF\\\\*.exe\",\"C:\\\\Windows\\\\System32\\\\Speech\\\\*.exe\",\"C:\\\\windows\\\\tracing\\\\*.exe\",\n \"c:\\\\windows\\\\IME\\\\*.exe\",\"c:\\\\Windows\\\\Performance\\\\*.exe\",\"c:\\\\windows\\\\intel\\\\*.exe\",\"c:\\\\windows\\\\ms\\\\*.exe\",\"C:\\\\Windows\\\\dot3svc\\\\*.exe\",\"C:\\\\Windows\\\\ServiceProfiles\\\\*.exe\",\n \"C:\\\\Windows\\\\panther\\\\*.exe\",\"C:\\\\Windows\\\\RemotePackages\\\\*.exe\",\"C:\\\\Windows\\\\OCR\\\\*.exe\",\"C:\\\\Windows\\\\appcompat\\\\*.exe\",\"C:\\\\Windows\\\\apppatch\\\\*.exe\",\"C:\\\\Windows\\\\addins\\\\*.exe\",\n \"C:\\\\Windows\\\\Setup\\\\*.exe\",\"C:\\\\Windows\\\\Help\\\\*.exe\",\"C:\\\\Windows\\\\SKB\\\\*.exe\",\"C:\\\\Windows\\\\Vss\\\\*.exe\",\"C:\\\\Windows\\\\Web\\\\*.exe\",\"C:\\\\Windows\\\\servicing\\\\*.exe\",\"C:\\\\Windows\\\\CbsTemp\\\\*.exe\",\n \"C:\\\\Windows\\\\Logs\\\\*.exe\",\"C:\\\\Windows\\\\WaaS\\\\*.exe\",\"C:\\\\Windows\\\\twain_32\\\\*.exe\",\"C:\\\\Windows\\\\ShellExperiences\\\\*.exe\",\"C:\\\\Windows\\\\ShellComponents\\\\*.exe\",\"C:\\\\Windows\\\\PLA\\\\*.exe\",\n \"C:\\\\Windows\\\\Migration\\\\*.exe\",\"C:\\\\Windows\\\\debug\\\\*.exe\",\"C:\\\\Windows\\\\Cursors\\\\*.exe\",\"C:\\\\Windows\\\\Containers\\\\*.exe\",\"C:\\\\Windows\\\\Boot\\\\*.exe\",\"C:\\\\Windows\\\\bcastdvr\\\\*.exe\",\n \"C:\\\\Windows\\\\assembly\\\\*.exe\",\"C:\\\\Windows\\\\TextInput\\\\*.exe\",\"C:\\\\Windows\\\\security\\\\*.exe\",\"C:\\\\Windows\\\\schemas\\\\*.exe\",\"C:\\\\Windows\\\\SchCache\\\\*.exe\",\"C:\\\\Windows\\\\Resources\\\\*.exe\",\n \"C:\\\\Windows\\\\rescache\\\\*.exe\",\"C:\\\\Windows\\\\Provisioning\\\\*.exe\",\"C:\\\\Windows\\\\PrintDialog\\\\*.exe\",\"C:\\\\Windows\\\\PolicyDefinitions\\\\*.exe\",\"C:\\\\Windows\\\\media\\\\*.exe\",\n \"C:\\\\Windows\\\\Globalization\\\\*.exe\",\"C:\\\\Windows\\\\L2Schemas\\\\*.exe\",\"C:\\\\Windows\\\\LiveKernelReports\\\\*.exe\",\"C:\\\\Windows\\\\ModemLogs\\\\*.exe\",\"C:\\\\Windows\\\\ImmersiveControlPanel\\\\*.exe\") and\n not process.name : (\"SpeechUXWiz.exe\",\"SystemSettings.exe\",\"TrustedInstaller.exe\",\"PrintDialog.exe\",\"MpSigStub.exe\",\"LMS.exe\",\"mpam-*.exe\")\n /* uncomment once in winlogbeat */\n /* and not (process.code_signature.subject_name == \"Microsoft Corporation\" and process.code_signature.trusted == true) */\n", + "risk_score": 47, + "rule_id": "ebfe1448-7fac-4d59-acea-181bd89b1f7f", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_path_cmdline.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_path_cmdline.json new file mode 100644 index 00000000000000..6c3ffac1d46059 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_from_unusual_path_cmdline.json @@ -0,0 +1,28 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies process execution from suspicious default Windows directories. This may be abused by adversaries to hide malware in trusted paths.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution from Unusual Directory - Command Line", + "note": "This is related to the Process Execution from an Unusual Directory rule", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.name : (\"wscript.exe\",\"cscript.exe\",\"rundll32.exe\",\"regsvr32.exe\",\"cmstp.exe\",\"RegAsm.exe\",\"installutil.exe\",\"mshta.exe\",\"RegSvcs.exe\") and\n /* add suspicious execution paths here */\nprocess.args : (\"C:\\\\PerfLogs\\\\*\",\"C:\\\\Users\\\\Public\\\\*\",\"C:\\\\Users\\\\Default\\\\*\",\"C:\\\\Windows\\\\Tasks\\\\*\",\"C:\\\\Intel\\\\*\", \"C:\\\\AMD\\\\Temp\\\\*\", \n \"C:\\\\Windows\\\\AppReadiness\\\\*\", \"C:\\\\Windows\\\\ServiceState\\\\*\",\"C:\\\\Windows\\\\security\\\\*\",\"C:\\\\Windows\\\\IdentityCRL\\\\*\",\"C:\\\\Windows\\\\Branding\\\\*\",\"C:\\\\Windows\\\\csc\\\\*\",\n \"C:\\\\Windows\\\\DigitalLocker\\\\*\",\"C:\\\\Windows\\\\en-US\\\\*\",\"C:\\\\Windows\\\\wlansvc\\\\*\",\"C:\\\\Windows\\\\Prefetch\\\\*\",\"C:\\\\Windows\\\\Fonts\\\\*\",\n \"C:\\\\Windows\\\\diagnostics\\\\*\",\"C:\\\\Windows\\\\TAPI\\\\*\",\"C:\\\\Windows\\\\INF\\\\*\",\"C:\\\\Windows\\\\System32\\\\Speech\\\\*\",\"C:\\\\windows\\\\tracing\\\\*\",\n \"c:\\\\windows\\\\IME\\\\*\",\"c:\\\\Windows\\\\Performance\\\\*\",\"c:\\\\windows\\\\intel\\\\*\",\"c:\\\\windows\\\\ms\\\\*\",\"C:\\\\Windows\\\\dot3svc\\\\*\",\"C:\\\\Windows\\\\ServiceProfiles\\\\*\",\n \"C:\\\\Windows\\\\panther\\\\*\",\"C:\\\\Windows\\\\RemotePackages\\\\*\",\"C:\\\\Windows\\\\OCR\\\\*\",\"C:\\\\Windows\\\\appcompat\\\\*\",\"C:\\\\Windows\\\\apppatch\\\\*\",\"C:\\\\Windows\\\\addins\\\\*\",\n \"C:\\\\Windows\\\\Setup\\\\*\",\"C:\\\\Windows\\\\Help\\\\*\",\"C:\\\\Windows\\\\SKB\\\\*\",\"C:\\\\Windows\\\\Vss\\\\*\",\"C:\\\\Windows\\\\Web\\\\*\",\"C:\\\\Windows\\\\servicing\\\\*\",\"C:\\\\Windows\\\\CbsTemp\\\\*\",\n \"C:\\\\Windows\\\\Logs\\\\*\",\"C:\\\\Windows\\\\WaaS\\\\*\",\"C:\\\\Windows\\\\twain_32\\\\*\",\"C:\\\\Windows\\\\ShellExperiences\\\\*\",\"C:\\\\Windows\\\\ShellComponents\\\\*\",\"C:\\\\Windows\\\\PLA\\\\*\",\n \"C:\\\\Windows\\\\Migration\\\\*\",\"C:\\\\Windows\\\\debug\\\\*\",\"C:\\\\Windows\\\\Cursors\\\\*\",\"C:\\\\Windows\\\\Containers\\\\*\",\"C:\\\\Windows\\\\Boot\\\\*\",\"C:\\\\Windows\\\\bcastdvr\\\\*\",\n \"C:\\\\Windows\\\\assembly\\\\*\",\"C:\\\\Windows\\\\TextInput\\\\*\",\"C:\\\\Windows\\\\security\\\\*\",\"C:\\\\Windows\\\\schemas\\\\*\",\"C:\\\\Windows\\\\SchCache\\\\*\",\"C:\\\\Windows\\\\Resources\\\\*\",\n \"C:\\\\Windows\\\\rescache\\\\*\",\"C:\\\\Windows\\\\Provisioning\\\\*\",\"C:\\\\Windows\\\\PrintDialog\\\\*\",\"C:\\\\Windows\\\\PolicyDefinitions\\\\*\",\"C:\\\\Windows\\\\media\\\\*\",\n \"C:\\\\Windows\\\\Globalization\\\\*\",\"C:\\\\Windows\\\\L2Schemas\\\\*\",\"C:\\\\Windows\\\\LiveKernelReports\\\\*\",\"C:\\\\Windows\\\\ModemLogs\\\\*\",\"C:\\\\Windows\\\\ImmersiveControlPanel\\\\*\")\n", + "risk_score": 47, + "rule_id": "cff92c41-2225-4763-b4ce-6f71e5bda5e6", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripting_osascript_exec_followed_by_netcon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripting_osascript_exec_followed_by_netcon.json new file mode 100644 index 00000000000000..c4d58dd594c2ab --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripting_osascript_exec_followed_by_netcon.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects execution via the Apple script interpreter (osascript) followed by a network connection from the same process within a short time period. Adversaries may use malicious scripts for execution and command and control.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Apple Script Execution followed by Network Connection", + "query": "sequence by host.id, process.entity_id with maxspan=30s\n [process where event.type == \"start\" and process.name == \"osascript\"]\n [network where event.type != \"end\" and process.name == \"osascript\" and destination.ip != \"::1\" and\n not cidrmatch(destination.ip, \"10.0.0.0/8\", \n \"172.16.0.0/12\", \n \"192.168.0.0/16\", \n \"127.0.0.0/8\", \n \"169.254.0.0/16\", \n \"224.0.0.0/4\", \n \"FE80::/10\", \n \"FF00::/8\")\n ]\n", + "references": [ + "https://developer.apple.com/library/archive/documentation/LanguagesUtilities/Conceptual/MacAutomationScriptingGuide/index.html" + ], + "risk_score": 47, + "rule_id": "47f76567-d58a-4fed-b32b-21f571e28910", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Command and Control", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0011", + "name": "Command and Control", + "reference": "https://attack.mitre.org/tactics/TA0011/" + }, + "technique": [ + { + "id": "T1105", + "name": "Ingress Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1105/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripts_process_started_via_wmi.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripts_process_started_via_wmi.json new file mode 100644 index 00000000000000..a6bf38f6880aea --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_scripts_process_started_via_wmi.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the built-in Windows script interpreters (cscript.exe or wscript.exe) being used to execute a process via Windows Management Instrumentation (WMI). This may be indicative of malicious activity.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Windows Script Interpreter Executing Process via WMI", + "query": "sequence by host.id with maxspan=5s\n [library where file.name : \"wmiutils.dll\" and process.name : (\"wscript.exe\", \"cscript.exe\")]\n [process where event.type in (\"start\", \"process_started\") and\n process.parent.name : \"wmiprvse.exe\" and\n user.domain != \"NT AUTHORITY\" and\n (process.pe.original_file_name in\n (\n \"cscript.exe\",\n \"wscript.exe\",\n \"PowerShell.EXE\",\n \"Cmd.Exe\",\n \"MSHTA.EXE\",\n \"RUNDLL32.EXE\",\n \"REGSVR32.EXE\",\n \"MSBuild.exe\",\n \"InstallUtil.exe\",\n \"RegAsm.exe\",\n \"RegSvcs.exe\",\n \"msxsl.exe\",\n \"CONTROL.EXE\",\n \"EXPLORER.EXE\",\n \"Microsoft.Workflow.Compiler.exe\",\n \"msiexec.exe\"\n ) or\n process.executable : (\"C:\\\\Users\\\\*.exe\", \"C:\\\\ProgramData\\\\*.exe\")\n )\n ]\n", + "risk_score": 47, + "rule_id": "b64b183e-1a76-422d-9179-7b389513e74d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + }, + { + "id": "T1047", + "name": "Windows Management Instrumentation", + "reference": "https://attack.mitre.org/techniques/T1047/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shared_modules_local_sxs_dll.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shared_modules_local_sxs_dll.json new file mode 100644 index 00000000000000..9a8774efffb982 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shared_modules_local_sxs_dll.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation, change, or deletion of a DLL module within a Windows SxS local folder. Adversaries may abuse shared modules to execute malicious payloads by instructing the Windows module loader to load DLLs from arbitrary local paths.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution via local SxS Shared Module", + "note": "The SxS DotLocal folder is a legitimate feature that can be abused to hijack standard modules loading order by forcing an executable on the same application.exe.local folder to load a malicious DLL module from the same directory.", + "query": "file where file.extension : \"dll\" and file.path : \"C:\\\\*\\\\*.exe.local\\\\*.dll\"\n", + "references": [ + "https://docs.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-redirection" + ], + "risk_score": 43, + "rule_id": "a3ea12f3-0d4e-4667-8b44-4230c63f3c75", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1129", + "name": "Shared Modules", + "reference": "https://attack.mitre.org/techniques/T1129/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shell_execution_via_apple_scripting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shell_execution_via_apple_scripting.json new file mode 100644 index 00000000000000..e5c41d27cb4e61 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_shell_execution_via_apple_scripting.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the execution of the shell process (sh) via scripting (JXA or AppleScript). Adversaries may use the doShellScript functionality in JXA or do shell script in AppleScript to execute system commands.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Shell Execution via Apple Scripting", + "query": "sequence by host.id with maxspan=5s\n [process where event.type in (\"start\", \"process_started\", \"info\") and process.name == \"osascript\"] by process.pid\n [process where event.type in (\"start\", \"process_started\") and process.name == \"sh\" and process.args == \"-c\"] by process.ppid\n", + "references": [ + "https://developer.apple.com/library/archive/technotes/tn2065/_index.html", + "https://objectivebythesea.com/v2/talks/OBTS_v2_Thomas.pdf" + ], + "risk_score": 47, + "rule_id": "d461fac0-43e8-49e2-85ea-3a58fe120b4f", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_image_load_wmi_ms_office.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_image_load_wmi_ms_office.json new file mode 100644 index 00000000000000..fb292015f4ae3b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_image_load_wmi_ms_office.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious image load (wmiutils.dll) from Microsoft Office processes. This behavior may indicate adversarial activity where child processes are spawned via Windows Management Instrumentation (WMI). This technique can be used to execute code and evade traditional parent/child processes spawned from MS Office products.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious WMI Image Load from MS Office", + "query": "library where process.name in (\"WINWORD.EXE\", \"EXCEL.EXE\", \"POWERPNT.EXE\", \"MSPUB.EXE\", \"MSACCESS.EXE\") and\n event.action == \"load\" and\n event.category == \"library\" and\n file.name == \"wmiutils.dll\"\n", + "references": [ + "https://medium.com/threatpunter/detecting-adversary-tradecraft-with-image-load-event-logging-and-eql-8de93338c16" + ], + "risk_score": 21, + "rule_id": "891cb88e-441a-4c3e-be2d-120d99fe7b0d", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1047", + "name": "Windows Management Instrumentation", + "reference": "https://attack.mitre.org/techniques/T1047/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json index 90c60ceea37abf..03e759e0529ba2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json @@ -8,13 +8,13 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Suspicious MS Office Child Process", - "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(eqnedt32.exe or excel.exe or fltldr.exe or msaccess.exe or mspub.exe or powerpnt.exe or winword.exe) and process.name:(Microsoft.Workflow.Compiler.exe or arp.exe or atbroker.exe or bginfo.exe or bitsadmin.exe or cdb.exe or certutil.exe or cmd.exe or cmstp.exe or cscript.exe or csi.exe or dnx.exe or dsget.exe or dsquery.exe or forfiles.exe or fsi.exe or ftp.exe or gpresult.exe or hostname.exe or ieexec.exe or iexpress.exe or installutil.exe or ipconfig.exe or mshta.exe or msxsl.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or odbcconf.exe or ping.exe or powershell.exe or pwsh.exe or qprocess.exe or quser.exe or qwinsta.exe or rcsi.exe or reg.exe or regasm.exe or regsvcs.exe or regsvr32.exe or sc.exe or schtasks.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or wmic.exe or wscript.exe or xwizard.exe)", - "risk_score": 21, + "query": "process where event.type in (\"start\", \"process_started\") and\n process.parent.name : (\"eqnedt32.exe\", \"excel.exe\", \"fltldr.exe\", \"msaccess.exe\", \"mspub.exe\", \"powerpnt.exe\", \"winword.exe\") and\n process.name : (\"Microsoft.Workflow.Compiler.exe\", \"arp.exe\", \"atbroker.exe\", \"bginfo.exe\", \"bitsadmin.exe\", \"cdb.exe\", \"certutil.exe\",\n \"cmd.exe\", \"cmstp.exe\", \"cscript.exe\", \"csi.exe\", \"dnx.exe\", \"dsget.exe\", \"dsquery.exe\", \"forfiles.exe\", \"fsi.exe\",\n \"ftp.exe\", \"gpresult.exe\", \"hostname.exe\", \"ieexec.exe\", \"iexpress.exe\", \"installutil.exe\", \"ipconfig.exe\", \"mshta.exe\",\n \"msxsl.exe\", \"nbtstat.exe\", \"net.exe\", \"net1.exe\", \"netsh.exe\", \"netstat.exe\", \"nltest.exe\", \"odbcconf.exe\", \"ping.exe\",\n \"powershell.exe\", \"pwsh.exe\", \"qprocess.exe\", \"quser.exe\", \"qwinsta.exe\", \"rcsi.exe\", \"reg.exe\", \"regasm.exe\", \"regsvcs.exe\",\n \"regsvr32.exe\", \"sc.exe\", \"schtasks.exe\", \"systeminfo.exe\", \"tasklist.exe\", \"tracert.exe\", \"whoami.exe\",\n \"wmic.exe\", \"wscript.exe\", \"xwizard.exe\", \"explorer.exe\", \"rundll32.exe\", \"hh.exe\")\n", + "risk_score": 47, "rule_id": "a624863f-a70d-417f-a7d2-7a404638d47f", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Host", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 5 + "type": "eql", + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_psexesvc.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_psexesvc.json index 205b5148f2fb4d..039953a9fccd82 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_psexesvc.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_psexesvc.json @@ -8,10 +8,10 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Suspicious Process Execution via Renamed PsExec Executable", - "query": "event.category:process and event.type:(start or process_started) and (process.pe.original_file_name:(psexesvc.exe or PSEXESVC.exe) or winlog.event_data.OriginalFileName:(psexesvc.exe or PSEXESVC.exe)) and process.parent.name:services.exe and not process.name:(psexesvc.exe or PSEXESVC.exe)", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.pe.original_file_name : \"psexesvc.exe\" and not process.name : \"PSEXESVC.exe\"\n", "risk_score": 47, "rule_id": "e2f9fdf5-8076-45ad-9427-41e0e03dc9c2", "severity": "medium", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_short_program_name.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_short_program_name.json new file mode 100644 index 00000000000000..a8d91c127826e8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_short_program_name.json @@ -0,0 +1,27 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies process execution with a single character process name. This is often done by adversaries while staging or executing temporary utilities.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Execution - Short Program Name", + "query": "process where event.type in (\"start\", \"process_started\") and length(process.name) > 0 and\n length(process.name) == 5 and host.os.name == \"Windows\" and length(process.pe.original_file_name) > 5\n", + "risk_score": 47, + "rule_id": "17c7f6a5-5bc9-4e1f-92bf-13632d24384d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json index fb77c4c78240cd..38454b3de3c691 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_dns_service_file_writes.json @@ -11,7 +11,7 @@ "license": "Elastic License", "name": "Unusual File Modification by dns.exe", "note": "### Investigating Unusual File Write\nDetection alerts from this rule indicate potential unusual/abnormal file writes from the DNS Server service process (`dns.exe`) after exploitation from CVE-2020-1350 (SigRed) has occurred. Here are some possible avenues of investigation:\n- Post-exploitation, adversaries may write additional files or payloads to the system as additional discovery/exploitation/persistence mechanisms.\n- Any suspicious or abnormal files written from `dns.exe` should be reviewed and investigated with care.", - "query": "event.category:file and process.name:dns.exe and not file.name:dns.log", + "query": "event.category:file and process.name:dns.exe and event.type:(creation or deletion or change) and not file.name:dns.log", "references": [ "https://research.checkpoint.com/2020/resolving-your-way-into-domain-admin-exploiting-a-17-year-old-bug-in-windows-dns-servers/", "https://msrc-blog.microsoft.com/2020/07/14/july-2020-security-update-cve-2020-1350-vulnerability-in-windows-domain-name-system-dns-server/" @@ -44,5 +44,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json index 7a7aec00cc8879..573b36ed2decf9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies unusual instances of rundll32.exe making outbound network connections. This may indicate adversarial activity and may identify malicious DLLs.", + "description": "Identifies unusual instances of rundll32.exe making outbound network connections. This may indicate adversarial Command and Control activity.", "from": "now-9m", "index": [ "winlogbeat-*", @@ -11,10 +11,11 @@ "language": "eql", "license": "Elastic License", "name": "Unusual Network Connection via RunDLL32", - "query": "sequence by process.entity_id\n [process where process.name : \"rundll32.exe\" and event.type == \"start\"]\n [network where process.name : \"rundll32.exe\" and\n not cidrmatch(destination.ip, \"10.0.0.0/8\", \"172.16.0.0/12\", \"192.168.0.0/16\", \"127.0.0.0/8\")]\n", - "risk_score": 21, + "query": "sequence by host.id, process.entity_id with maxspan=1m\n [process where event.type in (\"start\", \"process_started\", \"info\") and process.name : \"rundll32.exe\" and process.args_count == 1]\n [network where process.name : \"rundll32.exe\" and network.protocol != \"dns\" and network.direction == \"outgoing\" and\n not cidrmatch(destination.ip, \"10.0.0.0/8\", \"172.16.0.0/12\", \"192.168.0.0/16\", \"127.0.0.0/8\")]\n", + "risk_score": 47, + "rule_id": "52aaab7b-b51c-441a-89ce-4387b3aea886", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Host", @@ -40,5 +41,5 @@ } ], "type": "eql", - "version": 6 + "version": 7 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_explorer_suspicious_child_parent_args.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_explorer_suspicious_child_parent_args.json new file mode 100644 index 00000000000000..001b2d4043b4d9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_explorer_suspicious_child_parent_args.json @@ -0,0 +1,54 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious Windows explorer child process. Explorer.exe can be abused to launch malicious scripts or executables from a trusted parent process.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Explorer Child Process", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name : (\"cscript.exe\", \"wscript.exe\", \"powershell.exe\", \"rundll32.exe\", \"cmd.exe\", \"mshta.exe\", \"regsvr32.exe\") and\n /* Explorer started via DCOM */\n process.parent.name : \"explorer.exe\" and process.parent.args : \"-Embedding\"\n", + "risk_score": 43, + "rule_id": "9a5b4e31-6cde-4295-9ff7-6be1b8567e1b", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1064", + "name": "Scripting", + "reference": "https://attack.mitre.org/techniques/T1064/" + }, + { + "id": "T1192", + "name": "Spearphishing Link", + "reference": "https://attack.mitre.org/techniques/T1192/" + }, + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json index 13493a90e3e505..1e734bbc247abe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json @@ -44,17 +44,7 @@ "id": "T1064", "name": "Scripting", "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ + }, { "id": "T1086", "name": "PowerShell", @@ -64,5 +54,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_creation.json new file mode 100644 index 00000000000000..e56773fc9a004d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_creation.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a transport rule creation in Microsoft 365. Exchange Online mail transport rules should be set to not forward email to domains outside of your organization as a best practice. An adversary may create transport rules to exfiltrate data.", + "false_positives": [ + "A new transport rule may be created by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Transport Rule Creation", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"New-TransportRule\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/new-transportrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + ], + "risk_score": 47, + "rule_id": "ff4dd44a-0ac6-44c4-8609-3f81bc820f02", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1537", + "name": "Transfer Data to Cloud Account", + "reference": "https://attack.mitre.org/techniques/T1537/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_mod.json new file mode 100644 index 00000000000000..1b8243c67d1a26 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_microsoft_365_exchange_transport_rule_mod.json @@ -0,0 +1,53 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a transport rule has been disabled or deleted in Microsoft 365. Mail flow rules (also known as transport rules) are used to identify and take action on messages that flow through your organization. An adversary or insider threat may modify a transport rule to exfiltrate data or evade defenses.", + "false_positives": [ + "A transport rule may be modified by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Transport Rule Modification", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:(\"Remove-TransportRule\" or \"Disable-TransportRule\") and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-transportrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/powershell/module/exchange/disable-transportrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + ], + "risk_score": 47, + "rule_id": "272a6484-2663-46db-a532-ef734bf9a796", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1537", + "name": "Transfer Data to Cloud Account", + "reference": "https://attack.mitre.org/techniques/T1537/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_winrar_encryption.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_winrar_encryption.json new file mode 100644 index 00000000000000..8be2a1f77f0a79 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_winrar_encryption.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of WinRar or 7z to create an encrypted files. Adversaries will often compress and encrypt data in preparation for exfiltration.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Encrypting Files with WinRar or 7z", + "query": "process where event.type in (\"start\", \"process_started\") and\n ((process.name:\"rar.exe\" or process.code_signature.subject_name == \"win.rar GmbH\" or\n process.pe.original_file_name == \"Command line RAR\") and\n process.args == \"a\" and process.args : (\"-hp*\", \"-p*\", \"-dw\", \"-tb\", \"-ta\", \"/hp*\", \"/p*\", \"/dw\", \"/tb\", \"/ta\"))\n\n or\n (process.pe.original_file_name in (\"7z.exe\", \"7za.exe\") and\n process.args == \"a\" and process.args : (\"-p*\", \"-sdel\"))\n\n /* uncomment if noisy for backup software related FPs */\n /* not process.parent.executable : (\"C:\\\\Program Files\\\\*.exe\", \"C:\\\\Program Files (x86)\\\\*.exe\") */\n", + "references": [ + "https://www.welivesecurity.com/2020/12/02/turla-crutch-keeping-back-door-open/" + ], + "risk_score": 47, + "rule_id": "45d273fb-1dca-457d-9855-bcb302180c21", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Exfiltration" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1560", + "name": "Archive Collected Data", + "reference": "https://attack.mitre.org/techniques/T1560/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_admin_role_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_admin_role_deletion.json new file mode 100644 index 00000000000000..3703faa62ddf3d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_admin_role_deletion.json @@ -0,0 +1,35 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a custom admin role is deleted. An adversary may delete a custom admin role in order to impact the permissions or capabilities of system administrators.", + "false_positives": [ + "Google Workspace admin roles may be deleted by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace Admin Role Deletion", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:DELETE_ROLE", + "references": [ + "https://support.google.com/a/answer/2406043?hl=en" + ], + "risk_score": 47, + "rule_id": "93e63c3e-4154-4fc6-9f86-b411e0987bbf", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_mfa_enforcement_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_mfa_enforcement_disabled.json new file mode 100644 index 00000000000000..9fcf76532bdd9b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_mfa_enforcement_disabled.json @@ -0,0 +1,35 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when multi-factor authentication (MFA) enforcement is disabled for Google Workspace users. An adversary may disable MFA enforcement in order to weaken an organization\u2019s security controls.", + "false_positives": [ + "MFA policies may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace MFA Enforcement Disabled", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:ENFORCE_STRONG_AUTHENTICATION and gsuite.admin.new_value:false", + "references": [ + "https://support.google.com/a/answer/9176657?hl=en#" + ], + "risk_score": 47, + "rule_id": "cad4500a-abd7-4ef3-b5d3-95524de7cfe1", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_policy_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_policy_modified.json new file mode 100644 index 00000000000000..2db5d9260730ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/google_workspace_policy_modified.json @@ -0,0 +1,32 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a Google Workspace password policy is modified. An adversary may attempt to modify a password policy in order to weaken an organization\u2019s security controls.", + "false_positives": [ + "Password policies may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace Password Policy Modified", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:(CHANGE_APPLICATION_SETTING or CREATE_APPLICATION_SETTING) and gsuite.admin.setting.name:( \"Password Management - Enforce strong password\" or \"Password Management - Password reset frequency\" or \"Password Management - Enable password reuse\" or \"Password Management - Enforce password policy at next login\" or \"Password Management - Minimum password length\" or \"Password Management - Maximum password length\" )", + "risk_score": 47, + "rule_id": "a99f82f5-8e77-4f8b-b3ce-10c0f6afbc73", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json index f2ad30fa260204..e0bf31d9596bdb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json @@ -13,7 +13,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Revoke Okta API Token", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:system.api_token.revoke", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -48,5 +48,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json index d1852478c666fa..7d4acfc42db9c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to disrupt an organization's business operations by performing a denial of service (DoS) attack against its Okta infrastructure.", + "description": "Detects possible Denial of Service (DoS) attacks against an Okta organization. An adversary may attempt to disrupt an organization's business operations by performing a DoS attack against its Okta service.", "index": [ "filebeat-*", "logs-okta*" @@ -10,7 +10,7 @@ "language": "kuery", "license": "Elastic License", "name": "Possible Okta DoS Attack", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:(application.integration.rate_limit_exceeded or system.org.rate_limit.warning or system.org.rate_limit.violation or core.concurrency.org.limit.violation)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -50,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 935cf52d986ff7..1fa1bfe57b4831 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -168,14 +168,14 @@ import rule156 from './impact_iam_group_deletion.json'; import rule157 from './impact_possible_okta_dos_attack.json'; import rule158 from './impact_rds_cluster_deletion.json'; import rule159 from './initial_access_suspicious_activity_reported_by_okta_user.json'; -import rule160 from './okta_attempt_to_deactivate_okta_mfa_rule.json'; -import rule161 from './okta_attempt_to_modify_okta_mfa_rule.json'; +import rule160 from './okta_attempt_to_deactivate_okta_policy.json'; +import rule161 from './okta_attempt_to_deactivate_okta_policy_rule.json'; import rule162 from './okta_attempt_to_modify_okta_network_zone.json'; import rule163 from './okta_attempt_to_modify_okta_policy.json'; -import rule164 from './okta_threat_detected_by_okta_threatinsight.json'; -import rule165 from './persistence_administrator_privileges_assigned_to_okta_group.json'; -import rule166 from './persistence_attempt_to_create_okta_api_token.json'; -import rule167 from './persistence_attempt_to_deactivate_okta_policy.json'; +import rule164 from './okta_attempt_to_modify_okta_policy_rule.json'; +import rule165 from './okta_threat_detected_by_okta_threatinsight.json'; +import rule166 from './persistence_administrator_privileges_assigned_to_okta_group.json'; +import rule167 from './persistence_attempt_to_create_okta_api_token.json'; import rule168 from './persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json'; import rule169 from './defense_evasion_cloudtrail_logging_deleted.json'; import rule170 from './defense_evasion_ec2_network_acl_deletion.json'; @@ -226,105 +226,240 @@ import rule214 from './credential_access_domain_backup_dpapi_private_keys.json'; import rule215 from './persistence_gpo_schtask_service_creation.json'; import rule216 from './credential_access_compress_credentials_keychains.json'; import rule217 from './credential_access_kerberosdump_kcc.json'; -import rule218 from './execution_suspicious_psexesvc.json'; -import rule219 from './execution_via_xp_cmdshell_mssql_stored_procedure.json'; -import rule220 from './privilege_escalation_printspooler_service_suspicious_file.json'; -import rule221 from './privilege_escalation_printspooler_suspicious_spl_file.json'; -import rule222 from './defense_evasion_azure_diagnostic_settings_deletion.json'; -import rule223 from './execution_command_virtual_machine.json'; -import rule224 from './execution_via_hidden_shell_conhost.json'; -import rule225 from './impact_resource_group_deletion.json'; -import rule226 from './persistence_via_telemetrycontroller_scheduledtask_hijack.json'; -import rule227 from './persistence_via_update_orchestrator_service_hijack.json'; -import rule228 from './collection_update_event_hub_auth_rule.json'; -import rule229 from './credential_access_iis_apppoolsa_pwd_appcmd.json'; -import rule230 from './credential_access_iis_connectionstrings_dumping.json'; -import rule231 from './defense_evasion_event_hub_deletion.json'; -import rule232 from './defense_evasion_firewall_policy_deletion.json'; -import rule233 from './defense_evasion_sdelete_like_filename_rename.json'; -import rule234 from './lateral_movement_remote_ssh_login_enabled.json'; -import rule235 from './persistence_azure_automation_account_created.json'; -import rule236 from './persistence_azure_automation_runbook_created_or_modified.json'; -import rule237 from './persistence_azure_automation_webhook_created.json'; -import rule238 from './privilege_escalation_uac_bypass_diskcleanup_hijack.json'; -import rule239 from './credential_access_attempts_to_brute_force_okta_user_account.json'; -import rule240 from './credential_access_storage_account_key_regenerated.json'; -import rule241 from './defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json'; -import rule242 from './defense_evasion_system_critical_proc_abnormal_file_activity.json'; -import rule243 from './defense_evasion_unusual_system_vp_child_program.json'; -import rule244 from './discovery_blob_container_access_mod.json'; -import rule245 from './persistence_mfa_disabled_for_azure_user.json'; -import rule246 from './persistence_user_added_as_owner_for_azure_application.json'; -import rule247 from './persistence_user_added_as_owner_for_azure_service_principal.json'; -import rule248 from './defense_evasion_dotnet_compiler_parent_process.json'; -import rule249 from './defense_evasion_suspicious_managedcode_host_process.json'; -import rule250 from './execution_command_shell_started_by_unusual_process.json'; -import rule251 from './defense_evasion_masquerading_as_elastic_endpoint_process.json'; -import rule252 from './defense_evasion_masquerading_suspicious_werfault_childproc.json'; -import rule253 from './defense_evasion_masquerading_werfault.json'; -import rule254 from './credential_access_key_vault_modified.json'; -import rule255 from './credential_access_mimikatz_memssp_default_logs.json'; -import rule256 from './defense_evasion_code_injection_conhost.json'; -import rule257 from './defense_evasion_network_watcher_deletion.json'; -import rule258 from './initial_access_external_guest_user_invite.json'; -import rule259 from './defense_evasion_masquerading_renamed_autoit.json'; -import rule260 from './impact_azure_automation_runbook_deleted.json'; -import rule261 from './initial_access_consent_grant_attack_via_azure_registered_application.json'; -import rule262 from './persistence_azure_conditional_access_policy_modified.json'; -import rule263 from './persistence_azure_privileged_identity_management_role_modified.json'; -import rule264 from './command_and_control_teamviewer_remote_file_copy.json'; -import rule265 from './defense_evasion_installutil_beacon.json'; -import rule266 from './defense_evasion_mshta_beacon.json'; -import rule267 from './defense_evasion_network_connection_from_windows_binary.json'; -import rule268 from './defense_evasion_rundll32_no_arguments.json'; -import rule269 from './defense_evasion_suspicious_scrobj_load.json'; -import rule270 from './defense_evasion_suspicious_wmi_script.json'; -import rule271 from './execution_ms_office_written_file.json'; -import rule272 from './execution_pdf_written_file.json'; -import rule273 from './lateral_movement_cmd_service.json'; -import rule274 from './persistence_app_compat_shim.json'; -import rule275 from './command_and_control_remote_file_copy_desktopimgdownldr.json'; -import rule276 from './command_and_control_remote_file_copy_mpcmdrun.json'; -import rule277 from './defense_evasion_execution_suspicious_explorer_winword.json'; -import rule278 from './defense_evasion_suspicious_zoom_child_process.json'; -import rule279 from './ml_linux_anomalous_compiler_activity.json'; -import rule280 from './ml_linux_anomalous_kernel_module_arguments.json'; -import rule281 from './ml_linux_anomalous_sudo_activity.json'; -import rule282 from './ml_linux_system_information_discovery.json'; -import rule283 from './ml_linux_system_network_configuration_discovery.json'; -import rule284 from './ml_linux_system_network_connection_discovery.json'; -import rule285 from './ml_linux_system_process_discovery.json'; -import rule286 from './ml_linux_system_user_discovery.json'; -import rule287 from './discovery_post_exploitation_public_ip_reconnaissance.json'; -import rule288 from './initial_access_zoom_meeting_with_no_passcode.json'; -import rule289 from './defense_evasion_gcp_logging_sink_deletion.json'; -import rule290 from './defense_evasion_gcp_pub_sub_topic_deletion.json'; -import rule291 from './credential_access_gcp_iam_service_account_key_deletion.json'; -import rule292 from './credential_access_gcp_key_created_for_service_account.json'; -import rule293 from './defense_evasion_gcp_firewall_rule_created.json'; -import rule294 from './defense_evasion_gcp_firewall_rule_deleted.json'; -import rule295 from './defense_evasion_gcp_firewall_rule_modified.json'; -import rule296 from './defense_evasion_gcp_logging_bucket_deletion.json'; -import rule297 from './defense_evasion_gcp_storage_bucket_permissions_modified.json'; -import rule298 from './impact_gcp_storage_bucket_deleted.json'; -import rule299 from './initial_access_gcp_iam_custom_role_creation.json'; -import rule300 from './defense_evasion_gcp_storage_bucket_configuration_modified.json'; -import rule301 from './exfiltration_gcp_logging_sink_modification.json'; -import rule302 from './impact_gcp_iam_role_deletion.json'; -import rule303 from './impact_gcp_service_account_deleted.json'; -import rule304 from './impact_gcp_service_account_disabled.json'; -import rule305 from './impact_gcp_virtual_private_cloud_network_deleted.json'; -import rule306 from './impact_gcp_virtual_private_cloud_route_created.json'; -import rule307 from './impact_gcp_virtual_private_cloud_route_deleted.json'; -import rule308 from './ml_linux_anomalous_metadata_process.json'; -import rule309 from './ml_linux_anomalous_metadata_user.json'; -import rule310 from './ml_windows_anomalous_metadata_process.json'; -import rule311 from './ml_windows_anomalous_metadata_user.json'; -import rule312 from './persistence_gcp_service_account_created.json'; -import rule313 from './collection_gcp_pub_sub_subscription_creation.json'; -import rule314 from './collection_gcp_pub_sub_topic_creation.json'; -import rule315 from './defense_evasion_gcp_pub_sub_subscription_deletion.json'; -import rule316 from './persistence_azure_pim_user_added_global_admin.json'; +import rule218 from './defense_evasion_attempt_del_quarantine_attrib.json'; +import rule219 from './execution_suspicious_psexesvc.json'; +import rule220 from './execution_via_xp_cmdshell_mssql_stored_procedure.json'; +import rule221 from './privilege_escalation_printspooler_service_suspicious_file.json'; +import rule222 from './privilege_escalation_printspooler_suspicious_spl_file.json'; +import rule223 from './defense_evasion_azure_diagnostic_settings_deletion.json'; +import rule224 from './execution_command_virtual_machine.json'; +import rule225 from './execution_via_hidden_shell_conhost.json'; +import rule226 from './impact_resource_group_deletion.json'; +import rule227 from './persistence_via_telemetrycontroller_scheduledtask_hijack.json'; +import rule228 from './persistence_via_update_orchestrator_service_hijack.json'; +import rule229 from './collection_update_event_hub_auth_rule.json'; +import rule230 from './credential_access_iis_apppoolsa_pwd_appcmd.json'; +import rule231 from './credential_access_iis_connectionstrings_dumping.json'; +import rule232 from './defense_evasion_event_hub_deletion.json'; +import rule233 from './defense_evasion_firewall_policy_deletion.json'; +import rule234 from './defense_evasion_sdelete_like_filename_rename.json'; +import rule235 from './lateral_movement_remote_ssh_login_enabled.json'; +import rule236 from './persistence_azure_automation_account_created.json'; +import rule237 from './persistence_azure_automation_runbook_created_or_modified.json'; +import rule238 from './persistence_azure_automation_webhook_created.json'; +import rule239 from './privilege_escalation_uac_bypass_diskcleanup_hijack.json'; +import rule240 from './credential_access_attempts_to_brute_force_okta_user_account.json'; +import rule241 from './credential_access_storage_account_key_regenerated.json'; +import rule242 from './defense_evasion_suspicious_okta_user_password_reset_or_unlock_attempts.json'; +import rule243 from './defense_evasion_system_critical_proc_abnormal_file_activity.json'; +import rule244 from './defense_evasion_unusual_system_vp_child_program.json'; +import rule245 from './discovery_blob_container_access_mod.json'; +import rule246 from './persistence_mfa_disabled_for_azure_user.json'; +import rule247 from './persistence_user_added_as_owner_for_azure_application.json'; +import rule248 from './persistence_user_added_as_owner_for_azure_service_principal.json'; +import rule249 from './defense_evasion_dotnet_compiler_parent_process.json'; +import rule250 from './defense_evasion_suspicious_managedcode_host_process.json'; +import rule251 from './execution_command_shell_started_by_unusual_process.json'; +import rule252 from './defense_evasion_masquerading_as_elastic_endpoint_process.json'; +import rule253 from './defense_evasion_masquerading_suspicious_werfault_childproc.json'; +import rule254 from './defense_evasion_masquerading_werfault.json'; +import rule255 from './credential_access_key_vault_modified.json'; +import rule256 from './credential_access_mimikatz_memssp_default_logs.json'; +import rule257 from './defense_evasion_code_injection_conhost.json'; +import rule258 from './defense_evasion_network_watcher_deletion.json'; +import rule259 from './initial_access_external_guest_user_invite.json'; +import rule260 from './defense_evasion_masquerading_renamed_autoit.json'; +import rule261 from './impact_azure_automation_runbook_deleted.json'; +import rule262 from './initial_access_consent_grant_attack_via_azure_registered_application.json'; +import rule263 from './persistence_azure_conditional_access_policy_modified.json'; +import rule264 from './persistence_azure_privileged_identity_management_role_modified.json'; +import rule265 from './command_and_control_teamviewer_remote_file_copy.json'; +import rule266 from './defense_evasion_installutil_beacon.json'; +import rule267 from './defense_evasion_mshta_beacon.json'; +import rule268 from './defense_evasion_network_connection_from_windows_binary.json'; +import rule269 from './defense_evasion_rundll32_no_arguments.json'; +import rule270 from './defense_evasion_suspicious_scrobj_load.json'; +import rule271 from './defense_evasion_suspicious_wmi_script.json'; +import rule272 from './execution_ms_office_written_file.json'; +import rule273 from './execution_pdf_written_file.json'; +import rule274 from './lateral_movement_cmd_service.json'; +import rule275 from './persistence_app_compat_shim.json'; +import rule276 from './command_and_control_remote_file_copy_desktopimgdownldr.json'; +import rule277 from './command_and_control_remote_file_copy_mpcmdrun.json'; +import rule278 from './defense_evasion_execution_suspicious_explorer_winword.json'; +import rule279 from './defense_evasion_suspicious_zoom_child_process.json'; +import rule280 from './ml_linux_anomalous_compiler_activity.json'; +import rule281 from './ml_linux_anomalous_kernel_module_arguments.json'; +import rule282 from './ml_linux_anomalous_sudo_activity.json'; +import rule283 from './ml_linux_system_information_discovery.json'; +import rule284 from './ml_linux_system_network_configuration_discovery.json'; +import rule285 from './ml_linux_system_network_connection_discovery.json'; +import rule286 from './ml_linux_system_process_discovery.json'; +import rule287 from './ml_linux_system_user_discovery.json'; +import rule288 from './discovery_post_exploitation_public_ip_reconnaissance.json'; +import rule289 from './initial_access_zoom_meeting_with_no_passcode.json'; +import rule290 from './defense_evasion_gcp_logging_sink_deletion.json'; +import rule291 from './defense_evasion_gcp_pub_sub_topic_deletion.json'; +import rule292 from './credential_access_gcp_iam_service_account_key_deletion.json'; +import rule293 from './credential_access_gcp_key_created_for_service_account.json'; +import rule294 from './defense_evasion_gcp_firewall_rule_created.json'; +import rule295 from './defense_evasion_gcp_firewall_rule_deleted.json'; +import rule296 from './defense_evasion_gcp_firewall_rule_modified.json'; +import rule297 from './defense_evasion_gcp_logging_bucket_deletion.json'; +import rule298 from './defense_evasion_gcp_storage_bucket_permissions_modified.json'; +import rule299 from './impact_gcp_storage_bucket_deleted.json'; +import rule300 from './initial_access_gcp_iam_custom_role_creation.json'; +import rule301 from './defense_evasion_gcp_storage_bucket_configuration_modified.json'; +import rule302 from './exfiltration_gcp_logging_sink_modification.json'; +import rule303 from './impact_gcp_iam_role_deletion.json'; +import rule304 from './impact_gcp_service_account_deleted.json'; +import rule305 from './impact_gcp_service_account_disabled.json'; +import rule306 from './impact_gcp_virtual_private_cloud_network_deleted.json'; +import rule307 from './impact_gcp_virtual_private_cloud_route_created.json'; +import rule308 from './impact_gcp_virtual_private_cloud_route_deleted.json'; +import rule309 from './ml_linux_anomalous_metadata_process.json'; +import rule310 from './ml_linux_anomalous_metadata_user.json'; +import rule311 from './ml_windows_anomalous_metadata_process.json'; +import rule312 from './ml_windows_anomalous_metadata_user.json'; +import rule313 from './persistence_gcp_service_account_created.json'; +import rule314 from './collection_gcp_pub_sub_subscription_creation.json'; +import rule315 from './collection_gcp_pub_sub_topic_creation.json'; +import rule316 from './defense_evasion_gcp_pub_sub_subscription_deletion.json'; +import rule317 from './persistence_azure_pim_user_added_global_admin.json'; +import rule318 from './command_and_control_cobalt_strike_default_teamserver_cert.json'; +import rule319 from './defense_evasion_enable_inbound_rdp_with_netsh.json'; +import rule320 from './defense_evasion_execution_lolbas_wuauclt.json'; +import rule321 from './privilege_escalation_unusual_svchost_childproc_childless.json'; +import rule322 from './lateral_movement_rdp_tunnel_plink.json'; +import rule323 from './privilege_escalation_uac_bypass_winfw_mmc_hijack.json'; +import rule324 from './persistence_ms_office_addins_file.json'; +import rule325 from './discovery_adfind_command_activity.json'; +import rule326 from './discovery_security_software_wmic.json'; +import rule327 from './execution_command_shell_via_rundll32.json'; +import rule328 from './lateral_movement_suspicious_cmd_wmi.json'; +import rule329 from './lateral_movement_via_startup_folder_rdp_smb.json'; +import rule330 from './privilege_escalation_uac_bypass_com_interface_icmluautil.json'; +import rule331 from './privilege_escalation_uac_bypass_mock_windir.json'; +import rule332 from './defense_evasion_potential_processherpaderping.json'; +import rule333 from './privilege_escalation_uac_bypass_dll_sideloading.json'; +import rule334 from './execution_shared_modules_local_sxs_dll.json'; +import rule335 from './privilege_escalation_uac_bypass_com_clipup.json'; +import rule336 from './execution_via_explorer_suspicious_child_parent_args.json'; +import rule337 from './execution_from_unusual_directory.json'; +import rule338 from './execution_from_unusual_path_cmdline.json'; +import rule339 from './credential_access_kerberoasting_unusual_process.json'; +import rule340 from './discovery_peripheral_device.json'; +import rule341 from './lateral_movement_mount_hidden_or_webdav_share_net.json'; +import rule342 from './defense_evasion_deleting_websvr_access_logs.json'; +import rule343 from './defense_evasion_log_files_deleted.json'; +import rule344 from './defense_evasion_timestomp_touch.json'; +import rule345 from './lateral_movement_dcom_hta.json'; +import rule346 from './lateral_movement_execution_via_file_shares_sequence.json'; +import rule347 from './privilege_escalation_uac_bypass_com_ieinstal.json'; +import rule348 from './command_and_control_common_webservices.json'; +import rule349 from './command_and_control_encrypted_channel_freesslcert.json'; +import rule350 from './defense_evasion_process_termination_followed_by_deletion.json'; +import rule351 from './lateral_movement_remote_file_copy_hidden_share.json'; +import rule352 from './attempt_to_deactivate_okta_network_zone.json'; +import rule353 from './attempt_to_delete_okta_network_zone.json'; +import rule354 from './lateral_movement_dcom_mmc20.json'; +import rule355 from './lateral_movement_dcom_shellwindow_shellbrowserwindow.json'; +import rule356 from './okta_attempt_to_deactivate_okta_application.json'; +import rule357 from './okta_attempt_to_delete_okta_application.json'; +import rule358 from './okta_attempt_to_delete_okta_policy_rule.json'; +import rule359 from './okta_attempt_to_modify_okta_application.json'; +import rule360 from './persistence_administrator_role_assigned_to_okta_user.json'; +import rule361 from './lateral_movement_executable_tool_transfer_smb.json'; +import rule362 from './command_and_control_dns_tunneling_nslookup.json'; +import rule363 from './lateral_movement_execution_from_tsclient_mup.json'; +import rule364 from './lateral_movement_rdp_sharprdp_target.json'; +import rule365 from './persistence_google_workspace_api_access_granted_via_domain_wide_delegation_of_authority.json'; +import rule366 from './execution_suspicious_short_program_name.json'; +import rule367 from './lateral_movement_incoming_wmi.json'; +import rule368 from './persistence_via_hidden_run_key_valuename.json'; +import rule369 from './credential_access_potential_ssh_bruteforce.json'; +import rule370 from './credential_access_promt_for_pwd_via_osascript.json'; +import rule371 from './lateral_movement_remote_services.json'; +import rule372 from './application_added_to_google_workspace_domain.json'; +import rule373 from './defense_evasion_suspicious_powershell_imgload.json'; +import rule374 from './domain_added_to_google_workspace_trusted_domains.json'; +import rule375 from './execution_suspicious_image_load_wmi_ms_office.json'; +import rule376 from './google_workspace_admin_role_deletion.json'; +import rule377 from './google_workspace_mfa_enforcement_disabled.json'; +import rule378 from './google_workspace_policy_modified.json'; +import rule379 from './mfa_disabled_for_google_workspace_organization.json'; +import rule380 from './persistence_evasion_registry_ifeo_injection.json'; +import rule381 from './persistence_google_workspace_admin_role_assigned_to_user.json'; +import rule382 from './persistence_google_workspace_custom_admin_role_created.json'; +import rule383 from './persistence_google_workspace_role_modified.json'; +import rule384 from './persistence_suspicious_image_load_scheduled_task_ms_office.json'; +import rule385 from './defense_evasion_masquerading_trusted_directory.json'; +import rule386 from './exfiltration_microsoft_365_exchange_transport_rule_creation.json'; +import rule387 from './initial_access_microsoft_365_exchange_safelinks_disabled.json'; +import rule388 from './microsoft_365_exchange_dkim_signing_config_disabled.json'; +import rule389 from './persistence_appcertdlls_registry.json'; +import rule390 from './persistence_appinitdlls_registry.json'; +import rule391 from './persistence_registry_uncommon.json'; +import rule392 from './persistence_run_key_and_startup_broad.json'; +import rule393 from './persistence_services_registry.json'; +import rule394 from './persistence_startup_folder_file_written_by_suspicious_process.json'; +import rule395 from './persistence_startup_folder_scripts.json'; +import rule396 from './persistence_suspicious_com_hijack_registry.json'; +import rule397 from './persistence_via_lsa_security_support_provider_registry.json'; +import rule398 from './defense_evasion_microsoft_365_exchange_malware_filter_policy_deletion.json'; +import rule399 from './defense_evasion_microsoft_365_exchange_malware_filter_rule_mod.json'; +import rule400 from './defense_evasion_microsoft_365_exchange_safe_attach_rule_disabled.json'; +import rule401 from './exfiltration_microsoft_365_exchange_transport_rule_mod.json'; +import rule402 from './initial_access_microsoft_365_exchange_anti_phish_policy_deletion.json'; +import rule403 from './initial_access_microsoft_365_exchange_anti_phish_rule_mod.json'; +import rule404 from './lateral_movement_suspicious_rdp_client_imageload.json'; +import rule405 from './persistence_runtime_run_key_startup_susp_procs.json'; +import rule406 from './persistence_suspicious_scheduled_task_runtime.json'; +import rule407 from './defense_evasion_microsoft_365_exchange_dlp_policy_removed.json'; +import rule408 from './lateral_movement_scheduled_task_target.json'; +import rule409 from './persistence_microsoft_365_exchange_management_role_assignment.json'; +import rule410 from './persistence_microsoft_365_teams_guest_access_enabled.json'; +import rule411 from './credential_access_dump_registry_hives.json'; +import rule412 from './defense_evasion_scheduledjobs_at_protocol_enabled.json'; +import rule413 from './persistence_ms_outlook_vba_template.json'; +import rule414 from './persistence_suspicious_service_created_registry.json'; +import rule415 from './privilege_escalation_named_pipe_impersonation.json'; +import rule416 from './credential_access_cmdline_dump_tool.json'; +import rule417 from './credential_access_copy_ntds_sam_volshadowcp_cmdline.json'; +import rule418 from './credential_access_lsass_memdump_file_created.json'; +import rule419 from './lateral_movement_incoming_winrm_shell_execution.json'; +import rule420 from './lateral_movement_powershell_remoting_target.json'; +import rule421 from './defense_evasion_hide_encoded_executable_registry.json'; +import rule422 from './defense_evasion_port_forwarding_added_registry.json'; +import rule423 from './lateral_movement_rdp_enabled_registry.json'; +import rule424 from './privilege_escalation_printspooler_registry_copyfiles.json'; +import rule425 from './privilege_escalation_rogue_windir_environment_var.json'; +import rule426 from './execution_scripts_process_started_via_wmi.json'; +import rule427 from './command_and_control_iexplore_via_com.json'; +import rule428 from './command_and_control_remote_file_copy_scripts.json'; +import rule429 from './persistence_local_scheduled_task_scripting.json'; +import rule430 from './persistence_startup_folder_file_written_by_unsigned_process.json'; +import rule431 from './command_and_control_remote_file_copy_powershell.json'; +import rule432 from './credential_access_microsoft_365_brute_force_user_account_attempt.json'; +import rule433 from './microsoft_365_teams_custom_app_interaction_allowed.json'; +import rule434 from './persistence_microsoft_365_teams_external_access_enabled.json'; +import rule435 from './credential_access_microsoft_365_potential_password_spraying_attack.json'; +import rule436 from './defense_evasion_stop_process_service_threshold.json'; +import rule437 from './defense_evasion_unusual_dir_ads.json'; +import rule438 from './discovery_admin_recon.json'; +import rule439 from './discovery_file_dir_discovery.json'; +import rule440 from './discovery_net_view.json'; +import rule441 from './discovery_query_registry_via_reg.json'; +import rule442 from './discovery_remote_system_discovery_commands_windows.json'; +import rule443 from './exfiltration_winrar_encryption.json'; +import rule444 from './persistence_via_windows_management_instrumentation_event_subscription.json'; +import rule445 from './execution_scripting_osascript_exec_followed_by_netcon.json'; +import rule446 from './execution_shell_execution_via_apple_scripting.json'; +import rule447 from './persistence_creation_change_launch_agents_file.json'; +import rule448 from './persistence_creation_modif_launch_deamon_sequence.json'; +import rule449 from './persistence_folder_action_scripts_runtime.json'; +import rule450 from './persistence_login_logout_hooks_defaults.json'; +import rule451 from './privilege_escalation_explicit_creds_via_apple_scripting.json'; export const rawRules = [ rule1, @@ -643,4 +778,139 @@ export const rawRules = [ rule314, rule315, rule316, + rule317, + rule318, + rule319, + rule320, + rule321, + rule322, + rule323, + rule324, + rule325, + rule326, + rule327, + rule328, + rule329, + rule330, + rule331, + rule332, + rule333, + rule334, + rule335, + rule336, + rule337, + rule338, + rule339, + rule340, + rule341, + rule342, + rule343, + rule344, + rule345, + rule346, + rule347, + rule348, + rule349, + rule350, + rule351, + rule352, + rule353, + rule354, + rule355, + rule356, + rule357, + rule358, + rule359, + rule360, + rule361, + rule362, + rule363, + rule364, + rule365, + rule366, + rule367, + rule368, + rule369, + rule370, + rule371, + rule372, + rule373, + rule374, + rule375, + rule376, + rule377, + rule378, + rule379, + rule380, + rule381, + rule382, + rule383, + rule384, + rule385, + rule386, + rule387, + rule388, + rule389, + rule390, + rule391, + rule392, + rule393, + rule394, + rule395, + rule396, + rule397, + rule398, + rule399, + rule400, + rule401, + rule402, + rule403, + rule404, + rule405, + rule406, + rule407, + rule408, + rule409, + rule410, + rule411, + rule412, + rule413, + rule414, + rule415, + rule416, + rule417, + rule418, + rule419, + rule420, + rule421, + rule422, + rule423, + rule424, + rule425, + rule426, + rule427, + rule428, + rule429, + rule430, + rule431, + rule432, + rule433, + rule434, + rule435, + rule436, + rule437, + rule438, + rule439, + rule440, + rule441, + rule442, + rule443, + rule444, + rule445, + rule446, + rule447, + rule448, + rule449, + rule450, + rule451, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json index 1dab4e8df71b44..a2b89f6e82d236 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_consent_grant_attack_via_azure_registered_application.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Identifies when a user grants permissions to an Azure-registered application or when an administrator grants tenant-wide permissions to an application. An adversary may create an Azure-registered application that requests access to data such as contact information, email, or documents.", + "description": "Detects when a user grants permissions to an Azure-registered application or when an administrator grants tenant-wide permissions to an application. An adversary may create an Azure-registered application that requests access to data such as contact information, email, or documents.", "from": "now-25m", "index": [ "filebeat-*" @@ -11,7 +11,7 @@ "license": "Elastic License", "name": "Possible Consent Grant Attack via Azure-Registered Application", "note": "- The Azure Filebeat module must be enabled to use this rule.\n- In a consent grant attack, an attacker tricks an end user into granting a malicious application consent to access their data, usually via a phishing attack. After the malicious application has been granted consent, it has account-level access to data without the need for an organizational account.\n- Normal remediation steps, like resetting passwords for breached accounts or requiring Multi-Factor Authentication (MFA) on accounts, are not effective against this type of attack, since these are third-party applications and are external to the organization.\n- Security analysts should review the list of trusted applications for any suspicious items.\n", - "query": "event.dataset:(azure.activitylogs or azure.auditlogs) and ( azure.activitylogs.operation_name:\"Consent to application\" or azure.auditlogs.operation_name:\"Consent to application\" ) and event.outcome:success", + "query": "event.dataset:(azure.activitylogs or azure.auditlogs or o365.audit) and ( azure.activitylogs.operation_name:\"Consent to application\" or azure.auditlogs.operation_name:\"Consent to application\" or o365.audit.Operation:\"Consent to application.\" ) and event.outcome:success", "references": [ "https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/detect-and-remediate-illicit-consent-grants?view=o365-worldwide" ], @@ -59,5 +59,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_policy_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_policy_deletion.json new file mode 100644 index 00000000000000..44c88c3818e74c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_policy_deletion.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an anti-phishing policy in Microsoft 365. By default, Microsoft 365 includes built-in features that help protect users from phishing attacks. Anti-phishing polices increase this protection by refining settings to better detect and prevent attacks.", + "false_positives": [ + "An anti-phishing policy may be deleted by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Anti-Phish Policy Deletion", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Remove-AntiPhishPolicy\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-antiphishpolicy?view=exchange-ps", + "https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/set-up-anti-phishing-policies?view=o365-worldwide" + ], + "risk_score": 47, + "rule_id": "d68eb1b5-5f1c-4b6d-9e63-5b6b145cd4aa", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1566", + "name": "Phishing", + "reference": "https://attack.mitre.org/techniques/T1566/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_rule_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_rule_mod.json new file mode 100644 index 00000000000000..d3d78127c63fe4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_anti_phish_rule_mod.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the modification of an anti-phishing rule in Microsoft 365. By default, Microsoft 365 includes built-in features that help protect users from phishing attacks. Anti-phishing rules increase this protection by refining settings to better detect and prevent attacks.", + "false_positives": [ + "An anti-phishing rule may be deleted by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Anti-Phish Rule Modification", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:(\"Remove-AntiPhishRule\" or \"Disable-AntiPhishRule\") and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/remove-antiphishrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/powershell/module/exchange/disable-antiphishrule?view=exchange-ps" + ], + "risk_score": 47, + "rule_id": "97314185-2568-4561-ae81-f3e480e5e695", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1566", + "name": "Phishing", + "reference": "https://attack.mitre.org/techniques/T1566/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_safelinks_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_safelinks_disabled.json new file mode 100644 index 00000000000000..2f8fe344887fbf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_microsoft_365_exchange_safelinks_disabled.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a Safe Link policy is disabled in Microsoft 365. Safe Link policies for Office applications extend phishing protection to documents that contain hyperlinks, even after they have been delivered to a user.", + "false_positives": [ + "Disabling safe links may be done by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Safe Link Policy Disabled", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Disable-SafeLinksRule\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/disable-safelinksrule?view=exchange-ps", + "https://docs.microsoft.com/en-us/microsoft-365/security/office-365-security/atp-safe-links?view=o365-worldwide" + ], + "risk_score": 47, + "rule_id": "a989fa1b-9a11-4dd8-a3e9-f0de9c6eb5f2", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monioring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1566", + "name": "Phishing", + "reference": "https://attack.mitre.org/techniques/T1566/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json index ce0f44713523f5..00bdbec354a745 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json @@ -14,7 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "RDP (Remote Desktop Protocol) to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 21, "rule_id": "e56993d2-759c-4120-984c-9ec9bb940fd5", "severity": "low", @@ -58,5 +58,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json index b8f3e01823312c..ceacc5a300faf5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "RPC (Remote Procedure Call) from the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and not source.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" ) and destination.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 )", "risk_score": 73, "rule_id": "143cb236-0956-4f42-a706-814bcaa0cf5a", "severity": "high", @@ -40,5 +40,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json index e8e4ea4eb37466..99309b9a4a3094 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "RPC (Remote Procedure Call) to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 73, "rule_id": "32923416-763a-4531-bb35-f33b9232ecdb", "severity": "high", @@ -40,5 +40,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json index fec0f308a8d276..7741051b3fa29d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "SMB (Windows File Sharing) Activity to the Internet", - "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(139 or 445) or event.dataset:zeek.smb) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(139 or 445) or event.dataset:zeek.smb) and source.ip:( 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 ) and not destination.ip:( 10.0.0.0/8 or 127.0.0.0/8 or 169.254.0.0/16 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.0/4 or \"::1\" or \"FE80::/10\" or \"FF00::/8\" )", "risk_score": 73, "rule_id": "c82b2bd8-d701-420c-ba43-f11a155b681a", "severity": "high", @@ -55,5 +55,5 @@ } ], "type": "query", - "version": 5 + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json index 5b1946dc7c07d3..4d0cc3033a2e6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "This rule detects when a user reports suspicious activity for their Okta account. These events should be investigated, as they can help security teams identify when an adversary is attempting to gain access to their network.", + "description": "Detects when a user reports suspicious activity for their Okta account. These events should be investigated, as they can help security teams identify when an adversary is attempting to gain access to their network.", "false_positives": [ "A user may report suspicious activity on their Okta account in error." ], @@ -13,7 +13,7 @@ "language": "kuery", "license": "Elastic License", "name": "Suspicious Activity Reported by Okta User", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:user.account.report_suspicious_activity_by_enduser", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -93,5 +93,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_hta.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_hta.json new file mode 100644 index 00000000000000..590f82e31b36b1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_hta.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of Distributed Component Object Model (DCOM) to execute commands from a remote host, which are launched via the HTA Application COM Object. This behavior may indicate an attacker abusing a DCOM application to move laterally while attempting to evading detection.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Incoming DCOM Lateral Movement via MSHTA", + "query": "sequence with maxspan=1m\n [process where event.type in (\"start\", \"process_started\") and\n process.name : \"mshta.exe\" and process.args : \"-Embedding\"\n ] by host.id, process.entity_id\n [network where event.type == \"start\" and process.name : \"mshta.exe\" and \n network.direction == \"incoming\" and network.transport == \"tcp\" and\n source.port > 49151 and destination.port > 49151 and not source.address in (\"127.0.0.1\", \"::1\")\n ] by host.id, process.entity_id\n", + "references": [ + "https://codewhitesec.blogspot.com/2018/07/lethalhta.html" + ], + "risk_score": 73, + "rule_id": "622ecb68-fa81-4601-90b5-f8cd661e4520", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_mmc20.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_mmc20.json new file mode 100644 index 00000000000000..5dee8493d57ad8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_mmc20.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of Distributed Component Object Model (DCOM) to run commands from a remote host, which are launched via the MMC20 Application COM Object. This behavior may indicate an attacker abusing a DCOM application to move laterally.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Incoming DCOM Lateral Movement with MMC", + "query": "sequence by host.id with maxspan=1m\n [network where event.type == \"start\" and process.name : \"mmc.exe\" and\n source.port >= 49152 and destination.port >= 49152 and source.address not in (\"127.0.0.1\", \"::1\") and\n network.direction == \"incoming\" and network.transport == \"tcp\"\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"mmc.exe\"\n ] by process.parent.entity_id\n", + "references": [ + "https://enigma0x3.net/2017/01/05/lateral-movement-using-the-mmc20-application-com-object/" + ], + "risk_score": 73, + "rule_id": "51ce96fb-9e52-4dad-b0ba-99b54440fc9a", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_shellwindow_shellbrowserwindow.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_shellwindow_shellbrowserwindow.json new file mode 100644 index 00000000000000..4e04dc1d458cb3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_dcom_shellwindow_shellbrowserwindow.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of Distributed Component Object Model (DCOM) to run commands from a remote host, which are launched via the ShellBrowserWindow or ShellWindows Application COM Object. This behavior may indicate an attacker abusing a DCOM application to stealthily move laterally.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Incoming DCOM Lateral Movement with ShellBrowserWindow or ShellWindows", + "query": "sequence by host.id with maxspan=5s\n [network where event.type == \"start\" and process.name : \"explorer.exe\" and\n network.direction == \"incoming\" and network.transport == \"tcp\" and\n source.port > 49151 and destination.port > 49151 and not source.address in (\"127.0.0.1\", \"::1\")\n ] by process.entity_id\n [process where event.type in (\"start\", \"process_started\") and\n process.parent.name : \"explorer.exe\"\n ] by process.parent.entity_id\n", + "references": [ + "https://enigma0x3.net/2017/01/23/lateral-movement-via-dcom-round-2/" + ], + "risk_score": 47, + "rule_id": "8f919d4b-a5af-47ca-a594-6be59cd924a4", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_executable_tool_transfer_smb.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_executable_tool_transfer_smb.json new file mode 100644 index 00000000000000..bc3f48904c43f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_executable_tool_transfer_smb.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation or change of a Windows executable file over network shares. Adversaries may transfer tools or other files between systems in a compromised environment.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Lateral Tool Transfer", + "query": "sequence by host.id with maxspan=30s\n [network where event.type == \"start\" and process.pid == 4 and destination.port == 445 and\n network.direction == \"incoming\" and network.transport == \"tcp\" and\n source.address != \"127.0.0.1\" and source.address != \"::1\"\n ] by process.entity_id\n /* add more executable extensions here if they are not noisy in your environment */\n [file where event.type in (\"creation\", \"change\") and process.pid == 4 and file.extension : (\"exe\", \"dll\", \"bat\", \"cmd\")] by process.entity_id\n", + "risk_score": 47, + "rule_id": "58bc134c-e8d2-4291-a552-b4b3e537c60b", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1570", + "name": "Lateral Tool Transfer", + "reference": "https://attack.mitre.org/techniques/T1570/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_from_tsclient_mup.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_from_tsclient_mup.json new file mode 100644 index 00000000000000..35ca9326e3668b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_from_tsclient_mup.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution from the Remote Desktop Protocol (RDP) shared mountpoint tsclient on the target host. This may indicate a lateral movement attempt.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution via TSClient Mountpoint", + "query": "process where event.type in (\"start\", \"process_started\") and process.executable : \"\\\\Device\\\\Mup\\\\tsclient\\\\*.exe\"\n", + "references": [ + "https://posts.specterops.io/revisiting-remote-desktop-lateral-movement-8fb905cb46c3" + ], + "risk_score": 73, + "rule_id": "4fe9d835-40e1-452d-8230-17c147cafad8", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_via_file_shares_sequence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_via_file_shares_sequence.json new file mode 100644 index 00000000000000..d0f301249017e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_execution_via_file_shares_sequence.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the execution of a file that was created by the virtual system process. This may indicate lateral movement via network file shares.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote Execution via File Shares", + "query": "sequence with maxspan=1m\n [file where event.type in (\"creation\", \"change\") and process.pid == 4 and file.extension : \"exe\"] by host.id, file.path\n [process where event.type in (\"start\", \"process_started\")] by host.id, process.executable\n", + "references": [ + "https://blog.menasec.net/2020/08/new-trick-to-detect-lateral-movement.html" + ], + "risk_score": 47, + "rule_id": "ab75c24b-2502-43a0-bf7c-e60e662c811e", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1077", + "name": "Windows Admin Shares", + "reference": "https://attack.mitre.org/techniques/T1077/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_winrm_shell_execution.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_winrm_shell_execution.json new file mode 100644 index 00000000000000..0c9453940b3bc9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_winrm_shell_execution.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies remote execution via Windows Remote Management (WinRM) remote shell on a target host. This could be an indication of lateral movement.", + "false_positives": [ + "WinRM is a dual-use protocol that can be used for benign or malicious activity. It's important to baseline your environment to determine the amount of noise to expect from this tool." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Incoming Execution via WinRM Remote Shell", + "query": "sequence by host.id with maxspan=30s\n [network where process.pid == 4 and network.direction == \"incoming\" and\n destination.port in (5985, 5986) and network.protocol == \"http\" and not source.address in (\"::1\", \"127.0.0.1\")\n ]\n [process where event.type == \"start\" and process.parent.name : \"winrshost.exe\" and not process.name : \"conhost.exe\"]\n", + "risk_score": 47, + "rule_id": "1cd01db9-be24-4bef-8e7c-e923f0ff78ab", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_wmi.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_wmi.json new file mode 100644 index 00000000000000..130c8d37ed8530 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_incoming_wmi.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies processes executed via Windows Management Instrumentation (WMI) on a remote host. This could be indicative of adversary lateral movement, but could be noisy if administrators use WMI to remotely manage hosts.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "WMI Incoming Lateral Movement", + "query": "sequence by host.id with maxspan = 2s\n\n /* Accepted Incoming RPC connection by Winmgmt service */\n\n [network where process.name : \"svchost.exe\" and network.direction == \"incoming\" and\n source.address != \"127.0.0.1\" and source.address != \"::1\" and \n source.port >= 49152 and destination.port >= 49152\n ]\n\n /* Excluding Common FPs Nessus and SCCM */\n\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"WmiPrvSE.exe\" and\n not process.args : (\"C:\\\\windows\\\\temp\\\\nessus_*.txt\", \n \"C:\\\\windows\\\\TEMP\\\\nessus_*.TMP\", \n \"C:\\\\Windows\\\\CCM\\\\SystemTemp\\\\*\", \n \"C:\\\\Windows\\\\CCMCache\\\\*\", \n \"C:\\\\CCM\\\\Cache\\\\*\")\n ]\n", + "risk_score": 47, + "rule_id": "f3475224-b179-4f78-8877-c2bd64c26b88", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1047", + "name": "Windows Management Instrumentation", + "reference": "https://attack.mitre.org/techniques/T1047/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_mount_hidden_or_webdav_share_net.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_mount_hidden_or_webdav_share_net.json new file mode 100644 index 00000000000000..575b715239ad71 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_mount_hidden_or_webdav_share_net.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the use of net.exe to mount a WebDav or hidden remote share. This may indicate lateral movement or preparation for data exfiltration.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Mounting Hidden or WebDav Remote Shares", + "query": "process where event.type in (\"start\", \"process_started\") and\n ((process.name : \"net.exe\" or process.pe.original_file_name == \"net.exe\") or ((process.name : \"net1.exe\" or process.pe.original_file_name == \"net1.exe\") and\n not process.parent.name : \"net.exe\")) and\n process.args : \"use\" and\n /* including hidden and webdav based online shares such as onedrive */\n process.args : (\"\\\\\\\\*\\\\*$*\", \"\\\\\\\\*@SSL\\\\*\", \"http*\") and\n /* excluding shares deletion operation */\n not process.args : \"/d*\"\n", + "risk_score": 21, + "rule_id": "c4210e1c-64f2-4f48-b67e-b5a8ffe3aa14", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1077", + "name": "Windows Admin Shares", + "reference": "https://attack.mitre.org/techniques/T1077/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_powershell_remoting_target.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_powershell_remoting_target.json new file mode 100644 index 00000000000000..aae62e66ad2557 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_powershell_remoting_target.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies remote execution via Windows PowerShell remoting. Windows PowerShell remoting allows for running any Windows PowerShell command on one or more remote computers. This could be an indication of lateral movement.", + "false_positives": [ + "PowerShell remoting is a dual-use protocol that can be used for benign or malicious activity. It's important to baseline your environment to determine the amount of noise to expect from this tool." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Incoming Execution via PowerShell Remoting", + "query": "sequence by host.id with maxspan = 30s\n [network where network.direction == \"incoming\" and destination.port in (5985, 5986) and\n network.protocol == \"http\" and source.address != \"127.0.0.1\" and source.address != \"::1\"\n ]\n [process where event.type == \"start\" and process.parent.name : \"wsmprovhost.exe\" and not process.name : \"conhost.exe\"]\n", + "references": [ + "https://docs.microsoft.com/en-us/powershell/scripting/learn/remoting/running-remote-commands?view=powershell-7.1" + ], + "risk_score": 43, + "rule_id": "2772264c-6fb9-4d9d-9014-b416eed21254", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_enabled_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_enabled_registry.json new file mode 100644 index 00000000000000..00205b99c1cd9d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_enabled_registry.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies registry write modifications to enable Remote Desktop Protocol (RDP) access. This could be indicative of adversary lateral movement preparation.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "RDP Enabled via Registry", + "query": "registry where\nregistry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Terminal Server\\\\fDenyTSConnections\" and\nregistry.data.strings == \"0\" and not (process.name : \"svchost.exe\" and user.domain == \"NT AUTHORITY\") and\nnot process.executable : \"C:\\\\Windows\\\\System32\\\\SystemPropertiesRemote.exe\"\n", + "risk_score": 43, + "rule_id": "58aa72ca-d968-4f34-b9f7-bea51d75eb50", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_sharprdp_target.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_sharprdp_target.json new file mode 100644 index 00000000000000..9a9a9d0e7a202d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_sharprdp_target.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies potential behavior of SharpRDP, which is a tool that can be used to perform authenticated command execution against a remote target via Remote Desktop Protocol (RDP) for the purposes of lateral movement.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Potential SharpRDP Behavior", + "query": "/* Incoming RDP followed by a new RunMRU string value set to cmd, powershell, taskmgr or tsclient, followed by process execution within 1m */\n\nsequence by host.id with maxspan=1m\n [network where event.type == \"start\" and process.name : \"svchost.exe\" and destination.port == 3389 and \n network.direction == \"incoming\" and network.transport == \"tcp\" and\n source.address != \"127.0.0.1\" and source.address != \"::1\"\n ]\n\n [registry where process.name : \"explorer.exe\" and \n registry.path : (\"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\RunMRU\\\\*\") and\n registry.data.strings : (\"cmd.exe*\", \"powershell.exe*\", \"taskmgr*\", \"\\\\\\\\tsclient\\\\*.exe\\\\*\")\n ]\n \n [process where event.type in (\"start\", \"process_started\") and\n (process.parent.name : (\"cmd.exe\", \"powershell.exe\", \"taskmgr.exe\") or process.args : (\"\\\\\\\\tsclient\\\\*.exe\")) and \n not process.name : \"conhost.exe\"\n ]\n", + "references": [ + "https://posts.specterops.io/revisiting-remote-desktop-lateral-movement-8fb905cb46c3", + "https://github.com/sbousseaden/EVTX-ATTACK-SAMPLES/blob/master/Lateral%20Movement/LM_sysmon_3_12_13_1_SharpRDP.evtx" + ], + "risk_score": 73, + "rule_id": "8c81e506-6e82-4884-9b9a-75d3d252f967", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_tunnel_plink.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_tunnel_plink.json new file mode 100644 index 00000000000000..40e9a171909c04 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_rdp_tunnel_plink.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies potential use of an SSH utility to establish RDP over a reverse SSH Tunnel. This could be indicative of adversary lateral movement to interactively access restricted networks.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Potential Remote Desktop Tunneling Detected", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and \n/* RDP port and usual SSH tunneling related switches in commandline */\nwildcard(process.args, \"*:3389\") and wildcard(process.args,\"-L\", \"-P\", \"-R\", \"-pw\", \"-ssh\") \n", + "references": [ + "https://blog.netspi.com/how-to-access-rdp-over-a-reverse-ssh-tunnel/" + ], + "risk_score": 73, + "rule_id": "76fd43b7-3480-4dd9-8ad7-8bd36bfad92f", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_file_copy_hidden_share.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_file_copy_hidden_share.json new file mode 100644 index 00000000000000..06d07e92abe6c6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_file_copy_hidden_share.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a remote file copy attempt to a hidden network share. This may indicate lateral movement or data staging activity.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote File Copy to a Hidden Share", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name : (\"cmd.exe\", \"powershell.exe\", \"robocopy.exe\", \"xcopy.exe\") and \n process.args : (\"copy*\", \"move*\", \"cp\", \"mv\") and process.args : \"*$*\"\n", + "risk_score": 47, + "rule_id": "fa01341d-6662-426b-9d0c-6d81e33c8a9d", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1077", + "name": "Windows Admin Shares", + "reference": "https://attack.mitre.org/techniques/T1077/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_services.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_services.json new file mode 100644 index 00000000000000..fd9eeb9be8eb62 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_remote_services.json @@ -0,0 +1,45 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies remote execution of Windows services over remote procedure call (RPC). This could be indicative of lateral movement, but will be noisy if commonly done by administrators.\"", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remotely Started Services via RPC", + "query": "sequence with maxspan=1s\n [network where process.name : \"services.exe\" and\n network.direction == \"incoming\" and network.transport == \"tcp\" and \n source.port >= 49152 and destination.port >= 49152 and source.address not in (\"127.0.0.1\", \"::1\")\n ] by host.id, process.entity_id\n\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"services.exe\" and \n not (process.name : \"svchost.exe\" and process.args : \"tiledatamodelsvc\") and \n not (process.name : \"msiexec.exe\" and process.args : \"/V\")\n \n /* uncomment if psexec is noisy in your environment */\n /* and not process.name : \"PSEXESVC.exe\" */\n ] by host.id, process.parent.entity_id\n", + "risk_score": 47, + "rule_id": "aa9a274d-6b53-424d-ac5e-cb8ca4251650", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1035", + "name": "Service Execution", + "reference": "https://attack.mitre.org/techniques/T1035/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_scheduled_task_target.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_scheduled_task_target.json new file mode 100644 index 00000000000000..ca29829849a0e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_scheduled_task_target.json @@ -0,0 +1,60 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies remote scheduled task creations on a target host. This could be indicative of adversary lateral movement.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Remote Scheduled Task Creation", + "note": "Decode the base64 encoded tasks actions registry value to investigate the task configured action.", + "query": "/* Task Scheduler service incoming connection followed by TaskCache registry modification */\n\nsequence by host.id, process.entity_id with maxspan = 1m\n [network where process.name : \"svchost.exe\" and\n network.direction == \"incoming\" and source.port >= 49152 and destination.port >= 49152 and\n source.address != \"127.0.0.1\" and source.address != \"::1\"\n ]\n [registry where registry.path : \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Schedule\\\\TaskCache\\\\Tasks\\\\*\\\\Actions\"]\n", + "risk_score": 47, + "rule_id": "954ee7c8-5437-49ae-b2d6-2960883898e9", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_cmd_wmi.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_cmd_wmi.json new file mode 100644 index 00000000000000..f19b940e758f79 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_cmd_wmi.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious command execution (cmd) via Windows Management Instrumentation (WMI) on a remote host. This could be indicative of adversary lateral movement.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Cmd Execution via WMI", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.parent.name == \"WmiPrvSE.exe\" and process.name == \"cmd.exe\" and\n wildcard(process.args, \"\\\\\\\\127.0.0.1\\\\*\") and process.args in (\"2>&1\", \"1>\")\n", + "risk_score": 47, + "rule_id": "12f07955-1674-44f7-86b5-c35da0a6f41a", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1047", + "name": "Windows Management Instrumentation", + "reference": "https://attack.mitre.org/techniques/T1047/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_rdp_client_imageload.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_rdp_client_imageload.json new file mode 100644 index 00000000000000..65eedcd8348bae --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_suspicious_rdp_client_imageload.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious Image Loading of the Remote Desktop Services ActiveX Client (mstscax), this may indicate the presence of RDP lateral movement capability.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious RDP ActiveX Client Loaded", + "query": "library where file.name == \"mstscax.dll\" and\n /* depending on noise in your env add here extra paths */\n wildcard(process.executable, \"C:\\\\Windows\\\\*\",\n \"C:\\\\Users\\\\Public\\\\*\",\n \"C:\\\\Users\\\\Default\\\\*\",\n \"C:\\\\Intel\\\\*\",\n \"C:\\\\PerfLogs\\\\*\",\n \"C:\\\\ProgramData\\\\*\",\n \"\\\\Device\\\\Mup\\\\*\",\n \"\\\\\\\\*\") and\n /* add here FPs */\n not process.executable in (\"C:\\\\Windows\\\\System32\\\\mstsc.exe\", \"C:\\\\Windows\\\\SysWOW64\\\\mstsc.exe\")\n", + "references": [ + "https://posts.specterops.io/revisiting-remote-desktop-lateral-movement-8fb905cb46c3" + ], + "risk_score": 47, + "rule_id": "71c5cb27-eca5-4151-bb47-64bc3f883270", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_via_startup_folder_rdp_smb.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_via_startup_folder_rdp_smb.json new file mode 100644 index 00000000000000..02f49c816d7861 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_via_startup_folder_rdp_smb.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious file creations in the startup folder of a remote system. An adversary could abuse this to move laterally by dropping a malicious script or executable that will be executed after a reboot or user logon.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Lateral Movement via Startup Folder", + "query": "file where event.type in (\"creation\", \"change\") and\n /* via RDP TSClient mounted share or SMB */\n (process.name : \"mstsc.exe\" or process.pid == 4) and\n file.path : \"C:\\\\*\\\\Microsoft\\\\Windows\\\\Start Menu\\\\Programs\\\\Startup\\\\*\"\n", + "references": [ + "https://www.mdsec.co.uk/2017/06/rdpinception/" + ], + "risk_score": 73, + "rule_id": "25224a80-5a4a-4b8a-991e-6ab390465c4f", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Lateral Movement" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0008", + "name": "Lateral Movement", + "reference": "https://attack.mitre.org/tactics/TA0008/" + }, + "technique": [ + { + "id": "T1021", + "name": "Remote Services", + "reference": "https://attack.mitre.org/techniques/T1021/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/mfa_disabled_for_google_workspace_organization.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/mfa_disabled_for_google_workspace_organization.json new file mode 100644 index 00000000000000..68bb88edbed3ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/mfa_disabled_for_google_workspace_organization.json @@ -0,0 +1,32 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when multi-factor authentication (MFA) is disabled for a Google Workspace organization. An adversary may attempt to modify a password policy in order to weaken an organization\u2019s security controls.", + "false_positives": [ + "MFA settings may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "MFA Disabled for Google Workspace Organization", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:(ENFORCE_STRONG_AUTHENTICATION or ALLOW_STRONG_AUTHENTICATION) and gsuite.admin.new_value:false", + "risk_score": 47, + "rule_id": "e555105c-ba6d-481f-82bb-9b633e7b4827", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_exchange_dkim_signing_config_disabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_exchange_dkim_signing_config_disabled.json new file mode 100644 index 00000000000000..227bbe1189fef0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_exchange_dkim_signing_config_disabled.json @@ -0,0 +1,34 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a DomainKeys Identified Mail (DKIM) signing configuration is disabled in Microsoft 365. With DKIM in Microsoft 365, messages that are sent from Exchange Online will be cryptographically signed. This will allow the receiving email system to validate that the messages were generated by a server that the organization authorized and not being spoofed.", + "false_positives": [ + "Disabling a DKIM configuration may be done by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange DKIM Signing Configuration Disabled", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"Set-DkimSigningConfig\" and o365.audit.Parameters.Enabled:False and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/set-dkimsigningconfig?view=exchange-ps" + ], + "risk_score": 47, + "rule_id": "514121ce-c7b6-474a-8237-68ff71672379", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Data Protection" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_teams_custom_app_interaction_allowed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_teams_custom_app_interaction_allowed.json new file mode 100644 index 00000000000000..33f4bc886720c5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/microsoft_365_teams_custom_app_interaction_allowed.json @@ -0,0 +1,34 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when custom applications are allowed in Microsoft Teams. If an organization requires applications other than those available in the Teams app store, custom applications can be developed as packages and uploaded. An adversary may abuse this behavior to establish persistence in an environment.", + "false_positives": [ + "Custom applications may be allowed by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Teams Custom Application Interaction Allowed", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:MicrosoftTeams and event.category:web and event.action:TeamsTenantSettingChanged and o365.audit.Name:\"Allow sideloading and interaction of custom apps\" and o365.audit.NewValue:True and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload" + ], + "risk_score": 47, + "rule_id": "bbd1a775-8267-41fa-9232-20e5582596ac", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_application.json new file mode 100644 index 00000000000000..9d620536155e57 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_application.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to deactivate an Okta application. An adversary may attempt to modify, deactivate, or delete an Okta application in order to weaken an organization's security controls or disrupt their business operations.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if your organization's Okta applications are regularly deactivated and the behavior is expected." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate an Okta Application", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:application.lifecycle.deactivate", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Apps/Apps_Apps.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "edb91186-1c7e-4db8-b53e-bfa33a1a0a8a", + "severity": "low", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy.json new file mode 100644 index 00000000000000..87ccf8d5412c05 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to deactivate an Okta policy. An adversary may attempt to deactivate an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to deactivate an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "false_positives": [ + "If the behavior of deactivating Okta policies is expected, consider adding exceptions to this rule to filter false positives." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate an Okta Policy", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:policy.lifecycle.deactivate", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/Security_Policies.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "b719a170-3bdb-4141-b0e3-13e3cf627bfe", + "severity": "low", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "type": "query", + "version": 3 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy_rule.json similarity index 59% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy_rule.json index 0ee0bbd6d62267..d180a26181e4f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_policy_rule.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to deactivate an Okta multi-factor authentication (MFA) rule in order to remove or weaken an organization's security controls.", + "description": "Detects attempts to deactivate a rule within an Okta policy. An adversary may attempt to deactivate a rule within an Okta policy in order to remove or weaken an organization's security controls.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly deactivated in your organization." ], @@ -12,16 +12,17 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Deactivate Okta MFA Rule", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Attempt to Deactivate an Okta Policy Rule", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:policy.rule.deactivate", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/Security_Policies.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], - "risk_score": 21, + "risk_score": 47, "rule_id": "cc92c835-da92-45c9-9f29-b4992ad621a0", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Identity", @@ -31,5 +32,5 @@ "Identity and Access" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_application.json new file mode 100644 index 00000000000000..ef4b940b87b60b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_application.json @@ -0,0 +1,35 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to delete an Okta application. An adversary may attempt to modify, deactivate, or delete an Okta application in order to weaken an organization's security controls or disrupt their business operations.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if your organization's Okta applications are regularly deleted and the behavior is expected." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Delete an Okta Application", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:application.lifecycle.delete", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "d48e1c13-4aca-4d1f-a7b1-a9161c0ad86f", + "severity": "low", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json index 211fdb1ae3474f..295b9523eefdd2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to delete an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to delete an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "description": "Detects attempts to delete an Okta policy. An adversary may attempt to delete an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to delete an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly deleted in your organization." ], @@ -12,16 +12,17 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Delete Okta Policy", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Attempt to Delete an Okta Policy", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:policy.lifecycle.delete", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/Security_Policies.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], - "risk_score": 21, + "risk_score": 47, "rule_id": "b4bb1440-0fcb-4ed1-87e5-b06d58efc5e9", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Identity", @@ -31,5 +32,5 @@ "Monitoring" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy_rule.json new file mode 100644 index 00000000000000..d752e7dcb21ad2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy_rule.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to delete a rule within an Okta policy. An adversary may attempt to delete an Okta policy rule in order to weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly modified in your organization." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Delete an Okta Policy Rule", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:policy.rule.delete", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/Security_Policies.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "d5d86bf5-cf0c-4c06-b688-53fdc072fdfd", + "severity": "low", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_application.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_application.json new file mode 100644 index 00000000000000..5a05ea9277237e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_application.json @@ -0,0 +1,36 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to modify an Okta application. An adversary may attempt to modify, deactivate, or delete an Okta application in order to weaken an organization's security controls or disrupt their business operations.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if your organization's Okta applications are regularly modified and the behavior is expected." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Modify an Okta Application", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:application.lifecycle.update", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Apps/Apps_Apps.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "c74fd275-ab2c-4d49-8890-e2943fa65c09", + "severity": "low", + "tags": [ + "Elastic", + "Identity", + "Okta", + "Continuous Monitoring", + "SecOps", + "Monitoring" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json index 682dc17f0ed497..86ea5c81025c55 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Okta network zones can be configured to limit or restrict access to a network based on IP addresses or geolocations. An adversary may attempt to modify, delete, or deactivate an Okta network zone in order to remove or weaken an organization's security controls.", + "description": "Detects attempts to modify an Okta network zone. Okta network zones can be configured to limit or restrict access to a network based on IP addresses or geolocations. An adversary may attempt to modify, delete, or deactivate an Okta network zone in order to remove or weaken an organization's security controls.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if Oyour organization's Okta network zones are regularly modified." ], @@ -12,10 +12,11 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Modify Okta Network Zone", - "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.dataset:okta.system and event.action:(zone.update or zone.deactivate or zone.delete or network_zone.rule.disabled or zone.remove_blacklist)", + "name": "Attempt to Modify an Okta Network Zone", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:(zone.update or network_zone.rule.disabled or zone.remove_blacklist)", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/network/network-zones.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], @@ -31,5 +32,5 @@ "Network Security" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json index 88e556d37a27cf..c43f5fae05aa80 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to modify an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to modify an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "description": "Detects attempts to modify an Okta policy. An adversary may attempt to modify an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to modify an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly modified in your organization." ], @@ -12,8 +12,8 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Modify Okta Policy", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Attempt to Modify an Okta Policy", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:policy.lifecycle.update", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -31,5 +31,5 @@ "Monitoring" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy_rule.json similarity index 58% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy_rule.json index eb726e24c89dab..8590fb3110c4fb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy_rule.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to modify an Okta multi-factor authentication (MFA) rule in order to remove or weaken an organization's security controls.", + "description": "Detects attempts to modify a rule within an Okta policy. An adversary may attempt to modify an Okta policy rule in order to weaken an organization's security controls.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly modified in your organization." ], @@ -12,10 +12,11 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Modify Okta MFA Rule", - "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.dataset:okta.system and event.action:(policy.rule.update or policy.rule.delete)", + "name": "Attempt to Modify an Okta Policy Rule", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:policy.rule.update", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/Security_Policies.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], @@ -31,5 +32,5 @@ "Identity and Access" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json index 262a91f8e25c97..d1459abb679b23 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to modify or delete the sign on policy for an Okta application in order to remove or weaken an organization's security controls.", + "description": "Detects attempts to modify or delete a sign on policy for an Okta application. An adversary may attempt to modify or delete the sign on policy for an Okta application in order to remove or weaken an organization's security controls.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if sign on policies for Okta applications are regularly modified or deleted in your organization." ], @@ -13,9 +13,10 @@ "language": "kuery", "license": "Elastic License", "name": "Modification or Removal of an Okta Application Sign-On Policy", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:(application.policy.sign_on.update or application.policy.sign_on.rule.delete)", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/App_Based_Signon.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], @@ -31,5 +32,5 @@ "Identity and Access" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json index 0101ae04594547..2b3d8e88b0a496 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "This rule detects when Okta ThreatInsight identifies a request from a malicious IP address. Investigating requests from IP addresses identified as malicious by Okta ThreatInsight can help security teams monitor for and respond to credential based attacks against their organization, such as brute force and password spraying attacks.", + "description": "Detects when Okta ThreatInsight identifies a request from a malicious IP address. Investigating requests from IP addresses identified as malicious by Okta ThreatInsight can help security teams monitor for and respond to credential based attacks against their organization, such as brute force and password spraying attacks.", "index": [ "filebeat-*", "logs-okta*" @@ -10,7 +10,7 @@ "language": "kuery", "license": "Elastic License", "name": "Threat Detected by Okta ThreatInsight", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:security.threat.detected", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -28,5 +28,5 @@ "Monitoring" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json index fad3e3c9224781..e9f3729e6287a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json @@ -2,9 +2,9 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to assign administrator privileges to an Okta group in order to assign additional permissions to compromised user accounts.", + "description": "Detects when an administrator role is assigned to an Okta group. An adversary may attempt to assign administrator privileges to an Okta group in order to assign additional permissions to compromised user accounts and maintain access to their target organization.", "false_positives": [ - "Consider adding exceptions to this rule to filter false positives if administrator privileges are regularly assigned to Okta groups in your organization." + "Administrator roles may be assigned to Okta users by a Super Admin user. Verify that the behavior was expected. Exceptions can be added to this rule to filter expected behavior." ], "index": [ "filebeat-*", @@ -12,16 +12,17 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Administrator Privileges Assigned to Okta Group", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Administrator Privileges Assigned to an Okta Group", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:group.privilege.grant", "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/administrators-admin-comparison.htm", "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], - "risk_score": 21, + "risk_score": 47, "rule_id": "b8075894-0b62-46e5-977c-31275da34419", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Identity", @@ -48,5 +49,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_role_assigned_to_okta_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_role_assigned_to_okta_user.json new file mode 100644 index 00000000000000..b614511449aea2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_role_assigned_to_okta_user.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when an administrator role is assigned to an Okta user. An adversary may attempt to assign an administrator role to an Okta user in order to assign additional permissions to a user account and maintain access to their target's environment.", + "false_positives": [ + "Administrator roles may be assigned to Okta users by a Super Admin user. Verify that the behavior was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "index": [ + "filebeat-*", + "logs-okta*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Administrator Role Assigned to an Okta User", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:okta.system and event.action:user.account.privilege.grant", + "references": [ + "https://help.okta.com/en/prod/Content/Topics/Security/administrators-admin-comparison.htm", + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "f06414a6-f2a4-466d-8eba-10f85e8abf71", + "severity": "medium", + "tags": [ + "Elastic", + "Okta", + "SecOps", + "Monitoring", + "Continuous Monitoring" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appcertdlls_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appcertdlls_registry.json new file mode 100644 index 00000000000000..8f2c14ed5018cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appcertdlls_registry.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to maintain persistence by creating registry keys using AppCert DLLs. AppCert DLLs are loaded by every process using the common API functions to create processes.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Registry Persistence via AppCert DLL", + "query": "registry where\n/* uncomment once stable length(bytes_written_string) > 0 and */\n registry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Session Manager\\\\AppCertDLLs\\\\*\"\n", + "risk_score": 47, + "rule_id": "513f0ffd-b317-4b9c-9494-92ce861f22c7", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1182", + "name": "AppCert DLLs", + "reference": "https://attack.mitre.org/techniques/T1182/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appinitdlls_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appinitdlls_registry.json new file mode 100644 index 00000000000000..174961449c6fc7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_appinitdlls_registry.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Attackers may maintain persistence by creating registry keys using AppInit DLLs. AppInit DLLs are loaded by every process using the common library, user32.dll.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Registry Persistence via AppInit DLL", + "query": "registry where\n registry.path : (\"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Windows\\\\AppInit_Dlls\", \n \"HKLM\\\\SOFTWARE\\\\Wow6432Node\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Windows\\\\AppInit_Dlls\") and\n not process.executable : (\"C:\\\\Windows\\\\System32\\\\msiexec.exe\", \n \"C:\\\\Windows\\\\SysWOW64\\\\msiexec.exe\", \n \"C:\\\\Program Files\\\\Commvault\\\\ContentStore*\\\\Base\\\\cvd.exe\",\n \"C:\\\\Program Files (x86)\\\\Commvault\\\\ContentStore*\\\\Base\\\\cvd.exe\")\n", + "risk_score": 47, + "rule_id": "d0e159cf-73e9-40d1-a9ed-077e3158a855", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1103", + "name": "AppInit DLLs", + "reference": "https://attack.mitre.org/techniques/T1103/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json index 9d1a7c7aef464f..7f69ce8c9e31f2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may create an Okta API token to maintain access to an organization's network while they work to achieve their objectives. An attacker may abuse an API token to execute techniques such as creating user accounts or disabling security rules or policies.", + "description": "Detects attempts to create an Okta API token. An adversary may create an Okta API token to maintain access to an organization's network while they work to achieve their objectives. An attacker may abuse an API token to execute techniques such as creating user accounts or disabling security rules or policies.", "false_positives": [ "If the behavior of creating Okta API tokens is expected, consider adding exceptions to this rule to filter false positives." ], @@ -13,15 +13,15 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Create Okta API Token", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:system.api_token.create", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", "https://developer.okta.com/docs/reference/api/event-types/" ], - "risk_score": 21, + "risk_score": 47, "rule_id": "96b9f4ea-0e8c-435b-8d53-2096e75fcac5", - "severity": "low", + "severity": "medium", "tags": [ "Elastic", "Identity", @@ -48,5 +48,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json index 764c60b8294982..10789088601c50 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may deactivate multi-factor authentication (MFA) for an Okta user account in order to weaken the authentication requirements for the account.", + "description": "Detects attempts to deactivate multi-factor authentication (MFA) for an Okta user. An adversary may deactivate MFA for an Okta user account in order to weaken the authentication requirements for the account.", "false_positives": [ "If the behavior of deactivating MFA for Okta user accounts is expected, consider adding exceptions to this rule to filter false positives." ], @@ -12,8 +12,8 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Deactivate MFA for Okta User Account", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Attempt to Deactivate MFA for an Okta User Account", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:user.mfa.factor.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -48,5 +48,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json deleted file mode 100644 index 9003f6877341fe..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "author": [ - "Elastic" - ], - "description": "An adversary may attempt to deactivate an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to deactivate an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", - "false_positives": [ - "If the behavior of deactivating Okta policies is expected, consider adding exceptions to this rule to filter false positives." - ], - "index": [ - "filebeat-*", - "logs-okta*" - ], - "language": "kuery", - "license": "Elastic License", - "name": "Attempt to Deactivate Okta Policy", - "note": "The Okta Filebeat module must be enabled to use this rule.", - "query": "event.dataset:okta.system and event.action:policy.lifecycle.deactivate", - "references": [ - "https://developer.okta.com/docs/reference/api/system-log/", - "https://developer.okta.com/docs/reference/api/event-types/" - ], - "risk_score": 21, - "rule_id": "b719a170-3bdb-4141-b0e3-13e3cf627bfe", - "severity": "low", - "tags": [ - "Elastic", - "Identity", - "Okta", - "Continuous Monitoring", - "SecOps", - "Monitoring" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0003", - "name": "Persistence", - "reference": "https://attack.mitre.org/tactics/TA0003/" - }, - "technique": [ - { - "id": "T1098", - "name": "Account Manipulation", - "reference": "https://attack.mitre.org/techniques/T1098/" - } - ] - } - ], - "type": "query", - "version": 2 -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json index 4fef3e833a7b6a..85ca7e5b172296 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "An adversary may attempt to remove the multi-factor authentication (MFA) factors registered on an Okta user's account in order to register new MFA factors and abuse the account to blend in with normal activity in the victim's environment.", + "description": "Detects attempts to reset an Okta user's enrolled multi-factor authentication (MFA) factors. An adversary may attempt to reset the MFA factors for an Okta user's account in order to register new MFA factors and abuse the account to blend in with normal activity in the victim's environment.", "false_positives": [ "Consider adding exceptions to this rule to filter false positives if the MFA factors for Okta user accounts are regularly reset in your organization." ], @@ -12,8 +12,8 @@ ], "language": "kuery", "license": "Elastic License", - "name": "Attempt to Reset MFA Factors for Okta User Account", - "note": "The Okta Filebeat module must be enabled to use this rule.", + "name": "Attempt to Reset MFA Factors for an Okta User Account", + "note": "The Okta Fleet integration or Filebeat module must be enabled to use this rule.", "query": "event.dataset:okta.system and event.action:user.mfa.factor.reset_all", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", @@ -48,5 +48,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_change_launch_agents_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_change_launch_agents_file.json new file mode 100644 index 00000000000000..c54600fdf5f816 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_change_launch_agents_file.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary can establish persistence by installing a new launch agent that executes at login by using launchd or launchctl to load a plist into the appropriate directories.", + "false_positives": [ + "Trusted applications persisting via LaunchAgent" + ], + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Launch Agent Creation or Modification and Immediate Loading", + "query": "sequence by host.id with maxspan=1m\n [file where event.type != \"deletion\" and \n file.path : (\"/System/Library/LaunchAgents/*\", \"/Library/LaunchAgents/*\", \"/Users/*/Library/LaunchAgents/*\")\n ]\n [process where event.type in (\"start\", \"process_started\") and process.name == \"launchctl\" and process.args == \"load\"]\n", + "references": [ + "https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" + ], + "risk_score": 21, + "rule_id": "082e3f8c-6f80-485c-91eb-5b112cb79b28", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1159", + "name": "Launch Agent", + "reference": "https://attack.mitre.org/techniques/T1159/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_modif_launch_deamon_sequence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_modif_launch_deamon_sequence.json new file mode 100644 index 00000000000000..786fdc0ef8dc65 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_creation_modif_launch_deamon_sequence.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Adversaries may create or modify launch daemons to repeatedly execute malicious payloads as part of persistence.", + "false_positives": [ + "Trusted applications persisting via LaunchDaemons" + ], + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "LaunchDaemon Creation or Modification and Immediate Loading", + "query": "sequence by host.id with maxspan=1m\n [file where event.type != \"deletion\" and file.path in (\"/System/Library/LaunchDaemons/*\", \" /Library/LaunchDaemons/*\")]\n [process where event.type in (\"start\", \"process_started\") and process.name == \"launchctl\" and process.args == \"load\"]\n", + "references": [ + "https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html" + ], + "risk_score": 21, + "rule_id": "9d19ece6-c20e-481a-90c5-ccca596537de", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1543", + "name": "Create or Modify System Process", + "reference": "https://attack.mitre.org/techniques/T1543/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_evasion_registry_ifeo_injection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_evasion_registry_ifeo_injection.json new file mode 100644 index 00000000000000..5fb49313154c4f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_evasion_registry_ifeo_injection.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "The Debugger and SilentProcessExit registry keys can allow an adversary to intercept the execution of files, causing a different process to be executed. This functionality can be abused by an adversary to establish persistence.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Image File Execution Options Injection", + "query": "registry where length(registry.data.strings) > 0 and\n registry.path : (\"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Image File Execution Options\\\\*.exe\\\\Debugger\", \n \"HKLM\\\\SOFTWARE\\\\WOW6432Node\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Image File Execution Options\\\\*\\\\Debugger\", \n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\SilentProcessExit\\\\*\\\\MonitorProcess\", \n \"HKLM\\\\SOFTWARE\\\\WOW6432Node\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\SilentProcessExit\\\\*\\\\MonitorProcess\") and\n /* add FPs here */\n not registry.data.strings : (\"C:\\\\Program Files*\\\\ThinKiosk\\\\thinkiosk.exe\", \"*\\\\PSAppDeployToolkit\\\\*\")\n", + "references": [ + "https://oddvar.moe/2018/04/10/persistence-using-globalflags-in-image-file-execution-options-hidden-from-autoruns-exe/" + ], + "risk_score": 41, + "rule_id": "6839c821-011d-43bd-bd5b-acff00257226", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1183", + "name": "Image File Execution Options Injection", + "reference": "https://attack.mitre.org/techniques/T1183/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_folder_action_scripts_runtime.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_folder_action_scripts_runtime.json new file mode 100644 index 00000000000000..b114d447350866 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_folder_action_scripts_runtime.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "A Folder Action script is executed when the folder to which it is attached has items added or removed, or when its window is opened, closed, moved, or resized. Adversaries may abuse this feature to establish persistence by utilizing a malicious script.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via Folder Action Script", + "query": "sequence by host.id with maxspan=5s\n [process where event.type in (\"start\", \"process_started\", \"info\") and process.name == \"com.apple.foundation.UserScriptService\"] by process.pid\n [process where event.type in (\"start\", \"process_started\") and process.name in (\"osascript\", \"sh\")] by process.ppid\n", + "references": [ + "https://posts.specterops.io/folder-actions-for-persistence-on-macos-8923f222343d" + ], + "risk_score": 47, + "rule_id": "c292fa52-4115-408a-b897-e14f684b3cb7", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Execution", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1037", + "name": "Boot or Logon Initialization Scripts", + "reference": "https://attack.mitre.org/techniques/T1037/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_admin_role_assigned_to_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_admin_role_assigned_to_user.json new file mode 100644 index 00000000000000..16f20b731dadbb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_admin_role_assigned_to_user.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when an admin role is assigned to a Google Workspace user. An adversary may assign an admin role to a user in order to elevate the permissions of another user account and persist in their target\u2019s environment.", + "false_positives": [ + "Google Workspace admin role assignments may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace Admin Role Assigned to a User", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:ASSIGN_ROLE", + "references": [ + "https://support.google.com/a/answer/172176?hl=en" + ], + "risk_score": 47, + "rule_id": "68994a6c-c7ba-4e82-b476-26a26877adf6", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_api_access_granted_via_domain_wide_delegation_of_authority.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_api_access_granted_via_domain_wide_delegation_of_authority.json new file mode 100644 index 00000000000000..8ca413dc898d0b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_api_access_granted_via_domain_wide_delegation_of_authority.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a domain-wide delegation of authority is granted to a service account. Domain-wide delegation can be configured to grant third-party and internal applications to access the data of Google Workspace users. An adversary may configure domain-wide delegation to maintain access to their target\u2019s data.", + "false_positives": [ + "Domain-wide delegation of authority may be granted to service accounts by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace API Access Granted via Domain-Wide Delegation of Authority", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:AUTHORIZE_API_CLIENT_ACCESS", + "references": [ + "https://developers.google.com/admin-sdk/directory/v1/guides/delegation" + ], + "risk_score": 47, + "rule_id": "acbc8bb9-2486-49a8-8779-45fb5f9a93ee", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_custom_admin_role_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_custom_admin_role_created.json new file mode 100644 index 00000000000000..0b98ba7de8063f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_custom_admin_role_created.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a custom admin role is created in Google Workspace. An adversary may create a custom admin role in order to elevate the permissions of other user accounts and persist in their target\u2019s environment.", + "false_positives": [ + "Custom Google Workspace admin roles may be created by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace Custom Admin Role Created", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:CREATE_ROLE", + "references": [ + "https://support.google.com/a/answer/2406043?hl=en" + ], + "risk_score": 47, + "rule_id": "ad3f2807-2b3e-47d7-b282-f84acbbe14be", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_role_modified.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_role_modified.json new file mode 100644 index 00000000000000..d8c344cc0e0ba7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_google_workspace_role_modified.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects when a custom admin role or its permissions are modified. An adversary may modify a custom admin role in order to elevate the permissions of other user accounts and persist in their target\u2019s environment.", + "false_positives": [ + "Google Workspace admin roles may be modified by system administrators. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-130m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "Google Workspace Role Modified", + "note": "### Important Information Regarding Google Workspace Event Lag Times\n- As per Google's documentation, Google Workspace administrators may observe lag times ranging from minutes up to 3 days between the time of an event's occurrence and the event being visible in the Google Workspace admin/audit logs.\n- This rule is configured to run every 10 minutes with a lookback time of 130 minutes.\n- To reduce the risk of false negatives, consider reducing the interval that the Google Workspace (formerly G Suite) Filebeat module polls Google's reporting API for new events.\n- By default, `var.interval` is set to 2 hours (2h). Consider changing this interval to a lower value, such as 10 minutes (10m).\n- See the following references for further information.\n - https://support.google.com/a/answer/7061566\n - https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-module-gsuite.html", + "query": "event.dataset:gsuite.admin and event.provider:admin and event.category:iam and event.action:(ADD_PRIVILEGE or UPDATE_ROLE)", + "references": [ + "https://support.google.com/a/answer/2406043?hl=en" + ], + "risk_score": 47, + "rule_id": "6f435062-b7fc-4af9-acea-5b1ead65c5a5", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Google Workspace", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_scripting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_scripting.json new file mode 100644 index 00000000000000..ad28885de07404 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_scripting.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "A scheduled task was created by a Windows script via cscript.exe, wscript.exe or powershell.exe. This can be abused by an adversary to establish persistence.", + "false_positives": [ + "Legitimate scheduled tasks may be created during installation of new software." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Scheduled Task Created by a Windows Script", + "note": "Decode the base64 encoded Tasks Actions registry value to investigate the task's configured action.", + "query": "sequence by host.id with maxspan = 30s\n [library where file.name : \"taskschd.dll\" and process.name : (\"cscript.exe\", \"wscript.exe\", \"powershell.exe\")]\n [registry where registry.path : \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Schedule\\\\TaskCache\\\\Tasks\\\\*\\\\Actions\"]\n", + "risk_score": 43, + "rule_id": "689b9d57-e4d5-4357-ad17-9c334609d79a", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_login_logout_hooks_defaults.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_login_logout_hooks_defaults.json new file mode 100644 index 00000000000000..579c51c1bd6fc4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_login_logout_hooks_defaults.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies use of the Defaults command to install a login or logoff hook in MacOS. An adversary may abuse this capability to establish persistence in an environment by inserting code to be executed at login or logout.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via Login or Logout Hook", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.name == \"defaults\" and process.args == \"write\" and process.args in (\"LoginHook\", \"LogoutHook\")\n", + "references": [ + "https://www.virusbulletin.com/uploads/pdf/conference_slides/2014/Wardle-VB2014.pdf", + "https://www.manpagez.com/man/1/defaults/" + ], + "risk_score": 47, + "rule_id": "5d0265bf-dea9-41a9-92ad-48a8dcd05080", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1037", + "name": "Boot or Logon Initialization Scripts", + "reference": "https://attack.mitre.org/techniques/T1037/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_exchange_management_role_assignment.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_exchange_management_role_assignment.json new file mode 100644 index 00000000000000..851cfeb502e24a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_exchange_management_role_assignment.json @@ -0,0 +1,52 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when a new role is assigned to a management group in Microsoft 365. An adversary may attempt to add a role in order to maintain persistence in an environment.", + "false_positives": [ + "A new role may be assigned to a management group by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Exchange Management Group Role Assignment", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:Exchange and event.category:web and event.action:\"New-ManagementRoleAssignment\" and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/exchange/new-managementroleassignment?view=exchange-ps", + "https://docs.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide" + ], + "risk_score": 47, + "rule_id": "98995807-5b09-4e37-8a54-5cae5dc932d7", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Identity and Access" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_external_access_enabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_external_access_enabled.json new file mode 100644 index 00000000000000..350f775e48a582 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_external_access_enabled.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when external access is enabled in Microsoft Teams. External access lets Teams and Skype for Business users communicate with other users that are outside their organization. An adversary may enable external access or add an allowed domain to exfiltrate data or maintain persistence in an environment.", + "false_positives": [ + "Teams external access may be enabled by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Teams External Access Enabled", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:(SkypeForBusiness or MicrosoftTeams) and event.category:web and event.action:\"Set-CsTenantFederationConfiguration\" and o365.audit.Parameters.AllowFederatedUsers:True and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/microsoftteams/manage-external-access" + ], + "risk_score": 47, + "rule_id": "27f7c15a-91f8-4c3d-8b9e-1f99cc030a51", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_guest_access_enabled.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_guest_access_enabled.json new file mode 100644 index 00000000000000..69de0fce7dfc61 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_microsoft_365_teams_guest_access_enabled.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies when guest access is enabled in Microsoft Teams. Guest access in Teams allows people outside the organization to access teams and channels. An adversary may enable guest access to maintain persistence in an environment.", + "false_positives": [ + "Teams guest access may be enabled by a system or network administrator. Verify that the configuration change was expected. Exceptions can be added to this rule to filter expected behavior." + ], + "from": "now-30m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Microsoft 365 Teams Guest Access Enabled", + "note": "The Microsoft 365 Fleet integration or Filebeat module must be enabled to use this rule.", + "query": "event.dataset:o365.audit and event.provider:(SkypeForBusiness or MicrosoftTeams) and event.category:web and event.action:\"Set-CsTeamsClientConfiguration\" and o365.audit.Parameters.AllowGuestUser:True and event.outcome:success", + "references": [ + "https://docs.microsoft.com/en-us/powershell/module/skype/get-csteamsclientconfiguration?view=skype-ps" + ], + "risk_score": 47, + "rule_id": "5e552599-ddec-4e14-bad1-28aa42404388", + "severity": "medium", + "tags": [ + "Elastic", + "Cloud", + "Microsoft 365", + "Continuous Monitoring", + "SecOps", + "Configuration Audit" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_office_addins_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_office_addins_file.json new file mode 100644 index 00000000000000..ab7d3d48271617 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_office_addins_file.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to establish persistence on an endpoint by abusing Microsoft Office add-ins.", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via Microsoft Office AddIns", + "query": "file where event.type != \"deletion\" and\n wildcard(file.extension,\"wll\",\"xll\",\"ppa\",\"ppam\",\"xla\",\"xlam\") and\n wildcard(file.path, \"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\Word\\\\Startup\\\\*\",\n \"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\AddIns\\\\*\",\n \"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\Excel\\\\XLSTART\\\\*\")\n", + "references": [ + "https://labs.mwrinfosecurity.com/blog/add-in-opportunities-for-office-persistence/" + ], + "risk_score": 71, + "rule_id": "f44fa4b6-524c-4e87-8d9e-a32599e4fb7c", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1137", + "name": "Office Application Startup", + "reference": "https://attack.mitre.org/techniques/T1137/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_outlook_vba_template.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_outlook_vba_template.json new file mode 100644 index 00000000000000..7818bc61e67522 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ms_outlook_vba_template.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to establish persistence on an endpoint by installing a rogue Microsoft Outlook VBA Template.", + "false_positives": [ + "A legitimate VBA for Outlook is usually configured interactively via OUTLOOK.EXE." + ], + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via Microsoft Outlook VBA", + "query": "file where event.type != \"deletion\" and\n wildcard(file.path, \"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\Outlook\\\\VbaProject.OTM\")\n", + "references": [ + "https://www.mdsec.co.uk/2020/11/a-fresh-outlook-on-mail-based-persistence/", + "https://www.linkedin.com/pulse/outlook-backdoor-using-vba-samir-b-/" + ], + "risk_score": 43, + "rule_id": "397945f3-d39a-4e6f-8bcb-9656c2031438", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1137", + "name": "Office Application Startup", + "reference": "https://attack.mitre.org/techniques/T1137/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json index e7f4598a19f33d..c915dc79da65ae 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json @@ -7,13 +7,16 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Potential Modification of Accessibility Binaries", - "query": "event.category:process and event.type:(start or process_started) and process.parent.name:winlogon.exe and not process.name:(atbroker.exe or displayswitch.exe or magnify.exe or narrator.exe or osk.exe or sethc.exe or utilman.exe)", - "risk_score": 21, + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n process.parent.name : (\"Utilman.exe\", \"winlogon.exe\") and user.name == \"SYSTEM\" and\n process.args :\n (\n \"C:\\\\Windows\\\\System32\\\\osk.exe\",\n \"C:\\\\Windows\\\\System32\\\\Magnify.exe\",\n \"C:\\\\Windows\\\\System32\\\\Narrator.exe\",\n \"C:\\\\Windows\\\\System32\\\\Sethc.exe\",\n \"utilman.exe\",\n \"ATBroker.exe\",\n \"DisplaySwitch.exe\",\n \"sethc.exe\"\n )\n and not process.pe.original_file_name in\n (\n \"osk.exe\",\n \"sethc.exe\",\n \"utilman2.exe\",\n \"DisplaySwitch.exe\",\n \"ATBroker.exe\",\n \"ScreenMagnifier.exe\",\n \"SR.exe\",\n \"Narrator.exe\",\n \"magnify.exe\",\n \"MAGNIFY.EXE\"\n )\n\n/* uncomment once in winlogbeat to avoid bypass with rogue process with matching pe original file name */\n/* and process.code_signature.subject_name == \"Microsoft Windows\" and process.code_signature.status == \"trusted\" */\n", + "references": [ + "https://www.elastic.co/blog/practical-security-engineering-stateful-detection" + ], + "risk_score": 73, "rule_id": "7405ddf1-6c8e-41ce-818f-48bea6bcaed8", - "severity": "low", + "severity": "high", "tags": [ "Elastic", "Host", @@ -53,6 +56,6 @@ ] } ], - "type": "query", - "version": 4 + "type": "eql", + "version": 5 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_registry_uncommon.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_registry_uncommon.json new file mode 100644 index 00000000000000..c539ccfab16ed0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_registry_uncommon.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects changes to registry persistence keys that are uncommonly used or modified by legitimate programs. This could be an indication of an adversary's attempt to persist in a stealthy manner.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Uncommon Registry Persistence Change", + "query": "registry where\n /* uncomment once stable length(registry.data.strings) > 0 and */\n registry.path : (\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Terminal Server\\\\Install\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\*\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Terminal Server\\\\Install\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Runonce\\\\*\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Windows\\\\Load\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Windows\\\\Run\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Windows\\\\IconServiceLib\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Shell\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Shell\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\AppSetup\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Taskman\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Userinit\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\VmApplet\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\*\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System\\\\Shell\",\n \"HKLM\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Logoff\\\\Script\",\n \"HKLM\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Logon\\\\Script\",\n \"HKLM\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Shutdown\\\\Script\",\n \"HKLM\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Startup\\\\Script\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\*\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System\\\\Shell\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Logoff\\\\Script\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Logon\\\\Script\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Shutdown\\\\Script\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Policies\\\\Microsoft\\\\Windows\\\\System\\\\Scripts\\\\Startup\\\\Script\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Active Setup\\\\Installed Components\\\\*\\\\ShellComponent\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows CE Services\\\\AutoStartOnConnect\\\\MicrosoftActiveSync\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows CE Services\\\\AutoStartOnDisconnect\\\\MicrosoftActiveSync\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Ctf\\\\LangBarAddin\\\\*\\\\FilePath\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Internet Explorer\\\\Extensions\\\\*\\\\Exec\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Internet Explorer\\\\Extensions\\\\*\\\\Script\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Command Processor\\\\Autorun\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Ctf\\\\LangBarAddin\\\\*\\\\FilePath\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Internet Explorer\\\\Extensions\\\\*\\\\Exec\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Internet Explorer\\\\Extensions\\\\*\\\\Script\",\n \"HKEY_USERS\\\\*\\\\SOFTWARE\\\\Microsoft\\\\Command Processor\\\\Autorun\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Image File Execution Options\\\\*\\\\VerifierDlls\",\n \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\GpExtensions\\\\*\\\\DllName\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\SafeBoot\\\\AlternateShell\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Terminal Server\\\\Wds\\\\rdpwd\\\\StartupPrograms\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Terminal Server\\\\WinStations\\\\RDP-Tcp\\\\InitialProgram\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Session Manager\\\\BootExecute\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Session Manager\\\\SetupExecute\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Session Manager\\\\Execute\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\Session Manager\\\\S0InitialCommand\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\ServiceControlManagerExtension\",\n \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Control\\\\BootVerificationProgram\\\\ImagePath\",\n \"HKLM\\\\SYSTEM\\\\Setup\\\\CmdLine\",\n \"HKEY_USERS\\\\*\\\\Environment\\\\UserInitMprLogonScript\") and\n \n not registry.data.strings : (\"C:\\\\Windows\\\\system32\\\\userinit.exe\", \"cmd.exe\", \"C:\\\\Program Files (x86)\\\\*.exe\",\n \"C:\\\\Program Files\\\\*.exe\") and\n not (process.name : \"rundll32.exe\" and registry.path : \"*\\\\Software\\\\Microsoft\\\\Internet Explorer\\\\Extensions\\\\*\\\\Script\") and\n not process.executable : (\"C:\\\\Windows\\\\System32\\\\msiexec.exe\",\n \"C:\\\\Windows\\\\SysWOW64\\\\msiexec.exe\",\n \"C:\\\\ProgramData\\\\Microsoft\\\\Windows Defender\\\\Platform\\\\*\\\\MsMpEng.exe\",\n \"C:\\\\Program Files\\\\*.exe\",\n \"C:\\\\Program Files (x86)\\\\*.exe\")\n", + "references": [ + "https://www.microsoftpressstore.com/articles/article.aspx?p=2762082&seqNum=2" + ], + "risk_score": 47, + "rule_id": "54902e45-3467-49a4-8abc-529f2c8cfb80", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1112", + "name": "Modify Registry", + "reference": "https://attack.mitre.org/techniques/T1112/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_run_key_and_startup_broad.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_run_key_and_startup_broad.json new file mode 100644 index 00000000000000..19f8566ec02582 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_run_key_and_startup_broad.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies run key or startup key registry modifications. In order to survive reboots and other system interrupts, attackers will modify run keys within the registry or leverage startup folder items as a form of persistence.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Startup or Run Key Registry Modification", + "query": "/* uncomment length once stable */\nregistry where /* length(registry.data.strings) > 0 and */\n registry.path : (\n /* Machine Hive */\n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnceEx\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\User Shell Folders\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Shell Folders\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\*\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Shell\\\\*\", \n /* Users Hive */\n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnce\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\RunOnceEx\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\User Shell Folders\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Shell Folders\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\*\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Winlogon\\\\Shell\\\\*\"\n ) and\n /* add here common legit changes without making too restrictive as this is one of the most abused AESPs */\n not registry.data.strings : \"ctfmon.exe /n\" and\n not (registry.value : \"Application Restart #*\" and process.name : \"csrss.exe\") and\n user.domain != \"NT AUTHORITY\" and\n not registry.data.strings : (\"C:\\\\Program Files\\\\*.exe\", \"C:\\\\Program Files (x86)\\\\*.exe\") and\n not process.executable : (\"C:\\\\Windows\\\\System32\\\\msiexec.exe\", \"C:\\\\Windows\\\\SysWOW64\\\\msiexec.exe\")\n", + "risk_score": 21, + "rule_id": "97fc44d3-8dae-4019-ae83-298c3015600f", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_runtime_run_key_startup_susp_procs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_runtime_run_key_startup_susp_procs.json new file mode 100644 index 00000000000000..ea2e3727b3d23d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_runtime_run_key_startup_susp_procs.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution of suspicious persistent programs (scripts, rundll32, etc.) by looking at process lineage and command line usage.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution of Persistent Suspicious Program", + "query": "/* userinit followed by explorer followed by early child process of explorer (unlikely to be launched interactively) within 1m */\nsequence by host.id, user.name with maxspan=1m\n [process where event.type in (\"start\", \"process_started\") and process.name : \"userinit.exe\" and process.parent.name : \"winlogon.exe\"]\n [process where event.type in (\"start\", \"process_started\") and process.name : \"explorer.exe\"]\n [process where event.type in (\"start\", \"process_started\") and process.parent.name : \"explorer.exe\" and\n /* add suspicious programs here */\n process.pe.original_file_name in (\"cscript.exe\",\n \"wscript.exe\",\n \"PowerShell.EXE\",\n \"MSHTA.EXE\",\n \"RUNDLL32.EXE\",\n \"REGSVR32.EXE\",\n \"RegAsm.exe\",\n \"MSBuild.exe\",\n \"InstallUtil.exe\") and\n /* add potential suspicious paths here */\n process.args : (\"C:\\\\Users\\\\*\", \"C:\\\\ProgramData\\\\*\", \"C:\\\\Windows\\\\Temp\\\\*\", \"C:\\\\Windows\\\\Tasks\\\\*\", \"C:\\\\PerfLogs\\\\*\", \"C:\\\\Intel\\\\*\")\n ]\n", + "risk_score": 47, + "rule_id": "e7125cea-9fe1-42a5-9a05-b0792cf86f5a", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_services_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_services_registry.json new file mode 100644 index 00000000000000..d6ca742d89b491 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_services_registry.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies processes modifying the services registry key directly, instead of through the expected Windows APIs. This could be an indication of an adversary attempting to stealthily persist through abnormal service creation or modification of an existing service.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Persistence via Services Registry", + "query": "registry where registry.path : (\"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Services\\\\*\\\\ServiceDLL\", \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Services\\\\*\\\\ImagePath\") and\n not registry.data.strings : (\"C:\\\\windows\\\\system32\\\\Drivers\\\\*.sys\", \n \"\\\\SystemRoot\\\\System32\\\\drivers\\\\*.sys\", \n \"system32\\\\DRIVERS\\\\USBSTOR\") and\n not (process.name : \"procexp??.exe\" and registry.data.strings : \"C:\\\\*\\\\procexp*.sys\") and\n not process.executable : (\"C:\\\\Program Files*\\\\*.exe\", \n \"C:\\\\Windows\\\\System32\\\\svchost.exe\", \n \"C:\\\\Windows\\\\winsxs\\\\*\\\\TiWorker.exe\", \n \"C:\\\\Windows\\\\System32\\\\drvinst.exe\", \n \"C:\\\\Windows\\\\System32\\\\services.exe\", \n \"C:\\\\Windows\\\\System32\\\\msiexec.exe\", \n \"C:\\\\Windows\\\\System32\\\\regsvr32.exe\")\n", + "risk_score": 21, + "rule_id": "403ef0d3-8259-40c9-a5b6-d48354712e49", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1050", + "name": "New Service", + "reference": "https://attack.mitre.org/techniques/T1050/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_suspicious_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_suspicious_process.json new file mode 100644 index 00000000000000..7a398dad485d23 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_suspicious_process.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies files written to or modified in the startup folder by commonly abused processes. Adversaries may use this technique to maintain persistence.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Shortcut File Written or Modified for Persistence", + "query": "file where event.type != \"deletion\" and\n user.domain != \"NT AUTHORITY\" and\n file.path : (\"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\Windows\\\\Start Menu\\\\Programs\\\\Startup\\\\*\", \n \"C:\\\\ProgramData\\\\Microsoft\\\\Windows\\\\Start Menu\\\\Programs\\\\StartUp\\\\*\") and\n process.name : (\"cmd.exe\",\n \"powershell.exe\",\n \"wmic.exe\",\n \"mshta.exe\",\n \"pwsh.exe\",\n \"cscript.exe\",\n \"wscript.exe\",\n \"regsvr32.exe\",\n \"RegAsm.exe\",\n \"rundll32.exe\",\n \"EQNEDT32.EXE\",\n \"WINWORD.EXE\",\n \"EXCEL.EXE\",\n \"POWERPNT.EXE\",\n \"MSPUB.EXE\",\n \"MSACCESS.EXE\",\n \"iexplore.exe\",\n \"InstallUtil.exe\")\n", + "risk_score": 47, + "rule_id": "440e2db4-bc7f-4c96-a068-65b78da59bde", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_unsigned_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_unsigned_process.json new file mode 100644 index 00000000000000..f9410f73ad61af --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_file_written_by_unsigned_process.json @@ -0,0 +1,42 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies files written or modified in the startup folder by unsigned processes. Adversaries may abuse this technique to maintain persistence in an environment.", + "index": [ + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Startup Folder Persistence via Unsigned Process", + "query": "sequence by host.id, process.entity_id with maxspan=5s\n [process where event.type in (\"start\", \"process_started\") and process.code_signature.trusted == false and\n /* suspicious paths can be added here */\n process.executable : (\"C:\\\\Users\\\\*.exe\", \n \"C:\\\\ProgramData\\\\*.exe\", \n \"C:\\\\Windows\\\\Temp\\\\*.exe\", \n \"C:\\\\Windows\\\\Tasks\\\\*.exe\", \n \"C:\\\\Intel\\\\*.exe\", \n \"C:\\\\PerfLogs\\\\*.exe\")\n ]\n [file where event.type != \"deletion\" and user.domain != \"NT AUTHORITY\" and\n file.path : (\"C:\\\\Users\\\\*\\\\AppData\\\\Roaming\\\\Microsoft\\\\Windows\\\\Start Menu\\\\Programs\\\\Startup\\\\*\", \n \"C:\\\\ProgramData\\\\Microsoft\\\\Windows\\\\Start Menu\\\\Programs\\\\StartUp\\\\*\")\n ]\n", + "risk_score": 41, + "rule_id": "2fba96c0-ade5-4bce-b92f-a5df2509da3f", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_scripts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_scripts.json new file mode 100644 index 00000000000000..607cc7c8030dc2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_startup_folder_scripts.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies script engines creating files in the startup folder, or the creation of script files in the startup folder.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistent Scripts in the Startup Directory", + "query": "file where event.type != \"deletion\" and user.domain != \"NT AUTHORITY\"\n and (\n // detect shortcuts created by wscript.exe or cscript.exe\n file.path : \"C:\\\\*\\\\Programs\\\\Startup\\\\*.lnk\" and\n process.name : (\"wscript.exe\", \"cscript.exe\")\n ) or\n // detect vbs or js files created by any process\n file.path : (\"C:\\\\*\\\\Programs\\\\Startup\\\\*.vbs\", \n \"C:\\\\*\\\\Programs\\\\Startup\\\\*.vbe\", \n \"C:\\\\*\\\\Programs\\\\Startup\\\\*.wsh\", \n \"C:\\\\*\\\\Programs\\\\Startup\\\\*.wsf\", \n \"C:\\\\*\\\\Programs\\\\Startup\\\\*.js\")\n", + "risk_score": 47, + "rule_id": "f7c4dc5a-a58d-491d-9f14-9b66507121c0", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_com_hijack_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_com_hijack_registry.json new file mode 100644 index 00000000000000..117a5108d2cab6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_com_hijack_registry.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies Component Object Model (COM) hijacking via registry modification. Adversaries may establish persistence by executing malicious content triggered by hijacked references to COM objects.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Component Object Model Hijacking", + "query": "registry where\n /* uncomment once length is stable length(bytes_written_string) > 0 and */\n (registry.path : \"HK*}\\\\InprocServer32\\\\\" and registry.data.strings: (\"scrobj.dll\", \"C:\\\\*\\\\scrobj.dll\") and\n not registry.path : \"*\\\\{06290BD*-48AA-11D2-8432-006008C3FBFC}\\\\*\") \n or\n /* in general COM Registry changes on Users Hive is less noisy and worth alerting */\n (registry.path : (\"HKEY_USERS\\\\*Classes\\\\*\\\\InprocXServer32\\\\\", \n \"HKEY_USERS\\\\*Classes\\\\*\\\\LocalServer32\\\\\", \n \"HKEY_USERS\\\\*Classes\\\\*\\\\DelegateExecute\\\\\", \n \"HKEY_USERS\\\\*Classes\\\\*\\\\TreatAs\\\\\", \n \"HKEY_USERS\\\\*Classes\\\\CLSID\\\\*\\\\ScriptletURL\\\\\") and\n /* not necessary but good for filtering privileged installations */\n user.domain != \"NT AUTHORITY\")\n", + "references": [ + "https://bohops.com/2018/08/18/abusing-the-com-registry-structure-part-2-loading-techniques-for-evasion-and-persistence/" + ], + "risk_score": 47, + "rule_id": "16a52c14-7883-47af-8745-9357803f0d4c", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1122", + "name": "Component Object Model Hijacking", + "reference": "https://attack.mitre.org/techniques/T1122/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_image_load_scheduled_task_ms_office.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_image_load_scheduled_task_ms_office.json new file mode 100644 index 00000000000000..74fc89eae5914d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_image_load_scheduled_task_ms_office.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a suspicious image load (taskschd.dll) from Microsoft Office processes. This behavior may indicate adversarial activity where a scheduled task is configured via Windows Component Object Model (COM). This technique can be used to configure persistence and evade monitoring by avoiding the usage of the traditional Windows binary (schtasks.exe) used to manage scheduled tasks.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Image Load (taskschd.dll) from MS Office", + "query": "library where process.name in (\"WINWORD.EXE\", \"EXCEL.EXE\", \"POWERPNT.EXE\", \"MSPUB.EXE\", \"MSACCESS.EXE\") and\n event.action == \"load\" and\n event.category == \"library\" and\n file.name == \"taskschd.dll\"\n", + "references": [ + "https://medium.com/threatpunter/detecting-adversary-tradecraft-with-image-load-event-logging-and-eql-8de93338c16", + "https://www.clearskysec.com/wp-content/uploads/2020/10/Operation-Quicksand.pdf" + ], + "risk_score": 21, + "rule_id": "baa5d22c-5e1c-4f33-bfc9-efa73bb53022", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Execution" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_scheduled_task_runtime.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_scheduled_task_runtime.json new file mode 100644 index 00000000000000..101fc0eb0ac811 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_scheduled_task_runtime.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution of a suspicious program via scheduled tasks by looking at process lineage and command line usage.", + "false_positives": [ + "Legitimate scheduled tasks running third party software." + ], + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Execution via Scheduled Task", + "query": "process where event.type == \"start\" and\n /* Schedule service cmdline on Win10+ */\n process.parent.name : \"svchost.exe\" and process.parent.args : \"Schedule\" and\n /* add suspicious programs here */\n process.pe.original_file_name in\n (\n \"cscript.exe\",\n \"wscript.exe\",\n \"PowerShell.EXE\",\n \"Cmd.Exe\",\n \"MSHTA.EXE\",\n \"RUNDLL32.EXE\",\n \"REGSVR32.EXE\",\n \"MSBuild.exe\",\n \"InstallUtil.exe\",\n \"RegAsm.exe\",\n \"RegSvcs.exe\",\n \"msxsl.exe\",\n \"CONTROL.EXE\",\n \"EXPLORER.EXE\",\n \"Microsoft.Workflow.Compiler.exe\",\n \"msiexec.exe\"\n ) and\n /* add suspicious paths here */\n process.args : (\n \"C:\\\\Users\\\\*\",\n \"C:\\\\ProgramData\\\\*\", \n \"C:\\\\Windows\\\\Temp\\\\*\", \n \"C:\\\\Windows\\\\Tasks\\\\*\", \n \"C:\\\\PerfLogs\\\\*\", \n \"C:\\\\Intel\\\\*\", \n \"C:\\\\Windows\\\\Debug\\\\*\", \n \"C:\\\\HP\\\\*\")\n", + "risk_score": 43, + "rule_id": "5d1d6907-0747-4d5d-9b24-e4a18853dc0a", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1053", + "name": "Scheduled Task/Job", + "reference": "https://attack.mitre.org/techniques/T1053/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_service_created_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_service_created_registry.json new file mode 100644 index 00000000000000..6fea602025f466 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_suspicious_service_created_registry.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a suspicious ImagePath value. This could be an indication of an adversary attempting to stealthily persist or escalate privileges through abnormal service creation.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious ImagePath Service Creation", + "query": "registry where registry.path : \"HKLM\\\\SYSTEM\\\\ControlSet*\\\\Services\\\\*\\\\ImagePath\" and\n /* add suspicious registry ImagePath values here */\n registry.data.strings : (\"%COMSPEC%*\", \"*\\\\.\\\\pipe\\\\*\")\n", + "risk_score": 73, + "rule_id": "36a8e048-d888-4f61-a8b9-0f9e2e40f317", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1050", + "name": "New Service", + "reference": "https://attack.mitre.org/techniques/T1050/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_hidden_run_key_valuename.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_hidden_run_key_valuename.json new file mode 100644 index 00000000000000..97bd9efa161e6b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_hidden_run_key_valuename.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a persistence mechanism that utilizes the NtSetValueKey native API to create a hidden (null terminated) registry key. An adversary may use this method to hide from system utilities such as the Registry Editor (regedit).", + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via Hidden Run Key Detected", + "query": "/* Registry Path ends with backslash */\nregistry where /* length(registry.data.strings) > 0 and */\n registry.path : (\"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\\", \n \"HKLM\\\\Software\\\\WOW6432Node\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run\\\\\", \n \"HKEY_USERS\\\\*\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\\", \n \"HKLM\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\Explorer\\\\Run\\\\\")\n", + "references": [ + "https://github.com/outflanknl/SharpHide", + "https://github.com/ewhitehats/InvisiblePersistence/blob/master/InvisibleRegValues_Whitepaper.pdf" + ], + "risk_score": 73, + "rule_id": "a9b05c3b-b304-4bf9-970d-acdfaef2944c", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1060", + "name": "Registry Run Keys / Startup Folder", + "reference": "https://attack.mitre.org/techniques/T1060/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_lsa_security_support_provider_registry.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_lsa_security_support_provider_registry.json new file mode 100644 index 00000000000000..c1a0beb2e1fde1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_lsa_security_support_provider_registry.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies registry modifications related to the Windows Security Support Provider (SSP) configuration. Adversaries may abuse this to establish persistence in an environment.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Installation of Security Support Provider", + "query": "registry where\n registry.path : (\"HKLM\\\\SYSTEM\\\\CurrentControlSet\\\\Control\\\\Lsa\\\\Security Packages*\", \n \"HKLM\\\\SYSTEM\\\\CurrentControlSet\\\\Control\\\\Lsa\\\\OSConfig\\\\Security Packages*\") and\n not process.executable : (\"C:\\\\Windows\\\\System32\\\\msiexec.exe\", \"C:\\\\Windows\\\\SysWOW64\\\\msiexec.exe\")\n", + "risk_score": 47, + "rule_id": "e86da94d-e54b-4fb5-b96c-cecff87e8787", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1101", + "name": "Security Support Provider", + "reference": "https://attack.mitre.org/techniques/T1101/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json index 0622309387f35a..389c4ba4a3d41b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_telemetrycontroller_scheduledtask_hijack.json @@ -11,7 +11,7 @@ "language": "kuery", "license": "Elastic License", "name": "Persistence via TelemetryController Scheduled Task Hijack", - "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(CompatTelRunner.exe or compattelrunner.exe) and not process.name:(conhost.exe or DeviceCensus.exe or devicecensus.exe or CompatTelRunner.exe or compattelrunner.exe or DismHost.exe or dismhost.exe or rundll32.exe)", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(CompatTelRunner.exe or compattelrunner.exe) and process.args:-cv* and not process.name:(conhost.exe or DeviceCensus.exe or devicecensus.exe or CompatTelRunner.exe or compattelrunner.exe or DismHost.exe or dismhost.exe or rundll32.exe or powershell.exe)", "references": [ "https://www.trustedsec.com/blog/abusing-windows-telemetry-for-persistence/?utm_content=131234033&utm_medium=social&utm_source=twitter&hss_channel=tw-403811306" ], @@ -43,5 +43,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_windows_management_instrumentation_event_subscription.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_windows_management_instrumentation_event_subscription.json new file mode 100644 index 00000000000000..bb57e19369f62e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_windows_management_instrumentation_event_subscription.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary can use Windows Management Instrumentation (WMI) to install event filters, providers, consumers, and bindings that execute code when a defined event occurs. Adversaries may use the capabilities of WMI to subscribe to an event and execute arbitrary code when that event occurs, providing persistence on a system.", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Persistence via WMI Event Subscription", + "query": "process where event.type in (\"start\", \"process_started\") and\n (process.name : \"wmic.exe\" or process.pe.original_file_name == \"wmic.exe\") and\n process.args : \"create\" and\n process.args : (\"ActiveScriptEventConsumer\", \"CommandLineEventConsumer\")\n\n", + "risk_score": 21, + "rule_id": "9b6813a1-daf1-457e-b0e6-0bb4e55b8a4c", + "severity": "low", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Persistence" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1546", + "name": "Event Triggered Execution", + "reference": "https://attack.mitre.org/techniques/T1546/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_explicit_creds_via_apple_scripting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_explicit_creds_via_apple_scripting.json new file mode 100644 index 00000000000000..1b741cd1a8c97c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_explicit_creds_via_apple_scripting.json @@ -0,0 +1,64 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies execution of the security_authtrampoline process via the Apple script interpreter (osascript). This occurs when programs use AuthorizationExecute-WithPrivileges from the Security.framework to run another program with root privileges. It should not be run by itself, as this is a sign of execution with explicit logon credentials.", + "from": "now-9m", + "index": [ + "auditbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Execution with Explicit Credentials via Apple Scripting", + "query": "sequence by host.id with maxspan=5s\n [process where event.type in (\"start\", \"process_started\", \"info\") and process.name == \"osascript\"] by process.pid\n [process where event.type in (\"start\", \"process_started\") and process.name == \"security_authtrampoline\"] by process.ppid\n", + "references": [ + "https://objectivebythesea.com/v2/talks/OBTS_v2_Thomas.pdf", + "https://www.manpagez.com/man/8/security_authtrampoline/" + ], + "risk_score": 47, + "rule_id": "f0eb70e9-71e9-40cd-813f-bf8e8c812cb1", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "macOS", + "Threat Detection", + "Execution", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1059", + "name": "Command and Scripting Interpreter", + "reference": "https://attack.mitre.org/techniques/T1059/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_named_pipe_impersonation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_named_pipe_impersonation.json new file mode 100644 index 00000000000000..e440baf5c281e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_named_pipe_impersonation.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a privilege escalation attempt via named pipe impersonation. An adversary may abuse this technique by utilizing a framework such Metasploit's meterpreter getsystem command.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Privilege Escalation via Named Pipe Impersonation", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.pe.original_file_name in (\"Cmd.Exe\", \"PowerShell.EXE\") and \n process.args : \"echo\" and process.args : \">\" and process.args : \"\\\\\\\\.\\\\pipe\\\\*\"\n", + "references": [ + "https://www.ired.team/offensive-security/privilege-escalation/windows-namedpipes-privilege-escalation" + ], + "risk_score": 73, + "rule_id": "3ecbdc9e-e4f2-43fa-8cca-63802125e582", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1134", + "name": "Access Token Manipulation", + "reference": "https://attack.mitre.org/techniques/T1134/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_registry_copyfiles.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_registry_copyfiles.json new file mode 100644 index 00000000000000..76b17b0d892294 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_printspooler_registry_copyfiles.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Detects attempts to exploit a privilege escalation vulnerability (CVE-2020-1030) related to the print spooler service. Exploitation involves chaining multiple primitives to load an arbitrary DLL into the print spooler process running as SYSTEM.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Suspicious Print Spooler Point and Print DLL", + "query": "sequence by host.id with maxspan=30s\n[registry where\n registry.path : \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Print\\\\Printers\\\\*\\\\SpoolDirectory\" and\n registry.data.strings : \"C:\\\\Windows\\\\System32\\\\spool\\\\drivers\\\\x64\\\\4\"]\n[registry where\n registry.path : \"HKLM\\\\SOFTWARE\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\Print\\\\Printers\\\\*\\\\CopyFiles\\\\Payload\\\\Module\" and\n registry.data.strings : \"C:\\\\Windows\\\\System32\\\\spool\\\\drivers\\\\x64\\\\4\\\\*\"]\n", + "references": [ + "https://www.accenture.com/us-en/blogs/cyber-defense/discovering-exploiting-shutting-down-dangerous-windows-print-spooler-vulnerability", + "https://github.com/sbousseaden/EVTX-ATTACK-SAMPLES/blob/master/Privilege%20Escalation/privesc_sysmon_cve_20201030_spooler.evtx", + "https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2020-1030" + ], + "risk_score": 74, + "rule_id": "bd7eefee-f671-494e-98df-f01daf9e5f17", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1068", + "name": "Exploitation for Privilege Escalation", + "reference": "https://attack.mitre.org/techniques/T1068/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_rogue_windir_environment_var.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_rogue_windir_environment_var.json new file mode 100644 index 00000000000000..6ad1d8f89fcdd2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_rogue_windir_environment_var.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a privilege escalation attempt via a rogue Windows directory (Windir) environment variable. This is a known primitive that is often combined with other vulnerabilities to elevate privileges.", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Privilege Escalation via Windir Environment Variable", + "query": "registry where registry.path : (\"HKEY_USERS\\\\*\\\\Environment\\\\windir\", \"HKEY_USERS\\\\*\\\\Environment\\\\systemroot\") and \n not registry.data.strings : (\"C:\\\\windows\", \"%SystemRoot%\")\n", + "references": [ + "https://www.tiraniddo.dev/2017/05/exploiting-environment-variables-in.html" + ], + "risk_score": 71, + "rule_id": "d563aaba-2e72-462b-8658-3e5ea22db3a6", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1034", + "name": "Path Interception", + "reference": "https://attack.mitre.org/techniques/T1034/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_clipup.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_clipup.json new file mode 100644 index 00000000000000..2a1749d04fdfeb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_clipup.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to bypass User Account Control (UAC) by abusing an elevated COM Interface to launch a rogue Windows ClipUp program. Attackers may attempt to bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass Attempt with IEditionUpgradeManager Elevated COM Interface", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and process.name == \"Clipup.exe\" and \nprocess.executable != \"C:\\\\Windows\\\\System32\\\\ClipUp.exe\" and process.parent.name == \"dllhost.exe\" and\n /* CLSID of the Elevated COM Interface IEditionUpgradeManager */\n wildcard(process.parent.args,\"/Processid:{BD54C901-076B-434E-B6C7-17C531F4AB41}\")\n", + "references": [ + "https://github.com/hfiref0x/UACME" + ], + "risk_score": 71, + "rule_id": "b90cdde7-7e0d-4359-8bf0-2c112ce2008a", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_ieinstal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_ieinstal.json new file mode 100644 index 00000000000000..410124fdd699f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_ieinstal.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies User Account Control (UAC) bypass attempts by abusing an elevated COM Interface to launch a malicious program. Attackers may attempt to bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass Attempt via Elevated COM Internet Explorer Add-On Installer", + "query": "process where event.type in (\"start\", \"process_started\", \"info\") and\n wildcard(process.executable, \"C:\\\\*\\\\AppData\\\\*\\\\Temp\\\\IDC*.tmp\\\\*.exe\") and\n process.parent.name == \"ieinstal.exe\" and process.parent.args == \"-Embedding\"\n\n /* uncomment once in winlogbeat */\n /* and not (process.code_signature.subject_name == \"Microsoft Corporation\" and process.code_signature.trusted == true) */\n", + "references": [ + "https://swapcontext.blogspot.com/2020/11/uac-bypasses-from-comautoapprovallist.html" + ], + "risk_score": 47, + "rule_id": "fc7c0fa4-8f03-4b3e-8336-c5feab0be022", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_interface_icmluautil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_interface_icmluautil.json new file mode 100644 index 00000000000000..9f5cdfffa57c71 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_com_interface_icmluautil.json @@ -0,0 +1,44 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies User Account Control (UAC) bypass attempts via the ICMLuaUtil Elevated COM interface. Attackers may attempt to bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass via ICMLuaUtil Elevated COM Interface", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.parent.name == \"dllhost.exe\" and\n process.parent.args in (\"/Processid:{3E5FC7F9-9A51-4367-9063-A120244FBEC7}\", \"/Processid:{D2E7041B-2927-42FB-8E9F-7CE93B6DC937}\") and\n process.pe.original_file_name != \"WerFault.exe\"\n", + "risk_score": 73, + "rule_id": "68d56fdc-7ffa-4419-8e95-81641bd6f845", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json index 80b01f90d3cf4b..50774166af698c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_diskcleanup_hijack.json @@ -8,10 +8,10 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "UAC Bypass via DiskCleanup Scheduled Task Hijack", - "query": "event.category:process and event.type:(start or process_started) and process.args:(/autoclean or /AUTOCLEAN) and process.parent.name:svchost.exe and not process.executable:(\"C:\\Windows\\System32\\cleanmgr.exe\" or \"C:\\Windows\\SysWOW64\\cleanmgr.exe\")", + "query": "process where event.type in (\"start\", \"process_started\") and\nprocess.args:\"/autoclean\" and process.args:\"/d\" and\nnot process.executable : (\"C:\\\\Windows\\\\System32\\\\cleanmgr.exe\", \"C:\\\\Windows\\\\SysWOW64\\\\cleanmgr.exe\")\n", "risk_score": 47, "rule_id": "1dcc51f6-ba26-49e7-9ef4-2655abb2361e", "severity": "medium", @@ -39,6 +39,6 @@ ] } ], - "type": "query", - "version": 1 + "type": "eql", + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_dll_sideloading.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_dll_sideloading.json new file mode 100644 index 00000000000000..5ad7ca602a36a0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_dll_sideloading.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to bypass User Account Control (UAC) via DLL side-loading. Attackers may attempt to bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass Attempt via Privileged IFileOperation COM Interface", + "query": "file where event.type : \"change\" and process.name : \"dllhost.exe\" and\n /* Known modules names side loaded into process running with high or system integrity level for UAC Bypass, update here for new modules */\n file.name : (\"wow64log.dll\", \"comctl32.dll\", \"DismCore.dll\", \"OskSupport.dll\", \"duser.dll\", \"Accessibility.ni.dll\") and\n /* has no impact on rule logic just to avoid OS install related FPs */\n not file.path : (\"C:\\\\Windows\\\\SoftwareDistribution\\\\*\", \"C:\\\\Windows\\\\WinSxS\\\\*\")\n", + "references": [ + "https://github.com/hfiref0x/UACME" + ], + "risk_score": 73, + "rule_id": "5a14d01d-7ac8-4545-914c-b687c2cf66b3", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_mock_windir.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_mock_windir.json new file mode 100644 index 00000000000000..069dada4a099bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_mock_windir.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an attempt to bypass User Account Control (UAC) by masquerading as a Microsoft trusted Windows directory. Attackers may bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass Attempt via Windows Directory Masquerading", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.args : (\"C:\\\\Windows \\\\system32\\\\*.exe\", \"C:\\\\Windows \\\\SysWOW64\\\\*.exe\")\n", + "references": [ + "https://medium.com/tenable-techblog/uac-bypass-by-mocking-trusted-directories-24a96675f6e" + ], + "risk_score": 71, + "rule_id": "290aca65-e94d-403b-ba0f-62f320e63f51", + "severity": "high", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_winfw_mmc_hijack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_winfw_mmc_hijack.json new file mode 100644 index 00000000000000..23d18b4ad17d7e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_winfw_mmc_hijack.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to bypass User Account Control (UAC) by hijacking the Microsoft Management Console (MMC) Windows Firewall snap-in. Attackers bypass UAC to stealthily execute code with elevated permissions.", + "from": "now-9m", + "index": [ + "winlogbeat-*", + "logs-endpoint.events.*" + ], + "language": "eql", + "license": "Elastic License", + "name": "UAC Bypass via Windows Firewall Snap-In Hijack", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.parent.name == \"mmc.exe\" and\n /* process.Ext.token.integrity_level_name == \"high\" can be added in future for tuning */\n /* args of the Windows Firewall SnapIn */\n process.parent.args == \"WF.msc\" and process.name != \"WerFault.exe\"\n", + "references": [ + "https://github.com/AzAgarampur/byeintegrity-uac" + ], + "risk_score": 47, + "rule_id": "1178ae09-5aff-460a-9f2f-455cd0ac4d8e", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Privilege Escalation" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1088", + "name": "Bypass User Account Control", + "reference": "https://attack.mitre.org/techniques/T1088/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json index ad871716a67aab..a367f4c89a71c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json @@ -8,10 +8,14 @@ "winlogbeat-*", "logs-endpoint.events.*" ], - "language": "kuery", + "language": "eql", "license": "Elastic License", "name": "Unusual Parent-Child Relationship", - "query": "event.category:process and event.type:(start or process_started) and process.parent.executable:* and (process.parent.name:autochk.exe and not process.name:(chkdsk.exe or doskey.exe or WerFault.exe) or process.parent.name:smss.exe and not process.name:(autochk.exe or smss.exe or csrss.exe or wininit.exe or winlogon.exe or WerFault.exe) or process.name:autochk.exe and not process.parent.name:smss.exe or process.name:(fontdrvhost.exe or dwm.exe) and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:(consent.exe or RuntimeBroker.exe or TiWorker.exe) and not process.parent.name:svchost.exe or process.name:wermgr.exe and not process.parent.name:(svchost.exe or TiWorker.exe) or process.name:SearchIndexer.exe and not process.parent.name:services.exe or process.name:SearchProtocolHost.exe and not process.parent.name:(SearchIndexer.exe or dllhost.exe) or process.name:dllhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:smss.exe and not process.parent.name:(System or smss.exe) or process.name:csrss.exe and not process.parent.name:(smss.exe or svchost.exe) or process.name:wininit.exe and not process.parent.name:smss.exe or process.name:winlogon.exe and not process.parent.name:smss.exe or process.name:(lsass.exe or LsaIso.exe) and not process.parent.name:wininit.exe or process.name:LogonUI.exe and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:services.exe and not process.parent.name:wininit.exe or process.name:svchost.exe and not process.parent.name:(MsMpEng.exe or services.exe) or process.name:spoolsv.exe and not process.parent.name:services.exe or process.name:taskhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:taskhostw.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:userinit.exe and not process.parent.name:(dwm.exe or winlogon.exe))", + "query": "process where event.type in (\"start\", \"process_started\") and\nprocess.parent.name != null and\n (\n /* suspicious parent processes */\n (process.name:\"autochk.exe\" and not process.parent.name:\"smss.exe\") or\n (process.name:(\"fontdrvhost.exe\", \"dwm.exe\") and not process.parent.name:(\"wininit.exe\", \"winlogon.exe\")) or\n (process.name:(\"consent.exe\", \"RuntimeBroker.exe\", \"TiWorker.exe\") and not process.parent.name:\"svchost.exe\") or\n (process.name:\"SearchIndexer.exe\" and not process.parent.name:\"services.exe\") or\n (process.name:\"SearchProtocolHost.exe\" and not process.parent.name:(\"SearchIndexer.exe\", \"dllhost.exe\")) or\n (process.name:\"dllhost.exe\" and not process.parent.name:(\"services.exe\", \"svchost.exe\")) or\n (process.name:\"smss.exe\" and not process.parent.name:(\"System\", \"smss.exe\")) or\n (process.name:\"csrss.exe\" and not process.parent.name:(\"smss.exe\", \"svchost.exe\")) or\n (process.name:\"wininit.exe\" and not process.parent.name:\"smss.exe\") or\n (process.name:\"winlogon.exe\" and not process.parent.name:\"smss.exe\") or\n (process.name:(\"lsass.exe\", \"LsaIso.exe\") and not process.parent.name:\"wininit.exe\") or\n (process.name:\"LogonUI.exe\" and not process.parent.name:(\"wininit.exe\", \"winlogon.exe\")) or\n (process.name:\"services.exe\" and not process.parent.name:\"wininit.exe\") or\n (process.name:\"svchost.exe\" and not process.parent.name:(\"MsMpEng.exe\", \"services.exe\")) or\n (process.name:\"spoolsv.exe\" and not process.parent.name:\"services.exe\") or\n (process.name:\"taskhost.exe\" and not process.parent.name:(\"services.exe\", \"svchost.exe\")) or\n (process.name:\"taskhostw.exe\" and not process.parent.name:(\"services.exe\", \"svchost.exe\")) or\n (process.name:\"userinit.exe\" and not process.parent.name:(\"dwm.exe\", \"winlogon.exe\")) or\n (process.name:(\"wmiprvse.exe\", \"wsmprovhost.exe\", \"winrshost.exe\") and not process.parent.name:\"svchost.exe\") or\n /* suspicious child processes */\n (process.parent.name:(\"SearchProtocolHost.exe\", \"taskhost.exe\", \"csrss.exe\") and not process.name:(\"werfault.exe\", \"wermgr.exe\", \"WerFaultSecure.exe\")) or\n (process.parent.name:\"autochk.exe\" and not process.name:(\"chkdsk.exe\", \"doskey.exe\", \"WerFault.exe\")) or\n (process.parent.name:\"smss.exe\" and not process.name:(\"autochk.exe\", \"smss.exe\", \"csrss.exe\", \"wininit.exe\", \"winlogon.exe\", \"setupcl.exe\", \"WerFault.exe\")) or\n (process.parent.name:\"wermgr.exe\" and not process.name:(\"WerFaultSecure.exe\", \"wermgr.exe\", \"WerFault.exe\")) or\n (process.parent.name:\"conhost.exe\" and not process.name:(\"mscorsvw.exe\", \"wermgr.exe\", \"WerFault.exe\", \"WerFaultSecure.exe\"))\n )\n", + "references": [ + "https://github.com/sbousseaden/Slides/blob/master/Hunting MindMaps/PNG/Windows Processes TH.map.png", + "https://www.andreafortuna.org/2017/06/15/standard-windows-processes-a-brief-reference/" + ], "risk_score": 47, "rule_id": "35df0dd8-092d-4a83-88c1-5151a804f31b", "severity": "medium", @@ -39,6 +43,6 @@ ] } ], - "type": "query", - "version": 5 + "type": "eql", + "version": 6 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_svchost_childproc_childless.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_svchost_childproc_childless.json new file mode 100644 index 00000000000000..9ffd9eed711aa6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_svchost_childproc_childless.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies unusual child processes of Service Host (svchost.exe) that traditionally do not spawn any child processes. This may indicate a code injection or an equivalent form of exploitation.", + "false_positives": [ + "Changes to Windows services or a rarely executed child process." + ], + "from": "now-9m", + "index": [ + "logs-endpoint.events.*", + "winlogbeat-*" + ], + "language": "eql", + "license": "Elastic License", + "name": "Unusual Service Host Child Process - Childless Service", + "query": "process where event.type in (\"start\", \"process_started\") and\n process.parent.name : \"svchost.exe\" and\n\n /* based on svchost service arguments -s svcname where the service is known to be childless */\n\n process.parent.args : (\"WdiSystemHost\",\"LicenseManager\",\n \"StorSvc\",\"CDPSvc\",\"cdbhsvc\",\"BthAvctpSvc\",\"SstpSvc\",\"WdiServiceHost\",\n \"imgsvc\",\"TrkWks\",\"WpnService\",\"IKEEXT\",\"PolicyAgent\",\"CryptSvc\",\n \"netprofm\",\"ProfSvc\",\"StateRepository\",\"camsvc\",\"LanmanWorkstation\",\n \"NlaSvc\",\"EventLog\",\"hidserv\",\"DisplayEnhancementService\",\"ShellHWDetection\",\n \"AppHostSvc\",\"fhsvc\",\"CscService\",\"PushToInstall\") and\n\n /* unknown FPs can be added here */\n\n not process.name : (\"WerFault.exe\",\"WerFaultSecure.exe\",\"wermgr.exe\")\n", + "risk_score": 47, + "rule_id": "6a8ab9cc-4023-4d17-b5df-1a3e16882ce7", + "severity": "medium", + "tags": [ + "Elastic", + "Host", + "Windows", + "Threat Detection", + "Defense Evasion" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1093", + "name": "Process Hollowing", + "reference": "https://attack.mitre.org/techniques/T1093/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1055", + "name": "Process Injection", + "reference": "https://attack.mitre.org/techniques/T1055/" + } + ] + } + ], + "type": "eql", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index dab8769bcaa651..c63bd01cd18133 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -74,6 +74,7 @@ export const updateRules = async ({ schedule: { interval: ruleUpdate.interval ?? '5m' }, actions: throttle === 'rule' ? (ruleUpdate.actions ?? []).map(transformRuleToAlertAction) : [], throttle: null, + notifyWhen: null, }; const update = await alertsClient.update({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts index 86d85cd2a066e0..7585f1a1b510b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_converters.ts @@ -143,6 +143,7 @@ export const convertCreateAPIToInternalSchema = ( enabled: input.enabled ?? true, actions: input.throttle === 'rule' ? (input.actions ?? []).map(transformRuleToAlertAction) : [], throttle: null, + notifyWhen: null, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts index 5bb8d6d6746f9b..0af9d6ac4377d3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/schemas/rule_schemas.ts @@ -182,6 +182,7 @@ export const internalRuleCreate = t.type({ actions: actionsCamel, params: ruleParams, throttle: throttleOrNull, + notifyWhen: t.null, }); export type InternalRuleCreate = t.TypeOf; @@ -194,6 +195,7 @@ export const internalRuleUpdate = t.type({ actions: actionsCamel, params: ruleParams, throttle: throttleOrNull, + notifyWhen: t.null, }); export type InternalRuleUpdate = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts deleted file mode 100644 index 1c13de16d9b1e3..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ /dev/null @@ -1,216 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import { get } from 'lodash/fp'; -import { Logger } from 'src/core/server'; - -import { ListClient } from '../../../../../lists/server'; -import { BuildRuleMessage } from './rule_messages'; -import { - EntryList, - ExceptionListItemSchema, - entriesList, - Type, -} from '../../../../../lists/common/schemas'; -import { hasLargeValueList } from '../../../../common/detection_engine/utils'; -import { SearchTypes } from '../../../../common/detection_engine/types'; -import { SearchResponse } from '../../types'; - -// narrow unioned type to be single -const isStringableType = (val: SearchTypes): val is string | number | boolean => - ['string', 'number', 'boolean'].includes(typeof val); - -const isStringableArray = (val: SearchTypes): val is Array => { - if (!Array.isArray(val)) { - return false; - } - // TS does not allow .every to be called on val as-is, even though every type in the union - // is an array. https://github.com/microsoft/TypeScript/issues/36390 - // @ts-expect-error - return val.every((subVal) => isStringableType(subVal)); -}; - -export const createSetToFilterAgainst = async ({ - events, - field, - listId, - listType, - listClient, - logger, - buildRuleMessage, -}: { - events: SearchResponse['hits']['hits']; - field: string; - listId: string; - listType: Type; - listClient: ListClient; - logger: Logger; - buildRuleMessage: BuildRuleMessage; -}): Promise> => { - const valuesFromSearchResultField = events.reduce((acc, searchResultItem) => { - const valueField = get(field, searchResultItem._source); - if (valueField != null) { - if (isStringableType(valueField)) { - acc.add(valueField.toString()); - } else if (isStringableArray(valueField)) { - valueField.forEach((subVal) => acc.add(subVal.toString())); - } - } - return acc; - }, new Set()); - logger.debug( - `number of distinct values from ${field}: ${[...valuesFromSearchResultField].length}` - ); - - // matched will contain any list items that matched with the - // values passed in from the Set. - const matchedListItems = await listClient.getListItemByValues({ - listId, - type: listType, - value: [...valuesFromSearchResultField], - }); - - logger.debug(`number of matched items from list with id ${listId}: ${matchedListItems.length}`); - // create a set of list values that were a hit - easier to work with - const matchedListItemsSet = new Set(matchedListItems.map((item) => item.value)); - return matchedListItemsSet; -}; - -export const filterEventsAgainstList = async ({ - listClient, - exceptionsList, - logger, - eventSearchResult, - buildRuleMessage, -}: { - listClient: ListClient; - exceptionsList: ExceptionListItemSchema[]; - logger: Logger; - eventSearchResult: SearchResponse; - buildRuleMessage: BuildRuleMessage; -}): Promise> => { - try { - if (exceptionsList == null || exceptionsList.length === 0) { - logger.debug(buildRuleMessage('about to return original search result')); - return eventSearchResult; - } - - const exceptionItemsWithLargeValueLists = exceptionsList.reduce( - (acc, exception) => { - const { entries } = exception; - if (hasLargeValueList(entries)) { - return [...acc, exception]; - } - - return acc; - }, - [] - ); - - if (exceptionItemsWithLargeValueLists.length === 0) { - logger.debug( - buildRuleMessage('no exception items of type list found - returning original search result') - ); - return eventSearchResult; - } - - const valueListExceptionItems = exceptionsList.filter((listItem: ExceptionListItemSchema) => { - return listItem.entries.every((entry) => entriesList.is(entry)); - }); - - // now that we have all the exception items which are value lists (whether single entry or have multiple entries) - const res = await valueListExceptionItems.reduce['hits']['hits']>>( - async ( - filteredAccum: Promise['hits']['hits']>, - exceptionItem: ExceptionListItemSchema - ) => { - // 1. acquire the values from the specified fields to check - // e.g. if the value list is checking against source.ip, gather - // all the values for source.ip from the search response events. - - // 2. search against the value list with the values found in the search result - // and see if there are any matches. For every match, add that value to a set - // that represents the "matched" values - - // 3. filter the search result against the set from step 2 using the - // given operator (included vs excluded). - // acquire the list values we are checking for in the field. - const filtered = await filteredAccum; - const typedEntries = exceptionItem.entries.filter((entry): entry is EntryList => - entriesList.is(entry) - ); - const fieldAndSetTuples = await Promise.all( - typedEntries.map(async (entry) => { - const { list, field, operator } = entry; - const { id, type } = list; - const matchedSet = await createSetToFilterAgainst({ - events: filtered, - field, - listId: id, - listType: type, - listClient, - logger, - buildRuleMessage, - }); - - return Promise.resolve({ field, operator, matchedSet }); - }) - ); - - // check if for each tuple, the entry is not in both for when two value list entries exist. - // need to re-write this as a reduce. - const filteredEvents = filtered.filter((item) => { - const vals = fieldAndSetTuples.map((tuple) => { - const eventItem = get(tuple.field, item._source); - if (tuple.operator === 'included') { - // only create a signal if the field value is not in the value list - if (eventItem != null) { - if (isStringableType(eventItem)) { - return !tuple.matchedSet.has(eventItem); - } else if (isStringableArray(eventItem)) { - return !eventItem.some((val) => tuple.matchedSet.has(val)); - } - } - return true; - } else if (tuple.operator === 'excluded') { - // only create a signal if the field value is in the value list - if (eventItem != null) { - if (isStringableType(eventItem)) { - return tuple.matchedSet.has(eventItem); - } else if (isStringableArray(eventItem)) { - return eventItem.some((val) => tuple.matchedSet.has(val)); - } - } - return true; - } - return false; - }); - return vals.some((value) => value); - }); - const diff = eventSearchResult.hits.hits.length - filteredEvents.length; - logger.debug( - buildRuleMessage(`Exception with id ${exceptionItem.id} filtered out ${diff} events`) - ); - const toReturn = filteredEvents; - return toReturn; - }, - Promise.resolve['hits']['hits']>(eventSearchResult.hits.hits) - ); - - const toReturn: SearchResponse = { - took: eventSearchResult.took, - timed_out: eventSearchResult.timed_out, - _shards: eventSearchResult._shards, - hits: { - total: res.length, - max_score: eventSearchResult.hits.max_score, - hits: res, - }, - }; - return toReturn; - } catch (exc) { - throw new Error(`Failed to query lists index. Reason: ${exc.message}`); - } -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts new file mode 100644 index 00000000000000..9192eeb35d0e81 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.test.ts @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createFieldAndSetTuples } from './create_field_and_set_tuples'; +import { mockLogger, sampleDocWithSortId } from '../__mocks__/es_results'; + +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { listMock } from '../../../../../../lists/server/mocks'; +import { getSearchListItemResponseMock } from '../../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { EntryList } from '../../../../../../lists/common'; +import { buildRuleMessageMock as buildRuleMessage } from '../rule_messages.mock'; + +describe('filterEventsAgainstList', () => { + let listClient = listMock.getListClient(); + let exceptionItem = getExceptionListItemSchemaMock(); + let events = [sampleDocWithSortId('123', '1.1.1.1')]; + + beforeEach(() => { + jest.clearAllMocks(); + listClient = listMock.getListClient(); + listClient.searchListItemByValues = jest.fn(({ value }) => + Promise.resolve( + value.map((item) => ({ + ...getSearchListItemResponseMock(), + value: item, + })) + ) + ); + exceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ], + }; + events = [sampleDocWithSortId('123', '1.1.1.1')]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('it returns an empty array if exceptionItem entries are empty', async () => { + exceptionItem.entries = []; + const field = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(field).toEqual([]); + }); + + test('it returns a single field and set tuple if entries has a single item', async () => { + const field = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(field.length).toEqual(1); + }); + + test('it returns "included" if the operator is "included"', async () => { + (exceptionItem.entries[0] as EntryList).operator = 'included'; + const [{ operator }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(operator).toEqual('included'); + }); + + test('it returns "excluded" if the operator is "excluded"', async () => { + (exceptionItem.entries[0] as EntryList).operator = 'excluded'; + const [{ operator }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(operator).toEqual('excluded'); + }); + + test('it returns "field" if the "field is "source.ip"', async () => { + (exceptionItem.entries[0] as EntryList).field = 'source.ip'; + const [{ field }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(field).toEqual('source.ip'); + }); + + test('it returns a single matched set as a JSON.stringify() set from the "events"', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1')]; + (exceptionItem.entries[0] as EntryList).field = 'source.ip'; + const [{ matchedSet }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect([...matchedSet]).toEqual([JSON.stringify('1.1.1.1')]); + }); + + test('it returns two matched sets as a JSON.stringify() set from the "events"', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('456', '2.2.2.2')]; + (exceptionItem.entries[0] as EntryList).field = 'source.ip'; + const [{ matchedSet }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect([...matchedSet]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); + }); + + test('it returns an array as a set as a JSON.stringify() array from the "events"', async () => { + events = [sampleDocWithSortId('123', ['1.1.1.1', '2.2.2.2'])]; + (exceptionItem.entries[0] as EntryList).field = 'source.ip'; + const [{ matchedSet }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect([...matchedSet]).toEqual([JSON.stringify(['1.1.1.1', '2.2.2.2'])]); + }); + + test('it returns 2 fields when given two exception list items', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('456', '2.2.2.2')]; + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'destination.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const fields = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(fields.length).toEqual(2); + }); + + test('it returns two matched sets from two different events, one excluded, and one included', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('456', '2.2.2.2')]; + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'destination.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const [{ operator: operator1 }, { operator: operator2 }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(operator1).toEqual('included'); + expect(operator2).toEqual('excluded'); + }); + + test('it returns two fields from two different events', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('456', '2.2.2.2')]; + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'destination.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const [{ field: field1 }, { field: field2 }] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect(field1).toEqual('source.ip'); + expect(field2).toEqual('destination.ip'); + }); + + test('it returns two matches from two different events', async () => { + events = [ + sampleDocWithSortId('123', '1.1.1.1', '3.3.3.3'), + sampleDocWithSortId('456', '2.2.2.2', '5.5.5.5'), + ]; + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'destination.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const [ + { matchedSet: matchedSet1 }, + { matchedSet: matchedSet2 }, + ] = await createFieldAndSetTuples({ + listClient, + logger: mockLogger, + events, + exceptionItem, + buildRuleMessage, + }); + expect([...matchedSet1]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); + expect([...matchedSet2]).toEqual([JSON.stringify('3.3.3.3'), JSON.stringify('5.5.5.5')]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.ts new file mode 100644 index 00000000000000..d31b7a0eb613ea --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_field_and_set_tuples.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EntryList, entriesList } from '../../../../../../lists/common'; +import { createSetToFilterAgainst } from './create_set_to_filter_against'; +import { CreateFieldAndSetTuplesOptions, FieldSet } from './types'; + +export const createFieldAndSetTuples = async ({ + events, + exceptionItem, + listClient, + logger, + buildRuleMessage, +}: CreateFieldAndSetTuplesOptions): Promise => { + const typedEntries = exceptionItem.entries.filter((entry): entry is EntryList => + entriesList.is(entry) + ); + const fieldAndSetTuples = await Promise.all( + typedEntries.map(async (entry) => { + const { list, field, operator } = entry; + const { id, type } = list; + const matchedSet = await createSetToFilterAgainst({ + events, + field, + listId: id, + listType: type, + listClient, + logger, + buildRuleMessage, + }); + + return { field, operator, matchedSet }; + }) + ); + return fieldAndSetTuples; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts new file mode 100644 index 00000000000000..0ac09713cc8a67 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger, sampleDocWithSortId } from '../__mocks__/es_results'; + +import { listMock } from '../../../../../../lists/server/mocks'; +import { getSearchListItemResponseMock } from '../../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { createSetToFilterAgainst } from './create_set_to_filter_against'; +import { buildRuleMessageMock as buildRuleMessage } from '../rule_messages.mock'; + +describe('createSetToFilterAgainst', () => { + let listClient = listMock.getListClient(); + let events = [sampleDocWithSortId('123', '1.1.1.1')]; + + beforeEach(() => { + jest.clearAllMocks(); + listClient = listMock.getListClient(); + listClient.searchListItemByValues = jest.fn(({ value }) => + Promise.resolve( + value.map((item) => ({ + ...getSearchListItemResponseMock(), + value: item, + })) + ) + ); + events = [sampleDocWithSortId('123', '1.1.1.1')]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('it returns an empty array if list return is empty', async () => { + listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); + const field = await createSetToFilterAgainst({ + events, + field: 'source.ip', + listId: 'list-123', + listType: 'ip', + listClient, + logger: mockLogger, + buildRuleMessage, + }); + expect([...field]).toEqual([]); + }); + + test('it returns 1 field if the list returns a single item', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1')]; + const field = await createSetToFilterAgainst({ + events, + field: 'source.ip', + listId: 'list-123', + listType: 'ip', + listClient, + logger: mockLogger, + buildRuleMessage, + }); + expect(listClient.searchListItemByValues).toHaveBeenCalledWith({ + listId: 'list-123', + type: 'ip', + value: ['1.1.1.1'], + }); + expect([...field]).toEqual([JSON.stringify('1.1.1.1')]); + }); + + test('it returns 2 fields if the list returns 2 items', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('123', '2.2.2.2')]; + const field = await createSetToFilterAgainst({ + events, + field: 'source.ip', + listId: 'list-123', + listType: 'ip', + listClient, + logger: mockLogger, + buildRuleMessage, + }); + expect(listClient.searchListItemByValues).toHaveBeenCalledWith({ + listId: 'list-123', + type: 'ip', + value: ['1.1.1.1', '2.2.2.2'], + }); + expect([...field]).toEqual([JSON.stringify('1.1.1.1'), JSON.stringify('2.2.2.2')]); + }); + + test('it returns 0 fields if the field does not match up to a valid field within the event', async () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('123', '2.2.2.2')]; + const field = await createSetToFilterAgainst({ + events, + field: 'nonexistent.field', // field does not exist + listId: 'list-123', + listType: 'ip', + listClient, + logger: mockLogger, + buildRuleMessage, + }); + expect(listClient.searchListItemByValues).toHaveBeenCalledWith({ + listId: 'list-123', + type: 'ip', + value: [], + }); + expect([...field]).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts new file mode 100644 index 00000000000000..c546654676c83a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/create_set_to_filter_against.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash/fp'; +import { CreateSetToFilterAgainstOptions } from './types'; + +/** + * Creates a field set to filter against using the stringed version of the + * data type for compare. Creates a set of list values that are stringified that + * are easier to work with as well as ensures that deep values can work since it turns + * things into a string using JSON.stringify(). + * + * @param events The events to filter against + * @param field The field checking against the list + * @param listId The list id for the list function call + * @param listType The type of list for the list function call + * @param listClient The list client API + * @param logger logger for errors, debug, etc... + */ +export const createSetToFilterAgainst = async ({ + events, + field, + listId, + listType, + listClient, + logger, + buildRuleMessage, +}: CreateSetToFilterAgainstOptions): Promise> => { + const valuesFromSearchResultField = events.reduce((acc, searchResultItem) => { + const valueField = get(field, searchResultItem._source); + if (valueField != null) { + acc.add(valueField); + } + return acc; + }, new Set()); + + logger.debug( + buildRuleMessage( + `number of distinct values from ${field}: ${[...valuesFromSearchResultField].length}` + ) + ); + + const matchedListItems = await listClient.searchListItemByValues({ + listId, + type: listType, + value: [...valuesFromSearchResultField], + }); + + logger.debug( + buildRuleMessage( + `number of matched items from list with id ${listId}: ${matchedListItems.length}` + ) + ); + + return new Set( + matchedListItems + .filter((item) => item.items.length !== 0) + .map((item) => JSON.stringify(item.value)) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts new file mode 100644 index 00000000000000..6a045f6694da1e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sampleDocWithSortId } from '../__mocks__/es_results'; + +import { listMock } from '../../../../../../lists/server/mocks'; +import { getSearchListItemResponseMock } from '../../../../../../lists/common/schemas/response/search_list_item_schema.mock'; +import { filterEvents } from './filter_events'; +import { FieldSet } from './types'; + +describe('filterEvents', () => { + let listClient = listMock.getListClient(); + let events = [sampleDocWithSortId('123', '1.1.1.1')]; + + beforeEach(() => { + jest.clearAllMocks(); + listClient = listMock.getListClient(); + listClient.searchListItemByValues = jest.fn(({ value }) => + Promise.resolve( + value.map((item) => ({ + ...getSearchListItemResponseMock(), + value: item, + })) + ) + ); + events = [sampleDocWithSortId('123', '1.1.1.1')]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('it filters out the event if it is "included"', () => { + events = [sampleDocWithSortId('123', '1.1.1.1')]; + const fieldAndSetTuples: FieldSet[] = [ + { + field: 'source.ip', + operator: 'included', + matchedSet: new Set([JSON.stringify('1.1.1.1')]), + }, + ]; + const field = filterEvents({ + events, + fieldAndSetTuples, + }); + expect([...field]).toEqual([]); + }); + + test('it does not filter out the event if it is "excluded"', () => { + events = [sampleDocWithSortId('123', '1.1.1.1')]; + const fieldAndSetTuples: FieldSet[] = [ + { + field: 'source.ip', + operator: 'excluded', + matchedSet: new Set([JSON.stringify('1.1.1.1')]), + }, + ]; + const field = filterEvents({ + events, + fieldAndSetTuples, + }); + expect([...field]).toEqual(events); + }); + + test('it does NOT filter out the event if the field is not found', () => { + events = [sampleDocWithSortId('123', '1.1.1.1')]; + const fieldAndSetTuples: FieldSet[] = [ + { + field: 'madeup.nonexistent', // field does not exist + operator: 'included', + matchedSet: new Set([JSON.stringify('1.1.1.1')]), + }, + ]; + const field = filterEvents({ + events, + fieldAndSetTuples, + }); + expect([...field]).toEqual(events); + }); + + test('it does NOT filter out the event if it is in both an inclusion and exclusion list', () => { + events = [sampleDocWithSortId('123', '1.1.1.1'), sampleDocWithSortId('123', '2.2.2.2')]; + const fieldAndSetTuples: FieldSet[] = [ + { + field: 'source.ip', + operator: 'included', + matchedSet: new Set([JSON.stringify('1.1.1.1')]), + }, + { + field: 'source.ip', + operator: 'excluded', + matchedSet: new Set([JSON.stringify('1.1.1.1')]), + }, + ]; + + const field = filterEvents({ + events, + fieldAndSetTuples, + }); + expect([...field]).toEqual(events); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts new file mode 100644 index 00000000000000..e8667510da6869 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash/fp'; +import { SearchResponse } from '../../../types'; +import { FilterEventsOptions } from './types'; + +/** + * Check if for each tuple, the entry is not in both for when two or more value list entries exist. + * If the entry is in both an inclusion and exclusion list it will not be filtered out. + * @param events The events to check against + * @param fieldAndSetTuples The field and set tuples + */ +export const filterEvents = ({ + events, + fieldAndSetTuples, +}: FilterEventsOptions): SearchResponse['hits']['hits'] => { + return events.filter((item) => { + return fieldAndSetTuples + .map((tuple) => { + const eventItem = get(tuple.field, item._source); + if (eventItem == null) { + return true; + } else if (tuple.operator === 'included') { + // only create a signal if the event is not in the value list + return !tuple.matchedSet.has(JSON.stringify(eventItem)); + } else if (tuple.operator === 'excluded') { + // only create a signal if the event is in the value list + return tuple.matchedSet.has(JSON.stringify(eventItem)); + } else { + return false; + } + }) + .some((value) => value); + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts rename to x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts index 01e7e7160e1ae5..eb6e905c030388 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.test.ts @@ -5,27 +5,27 @@ */ import uuid from 'uuid'; -import { filterEventsAgainstList } from './filter_events_with_list'; -import { buildRuleMessageFactory } from './rule_messages'; -import { mockLogger, repeatedSearchResultsWithSortId } from './__mocks__/es_results'; +import { filterEventsAgainstList } from './filter_events_against_list'; +import { buildRuleMessageMock as buildRuleMessage } from '../rule_messages.mock'; +import { mockLogger, repeatedSearchResultsWithSortId } from '../__mocks__/es_results'; -import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getListItemResponseMock } from '../../../../../lists/common/schemas/response/list_item_schema.mock'; -import { listMock } from '../../../../../lists/server/mocks'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { listMock } from '../../../../../../lists/server/mocks'; +import { getSearchListItemResponseMock } from '../../../../../../lists/common/schemas/response/search_list_item_schema.mock'; const someGuids = Array.from({ length: 13 }).map((x) => uuid.v4()); -const buildRuleMessage = buildRuleMessageFactory({ - id: 'fake id', - ruleId: 'fake rule id', - index: 'fakeindex', - name: 'fake name', -}); + describe('filterEventsAgainstList', () => { let listClient = listMock.getListClient(); + beforeEach(() => { jest.clearAllMocks(); listClient = listMock.getListClient(); - listClient.getListItemByValues = jest.fn().mockResolvedValue([]); + listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should respond with eventSearchResult if exceptionList is empty array', async () => { @@ -87,6 +87,7 @@ describe('filterEventsAgainstList', () => { }); expect(res.hits.hits.length).toEqual(4); }); + it('should respond with less items in the list if some values match', async () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -100,10 +101,10 @@ describe('filterEventsAgainstList', () => { }, }, ]; - listClient.getListItemByValues = jest.fn(({ value }) => + listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ - ...getListItemResponseMock(), + ...getSearchListItemResponseMock(), value: item, })) ) @@ -120,8 +121,8 @@ describe('filterEventsAgainstList', () => { ]), buildRuleMessage, }); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( 'ci-badguys.txt' ); expect(res.hits.hits.length).toEqual(2); @@ -159,13 +160,13 @@ describe('filterEventsAgainstList', () => { ]; // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, - { ...getListItemResponseMock(), value: '4.4.4.4' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: '4.4.4.4' }, ]); // this call represents an exception list with a value list containing ['6.6.6.6'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '6.6.6.6' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '6.6.6.6' }, ]); const res = await filterEventsAgainstList({ @@ -185,7 +186,7 @@ describe('filterEventsAgainstList', () => { ]), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); expect(res.hits.hits.length).toEqual(6); // @ts-expect-error @@ -221,12 +222,12 @@ describe('filterEventsAgainstList', () => { ]; // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, ]); // this call represents an exception list with a value list containing ['6.6.6.6'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '6.6.6.6' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '6.6.6.6' }, ]); const res = await filterEventsAgainstList({ @@ -246,7 +247,7 @@ describe('filterEventsAgainstList', () => { ]), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); // @ts-expect-error const ipVals = res.hits.hits.map((item) => item._source.source.ip); expect(res.hits.hits.length).toEqual(7); @@ -280,12 +281,12 @@ describe('filterEventsAgainstList', () => { ]; // this call represents an exception list with a value list containing ['2.2.2.2'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, ]); // this call represents an exception list with a value list containing ['4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '4.4.4.4' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: '4.4.4.4' }, ]); const res = await filterEventsAgainstList({ @@ -321,7 +322,7 @@ describe('filterEventsAgainstList', () => { ), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); expect(res.hits.hits.length).toEqual(8); // @ts-expect-error @@ -362,8 +363,8 @@ describe('filterEventsAgainstList', () => { ]; // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValue([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValue([ + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, ]); const res = await filterEventsAgainstList({ @@ -383,7 +384,7 @@ describe('filterEventsAgainstList', () => { ]), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); expect(res.hits.hits.length).toEqual(9); // @ts-expect-error @@ -401,7 +402,7 @@ describe('filterEventsAgainstList', () => { ]).toEqual(ipVals); }); - it('should respond with less items in the list given one exception item with two entries of type list and array of values in document', async () => { + it('should respond with same items in the list given one exception item with two entries of type list and array of values in document', async () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ { @@ -425,12 +426,12 @@ describe('filterEventsAgainstList', () => { ]; // this call represents an exception list with a value list containing ['2.2.2.2'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: ['2.2.2.2', '3.3.3.3'] }, ]); // this call represents an exception list with a value list containing ['4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '4.4.4.4' }, + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: ['3.3.3.3', '4.4.4.4'] }, ]); const res = await filterEventsAgainstList({ @@ -454,17 +455,16 @@ describe('filterEventsAgainstList', () => { ), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].value).toEqual([ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].value).toEqual([ + ['1.1.1.1', '1.1.1.1'], + ['1.1.1.1', '2.2.2.2'], + ['2.2.2.2', '3.3.3.3'], ]); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[1][0].value).toEqual([ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', - '4.4.4.4', + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[1][0].value).toEqual([ + ['1.1.1.1', '2.2.2.2'], + ['2.2.2.2', '3.3.3.3'], + ['3.3.3.3', '4.4.4.4'], ]); expect(res.hits.hits.length).toEqual(2); @@ -505,6 +505,7 @@ describe('filterEventsAgainstList', () => { }); expect(res.hits.hits.length).toEqual(0); }); + it('should respond with less items in the list if some values match', async () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ @@ -518,10 +519,10 @@ describe('filterEventsAgainstList', () => { }, }, ]; - listClient.getListItemByValues = jest.fn(({ value }) => + listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ - ...getListItemResponseMock(), + ...getSearchListItemResponseMock(), value: item, })) ) @@ -538,14 +539,14 @@ describe('filterEventsAgainstList', () => { ]), buildRuleMessage, }); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( 'ci-badguys.txt' ); expect(res.hits.hits.length).toEqual(2); }); - it('should respond with less items in the list given one exception item with two entries of type list and array of values in document', async () => { + it('should respond with the same items in the list given one exception item with two entries of type list and array of values in document', async () => { const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ { @@ -568,13 +569,16 @@ describe('filterEventsAgainstList', () => { }, ]; - // this call represents an exception list with a value list containing ['2.2.2.2'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '2.2.2.2' }, + // this call represents an exception list with a value list containing ['2.2.2.2', '3.3.3.3'] + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { + ...getSearchListItemResponseMock(), + value: ['1.1.1.1', '2.2.2.2'], + }, ]); - // this call represents an exception list with a value list containing ['4.4.4.4'] - (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ - { ...getListItemResponseMock(), value: '4.4.4.4' }, + // this call represents an exception list with a value list containing ['3.3.3.3', '4.4.4.4'] + (listClient.searchListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getSearchListItemResponseMock(), value: ['3.3.3.3', '4.4.4.4'] }, ]); const res = await filterEventsAgainstList({ @@ -598,17 +602,16 @@ describe('filterEventsAgainstList', () => { ), buildRuleMessage, }); - expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].value).toEqual([ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', + expect(listClient.searchListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[0][0].value).toEqual([ + ['1.1.1.1', '1.1.1.1'], + ['1.1.1.1', '2.2.2.2'], + ['2.2.2.2', '3.3.3.3'], ]); - expect((listClient.getListItemByValues as jest.Mock).mock.calls[1][0].value).toEqual([ - '1.1.1.1', - '2.2.2.2', - '3.3.3.3', - '4.4.4.4', + expect((listClient.searchListItemByValues as jest.Mock).mock.calls[1][0].value).toEqual([ + ['1.1.1.1', '2.2.2.2'], + ['2.2.2.2', '3.3.3.3'], + ['3.3.3.3', '4.4.4.4'], ]); expect(res.hits.hits.length).toEqual(2); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.ts new file mode 100644 index 00000000000000..e6c20713afd979 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/filter_events_against_list.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ExceptionListItemSchema, entriesList } from '../../../../../../lists/common/schemas'; +import { hasLargeValueList } from '../../../../../common/detection_engine/utils'; +import { FilterEventsAgainstListOptions } from './types'; +import { filterEvents } from './filter_events'; +import { createFieldAndSetTuples } from './create_field_and_set_tuples'; +import { SearchResponse } from '../../../types'; + +/** + * Filters events against a large value based list. It does this through these + * steps below. + * + * 1. acquire the values from the specified fields to check + * e.g. if the value list is checking against source.ip, gather + * all the values for source.ip from the search response events. + * + * 2. search against the value list with the values found in the search result + * and see if there are any matches. For every match, add that value to a set + * that represents the "matched" values + * + * 3. filter the search result against the set from step 2 using the + * given operator (included vs excluded). + * acquire the list values we are checking for in the field. + * + * @param listClient The list client to use for queries + * @param exceptionsList The exception list + * @param logger Logger for messages + * @param eventSearchResult The current events from the search + */ +export const filterEventsAgainstList = async ({ + listClient, + exceptionsList, + logger, + eventSearchResult, + buildRuleMessage, +}: FilterEventsAgainstListOptions): Promise> => { + try { + const atLeastOneLargeValueList = exceptionsList.some(({ entries }) => + hasLargeValueList(entries) + ); + + if (!atLeastOneLargeValueList) { + logger.debug( + buildRuleMessage('no exception items of type list found - returning original search result') + ); + return eventSearchResult; + } + + const valueListExceptionItems = exceptionsList.filter((listItem: ExceptionListItemSchema) => { + return listItem.entries.every((entry) => entriesList.is(entry)); + }); + + const res = await valueListExceptionItems.reduce['hits']['hits']>>( + async ( + filteredAccum: Promise['hits']['hits']>, + exceptionItem: ExceptionListItemSchema + ) => { + const events = await filteredAccum; + const fieldAndSetTuples = await createFieldAndSetTuples({ + events, + exceptionItem, + listClient, + logger, + buildRuleMessage, + }); + const filteredEvents = filterEvents({ events, fieldAndSetTuples }); + const diff = eventSearchResult.hits.hits.length - filteredEvents.length; + logger.debug( + buildRuleMessage(`Exception with id ${exceptionItem.id} filtered out ${diff} events`) + ); + return filteredEvents; + }, + Promise.resolve['hits']['hits']>(eventSearchResult.hits.hits) + ); + + return { + took: eventSearchResult.took, + timed_out: eventSearchResult.timed_out, + _shards: eventSearchResult._shards, + hits: { + total: res.length, + max_score: eventSearchResult.hits.max_score, + hits: res, + }, + }; + } catch (exc) { + throw new Error(`Failed to query large value based lists index. Reason: ${exc.message}`); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/types.ts new file mode 100644 index 00000000000000..673719d87dcd05 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filters/types.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'src/core/server'; + +import { ListClient } from '../../../../../../lists/server'; +import { BuildRuleMessage } from '../rule_messages'; +import { ExceptionListItemSchema, Type } from '../../../../../../lists/common/schemas'; +import { SearchResponse } from '../../../types'; + +export interface FilterEventsAgainstListOptions { + listClient: ListClient; + exceptionsList: ExceptionListItemSchema[]; + logger: Logger; + eventSearchResult: SearchResponse; + buildRuleMessage: BuildRuleMessage; +} + +export interface CreateSetToFilterAgainstOptions { + events: SearchResponse['hits']['hits']; + field: string; + listId: string; + listType: Type; + listClient: ListClient; + logger: Logger; + buildRuleMessage: BuildRuleMessage; +} + +export interface FilterEventsOptions { + events: SearchResponse['hits']['hits']; + fieldAndSetTuples: FieldSet[]; +} + +export interface CreateFieldAndSetTuplesOptions { + events: SearchResponse['hits']['hits']; + exceptionItem: ExceptionListItemSchema; + listClient: ListClient; + logger: Logger; + buildRuleMessage: BuildRuleMessage; +} + +export interface FieldSet { + field: string; + operator: 'excluded' | 'included'; + matchedSet: Set; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_messages.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_messages.mock.ts new file mode 100644 index 00000000000000..9478ed18d472bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/rule_messages.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildRuleMessageFactory } from './rule_messages'; + +export const buildRuleMessageMock = buildRuleMessageFactory({ + id: 'fake id', + ruleId: 'fake rule id', + index: 'fakeindex', + name: 'fake name', +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index c82c1fe969ee3c..46722c69e53e36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -19,10 +19,11 @@ import { buildRuleMessageFactory } from './rule_messages'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import uuid from 'uuid'; -import { getListItemResponseMock } from '../../../../../lists/common/schemas/response/list_item_schema.mock'; import { listMock } from '../../../../../lists/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { BulkResponse } from './types'; +import { SearchListItemArraySchema } from '../../../../../lists/common/schemas'; +import { getSearchListItemResponseMock } from '../../../../../lists/common/schemas/response/search_list_item_schema.mock'; const buildRuleMessage = buildRuleMessageFactory({ id: 'fake id', @@ -39,7 +40,7 @@ describe('searchAfterAndBulkCreate', () => { beforeEach(() => { jest.clearAllMocks(); listClient = listMock.getListClient(); - listClient.getListItemByValues = jest.fn().mockResolvedValue([]); + listClient.searchListItemByValues = jest.fn().mockResolvedValue([]); inputIndexPattern = ['auditbeat-*']; mockService = alertsMock.createAlertServices(); }); @@ -362,9 +363,12 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when all search results are in the allowlist and with sortId present', async () => { - listClient.getListItemByValues = jest - .fn() - .mockResolvedValue([{ value: '1.1.1.1' }, { value: '2.2.2.2' }, { value: '3.3.3.3' }]); + const searchListItems: SearchListItemArraySchema = [ + { ...getSearchListItemResponseMock(), value: '1.1.1.1' }, + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: '3.3.3.3' }, + ]; + listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); const sampleParams = sampleRuleAlertParams(30); mockService.callCluster .mockResolvedValueOnce( @@ -423,9 +427,14 @@ describe('searchAfterAndBulkCreate', () => { }); test('should return success when all search results are in the allowlist and no sortId present', async () => { - listClient.getListItemByValues = jest - .fn() - .mockResolvedValue([{ value: '1.1.1.1' }, { value: '2.2.2.2' }, { value: '3.3.3.3' }]); + const searchListItems: SearchListItemArraySchema = [ + { ...getSearchListItemResponseMock(), value: '1.1.1.1' }, + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + { ...getSearchListItemResponseMock(), value: '2.2.2.2' }, + ]; + + listClient.searchListItemByValues = jest.fn().mockResolvedValue(searchListItems); const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce( repeatedSearchResultsWithNoSortId(4, 4, someGuids.slice(0, 3), [ @@ -605,10 +614,10 @@ describe('searchAfterAndBulkCreate', () => { }) .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); - listClient.getListItemByValues = jest.fn(({ value }) => + listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ - ...getListItemResponseMock(), + ...getSearchListItemResponseMock(), value: item, })) ) @@ -711,10 +720,10 @@ describe('searchAfterAndBulkCreate', () => { ]; const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); - listClient.getListItemByValues = jest.fn(({ value }) => + listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ - ...getListItemResponseMock(), + ...getSearchListItemResponseMock(), value: item, })) ) @@ -771,10 +780,10 @@ describe('searchAfterAndBulkCreate', () => { .mockImplementation(() => { throw Error('Fake Error'); // throws the exception we are testing }); - listClient.getListItemByValues = jest.fn(({ value }) => + listClient.searchListItemByValues = jest.fn(({ value }) => Promise.resolve( value.slice(0, 2).map((item) => ({ - ...getListItemResponseMock(), + ...getSearchListItemResponseMock(), value: item, })) ) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 0e6ddbc766faae..32865e117cba90 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -6,7 +6,7 @@ import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; -import { filterEventsAgainstList } from './filter_events_with_list'; +import { filterEventsAgainstList } from './filters/filter_events_against_list'; import { sendAlertTelemetryEvents } from './send_telemetry_events'; import { createSearchAfterReturnType, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 7965a09efefa91..8d4dd877996db3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -66,7 +66,7 @@ import { buildSignalFromEvent, buildSignalGroupFromSequence } from './build_bulk import { createThreatSignals } from './threat_mapping/create_threat_signals'; import { getIndexVersion } from '../routes/index/get_index_version'; import { MIN_EQL_RULE_INDEX_VERSION } from '../routes/index/get_signals_template'; -import { filterEventsAgainstList } from './filter_events_with_list'; +import { filterEventsAgainstList } from './filters/filter_events_against_list'; import { isOutdated } from '../migrations/helpers'; export const signalRulesAlertType = ({ @@ -338,7 +338,7 @@ export const signalRulesAlertType = ({ must: [ { term: { - [threshold.field ?? 'signal.rule.rule_id']: bucket.key, + [threshold.field || 'signal.rule.rule_id']: bucket.key, }, }, { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index c4ee231ee1d680..7363f0559469e3 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -359,6 +359,7 @@ export class Plugin implements IPlugin ): Promise => { const { indexName, eventId, docValueFields = [] } = options; - const fieldsData = cloneDeep(response.rawResponse.hits.hits[0].fields ?? {}); + const fieldsData = cloneDeep(response.rawResponse.hits.hits[0]?.fields ?? {}); const hitsData = cloneDeep(response.rawResponse.hits.hits[0] ?? {}); delete hitsData._source; delete hitsData.fields; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts index 826353a08ca56b..885081e859dd75 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import _ from 'lodash'; import sampleJsonResponse from './es_sample_response.json'; import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; import { getActiveEntriesAndGenerateAlerts, transformResults } from '../geo_containment'; @@ -120,7 +121,7 @@ describe('geo_containment', () => { describe('getActiveEntriesAndGenerateAlerts', () => { const testAlertActionArr: unknown[] = []; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); testAlertActionArr.length = 0; }); @@ -154,12 +155,54 @@ describe('geo_containment', () => { }, ], ]); + + const expectedContext = [ + { + actionGroupId: 'Tracked entity contained', + context: { + containingBoundaryId: '123', + entityDocumentId: 'docId1', + entityId: 'a', + entityLocation: 'POINT (0 0)', + }, + instanceId: 'a-123', + }, + { + actionGroupId: 'Tracked entity contained', + context: { + containingBoundaryId: '456', + entityDocumentId: 'docId2', + entityId: 'b', + entityLocation: 'POINT (0 0)', + }, + instanceId: 'b-456', + }, + { + actionGroupId: 'Tracked entity contained', + context: { + containingBoundaryId: '789', + entityDocumentId: 'docId3', + entityId: 'c', + entityLocation: 'POINT (0 0)', + }, + instanceId: 'c-789', + }, + ]; const emptyShapesIdsNamesMap = {}; - const scheduleActions = jest.fn((alertInstance: string, context: Record) => { - testAlertActionArr.push(context.entityId); - }); - const alertInstanceFactory = (x: string) => ({ scheduleActions }); + const alertInstanceFactory = (instanceId: string) => { + return { + scheduleActions: (actionGroupId: string, context: Record) => { + const contextKeys = Object.keys(expectedContext[0].context); + const contextSubset = _.pickBy(context, (v, k) => contextKeys.includes(k)); + testAlertActionArr.push({ + actionGroupId, + instanceId, + context: contextSubset, + }); + }, + }; + }; const currentDateTime = new Date(); it('should use currently active entities if no older entity entries', () => { @@ -172,8 +215,7 @@ describe('geo_containment', () => { currentDateTime ); expect(allActiveEntriesMap).toEqual(currLocationMap); - expect(scheduleActions.mock.calls.length).toEqual(allActiveEntriesMap.size); - expect(testAlertActionArr).toEqual([...allActiveEntriesMap.keys()]); + expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should overwrite older identical entity entries', () => { const prevLocationMapWithIdenticalEntityEntry = { @@ -192,8 +234,7 @@ describe('geo_containment', () => { currentDateTime ); expect(allActiveEntriesMap).toEqual(currLocationMap); - expect(scheduleActions.mock.calls.length).toEqual(allActiveEntriesMap.size); - expect(testAlertActionArr).toEqual([...allActiveEntriesMap.keys()]); + expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should preserve older non-identical entity entries', () => { const prevLocationMapWithNonIdenticalEntityEntry = { @@ -204,6 +245,20 @@ describe('geo_containment', () => { docId: 'docId7', }, }; + const expectedContextPlusD = [ + { + actionGroupId: 'Tracked entity contained', + context: { + containingBoundaryId: '999', + entityDocumentId: 'docId7', + entityId: 'd', + entityLocation: 'POINT (0 0)', + }, + instanceId: 'd-999', + }, + ...expectedContext, + ]; + const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( prevLocationMapWithNonIdenticalEntityEntry, currLocationMap, @@ -213,8 +268,7 @@ describe('geo_containment', () => { ); expect(allActiveEntriesMap).not.toEqual(currLocationMap); expect(allActiveEntriesMap.has('d')).toBeTruthy(); - expect(scheduleActions.mock.calls.length).toEqual(allActiveEntriesMap.size); - expect(testAlertActionArr).toEqual([...allActiveEntriesMap.keys()]); + expect(testAlertActionArr).toMatchObject(expectedContextPlusD); }); it('should remove "other" entries and schedule the expected number of actions', () => { const emptyPrevLocationMap = {}; @@ -233,8 +287,7 @@ describe('geo_containment', () => { currentDateTime ); expect(allActiveEntriesMap).toEqual(currLocationMap); - expect(scheduleActions.mock.calls.length).toEqual(allActiveEntriesMap.size); - expect(testAlertActionArr).toEqual([...allActiveEntriesMap.keys()]); + expect(testAlertActionArr).toMatchObject(expectedContext); }); }); }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d050271ae90b2f..4ec88a500b42a5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -577,7 +577,6 @@ "dashboard.createNewVisualizationButton": "新規作成...", "dashboard.createNewVisualizationButtonAriaLabel": "新規ビジュアライゼーションを追加", "dashboard.dashboardAppBreadcrumbsTitle": "ダッシュボード", - "dashboard.dashboardBreadcrumbsTitle": "ダッシュボード", "dashboard.dashboardGrid.toast.unableToLoadDashboardDangerMessage": "ダッシュボードが読み込めません。", "dashboard.dashboardPageTitle": "ダッシュボード", "dashboard.dashboardWasNotSavedDangerMessage": "ダッシュボード「{dashTitle}」が保存されませんでした。エラー:{errorMessage}", @@ -604,7 +603,6 @@ "dashboard.listing.createNewDashboard.newToKibanaDescription": "Kibanaは初心者ですか?{sampleDataInstallLink}してお試しください。", "dashboard.listing.createNewDashboard.sampleDataInstallLinkText": "サンプルデータをインストール", "dashboard.listing.createNewDashboard.title": "初めてのダッシュボードを作成してみましょう。", - "dashboard.listing.dashboardsTitle": "ダッシュボード", "dashboard.listing.noItemsMessage": "ダッシュボードがないようです。", "dashboard.listing.table.descriptionColumnName": "説明", "dashboard.listing.table.entityName": "ダッシュボード", @@ -658,7 +656,6 @@ "dashboard.topNave.shareConfigDescription": "ダッシュボードを共有します", "dashboard.topNave.viewConfigDescription": "編集をキャンセルして表示限定モードに切り替えます", "dashboard.urlWasRemovedInSixZeroWarningMessage": "URL「dashboard/create」は6.0で廃止されました。ブックマークを更新してください。", - "dashboard.visitVisualizeAppLinkText": "可視化アプリにアクセス", "data.advancedSettings.courier.batchSearchesText": "無効の場合、ダッシュボードパネルは個々に読み込まれ、検索リクエストはユーザーが移動するか\n クエリを更新すると停止します。有効の場合、ダッシュボードパネルはすべてのデータが読み込まれると同時に読み込まれ、\n 検索は停止しません。", "data.advancedSettings.courier.batchSearchesTextDeprecation": "この設定はサポートが終了し、Kibana 8.0 では削除されます。", "data.advancedSettings.courier.batchSearchesTitle": "同時検索のバッチ処理", @@ -17738,7 +17735,6 @@ "xpack.securitySolution.notes.notesTitle": "メモ", "xpack.securitySolution.notes.previewMarkdownTitle": "プレビュー(マークダウン)", "xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "ユーザーまたはメモでフィルター", - "xpack.securitySolution.open.timeline.allActionsTooltip": "すべてのアクション", "xpack.securitySolution.open.timeline.batchActionsTitle": "一斉アクション", "xpack.securitySolution.open.timeline.cancelButton": "キャンセル", "xpack.securitySolution.open.timeline.collapseButton": "縮小", @@ -17946,7 +17942,6 @@ "xpack.securitySolution.timeline.autosave.warning.refresh.title": "タイムラインを更新", "xpack.securitySolution.timeline.autosave.warning.title": "更新されるまで自動保存は無効です", "xpack.securitySolution.timeline.body.actions.collapseAriaLabel": "縮小", - "xpack.securitySolution.timeline.body.actions.collapseEventTooltip": "イベントを折りたたむ", "xpack.securitySolution.timeline.body.actions.expandAriaLabel": "拡張", "xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip": "イベントを分析します", "xpack.securitySolution.timeline.body.copyToClipboardButtonLabel": "クリップボードにコピー", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d159bdd38800d1..c3a63660ad057b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -577,7 +577,6 @@ "dashboard.createNewVisualizationButton": "新建", "dashboard.createNewVisualizationButtonAriaLabel": "新建可视化按钮", "dashboard.dashboardAppBreadcrumbsTitle": "仪表板", - "dashboard.dashboardBreadcrumbsTitle": "仪表板", "dashboard.dashboardGrid.toast.unableToLoadDashboardDangerMessage": "无法加载仪表板。", "dashboard.dashboardPageTitle": "仪表板", "dashboard.dashboardWasNotSavedDangerMessage": "仪表板“{dashTitle}”未保存。错误:{errorMessage}", @@ -604,7 +603,6 @@ "dashboard.listing.createNewDashboard.newToKibanaDescription": "Kibana 新手?{sampleDataInstallLink}来试用一下。", "dashboard.listing.createNewDashboard.sampleDataInstallLinkText": "安装一些样例数据", "dashboard.listing.createNewDashboard.title": "创建您的首个仪表板", - "dashboard.listing.dashboardsTitle": "仪表板", "dashboard.listing.noItemsMessage": "似乎您没有任何仪表板。", "dashboard.listing.table.descriptionColumnName": "描述", "dashboard.listing.table.entityName": "仪表板", @@ -658,7 +656,6 @@ "dashboard.topNave.shareConfigDescription": "共享仪表板", "dashboard.topNave.viewConfigDescription": "取消编辑并切换到仅查看模式", "dashboard.urlWasRemovedInSixZeroWarningMessage": "6.0 中已移除 url“dashboard/create”。请更新您的书签。", - "dashboard.visitVisualizeAppLinkText": "访问 Visualize 应用", "data.advancedSettings.courier.batchSearchesText": "禁用时,仪表板面板将分别加载,用户离开时或更新查询时,\n 搜索请求将终止。如果启用,加载所有数据时,仪表板面板将一起加载,并且\n 搜索将不会终止。", "data.advancedSettings.courier.batchSearchesTextDeprecation": "此设置已过时,将在 Kibana 8.0 中移除。", "data.advancedSettings.courier.batchSearchesTitle": "批量并发搜索", @@ -17756,7 +17753,6 @@ "xpack.securitySolution.notes.notesTitle": "备注", "xpack.securitySolution.notes.previewMarkdownTitle": "预览 (Markdown)", "xpack.securitySolution.notes.search.FilterByUserOrNotePlaceholder": "按用户或备注筛选", - "xpack.securitySolution.open.timeline.allActionsTooltip": "所有操作", "xpack.securitySolution.open.timeline.batchActionsTitle": "批处理操作", "xpack.securitySolution.open.timeline.cancelButton": "取消", "xpack.securitySolution.open.timeline.collapseButton": "折叠", @@ -17964,7 +17960,6 @@ "xpack.securitySolution.timeline.autosave.warning.refresh.title": "刷新时间线", "xpack.securitySolution.timeline.autosave.warning.title": "刷新后才会启用自动保存", "xpack.securitySolution.timeline.body.actions.collapseAriaLabel": "折叠", - "xpack.securitySolution.timeline.body.actions.collapseEventTooltip": "折叠事件", "xpack.securitySolution.timeline.body.actions.expandAriaLabel": "展开", "xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip": "分析事件", "xpack.securitySolution.timeline.body.copyToClipboardButtonLabel": "复制到剪贴板", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index e1011e2fe69b9d..32b663c5693fca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -29,7 +29,7 @@ import { mapFiltersToKql, } from './alert_api'; import uuid from 'uuid'; -import { ALERTS_FEATURE_ID } from '../../../../alerts/common'; +import { AlertNotifyWhenType, ALERTS_FEATURE_ID } from '../../../../alerts/common'; const http = httpServiceMock.createStartContract(); @@ -548,6 +548,7 @@ describe('createAlert', () => { actions: [], params: {}, throttle: null, + notifyWhen: 'onActionGroupChange' as AlertNotifyWhenType, createdAt: new Date('1970-01-01T00:00:00.000Z'), updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, @@ -573,7 +574,7 @@ describe('createAlert', () => { Array [ "/api/alerts/alert", Object { - "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerts\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", + "body": "{\\"name\\":\\"test\\",\\"consumer\\":\\"alerts\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"actions\\":[],\\"params\\":{},\\"throttle\\":null,\\"notifyWhen\\":\\"onActionGroupChange\\",\\"createdAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"updatedAt\\":\\"1970-01-01T00:00:00.000Z\\",\\"apiKey\\":null,\\"apiKeyOwner\\":null}", }, ] `); @@ -596,6 +597,7 @@ describe('updateAlert', () => { updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, apiKeyOwner: null, + notifyWhen: 'onThrottleInterval' as AlertNotifyWhenType, }; const resolvedValue: Alert = { ...alertToUpdate, @@ -619,7 +621,7 @@ describe('updateAlert', () => { Array [ "/api/alerts/alert/123", Object { - "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[]}", + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"notifyWhen\\":\\"onThrottleInterval\\"}", }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index d34481850ca4a3..52ab33566da74d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -195,12 +195,15 @@ export async function updateAlert({ id, }: { http: HttpSetup; - alert: Pick; + alert: Pick< + AlertUpdates, + 'throttle' | 'name' | 'tags' | 'schedule' | 'params' | 'actions' | 'notifyWhen' + >; id: string; }): Promise { return await http.put(`${BASE_ALERT_API_PATH}/alert/${id}`, { body: JSON.stringify( - pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions']) + pick(alert, ['throttle', 'name', 'tags', 'schedule', 'params', 'actions', 'notifyWhen']) ), }); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index b19b6eb5f7a3e8..c10653d14d409c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -775,6 +775,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx index 43ece9fc10c319..48360647e24ee5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.test.tsx @@ -397,6 +397,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index f7b00a2ccf0b97..be68036c0f7435 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -286,6 +286,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 24e20c5d477f77..b24d552bd5c489 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -126,6 +126,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx index d026c43b8496a0..f025c886e0712d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/view_in_app.test.tsx @@ -84,6 +84,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 03c8b539227a20..5ab2c7f5a586ca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -59,6 +59,7 @@ const AlertAdd = ({ }, actions: [], tags: [], + notifyWhen: 'onActionGroupChange', ...(initialValues ? initialValues : {}), }), [alertTypeId, consumer, initialValues] diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index 4af54bbb80e9f1..25f830df58df53 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -103,6 +103,7 @@ describe('alert_edit', () => { tags: [], name: 'test alert', throttle: null, + notifyWhen: null, apiKeyOwner: null, createdBy: 'elastic', updatedBy: 'elastic', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index 785eaeb9059d77..26aca1bb5e4a0b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -378,26 +378,6 @@ describe('alert_form', () => { expect(alertTypeSelectOptions.exists()).toBeTruthy(); }); - it('should update throttle value', async () => { - const newThrottle = 17; - await setup(); - const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleField.exists()).toBeTruthy(); - throttleField.at(1).simulate('change', { target: { value: newThrottle.toString() } }); - const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); - }); - - it('should unset throttle value', async () => { - const newThrottle = ''; - await setup(); - const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleField.exists()).toBeTruthy(); - throttleField.at(1).simulate('change', { target: { value: newThrottle } }); - const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); - expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); - }); - it('renders alert type description', async () => { await setup(); const alertDescription = wrapper.find('[data-test-subj="alertDescription"]'); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 3a8835825acd19..3259e405e3f70d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -32,8 +32,6 @@ import { EuiNotificationBadge, EuiErrorBoundary, } from '@elastic/eui'; -import { some, filter, map, fold } from 'fp-ts/lib/Option'; -import { pipe } from 'fp-ts/lib/pipeable'; import { capitalize, isObject } from 'lodash'; import { KibanaFeature } from '../../../../../features/public'; import { @@ -67,6 +65,7 @@ import './alert_form.scss'; import { useKibana } from '../../../common/lib/kibana'; import { recoveredActionGroupMessage } from '../../constants'; import { getDefaultsForActionParams } from '../../lib/get_defaults_for_action_params'; +import { AlertNotifyWhen } from './alert_notify_when'; const ENTER_KEY = 13; @@ -168,7 +167,7 @@ export const AlertForm = ({ alert.throttle ? getDurationNumberInItsUnit(alert.throttle) : null ); const [alertThrottleUnit, setAlertThrottleUnit] = useState( - alert.throttle ? getDurationUnitValue(alert.throttle) : 'm' + alert.throttle ? getDurationUnitValue(alert.throttle) : 'h' ); const [defaultActionGroupId, setDefaultActionGroupId] = useState(undefined); const [alertTypesIndex, setAlertTypesIndex] = useState(null); @@ -572,22 +571,6 @@ export const AlertForm = ({ ); - const labelForAlertRenotify = ( - <> - {' '} - - - ); - return ( @@ -608,7 +591,6 @@ export const AlertForm = ({ fullWidth autoFocus={true} isInvalid={errors.name.length > 0 && alert.name !== undefined} - compressed name="name" data-test-subj="alertNameInput" value={alert.name || ''} @@ -633,7 +615,6 @@ export const AlertForm = ({ { @@ -674,7 +655,6 @@ export const AlertForm = ({ fullWidth min={1} isInvalid={errors.interval.length > 0} - compressed value={alertInterval || ''} name="interval" data-test-subj="intervalInput" @@ -689,7 +669,6 @@ export const AlertForm = ({ { @@ -702,52 +681,25 @@ export const AlertForm = ({ - - - - { - pipe( - some(e.target.value.trim()), - filter((value) => value !== ''), - map((value) => parseInt(value, 10)), - filter((value) => !isNaN(value)), - fold( - () => { - // unset throttle - setAlertThrottle(null); - setAlertProperty('throttle', null); - }, - (throttle) => { - setAlertThrottle(throttle); - setAlertProperty('throttle', `${throttle}${alertThrottleUnit}`); - } - ) - ); - }} - /> - - - { - setAlertThrottleUnit(e.target.value); - if (alertThrottle) { - setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); - } - }} - /> - - - + { + setAlertProperty('notifyWhen', notifyWhen); + }, + [setAlertProperty] + )} + onThrottleChange={useCallback( + (throttle: number | null, throttleUnit: string) => { + setAlertThrottle(throttle); + setAlertThrottleUnit(throttleUnit); + setAlertProperty('throttle', throttle ? `${throttle}${throttleUnit}` : null); + }, + [setAlertProperty] + )} + /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx new file mode 100644 index 00000000000000..62e35229c90222 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.test.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { Alert } from '../../../types'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { AlertNotifyWhen } from './alert_notify_when'; + +describe('alert_notify_when', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const onNotifyWhenChange = jest.fn(); + const onThrottleChange = jest.fn(); + + describe('action_frequency_form new alert', () => { + let wrapper: ReactWrapper; + + async function setup(overrides = {}) { + const initialAlert = ({ + name: 'test', + params: {}, + consumer: ALERTS_FEATURE_ID, + schedule: { + interval: '1m', + }, + actions: [], + tags: [], + muteAll: false, + enabled: false, + mutedInstanceIds: [], + notifyWhen: 'onActionGroupChange', + ...overrides, + } as unknown) as Alert; + + wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + } + + it(`should determine initial selection from throttle value if 'notifyWhen' is null`, async () => { + await setup({ notifyWhen: null }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActiveAlert'); + }); + + it(`should correctly select 'onActionGroupChange' option on initial render`, async () => { + await setup(); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActionGroupChange'); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + }); + + it(`should correctly select 'onActiveAlert' option on initial render`, async () => { + await setup({ + notifyWhen: 'onActiveAlert', + }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onActiveAlert'); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + }); + + it(`should correctly select 'onThrottleInterval' option on initial render and render throttle inputs`, async () => { + await setup({ + notifyWhen: 'onThrottleInterval', + }); + const notifyWhenSelect = wrapper.find('[data-test-subj="notifyWhenSelect"]'); + expect(notifyWhenSelect.exists()).toBeTruthy(); + expect(notifyWhenSelect.first().prop('valueOfSelected')).toEqual('onThrottleInterval'); + + const throttleInput = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleInput.exists()).toBeTruthy(); + expect(throttleInput.at(1).prop('value')).toEqual(1); + + const throttleUnitInput = wrapper.find('[data-test-subj="throttleUnitInput"]'); + expect(throttleUnitInput.exists()).toBeTruthy(); + expect(throttleUnitInput.at(1).prop('value')).toEqual('m'); + }); + + it('should update action frequency type correctly', async () => { + await setup(); + + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onActiveAlert"]').simulate('click'); + wrapper.update(); + expect(onNotifyWhenChange).toHaveBeenCalledWith('onActiveAlert'); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'm'); + + wrapper.find('button[data-test-subj="notifyWhenSelect"]').simulate('click'); + wrapper.update(); + wrapper.find('button[data-test-subj="onActionGroupChange"]').simulate('click'); + wrapper.update(); + expect(wrapper.find('[data-test-subj="throttleInput"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="throttleUnitInput"]').exists()).toBeFalsy(); + expect(onNotifyWhenChange).toHaveBeenCalledWith('onActionGroupChange'); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'm'); + }); + + it('should renders throttle input when custom throttle is selected and update throttle value', async () => { + await setup({ + notifyWhen: 'onThrottleInterval', + }); + + const newThrottle = 17; + const throttleField = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleField.exists()).toBeTruthy(); + throttleField.at(1).simulate('change', { target: { value: newThrottle.toString() } }); + const throttleFieldAfterUpdate = wrapper.find('[data-test-subj="throttleInput"]'); + expect(throttleFieldAfterUpdate.at(1).prop('value')).toEqual(newThrottle); + expect(onThrottleChange).toHaveBeenCalledWith(17, 'm'); + + const newThrottleUnit = 'h'; + const throttleUnitField = wrapper.find('[data-test-subj="throttleUnitInput"]'); + expect(throttleUnitField.exists()).toBeTruthy(); + throttleUnitField.at(1).simulate('change', { target: { value: newThrottleUnit } }); + expect(onThrottleChange).toHaveBeenCalledWith(null, 'h'); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx new file mode 100644 index 00000000000000..da872484dda4a2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_notify_when.tsx @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiFormRow, + EuiFieldNumber, + EuiSelect, + EuiText, + EuiSpacer, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; +import { some, filter, map } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { InitialAlert } from './alert_reducer'; +import { getTimeOptions } from '../../../common/lib/get_time_options'; +import { AlertNotifyWhenType } from '../../../types'; + +const DEFAULT_NOTIFY_WHEN_VALUE: AlertNotifyWhenType = 'onActionGroupChange'; + +const NOTIFY_WHEN_OPTIONS: Array> = [ + { + value: 'onActionGroupChange', + inputDisplay: 'Run only on status change', + 'data-test-subj': 'onActionGroupChange', + dropdownDisplay: ( + + + + + +

    + +

    +
    +
    + ), + }, + { + value: 'onActiveAlert', + inputDisplay: 'Run every time alert is active', + 'data-test-subj': 'onActiveAlert', + dropdownDisplay: ( + + + + + +

    + +

    +
    +
    + ), + }, + { + value: 'onThrottleInterval', + inputDisplay: 'Set a custom action interval', + 'data-test-subj': 'onThrottleInterval', + dropdownDisplay: ( + + + + + +

    + +

    +
    +
    + ), + }, +]; + +interface AlertNotifyWhenProps { + alert: InitialAlert; + throttle: number | null; + throttleUnit: string; + onNotifyWhenChange: (notifyWhen: AlertNotifyWhenType) => void; + onThrottleChange: (throttle: number | null, throttleUnit: string) => void; +} + +export const AlertNotifyWhen = ({ + alert, + throttle, + throttleUnit, + onNotifyWhenChange, + onThrottleChange, +}: AlertNotifyWhenProps) => { + const [alertThrottle, setAlertThrottle] = useState(throttle || 1); + const [showCustomThrottleOpts, setShowCustomThrottleOpts] = useState(false); + const [notifyWhenValue, setNotifyWhenValue] = useState( + DEFAULT_NOTIFY_WHEN_VALUE + ); + + useEffect(() => { + if (alert.notifyWhen) { + setNotifyWhenValue(alert.notifyWhen); + } else { + // If 'notifyWhen' is not set, derive value from existence of throttle value + setNotifyWhenValue(alert.throttle ? 'onThrottleInterval' : 'onActiveAlert'); + } + }, [alert]); + + useEffect(() => { + setShowCustomThrottleOpts(notifyWhenValue === 'onThrottleInterval'); + }, [notifyWhenValue]); + + const onNotifyWhenValueChange = useCallback((newValue: AlertNotifyWhenType) => { + onThrottleChange(newValue === 'onThrottleInterval' ? alertThrottle : null, throttleUnit); + onNotifyWhenChange(newValue); + setNotifyWhenValue(newValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const labelForAlertRenotify = ( + <> + {' '} + + + ); + + return ( + + + + + + {showCustomThrottleOpts && ( + + + + + + { + pipe( + some(e.target.value.trim()), + filter((value) => value !== ''), + map((value) => parseInt(value, 10)), + filter((value) => !isNaN(value)), + map((value) => { + setAlertThrottle(value); + onThrottleChange(value, throttleUnit); + }) + ); + }} + /> + + + { + onThrottleChange(throttle, e.target.value); + }} + /> + + + + + )} + + + + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts index 4e4d8e237aa2fd..71f486cb311b94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.test.ts @@ -18,6 +18,7 @@ describe('alert reducer', () => { }, actions: [], tags: [], + notifyWhen: 'onActionGroupChange', } as unknown) as Alert; }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts index e54895318fc700..b86e0d1555315e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_reducer.ts @@ -10,7 +10,7 @@ import { AlertActionParam, IntervalSchedule } from '../../../../../alerts/common import { Alert, AlertAction } from '../../../types'; export type InitialAlert = Partial & - Pick; + Pick; interface CommandType< T extends diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx index 47ef744f5d95c3..4de4ea02e567a2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.test.tsx @@ -250,6 +250,7 @@ function mockAlert(overloads: Partial = {}): Alert { updatedAt: new Date(), apiKeyOwner: null, throttle: null, + notifyWhen: null, muteAll: false, mutedInstanceIds: [], executionStatus: { diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index acd242eed17feb..f950bbbd8ed251 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -20,6 +20,7 @@ import { AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, + AlertNotifyWhenType, } from '../../alerts/common'; export { Alert, @@ -30,6 +31,7 @@ export { AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, + AlertNotifyWhenType, }; export { ActionType }; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts index e2baf39905bfdc..b4d648d05abc9f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_index_status.ts @@ -15,7 +15,7 @@ export const getIndexStatus: UMElasticsearchQueryFn<{}, StatesIndexStatus> = asy _shards: { total }, count, }, - } = await uptimeEsClient.count({}); + } = await uptimeEsClient.count({ terminateAfter: 1 }); return { indexExists: total > 0, docCount: count, diff --git a/x-pack/test/accessibility/apps/helpers.ts b/x-pack/test/accessibility/apps/helpers.ts new file mode 100644 index 00000000000000..7576f1c1d4ef4b --- /dev/null +++ b/x-pack/test/accessibility/apps/helpers.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This function clears all pipelines to ensure that there in an empty state before starting each test. +export async function deleteAllPipelines(client: any, logger: any) { + const pipelines = await client.ingest.getPipeline(); + const pipeLineIds = Object.keys(pipelines.body); + await logger.debug(pipelines); + if (pipeLineIds.length > 0) { + pipeLineIds.forEach(async (newId: any) => { + await client.ingest.deletePipeline({ id: newId }); + }); + } +} + +export async function putSamplePipeline(client: any) { + return await client.ingest.putPipeline({ + id: 'testPipeline', + body: { + description: 'describe pipeline', + version: 123, + processors: [ + { + set: { + field: 'foo', + value: 'bar', + }, + }, + ], + }, + }); +} diff --git a/x-pack/test/accessibility/apps/ingest_node_pipelines.ts b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts new file mode 100644 index 00000000000000..cb47cb6cb7a9d6 --- /dev/null +++ b/x-pack/test/accessibility/apps/ingest_node_pipelines.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { deleteAllPipelines, putSamplePipeline } from './helpers'; +export default function ({ getService, getPageObjects }: any) { + const { common } = getPageObjects(['common']); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const esClient = getService('es'); + const log = getService('log'); + const a11y = getService('a11y'); /* this is the wrapping service around axe */ + + describe('Ingest Node Pipelines', async () => { + before(async () => { + await putSamplePipeline(esClient); + await common.navigateToApp('ingestPipelines'); + }); + + it('List View', async () => { + await retry.waitFor('Ingest Node Pipelines page to be visible', async () => { + await common.navigateToApp('ingestPipelines'); + return testSubjects.exists('pipelineDetailsLink') ? true : false; + }); + await a11y.testAppSnapshot(); + }); + + it('List View', async () => { + await testSubjects.click('pipelineDetailsLink'); + await retry.waitFor('testPipeline detail panel to be visible', async () => { + if (!testSubjects.isDisplayed('pipelineDetails')) { + await testSubjects.click('pipelineDetailsLink'); + } + return testSubjects.isDisplayed('pipelineDetails') ? true : false; + }); + await a11y.testAppSnapshot(); + }); + + it('Empty State Home View', async () => { + await deleteAllPipelines(esClient, log); + await common.navigateToApp('ingestPipelines'); + await retry.waitFor('Create New Pipeline Title to be visible', async () => { + return testSubjects.exists('title') ? true : false; + }); /* confirm you're on the correct page and that it's loaded */ + await a11y.testAppSnapshot(); /* this expects that there are no failures found by axe */ + }); + + it('Create Pipeline Wizard', async () => { + await testSubjects.click('emptyStateCreatePipelineButton'); + await retry.waitFor('Create pipeline page one to be visible', async () => { + return testSubjects.isDisplayed('pageTitle') ? true : false; + }); + await a11y.testAppSnapshot(); + await testSubjects.click('addProcessorButton'); + await retry.waitFor('Configure Pipeline flyout to be visible', async () => { + return testSubjects.isDisplayed('configurePipelineHeader') ? true : false; + }); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index 8dace50a1ec876..40acd461ab6a18 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -26,6 +26,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/users'), require.resolve('./apps/roles'), require.resolve('./apps/kibana_overview'), + require.resolve('./apps/ingest_node_pipelines'), ], pageObjects, diff --git a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts index 2e7a4e325094c2..e4db829cc283a0 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts @@ -13,6 +13,7 @@ export function getTestAlertData(overwrites = {}) { consumer: 'alertsFixture', schedule: { interval: '1m' }, throttle: '1m', + notifyWhen: 'onThrottleInterval', actions: [], params: {}, ...overwrites, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts index 19d90378e8b7a8..720a0f20648f24 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts @@ -115,6 +115,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: user.username, apiKeyOwner: user.username, muteAll: false, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts index adfe5cd27b33a1..55b148f0c50195 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts @@ -75,6 +75,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { createdAt: match.createdAt, updatedAt: match.updatedAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: 'elastic', apiKeyOwner: 'elastic', muteAll: false, @@ -272,6 +273,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { apiKeyOwner: null, muteAll: false, mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', createdAt: match.createdAt, updatedAt: match.updatedAt, executionStatus: match.executionStatus, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts index 93e9be771ab5c6..87d7b2327dd613 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts @@ -71,6 +71,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { updatedAt: response.body.updatedAt, createdAt: response.body.createdAt, throttle: '1m', + notifyWhen: 'onThrottleInterval', updatedBy: 'elastic', apiKeyOwner: 'elastic', muteAll: false, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index 6b03492432accd..b3ad00bd1ce8b0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -73,6 +73,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }, ], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -171,6 +172,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -254,6 +256,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -348,6 +351,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -451,6 +455,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -529,6 +534,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '12s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -798,6 +804,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1m' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -863,6 +870,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1m' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) @@ -938,6 +946,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { schedule: { interval: '1s' }, actions: [], throttle: '1m', + notifyWhen: 'onThrottleInterval', }; const response = await supertestWithoutAuth .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index cf7fc9edd9529d..8bf0a2a0f034fd 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -83,6 +83,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { updatedBy: null, apiKeyOwner: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: response.body.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 850ec24789f5b1..ffe25cfe684ac9 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -52,6 +52,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { scheduledTaskId: match.scheduledTaskId, updatedBy: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: match.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts index 14a57f57c9237d..8323e26585329f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts @@ -46,6 +46,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { updatedBy: null, apiKeyOwner: null, throttle: '1m', + notifyWhen: 'onThrottleInterval', muteAll: false, mutedInstanceIds: [], createdAt: response.body.createdAt, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index d8a0f279222c7a..2b24a75fab8441 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_space1')); loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./builtin_alert_types')); + loadTestFile(require.resolve('./notify_when')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index bd6afacf206d9d..56866b36a292b5 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -91,5 +91,14 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(response.status).to.eql(200); expect(response.body.updatedAt).to.eql('2020-06-17T15:35:39.839Z'); }); + + it('7.11.0 migrates alerts to contain `notifyWhen` field', async () => { + const response = await supertest.get( + `${getUrlPrefix(``)}/api/alerts/alert/74f3e6d7-b7bb-477d-ac28-92ee22728e6e` + ); + + expect(response.status).to.eql(200); + expect(response.body.notifyWhen).to.eql('onActiveAlert'); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts new file mode 100644 index 00000000000000..234fbb580210b4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/notify_when.ts @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover, getTestAlertData, getEventLog } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { IValidatedEvent } from '../../../../../plugins/event_log/server'; + +// eslint-disable-next-line import/no-default-export +export default function createNotifyWhenTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('notifyWhen', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => await objectRemover.removeAll()); + + it(`alert with notifyWhen=onActiveAlert should always execute actions `, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [true, true, true, false, true, true], + }; + const expectedActionGroupBasedOnPattern = pattern.instance.map((active: boolean) => + active ? 'default' : 'recovered' + ); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 6 }], // one more action (for recovery) will be executed after the last pattern fires + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + + it(`alert with notifyWhen=onActionGroupChange should execute actions when action group changes`, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [true, true, false, false, true, false], + }; + const expectedActionGroupBasedOnPattern = ['default', 'recovered', 'default', 'recovered']; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActionGroupChange', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 4 }], + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + + it(`alert with notifyWhen=onActionGroupChange should only execute actions when action subgroup changes`, async () => { + const { body: defaultAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Default Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: recoveredAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My Recovered Action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const pattern = { + instance: [ + 'subgroup1', + 'subgroup1', + false, + false, + 'subgroup1', + 'subgroup2', + 'subgroup2', + false, + ], + }; + const expectedActionGroupBasedOnPattern = [ + 'default', + 'recovered', + 'default', + 'default', + 'recovered', + ]; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + throttle: null, + notifyWhen: 'onActionGroupChange', + actions: [ + { + id: defaultAction.id, + group: 'default', + params: {}, + }, + { + id: recoveredAction.id, + group: 'recovered', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdAlert.id, + provider: 'alerting', + actions: new Map([ + ['execute-action', { gte: 5 }], + ['new-instance', { equal: 2 }], + ]), + }); + }); + + const executeActionEvents = getEventsByAction(events, 'execute-action'); + const executeActionEventsActionGroup = executeActionEvents.map( + (event) => event?.kibana?.alerting?.action_group_id + ); + expect(executeActionEventsActionGroup).to.eql(expectedActionGroupBasedOnPattern); + }); + }); +} + +function getEventsByAction(events: IValidatedEvent[], action: string) { + return events.filter((event) => event?.event?.action === action); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index f44a7d71318799..f7e6a402e40615 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -54,6 +54,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { apiKeyOwner: null, muteAll: false, mutedInstanceIds: [], + notifyWhen: 'onThrottleInterval', scheduledTaskId: createdAlert.scheduledTaskId, createdAt: response.body.createdAt, updatedAt: response.body.updatedAt, diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/doc_count.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/doc_count.json index 69d768b1126df4..6ff7ea58c30f0c 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/doc_count.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/doc_count.json @@ -1,4 +1,4 @@ { "indexExists": true, - "docCount": 2000 -} \ No newline at end of file + "docCount": 1 +} diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts index e29487880de6b6..a5793489cd8d02 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/double.ts @@ -404,9 +404,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"is in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a double against an index that has the doubles stored as real doubles. - describe.skip('working against double values in the data set', () => { + describe('working against double values in the data set', () => { it('will return 3 results if we have a list that includes 1 double', async () => { await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); const rule = getRuleForSignalTesting(['double']); @@ -545,17 +543,19 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([]); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 1 result if we have a list which contains the double range of 1.0-1.2', async () => { - await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt'); + it('will return 1 result if we have a list which contains the double range of 1.0-1.2', async () => { + await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt', [ + '1.0', + '1.2', + ]); const rule = getRuleForSignalTesting(['double_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'double', list: { id: 'list_items.txt', - type: 'ip', + type: 'double_range', }, operator: 'included', type: 'list', @@ -565,16 +565,14 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccess(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); expect(hits).to.eql(['1.3']); }); }); }); describe('"is not in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a double against an index that has the doubles stored as real doubles. - describe.skip('working against double values in the data set', () => { + describe('working against double values in the data set', () => { it('will return 1 result if we have a list that excludes 1 double', async () => { await importFile(supertest, 'double', ['1.0'], 'list_items.txt'); const rule = getRuleForSignalTesting(['double']); @@ -715,17 +713,19 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 3 results if we have a list which contains the double range of 1.0-1.2', async () => { - await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt'); + it('will return 3 results if we have a list which contains the double range of 1.0-1.2', async () => { + await importFile(supertest, 'double_range', ['1.0-1.2'], 'list_items.txt', [ + '1.0', + '1.2', + ]); const rule = getRuleForSignalTesting(['double_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'double', list: { id: 'list_items.txt', - type: 'ip', + type: 'double_range', }, operator: 'excluded', type: 'list', @@ -733,9 +733,9 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccess(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.double).sort(); expect(hits).to.eql(['1.0', '1.1', '1.2']); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts index d68f0f6a69277e..955d27c086466f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts @@ -404,9 +404,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"is in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a float against an index that has the floats stored as real floats. - describe.skip('working against float values in the data set', () => { + describe('working against float values in the data set', () => { it('will return 3 results if we have a list that includes 1 float', async () => { await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); const rule = getRuleForSignalTesting(['float']); @@ -545,17 +543,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([]); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 1 result if we have a list which contains the float range of 1.0-1.2', async () => { - await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt'); + it('will return 1 result if we have a list which contains the float range of 1.0-1.2', async () => { + await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt', ['1.0', '1.2']); const rule = getRuleForSignalTesting(['float_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'float', list: { id: 'list_items.txt', - type: 'ip', + type: 'float_range', }, operator: 'included', type: 'list', @@ -565,16 +562,14 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccess(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); expect(hits).to.eql(['1.3']); }); }); }); describe('"is not in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a float against an index that has the floats stored as real floats. - describe.skip('working against float values in the data set', () => { + describe('working against float values in the data set', () => { it('will return 1 result if we have a list that excludes 1 float', async () => { await importFile(supertest, 'float', ['1.0'], 'list_items.txt'); const rule = getRuleForSignalTesting(['float']); @@ -715,17 +710,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql(['1.0', '1.1', '1.2', '1.3']); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 3 results if we have a list which contains the float range of 1.0-1.2', async () => { - await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt'); + it('will return 3 results if we have a list which contains the float range of 1.0-1.2', async () => { + await importFile(supertest, 'float_range', ['1.0-1.2'], 'list_items.txt', ['1.0', '1.2']); const rule = getRuleForSignalTesting(['float_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'float', list: { id: 'list_items.txt', - type: 'ip', + type: 'float_range', }, operator: 'excluded', type: 'list', @@ -733,9 +727,9 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccess(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.float).sort(); expect(hits).to.eql(['1.0', '1.1', '1.2']); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts index 0fbb97d2844291..6b32eb19c83d97 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/index.ts @@ -16,8 +16,11 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./float')); loadTestFile(require.resolve('./integer')); loadTestFile(require.resolve('./ip')); + loadTestFile(require.resolve('./ip_array')); loadTestFile(require.resolve('./keyword')); + loadTestFile(require.resolve('./keyword_array')); loadTestFile(require.resolve('./long')); loadTestFile(require.resolve('./text')); + loadTestFile(require.resolve('./text_array')); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts index 9b38f0f7cbb42b..a1275afe288bf1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts @@ -404,9 +404,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"is in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a integer against an index that has the integers stored as real integers. - describe.skip('working against integer values in the data set', () => { + describe('working against integer values in the data set', () => { it('will return 3 results if we have a list that includes 1 integer', async () => { await importFile(supertest, 'integer', ['1'], 'list_items.txt'); const rule = getRuleForSignalTesting(['integer']); @@ -545,17 +543,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([]); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 1 result if we have a list which contains the integer range of 1-3', async () => { - await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt'); + it('will return 1 result if we have a list which contains the integer range of 1-3', async () => { + await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt', ['1', '2']); const rule = getRuleForSignalTesting(['integer_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'integer', list: { id: 'list_items.txt', - type: 'ip', + type: 'integer_range', }, operator: 'included', type: 'list', @@ -565,16 +562,14 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccess(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); expect(hits).to.eql(['4']); }); }); }); describe('"is not in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a integer against an index that has the integers stored as real integers. - describe.skip('working against integer values in the data set', () => { + describe('working against integer values in the data set', () => { it('will return 1 result if we have a list that excludes 1 integer', async () => { await importFile(supertest, 'integer', ['1'], 'list_items.txt'); const rule = getRuleForSignalTesting(['integer']); @@ -715,17 +710,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql(['1', '2', '3', '4']); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 3 results if we have a list which contains the integer range of 1-3', async () => { - await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt'); + it('will return 3 results if we have a list which contains the integer range of 1-3', async () => { + await importFile(supertest, 'integer_range', ['1-3'], 'list_items.txt', ['1', '2', '3']); const rule = getRuleForSignalTesting(['integer_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'integer', list: { id: 'list_items.txt', - type: 'ip', + type: 'integer_range', }, operator: 'excluded', type: 'list', @@ -733,9 +727,9 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccess(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.integer).sort(); expect(hits).to.eql(['1', '2', '3']); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts index c3537efc12de77..311354c63ca4a0 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip.ts @@ -180,7 +180,7 @@ export default ({ getService }: FtrProviderContext) => { expect(ips).to.eql([]); }); - it('should filter a CIDR range of 127.0.0.1/30', async () => { + it('should filter a CIDR range of "127.0.0.1/30"', async () => { const rule = getRuleForSignalTesting(['ip']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -494,9 +494,12 @@ export default ({ getService }: FtrProviderContext) => { expect(ips).to.eql([]); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 1 result if we have a list which contains the CIDR range of 127.0.0.1/30', async () => { - await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt'); + it('will return 1 result if we have a list which contains the CIDR range of "127.0.0.1/30"', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + ]); const rule = getRuleForSignalTesting(['ip']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -504,7 +507,63 @@ export default ({ getService }: FtrProviderContext) => { field: 'ip', list: { id: 'list_items.txt', - type: 'ip', + type: 'ip_range', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + + it('will return 1 result if we have a list which contains the range syntax of "127.0.0.1-127.0.0.3"', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1-127.0.0.3'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + ]); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.4']); + }); + + it('will return 1 result if we have a list which contains the range mixed syntax of "127.0.0.1/32,127.0.0.2-127.0.0.3"', async () => { + await importFile( + supertest, + 'ip_range', + ['127.0.0.1/32', '127.0.0.2-127.0.0.3'], + 'list_items.txt', + ['127.0.0.1', '127.0.0.2', '127.0.0.3'] + ); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', }, operator: 'included', type: 'list', @@ -594,9 +653,12 @@ export default ({ getService }: FtrProviderContext) => { expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 3 results if we have a list which contains the CIDR range of 127.0.0.1/30', async () => { - await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt'); + it('will return 3 results if we have a list which contains the CIDR range of "127.0.0.1/30"', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1/30'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + ]); const rule = getRuleForSignalTesting(['ip']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -604,9 +666,36 @@ export default ({ getService }: FtrProviderContext) => { field: 'ip', list: { id: 'list_items.txt', - type: 'ip', + type: 'ip_range', }, - operator: 'included', + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql(['127.0.0.1', '127.0.0.2', '127.0.0.3']); + }); + + it('will return 3 results if we have a list which contains the range syntax of "127.0.0.1-127.0.0.3"', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1-127.0.0.3'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + ]); + const rule = getRuleForSignalTesting(['ip']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'excluded', type: 'list', }, ], diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts new file mode 100644 index 00000000000000..8f4827ec6e71c2 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts @@ -0,0 +1,735 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type ip', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/ip_as_array'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/ip_as_array'); + }); + + describe('"is" operator', () => { + it('should find all the ips from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + [], + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('should filter 1 single ip if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + [], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('should filter 2 ips if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.5', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + + it('should filter 3 ips if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.5', + }, + ], + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.8', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[]]); + }); + + it('should filter a CIDR range of "127.0.0.1/30"', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.1/30', // CIDR IP Range is 127.0.0.0 - 127.0.0.3 + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + [], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('should filter a CIDR range of "127.0.0.4/31"', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match', + value: '127.0.0.4/31', // CIDR IP Range is 127.0.0.4 - 127.0.0.5 + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '192.168.0.1', // this value does not exist + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.1', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']]); + }); + + it('will return just 1 result we excluded 2 from the same array elements', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.1', + }, + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.2', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']]); + }); + + it('will return 0 results if we exclude two ips', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.1', + }, + ], + [ + { + field: 'ip', + operator: 'excluded', + type: 'match', + value: '127.0.0.5', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single ip if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + [], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('should filter 2 ips if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.5'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + + it('should filter 3 ips if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.5', '127.0.0.8'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[]]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['192.168.0.1', '192.168.0.2'], // These values do not exist + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['127.0.0.1', '127.0.0.5'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ]); + }); + }); + + describe('"exists" operator', () => { + it('will return 1 empty result if matching against ip', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[]]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 3 results if matching against ip', async () => { + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + }); + + describe('"is in list" operator', () => { + it('will return 3 results if we have a list that includes 1 ip', async () => { + await importFile(supertest, 'ip', ['127.0.0.1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + [], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('will return 2 results if we have a list that includes 2 ips', async () => { + await importFile(supertest, 'ip', ['127.0.0.1', '127.0.0.5'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + + it('will return 1 result if we have a list that includes all ips', async () => { + await importFile( + supertest, + 'ip', + ['127.0.0.1', '127.0.0.5', '127.0.0.8'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[]]); + }); + + it('will return 2 results if we have a list which contains the CIDR ranges of "127.0.0.1/32, 127.0.0.2/31, 127.0.0.4/30"', async () => { + await importFile( + supertest, + 'ip_range', + ['127.0.0.1/32', '127.0.0.2/31', '127.0.0.4/30'], + 'list_items.txt', + [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '127.0.0.4', + '127.0.0.5', + '127.0.0.6', + '127.0.0.7', + ] + ); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + + it('will return 2 results if we have a list which contains the range syntax of "127.0.0.1-127.0.0.7', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1-127.0.0.7'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '127.0.0.4', + '127.0.0.5', + '127.0.0.6', + '127.0.0.7', + ]); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 ip', async () => { + await importFile(supertest, 'ip', ['127.0.0.1'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4']]); + }); + + it('will return 2 results if we have a list that excludes 2 ips', async () => { + await importFile(supertest, 'ip', ['127.0.0.1', '127.0.0.5'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ]); + }); + + it('will return 3 results if we have a list that excludes all ips', async () => { + await importFile( + supertest, + 'ip', + ['127.0.0.1', '127.0.0.5', '127.0.0.8'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ['127.0.0.8', '127.0.0.9', '127.0.0.10'], + ]); + }); + + it('will return 3 results if we have a list which contains the CIDR ranges of "127.0.0.1/32, 127.0.0.2/31, 127.0.0.4/30"', async () => { + await importFile( + supertest, + 'ip_range', + ['127.0.0.1/32', '127.0.0.2/31', '127.0.0.4/30'], + 'list_items.txt', + [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '127.0.0.4', + '127.0.0.5', + '127.0.0.6', + '127.0.0.7', + ] + ); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ]); + }); + + it('will return 3 results if we have a list which contains the range syntax of "127.0.0.1-127.0.0.7"', async () => { + await importFile(supertest, 'ip_range', ['127.0.0.1-127.0.0.7'], 'list_items.txt', [ + '127.0.0.1', + '127.0.0.2', + '127.0.0.3', + '127.0.0.4', + '127.0.0.5', + '127.0.0.6', + '127.0.0.7', + ]); + const rule = getRuleForSignalTesting(['ip_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'ip', + list: { + id: 'list_items.txt', + type: 'ip_range', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const ips = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + expect(ips).to.eql([ + ['127.0.0.1', '127.0.0.2', '127.0.0.3', '127.0.0.4'], + ['127.0.0.5', null, '127.0.0.6', '127.0.0.7'], + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts index 0c227c9acc38c8..e4e80cb1b65ea4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword.ts @@ -402,6 +402,39 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"is in list" operator', () => { + it('will return 4 results if we have two lists with an AND contradiction keyword === "word one" AND keyword === "word two"', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items_1.txt'); + await importFile(supertest, 'keyword', ['word two'], 'list_items_2.txt'); + const rule = getRuleForSignalTesting(['keyword']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items_1.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + { + field: 'keyword', + list: { + id: 'list_items_2.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); + }); + it('will return 3 results if we have a list that includes 1 keyword', async () => { await importFile(supertest, 'keyword', ['word one'], 'list_items.txt'); const rule = getRuleForSignalTesting(['keyword']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts new file mode 100644 index 00000000000000..01e301c350851b --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts @@ -0,0 +1,624 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type keyword', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/keyword_as_array'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/keyword_as_array'); + }); + + describe('"is" operator', () => { + it('should find all the keyword from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('should filter 1 single keyword if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('should filter 2 keyword if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word seven', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('should filter 3 keyword if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word six', + }, + ], + [ + { + field: 'keyword', + operator: 'included', + type: 'match', + value: 'word nine', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 0 results if we exclude two keyword', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match', + value: 'word five', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single keyword if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('should filter 2 keyword if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one', 'word six'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('should filter 3 keyword if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'match_any', + value: ['word one', 'word five', 'word eight'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'match_any', + value: ['word one', 'word six'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + + describe('"exists" operator', () => { + it('will return 1 results if matching against keyword for the empty array', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 3 results if matching against keyword', async () => { + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + + describe('"is in list" operator', () => { + it('will return 4 results if we have two lists with an AND contradiction keyword === "word one" AND keyword === "word five"', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items_1.txt'); + await importFile(supertest, 'keyword', ['word five'], 'list_items_2.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items_1.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + { + field: 'keyword', + list: { + id: 'list_items_2.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('will return 3 results if we have two lists with an AND keyword === "word one" AND keyword === "word two" since we have an array', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items_1.txt'); + await importFile(supertest, 'keyword', ['word two'], 'list_items_2.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items_1.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + { + field: 'keyword', + list: { + id: 'list_items_2.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('will return 3 results if we have a list that includes 1 keyword', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('will return 2 results if we have a list that includes 2 keyword', async () => { + await importFile(supertest, 'keyword', ['word one', 'word six'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('will return only the empty array for results if we have a list that includes all keyword', async () => { + await importFile( + supertest, + 'keyword', + ['word one', 'word five', 'word eight'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 keyword', async () => { + await importFile(supertest, 'keyword', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 1 result if we have a list that excludes 1 keyword but repeat 2 elements from the array in the list', async () => { + await importFile(supertest, 'keyword', ['word one', 'word two'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 2 results if we have a list that excludes 2 keyword', async () => { + await importFile(supertest, 'keyword', ['word one', 'word five'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('will return 3 results if we have a list that excludes 3 items', async () => { + await importFile( + supertest, + 'keyword', + ['word one', 'word six', 'word ten'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['keyword_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'keyword', + list: { + id: 'list_items.txt', + type: 'keyword', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.keyword).sort(); + expect(hits).to.eql([ + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts index 5c110996c21984..ee52c41bc78e87 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts @@ -404,9 +404,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"is in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a long against an index that has the longs stored as real longs. - describe.skip('working against long values in the data set', () => { + describe('working against long values in the data set', () => { it('will return 3 results if we have a list that includes 1 long', async () => { await importFile(supertest, 'long', ['1'], 'list_items.txt'); const rule = getRuleForSignalTesting(['long']); @@ -545,17 +543,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([]); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 1 result if we have a list which contains the long range of 1-3', async () => { - await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt'); + it('will return 1 result if we have a list which contains the long range of 1-3', async () => { + await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt', ['1', '2', '3']); const rule = getRuleForSignalTesting(['long_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'long', list: { id: 'list_items.txt', - type: 'ip', + type: 'long_range', }, operator: 'included', type: 'list', @@ -565,16 +562,14 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccess(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); expect(hits).to.eql(['4']); }); }); }); describe('"is not in list" operator', () => { - // TODO: Enable this test once the bugs are fixed, we cannot use a list of strings that represent - // a long against an index that has the longs stored as real longs. - describe.skip('working against long values in the data set', () => { + describe('working against long values in the data set', () => { it('will return 1 result if we have a list that excludes 1 long', async () => { await importFile(supertest, 'long', ['1'], 'list_items.txt'); const rule = getRuleForSignalTesting(['long']); @@ -715,17 +710,16 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql(['1', '2', '3', '4']); }); - // TODO: Fix this bug and then unskip this test - it.skip('will return 3 results if we have a list which contains the long range of 1-3', async () => { - await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt'); + it('will return 3 results if we have a list which contains the long range of 1-3', async () => { + await importFile(supertest, 'long_range', ['1-3'], 'list_items.txt', ['1', '2', '3']); const rule = getRuleForSignalTesting(['long_as_string']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ { - field: 'ip', + field: 'long', list: { id: 'list_items.txt', - type: 'ip', + type: 'long_range', }, operator: 'excluded', type: 'list', @@ -733,9 +727,9 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccess(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source.ip).sort(); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.long).sort(); expect(hits).to.eql(['1', '2', '3']); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts index d2066b1023d3c2..095d8851493899 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts @@ -592,10 +592,37 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // TODO: Unskip these once this is fixed - describe.skip('working against text values with spaces', () => { + describe('working against text values with spaces', () => { it('will return 3 results if we have a list that includes 1 text', async () => { - await importFile(supertest, 'text', ['one'], 'list_items.txt'); + await importTextFile(supertest, 'text', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word four', 'word three', 'word two']); + }); + + it('will return 3 results if we have a list that includes 1 text with additional wording', async () => { + await importTextFile( + supertest, + 'text', + ['word one additional wording'], + 'list_items.txt' + ); const rule = getRuleForSignalTesting(['text']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -618,7 +645,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('will return 2 results if we have a list that includes 2 text', async () => { - await importFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + await importFile(supertest, 'text', ['word one', 'word three'], 'list_items.txt'); const rule = getRuleForSignalTesting(['text']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -644,7 +671,7 @@ export default ({ getService }: FtrProviderContext) => { await importTextFile( supertest, 'text', - ['one', 'two', 'three', 'four'], + ['word one', 'word two', 'word three', 'word four'], 'list_items.txt' ); const rule = getRuleForSignalTesting(['text']); @@ -746,10 +773,37 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // TODO: Unskip these once this is fixed - describe.skip('working against text values with spaces', () => { + describe('working against text values with spaces', () => { it('will return 1 result if we have a list that excludes 1 text', async () => { - await importTextFile(supertest, 'text', ['one'], 'list_items.txt'); + await importTextFile(supertest, 'text', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql(['word one']); + }); + + it('will return 1 result if we have a list that excludes 1 text with additional wording', async () => { + await importTextFile( + supertest, + 'text', + ['word one additional wording'], + 'list_items.txt' + ); const rule = getRuleForSignalTesting(['text']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -772,7 +826,7 @@ export default ({ getService }: FtrProviderContext) => { }); it('will return 2 results if we have a list that excludes 2 text', async () => { - await importTextFile(supertest, 'text', ['one', 'three'], 'list_items.txt'); + await importTextFile(supertest, 'text', ['word one', 'word three'], 'list_items.txt'); const rule = getRuleForSignalTesting(['text']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -798,7 +852,7 @@ export default ({ getService }: FtrProviderContext) => { await importTextFile( supertest, 'text', - ['one', 'two', 'three', 'four'], + ['word one', 'word two', 'word three', 'word four'], 'list_items.txt' ); const rule = getRuleForSignalTesting(['text']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts new file mode 100644 index 00000000000000..ed63f1a0db25f5 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts @@ -0,0 +1,619 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, + importFile, +} from '../../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccess, + waitForSignalsToBePresent, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for data type text', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load('rule_exceptions/text_as_array'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload('rule_exceptions/text_as_array'); + }); + + describe('"is" operator', () => { + it('should find all the text from the data set when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('should filter 1 single text if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('should filter 2 text if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word seven', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('should filter 3 text if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word six', + }, + ], + [ + { + field: 'text', + operator: 'included', + type: 'match', + value: 'word nine', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: '500.0', // this value is not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('will return just 1 result we excluded', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 0 results if we exclude two text', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word one', + }, + ], + [ + { + field: 'text', + operator: 'excluded', + type: 'match', + value: 'word five', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single text if it is set as an exception', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('should filter 2 text if both are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one', 'word six'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('should filter 3 text if all 3 are set as exceptions', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'match_any', + value: ['word one', 'word five', 'word eight'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not one of" operator', () => { + it('will return 0 results if it cannot find what it is excluding', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match_any', + value: ['500', '600'], // both these values are not in the data set + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([]); + }); + + it('will return just the result we excluded', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'match_any', + value: ['word one', 'word six'], + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + + describe('"exists" operator', () => { + it('will return 1 results if matching against text for the empty array', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'included', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"does not exist" operator', () => { + it('will return 3 results if matching against text', async () => { + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + operator: 'excluded', + type: 'exists', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + + describe('"is in list" operator', () => { + it('will return 4 results if we have two lists with an AND contradiction text === "word one" AND text === "word five"', async () => { + await importFile(supertest, 'text', ['word one'], 'list_items_1.txt'); + await importFile(supertest, 'text', ['word five'], 'list_items_2.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items_1.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + { + field: 'text', + list: { + id: 'list_items_2.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('will return 3 results if we have two lists with an AND text === "word one" AND text === "word two" since we have an array', async () => { + await importFile(supertest, 'text', ['word one'], 'list_items_1.txt'); + await importFile(supertest, 'text', ['word two'], 'list_items_2.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items_1.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + { + field: 'text', + list: { + id: 'list_items_2.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('will return 3 results if we have a list that includes 1 text', async () => { + await importFile(supertest, 'text', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + [], + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ]); + }); + + it('will return 2 results if we have a list that includes 2 text', async () => { + await importFile(supertest, 'text', ['word one', 'word six'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); + }); + + it('will return only the empty array for results if we have a list that includes all text', async () => { + await importFile( + supertest, + 'text', + ['word one', 'word five', 'word eight'], + 'list_items.txt' + ); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'included', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([[]]); + }); + }); + + describe('"is not in list" operator', () => { + it('will return 1 result if we have a list that excludes 1 text', async () => { + await importFile(supertest, 'text', ['word one'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 1 result if we have a list that excludes 1 text but repeat 2 elements from the array in the list', async () => { + await importFile(supertest, 'text', ['word one', 'word two'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([['word one', 'word two', 'word three', 'word four']]); + }); + + it('will return 2 results if we have a list that excludes 2 text', async () => { + await importFile(supertest, 'text', ['word one', 'word five'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + + it('will return 3 results if we have a list that excludes 3 items', async () => { + await importFile(supertest, 'text', ['word one', 'word six', 'word ten'], 'list_items.txt'); + const rule = getRuleForSignalTesting(['text_as_array']); + const { id } = await createRuleWithExceptionEntries(supertest, rule, [ + [ + { + field: 'text', + list: { + id: 'list_items.txt', + type: 'text', + }, + operator: 'excluded', + type: 'list', + }, + ], + ]); + await waitForRuleSuccess(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.text).sort(); + expect(hits).to.eql([ + ['word eight', 'word nine', 'word ten'], + ['word five', null, 'word six', 'word seven'], + ['word one', 'word two', 'word three', 'word four'], + ]); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts index 59fc287c6cf2eb..810c7c60f3836b 100644 --- a/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts +++ b/x-pack/test/functional/apps/license_management/feature_controls/license_management_security.ts @@ -55,13 +55,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(links.map((link) => link.text)).to.contain('Stack Management'); }); - it('should render the "Stack" section with License Management', async () => { - await PageObjects.common.navigateToApp('management'); - const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ - sectionId: 'stack', - sectionLinks: ['license_management', 'upgrade_assistant'], + describe('[SkipCloud] global dashboard with license management user: skip cloud', function () { + this.tags('skipCloud'); + it('should render the "Stack" section with License Management', async () => { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[2]).to.eql({ + sectionId: 'stack', + sectionLinks: ['license_management', 'upgrade_assistant'], + }); }); }); }); diff --git a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts index 1f541dbe03537c..327e38bc66f05e 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/feature_controls/upgrade_assistant_security.ts @@ -13,7 +13,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const managementMenu = getService('managementMenu'); - describe('security', () => { + describe('security', function () { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.common.navigateToApp('home'); @@ -58,13 +58,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(links.map((link) => link.text)).to.contain('Stack Management'); }); - it('should render the "Stack" section with Upgrde Assistant', async () => { - await PageObjects.common.navigateToApp('management'); - const sections = await managementMenu.getSections(); - expect(sections).to.have.length(3); - expect(sections[2]).to.eql({ - sectionId: 'stack', - sectionLinks: ['license_management', 'upgrade_assistant'], + describe('[SkipCloud] global dashboard all with global_upgrade_assistant_role', function () { + this.tags('skipCloud'); + it('should render the "Stack" section with Upgrde Assistant', async function () { + await PageObjects.common.navigateToApp('management'); + const sections = await managementMenu.getSections(); + expect(sections).to.have.length(3); + expect(sections[2]).to.eql({ + sectionId: 'stack', + sectionLinks: ['license_management', 'upgrade_assistant'], + }); }); }); }); diff --git a/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/data.json b/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/data.json new file mode 100644 index 00000000000000..4a4316a05b2d8d --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "ip_as_array", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "ip": [] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "ip_as_array", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "ip": ["127.0.0.1", "127.0.0.2", "127.0.0.3", "127.0.0.4"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "ip_as_array", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "ip": ["127.0.0.5", null, "127.0.0.6", "127.0.0.7"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "ip_as_array", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "ip": ["127.0.0.8", "127.0.0.9", "127.0.0.10"] + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/mappings.json new file mode 100644 index 00000000000000..c46b79ce113816 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/ip_as_array/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "ip_as_array", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "ip": { "type": "ip" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/data.json b/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/data.json new file mode 100644 index 00000000000000..2c51d4cbc63c32 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "keyword_as_array", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "keyword": [] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "keyword_as_array", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "keyword": ["word one", "word two", "word three", "word four"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "keyword_as_array", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "keyword": ["word five", null, "word six", "word seven"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "keyword_as_array", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "keyword": ["word eight", "word nine", "word ten"] + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/mappings.json new file mode 100644 index 00000000000000..df62e96aecfc9c --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/keyword_as_array/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "keyword_as_array", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "keyword": { "type": "keyword" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/data.json b/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/data.json new file mode 100644 index 00000000000000..228132cda90d25 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "text_as_array", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "text": [] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "text_as_array", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "text": ["word one", "word two", "word three", "word four"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "text_as_array", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "text": ["word five", null, "word six", "word seven"] + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "text_as_array", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "text": ["word eight", "word nine", "word ten"] + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/mappings.json new file mode 100644 index 00000000000000..b0a3885da991f2 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/text_as_array/mappings.json @@ -0,0 +1,20 @@ +{ + "type": "index", + "value": { + "index": "text_as_array", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "keyword": { "type": "text" } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts index d2f81c34cd5d4d..94b2f802530f63 100644 --- a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts +++ b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts @@ -17,6 +17,10 @@ export function IngestPipelinesPageProvider({ getService, getPageObjects }: FtrP return await testSubjects.getVisibleText('appTitle'); }, + async emptyStateHeaderText() { + return await testSubjects.getVisibleText('title'); + }, + async createNewPipeline({ name, description, diff --git a/x-pack/test/functional/services/uptime/alerts.ts b/x-pack/test/functional/services/uptime/alerts.ts index fa0c035b9183e5..67b80e2ddf327e 100644 --- a/x-pack/test/functional/services/uptime/alerts.ts +++ b/x-pack/test/functional/services/uptime/alerts.ts @@ -38,6 +38,8 @@ export function UptimeAlertsProvider({ getService }: FtrProviderContext) { return testSubjects.setValue('intervalInput', value); }, async setAlertThrottleInterval(value: string) { + await testSubjects.click('notifyWhenSelect'); + await testSubjects.click('onThrottleInterval'); return testSubjects.setValue('throttleInput', value); }, async setAlertExpressionValue( diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7444c17a7a45d9..2598fc890211b1 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -72,6 +72,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const alertName = generateUniqueKey(); await defineAlert(alertName); + await testSubjects.click('notifyWhenSelect'); + await testSubjects.click('onThrottleInterval'); + await testSubjects.setValue('throttleInput', '10'); + await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('addNewActionConnectorButton-.slack'); const slackConnectorName = generateUniqueKey(); diff --git a/x-pack/test/lists_api_integration/utils.ts b/x-pack/test/lists_api_integration/utils.ts index 53472b459b8acd..c008f4e1a4e574 100644 --- a/x-pack/test/lists_api_integration/utils.ts +++ b/x-pack/test/lists_api_integration/utils.ts @@ -175,12 +175,14 @@ export const deleteAllExceptions = async (es: Client): Promise => { * @param type The type to import as * @param contents The contents of the import * @param fileName filename to import as + * @param testValues Optional test values in case you're using CIDR or range based lists */ export const importFile = async ( supertest: SuperTest, type: Type, contents: string[], - fileName: string + fileName: string, + testValues?: string[] ): Promise => { await supertest .post(`${LIST_ITEM_URL}/_import?type=${type}`) @@ -191,7 +193,8 @@ export const importFile = async ( // although we have pushed the list and its items, it is async so we // have to wait for the contents before continuing - await waitForListItems(supertest, contents, fileName); + const testValuesOrContents = testValues ?? contents; + await waitForListItems(supertest, testValuesOrContents, fileName); }; /**