From 05b774aee65a04bc93ecf162c5eb16c06920201c Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Thu, 9 Jun 2022 13:00:47 -0600 Subject: [PATCH] [Security Solution][Detections] Related Integrations & Required Fields Feedback & Fixes (#133050) ## Summary Addressing the following feedback from https://github.com/elastic/kibana/pull/132847: - [X] On the Rule Management page package name is used instead of package title when the package has only 1 integration: - [X] move integrations_popover to `related_integrations` directory - [X] update useInstalledIntegrations to always return array of installedIntegration - [X] move useInstalledIntegrations to `related_integrations` directory - [X] Slight update to copy in Rules Table popover - [X] Ok to use Rule Details UI within Rules Table popover content - [X] Sort integrations alphabetically - [X] Update left padding on version mis-match tooltip - [X] https://github.com/elastic/kibana/issues/133291 - [X] https://github.com/elastic/kibana/issues/133269 - [X] Add Kibana Advanced Setting for disabling integrations badge on Rules Table

- [ ] Adds tests - [x] `useInstalledIntegrations` hook - [X] relatedIntegrations utils - [x] IntegrationDescription - [ ] Add loaders where necessary since there can now be API delay - May hold off on loaders as transition from no installed integrations -> installed integrations isn't too bad as-is ##### Updated integrations popover content on Rules Table to match Rule Details design

In discussions with @banderror reviewing the different integration states (uninstalled, installed, enabled, and agents enrolled), we are now capturing the distinction between `Installed` and `Enabled` so that we don't confuse users when a package is installed but the integration isn't enabled/configured. I also added tooltips for clarifying each state and what action the user should perform to ensure compatibility. In collab with @yiyangliu9286 @jethr0null (comments below) -- we've consolidated to a single `Installed: enabled` badge, and updated `Uninstalled` to `Not installed` as well. ##### Tooltips
Not installed

Installed

Installed: enabled

### Checklist Delete any items that are not applicable to this PR. - [X] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [X] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - Collaborating with docs teams on this dedicated docs issue: https://github.com/elastic/security-docs/issues/2015 - [X] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [X] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) (cherry picked from commit 7bfcb5290131efa95dcda26c46518e5bb245d7fe) # Conflicts: # x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.tsx # x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx # x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx # x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx --- docs/management/advanced-options.asciidoc | 8 +- .../security_solution/common/constants.ts | 4 + .../schemas/response/index.ts | 1 + .../integrations_popover/helpers.tsx | 72 ------ .../components/integrations_popover/index.tsx | 143 ------------ .../rules/description_step/helpers.tsx | 10 +- .../rules/description_step/index.tsx | 2 +- .../required_integrations_description.tsx | 95 -------- .../rules/description_step/translations.tsx | 14 -- .../integrations_description/index.test.tsx | 37 +++ .../integrations_description/index.tsx | 109 +++++++++ .../integrations_popover/index.tsx | 116 ++++++++++ .../rules/related_integrations/mock.ts | 100 +++++++++ .../related_integrations/translations.ts | 94 ++++++++ .../use_installed_integrations.test.tsx | 128 +++++++++++ .../use_installed_integrations.tsx | 43 ++++ .../rules/related_integrations/utils.test.tsx | 212 ++++++++++++++++++ .../rules/related_integrations/utils.tsx | 113 ++++++++++ .../detection_engine/rules/__mocks__/api.ts | 28 +++ .../detection_engine/rules/translations.ts | 7 - .../rules/use_installed_integrations.tsx | 59 ----- .../rules/all/use_columns.tsx | 23 +- .../detection_engine/rules/translations.ts | 50 ----- .../security_solution/server/ui_settings.ts | 19 +- 24 files changed, 1036 insertions(+), 451 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_installed_integrations.tsx diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index ca5407576a7f5..ef4a8694de47a 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -449,6 +449,9 @@ events. [[securitysolution-threatindices]]`securitySolution:defaultThreatIndex`:: A comma-delimited list of Threat Intelligence indices from which the {security-app} collects indicators. +[[securitysolution-enableCcsWarning]]`securitySolution:enableCcsWarning`:: Enables +privilege check warnings in rules for CCS indices. + [[securitysolution-enablenewsfeed]]`securitySolution:enableNewsFeed`:: Enables the security news feed on the Security *Overview* page. @@ -464,7 +467,10 @@ The URL from which the security news feed content is retrieved. The default refresh interval for the Security time filter, in milliseconds. [[security-solution-rules-table-refresh]]`securitySolution:rulesTableRefresh`:: -The default period of time in the Security time filter. +Enables auto refresh on the rules and monitoring tables, in milliseconds. + +[[securitySolution-showRelatedIntegrations]]`securitySolution:showRelatedIntegrations`:: +Shows related integrations on the rules and monitoring tables. [[securitysolution-timedefaults]]`securitySolution:timeDefaults`:: The default period of time in the Security time filter. diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4153317a00ed0..8c6683f75b495 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -216,6 +216,10 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ { "name": "talosIntelligence.com", "url_template": "https://talosintelligence.com/reputation_center/lookup?search={{ip}}" } ]`; +/** This Kibana Advanced Setting shows related integrations on the Rules Table */ +export const SHOW_RELATED_INTEGRATIONS_SETTING = + 'securitySolution:showRelatedIntegrations' as const; + /** * Id for the notifications alerting type * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts index c30ea389fad76..e88f07faa2684 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -6,6 +6,7 @@ */ export * from './error_schema'; +export * from './get_installed_integrations_response_schema'; export * from './get_rule_execution_events_response'; export * from './import_rules_schema'; export * from './prepackaged_rules_schema'; diff --git a/x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.tsx deleted file mode 100644 index 65854b0cc1fb5..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiLink } from '@elastic/eui'; -import { capitalize } from 'lodash'; -import React from 'react'; -import semver from 'semver'; -import { - RelatedIntegration, - RelatedIntegrationArray, -} from '../../../../common/detection_engine/schemas/common'; - -/** - * Returns an `EuiLink` that will link to a given package/integration/version page within fleet - * @param integration - * @param basePath - */ -export const getIntegrationLink = (integration: RelatedIntegration, basePath: string) => { - const integrationURL = `${basePath}/app/integrations/detail/${integration.package}-${ - integration.version - }/overview${integration.integration ? `?integration=${integration.integration}` : ''}`; - return ( - - {`${capitalize(integration.package)} ${capitalize(integration.integration)}`} - - ); -}; - -export interface InstalledIntegration extends RelatedIntegration { - targetVersion: string; - versionSatisfied?: boolean; -} - -/** - * Given an array of integrations and an array of installed integrations this will return which - * integrations are `available`/`uninstalled` and which are `installed`, and also augmented with - * `targetVersion` and `versionSatisfied` - * @param integrations - * @param installedIntegrations - */ -export const getInstalledRelatedIntegrations = ( - integrations: RelatedIntegrationArray, - installedIntegrations: RelatedIntegrationArray -): { - availableIntegrations: RelatedIntegrationArray; - installedRelatedIntegrations: InstalledIntegration[]; -} => { - const availableIntegrations: RelatedIntegrationArray = []; - const installedRelatedIntegrations: InstalledIntegration[] = []; - - integrations.forEach((i: RelatedIntegration) => { - const match = installedIntegrations.find( - (installed) => installed.package === i.package && installed?.integration === i?.integration - ); - if (match != null) { - // Version check e.g. fleet match `1.2.3` satisfies rule dependency `~1.2.1` - const versionSatisfied = semver.satisfies(match.version, i.version); - installedRelatedIntegrations.push({ ...match, targetVersion: i.version, versionSatisfied }); - } else { - availableIntegrations.push(i); - } - }); - - return { - availableIntegrations, - installedRelatedIntegrations, - }; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx b/x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx deleted file mode 100644 index 8574a96ed9516..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; -import { - EuiPopover, - EuiBadge, - EuiPopoverTitle, - EuiFlexGroup, - EuiText, - EuiIconTip, -} from '@elastic/eui'; -import styled from 'styled-components'; -import { useBasePath } from '../../lib/kibana'; -import { getInstalledRelatedIntegrations, getIntegrationLink } from './helpers'; -import { useInstalledIntegrations } from '../../../detections/containers/detection_engine/rules/use_installed_integrations'; -import type { RelatedIntegrationArray } from '../../../../common/detection_engine/schemas/common'; - -import * as i18n from '../../../detections/pages/detection_engine/rules/translations'; - -export interface IntegrationsPopoverProps { - integrations: RelatedIntegrationArray; -} - -const IntegrationsPopoverWrapper = styled(EuiFlexGroup)` - width: 100%; -`; - -const PopoverContentWrapper = styled('div')` - max-height: 400px; - max-width: 368px; - overflow: auto; - line-height: ${({ theme }) => theme.eui.euiLineHeight}; -`; - -const IntegrationListItem = styled('li')` - list-style-type: disc; - margin-left: 25px; -`; -/** - * Component to render installed and available integrations - * @param integrations - array of integrations to display - */ -const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps) => { - const [isPopoverOpen, setPopoverOpen] = useState(false); - const { data } = useInstalledIntegrations({ packages: [] }); - const basePath = useBasePath(); - - const allInstalledIntegrations: RelatedIntegrationArray = data ?? []; - const { availableIntegrations, installedRelatedIntegrations } = getInstalledRelatedIntegrations( - integrations, - allInstalledIntegrations - ); - - const badgeTitle = - data != null - ? `${installedRelatedIntegrations.length}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}` - : `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`; - - return ( - - setPopoverOpen(!isPopoverOpen)} - onClickAriaLabel={badgeTitle} - > - {badgeTitle} - - } - isOpen={isPopoverOpen} - closePopover={() => setPopoverOpen(!isPopoverOpen)} - repositionOnScroll - > - - {i18n.INTEGRATIONS_POPOVER_TITLE(integrations.length)} - - - - {data != null && ( - <> - - {i18n.INTEGRATIONS_POPOVER_DESCRIPTION_INSTALLED( - installedRelatedIntegrations.length - )} - -
    - {installedRelatedIntegrations.map((integration, index) => ( - - {getIntegrationLink(integration, basePath)} - {!integration?.versionSatisfied && ( - - )} - - ))} -
- - )} - {availableIntegrations.length > 0 && ( - <> - - {i18n.INTEGRATIONS_POPOVER_DESCRIPTION_UNINSTALLED(availableIntegrations.length)} - -
    - {availableIntegrations.map((integration, index) => ( - - {getIntegrationLink(integration, basePath)} - - ))} -
- - )} -
-
-
- ); -}; - -const MemoizedIntegrationsPopover = React.memo(IntegrationsPopoverComponent); -MemoizedIntegrationsPopover.displayName = 'IntegrationsPopover'; - -export const IntegrationsPopover = - MemoizedIntegrationsPopover as typeof IntegrationsPopoverComponent; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index 3110e63a5442a..394b1712df401 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -20,6 +20,8 @@ import { } from '@elastic/eui'; import { ALERT_RISK_SCORE } from '@kbn/rule-data-utils'; +import { castEsToKbnFieldTypeName } from '@kbn/field-types'; + import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; @@ -556,7 +558,7 @@ export const buildRequiredFieldsDescription = ( label: string, requiredFields: RequiredFieldArray ): ListItems[] => { - if (requiredFields == null) { + if (isEmpty(requiredFields)) { return []; } @@ -569,7 +571,11 @@ export const buildRequiredFieldsDescription = ( - + diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 382c23b64cd09..df5fcb8d96ba7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import { ThreatMapping, Threats, Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { DataViewBase, Filter, FilterStateStore } from '@kbn/es-query'; import { FilterManager } from '@kbn/data-plugin/public'; -import { buildRelatedIntegrationsDescription } from './required_integrations_description'; +import { buildRelatedIntegrationsDescription } from '../related_integrations/integrations_description'; import type { RelatedIntegrationArray, RequiredFieldArray, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx deleted file mode 100644 index 3e07ccd17dfa8..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { EuiBadge, EuiIconTip } from '@elastic/eui'; -import { INTEGRATIONS_INSTALLED_VERSION_TOOLTIP } from '../../../pages/detection_engine/rules/translations'; -import { useInstalledIntegrations } from '../../../containers/detection_engine/rules/use_installed_integrations'; -import { - getInstalledRelatedIntegrations, - getIntegrationLink, -} from '../../../../common/components/integrations_popover/helpers'; - -import { - RelatedIntegration, - RelatedIntegrationArray, -} from '../../../../../common/detection_engine/schemas/common'; -import { useBasePath } from '../../../../common/lib/kibana'; -import { ListItems } from './types'; -import * as i18n from './translations'; - -const Wrapper = styled.div` - overflow: hidden; -`; - -const IntegrationDescriptionComponent: React.FC<{ integration: RelatedIntegration }> = ({ - integration, -}) => { - const basePath = useBasePath(); - const badgeInstalledColor = '#E0E5EE'; - const badgeUninstalledColor = 'accent'; - const { data } = useInstalledIntegrations({ packages: [] }); - - const allInstalledIntegrations: RelatedIntegrationArray = data ?? []; - const { availableIntegrations, installedRelatedIntegrations } = getInstalledRelatedIntegrations( - [integration], - allInstalledIntegrations - ); - - if (availableIntegrations.length > 0) { - return ( - - {getIntegrationLink(integration, basePath)}{' '} - {data != null && ( - {i18n.RELATED_INTEGRATIONS_UNINSTALLED} - )} - - ); - } else if (installedRelatedIntegrations.length > 0) { - return ( - - {getIntegrationLink(integration, basePath)}{' '} - {i18n.RELATED_INTEGRATIONS_INSTALLED} - {!installedRelatedIntegrations[0]?.versionSatisfied && ( - - )} - - ); - } else { - return <>; - } -}; - -export const IntegrationDescription = React.memo(IntegrationDescriptionComponent); - -const RelatedIntegrationsDescription: React.FC<{ integrations: RelatedIntegrationArray }> = ({ - integrations, -}) => ( - <> - {integrations?.map((integration, index) => ( - - ))} - -); - -export const buildRelatedIntegrationsDescription = ( - label: string, - relatedIntegrations: RelatedIntegrationArray -): ListItems[] => [ - { - title: label, - description: , - }, -]; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx index ce18676eae86f..9f688aec63719 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx @@ -118,17 +118,3 @@ export const EQL_TIMESTAMP_FIELD_LABEL = i18n.translate( defaultMessage: 'Timestamp field', } ); - -export const RELATED_INTEGRATIONS_INSTALLED = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrationsInstalledDescription', - { - defaultMessage: 'Installed', - } -); - -export const RELATED_INTEGRATIONS_UNINSTALLED = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDescription.relatedIntegrationsUninstalledDescription', - { - defaultMessage: 'Uninstalled', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx new file mode 100644 index 0000000000000..d337da3cb60bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { installedIntegrationsBase, relatedIntegrations } from '../mock'; +import { useInstalledIntegrations } from '../use_installed_integrations'; +import { getInstalledRelatedIntegrations } from '../utils'; +import { IntegrationDescription } from '.'; +import { render, screen } from '@testing-library/react'; + +jest.mock('../../../../../common/lib/kibana'); +jest.mock('../use_installed_integrations'); + +const mockUseInstalledIntegrations = useInstalledIntegrations as jest.Mock; +mockUseInstalledIntegrations.mockReturnValue({ + data: installedIntegrationsBase, + isLoading: false, + isFetching: false, +}); + +describe('IntegrationDescription', () => { + test('Shows total events returned', () => { + const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); + + const integrationDetails = getInstalledRelatedIntegrations( + relatedIntegrations, + allInstalledIntegrations + ); + render(); + expect(screen.getByTestId('integrationLink')).toHaveTextContent('Aws Cloudtrail'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx new file mode 100644 index 0000000000000..839ad0eedca1b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_description/index.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiBadge, EuiIconTip, EuiToolTip } from '@elastic/eui'; +import { useInstalledIntegrations } from '../use_installed_integrations'; +import { getInstalledRelatedIntegrations, getIntegrationLink, IntegrationDetails } from '../utils'; + +import { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common'; +import { useBasePath } from '../../../../../common/lib/kibana'; +import { ListItems } from '../../description_step/types'; +import * as i18n from '../translations'; + +const Wrapper = styled.div` + overflow: hidden; +`; + +const PaddedBadge = styled(EuiBadge)` + margin-left: 5px; +`; + +const VersionWarningIconContainer = styled.span` + margin-left: 5px; +`; + +export const IntegrationDescriptionComponent: React.FC<{ integration: IntegrationDetails }> = ({ + integration, +}) => { + const basePath = useBasePath(); + const badgeInstalledColor = 'success'; + const badgeUninstalledColor = '#E0E5EE'; + const badgeColor = integration.is_installed ? badgeInstalledColor : badgeUninstalledColor; + const badgeTooltip = integration.is_installed + ? integration.is_enabled + ? i18n.INTEGRATIONS_ENABLED_TOOLTIP + : i18n.INTEGRATIONS_INSTALLED_TOOLTIP + : i18n.INTEGRATIONS_UNINSTALLED_TOOLTIP; + const badgeText = integration.is_installed + ? integration.is_enabled + ? i18n.INTEGRATIONS_ENABLED + : i18n.INTEGRATIONS_INSTALLED + : i18n.INTEGRATIONS_UNINSTALLED; + + return ( + + {getIntegrationLink(integration, basePath)}{' '} + + {badgeText} + + {integration.is_installed && !integration.version_satisfied && ( + + + + )} + + ); +}; + +export const IntegrationDescription = React.memo(IntegrationDescriptionComponent); + +export const RelatedIntegrationsDescription: React.FC<{ + integrations: RelatedIntegrationArray; +}> = ({ integrations }) => { + const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); + + const integrationDetails = getInstalledRelatedIntegrations( + integrations, + allInstalledIntegrations + ); + + return ( + <> + {integrationDetails.map((integration, index) => ( + + ))} + + ); +}; + +export const buildRelatedIntegrationsDescription = ( + label: string, + relatedIntegrations: RelatedIntegrationArray | undefined +): ListItems[] => { + if (relatedIntegrations == null || relatedIntegrations.length === 0) { + return []; + } + + return [ + { + title: label, + description: , + }, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.tsx new file mode 100644 index 0000000000000..87c5cead4f06c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/integrations_popover/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiPopover, + EuiBadge, + EuiPopoverTitle, + EuiFlexGroup, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { IntegrationDescription } from '../integrations_description'; +import { getInstalledRelatedIntegrations } from '../utils'; +import { useInstalledIntegrations } from '../use_installed_integrations'; +import type { RelatedIntegrationArray } from '../../../../../../common/detection_engine/schemas/common'; + +import * as i18n from '../translations'; + +export interface IntegrationsPopoverProps { + integrations: RelatedIntegrationArray; +} + +const IntegrationsPopoverWrapper = styled(EuiFlexGroup)` + width: 100%; +`; + +const PopoverTitleWrapper = styled(EuiPopoverTitle)` + max-width: 390px; +`; + +const PopoverContentWrapper = styled('div')` + max-height: 400px; + max-width: 390px; + overflow: auto; + line-height: ${({ theme }) => theme.eui.euiLineHeight}; +`; + +const IntegrationListItem = styled('li')` + list-style-type: disc; + margin-left: 25px; + margin-bottom: 5px; +`; + +/** + * Component to render installed and available integrations + * @param integrations - array of integrations to display + */ +const IntegrationsPopoverComponent = ({ integrations }: IntegrationsPopoverProps) => { + const [isPopoverOpen, setPopoverOpen] = useState(false); + const { data: allInstalledIntegrations } = useInstalledIntegrations({ packages: [] }); + + const integrationDetails = getInstalledRelatedIntegrations( + integrations, + allInstalledIntegrations + ); + + const totalRelatedIntegrationsInstalled = integrationDetails.filter((i) => i.is_enabled).length; + const badgeTitle = + allInstalledIntegrations != null + ? `${totalRelatedIntegrationsInstalled}/${integrations.length} ${i18n.INTEGRATIONS_BADGE}` + : `${integrations.length} ${i18n.INTEGRATIONS_BADGE}`; + + return ( + + setPopoverOpen(!isPopoverOpen)} + onClickAriaLabel={badgeTitle} + > + {badgeTitle} + + } + isOpen={isPopoverOpen} + closePopover={() => setPopoverOpen(!isPopoverOpen)} + repositionOnScroll + > + + {i18n.INTEGRATIONS_POPOVER_TITLE(integrations.length)} + + + {i18n.INTEGRATIONS_POPOVER_DESCRIPTION(integrations.length)} + +
    + {integrationDetails.map((integration, index) => ( + + + + ))} +
+
+
+
+ ); +}; + +const MemoizedIntegrationsPopover = React.memo(IntegrationsPopoverComponent); +MemoizedIntegrationsPopover.displayName = 'IntegrationsPopover'; + +export const IntegrationsPopover = + MemoizedIntegrationsPopover as typeof IntegrationsPopoverComponent; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts new file mode 100644 index 0000000000000..548839304ed03 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/mock.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IntegrationDetails } from './utils'; +import { + InstalledIntegrationArray, + RelatedIntegrationArray, +} from '../../../../../common/detection_engine/schemas/common'; + +export const relatedIntegrations: RelatedIntegrationArray = [ + { + package: 'system', + version: '1.6.4', + }, + { + package: 'aws', + integration: 'cloudtrail', + version: '~1.11.0', + }, +]; + +export const installedIntegrationsBase: InstalledIntegrationArray = [ + { package_name: 'system', package_title: 'System', package_version: '1.6.4', is_enabled: true }, +]; + +export const installedIntegrationsAWSCloudwatch: InstalledIntegrationArray = [ + { + package_name: 'aws', + package_title: 'AWS', + package_version: '1.11.0', + integration_name: 'billing', + integration_title: 'AWS Billing Metrics', + is_enabled: false, + }, + { + package_name: 'aws', + package_title: 'AWS', + package_version: '1.11.0', + integration_name: 'cloudtrail', + integration_title: 'AWS Cloudtrail Logs', + is_enabled: false, + }, + { + package_name: 'aws', + package_title: 'AWS', + package_version: '1.11.0', + integration_name: 'cloudwatch', + integration_title: 'AWS CloudWatch', + is_enabled: true, + }, + { package_name: 'system', package_title: 'System', package_version: '1.6.4', is_enabled: true }, + { + package_name: 'atlassian_bitbucket', + package_title: 'Atlassian Bitbucket', + package_version: '1.0.1', + integration_name: 'audit', + integration_title: 'Audit Logs', + is_enabled: true, + }, +]; + +export const integrationDetailsUninstalled: IntegrationDetails = { + package_name: 'test', + package_title: 'Test', + package_version: '1.2.3', + integration_name: 'integration', + integration_title: 'Integration', + is_enabled: false, + is_installed: false, + target_version: '1.2.3', + version_satisfied: false, +}; + +export const integrationDetailsInstalled: IntegrationDetails = { + package_name: 'test', + package_title: 'Test', + package_version: '1.2.3', + integration_name: 'integration', + integration_title: 'Integration', + is_enabled: false, + is_installed: true, + target_version: '1.2.3', + version_satisfied: true, +}; + +export const integrationDetailsEnabled: IntegrationDetails = { + package_name: 'test', + package_title: 'Test', + package_version: '1.1.3', + integration_name: 'integration', + integration_title: 'Integration', + is_enabled: true, + is_installed: true, + target_version: '1.3.3', + version_satisfied: false, +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.ts new file mode 100644 index 0000000000000..b02b84221c315 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/translations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INTEGRATIONS_INSTALLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.installedTitle', + { + defaultMessage: 'Installed', + } +); + +export const INTEGRATIONS_INSTALLED_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.installedTooltip', + { + defaultMessage: + 'Integration is installed. Configure an integration policy and ensure Elastic Agents are assigned this policy to ingest compatible events.', + } +); + +export const INTEGRATIONS_UNINSTALLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTitle', + { + defaultMessage: 'Not installed', + } +); + +export const INTEGRATIONS_UNINSTALLED_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.uninstalledTooltip', + { + defaultMessage: + 'Integration is not installed. Follow the integration link to install and configure the integration.', + } +); + +export const INTEGRATIONS_ENABLED = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTitle', + { + defaultMessage: 'Installed: enabled', + } +); + +export const INTEGRATIONS_ENABLED_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.enabledTooltip', + { + defaultMessage: + 'Integration is installed and an integration policy with the required configuration exists. Ensure Elastic Agents are assigned this policy to ingest compatible events.', + } +); + +export const INTEGRATIONS_BADGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.badgeTitle', + { + defaultMessage: 'integrations', + } +); + +export const INTEGRATIONS_POPOVER_TITLE = (integrationsCount: number) => + i18n.translate('xpack.securitySolution.detectionEngine.relatedIntegrations.popoverTitle', { + values: { integrationsCount }, + defaultMessage: + '[{integrationsCount}] Related {integrationsCount, plural, =1 {integration} other {integrations}} available', + }); + +export const INTEGRATIONS_POPOVER_DESCRIPTION = (integrationsCount: number) => + i18n.translate('xpack.securitySolution.detectionEngine.relatedIntegrations.popoverDescription', { + values: { integrationsCount }, + defaultMessage: + 'Install and configure {integrationsCount, plural, =1 {the below integration} other {one or more of the below integrations}} to ingest the necessary data for this detection rule:', + }); + +export const INTEGRATIONS_INSTALLED_VERSION_TOOLTIP = ( + installedVersion: string, + targetVersion: string +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.relatedIntegrations.popoverDescriptionInstalledVersionTooltip', + { + values: { installedVersion, targetVersion }, + defaultMessage: + 'Version mismatch -- please resolve! Installed version `{installedVersion}` when target version `{targetVersion}`', + } + ); + +export const INTEGRATIONS_FETCH_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.relatedIntegrations.fetchFailDescription', + { + defaultMessage: 'Failed to fetch installed integrations', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx new file mode 100644 index 0000000000000..9ce7134db5f2a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('../../../containers/detection_engine/rules/api'); +jest.mock('../../../../common/lib/kibana'); + +import React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { renderHook, cleanup } from '@testing-library/react-hooks'; + +import { useInstalledIntegrations } from './use_installed_integrations'; + +import * as api from '../../../containers/detection_engine/rules/api'; +import { useToasts } from '../../../../common/lib/kibana'; + +describe('useInstalledIntegrations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + cleanup(); + }); + + const createReactQueryWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Turn retries off, otherwise we won't be able to test errors + retry: false, + }, + }, + }); + const wrapper: React.FC = ({ children }) => ( + {children} + ); + return wrapper; + }; + + const render = () => + renderHook( + () => + useInstalledIntegrations({ + packages: [], + }), + { + wrapper: createReactQueryWrapper(), + } + ); + + it('calls the API via fetchInstalledIntegrations', async () => { + const fetchInstalledIntegrations = jest.spyOn(api, 'fetchInstalledIntegrations'); + + const { waitForNextUpdate } = render(); + + await waitForNextUpdate(); + + expect(fetchInstalledIntegrations).toHaveBeenCalledTimes(1); + expect(fetchInstalledIntegrations).toHaveBeenLastCalledWith( + expect.objectContaining({ packages: [] }) + ); + }); + + it('fetches data from the API', async () => { + const { result, waitForNextUpdate } = render(); + + // It starts from a loading state + expect(result.current.isLoading).toEqual(true); + expect(result.current.isSuccess).toEqual(false); + expect(result.current.isError).toEqual(false); + + // When fetchRuleExecutionEvents returns + await waitForNextUpdate(); + + // It switches to a success state + expect(result.current.isLoading).toEqual(false); + expect(result.current.isSuccess).toEqual(true); + expect(result.current.isError).toEqual(false); + expect(result.current.data).toEqual([ + { + integration_name: 'audit', + integration_title: 'Audit Logs', + is_enabled: true, + package_name: 'atlassian_bitbucket', + package_title: 'Atlassian Bitbucket', + package_version: '1.0.1', + }, + { + is_enabled: true, + package_name: 'system', + package_title: 'System', + package_version: '1.6.4', + }, + ]); + }); + + // Skipping until we re-enable errors + it.skip('handles exceptions from the API', async () => { + const exception = new Error('Boom!'); + jest.spyOn(api, 'fetchInstalledIntegrations').mockRejectedValue(exception); + + const { result, waitForNextUpdate } = render(); + + // It starts from a loading state + expect(result.current.isLoading).toEqual(true); + expect(result.current.isSuccess).toEqual(false); + expect(result.current.isError).toEqual(false); + + // When fetchRuleExecutionEvents throws + await waitForNextUpdate(); + + // It switches to an error state + expect(result.current.isLoading).toEqual(false); + expect(result.current.isSuccess).toEqual(false); + expect(result.current.isError).toEqual(true); + expect(result.current.error).toEqual(exception); + + // And shows a toast with the caught exception + expect(useToasts().addError).toHaveBeenCalledTimes(1); + expect(useToasts().addError).toHaveBeenCalledWith(exception, { + title: 'Failed to fetch installed integrations', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx new file mode 100644 index 0000000000000..ec046c9fa662c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/use_installed_integrations.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from 'react-query'; +import { InstalledIntegrationArray } from '../../../../../common/detection_engine/schemas/common'; +import { fetchInstalledIntegrations } from '../../../containers/detection_engine/rules/api'; +// import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +// import * as i18n from './translations'; + +export interface UseInstalledIntegrationsArgs { + packages?: string[]; +} + +export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsArgs) => { + // const { addError } = useAppToasts(); + + return useQuery( + [ + 'installedIntegrations', + { + packages, + }, + ], + async ({ signal }) => { + const integrations = await fetchInstalledIntegrations({ + packages, + signal, + }); + return integrations.installed_integrations ?? []; + }, + { + keepPreviousData: true, + onError: (e) => { + // Suppressing for now to prevent excessive errors when fleet isn't configured + // addError(e, { title: i18n.INTEGRATIONS_FETCH_FAILURE }); + }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx new file mode 100644 index 0000000000000..0cce6f17691c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.test.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + integrationDetailsEnabled, + integrationDetailsInstalled, + integrationDetailsUninstalled, +} from './mock'; +import { render } from '@testing-library/react'; +import { getInstalledRelatedIntegrations, getIntegrationLink } from './utils'; + +describe('Related Integrations Utilities', () => { + describe('#getIntegrationLink', () => { + describe('it returns a correctly formatted integrations link', () => { + test('given an uninstalled integrationDetails', () => { + const link = getIntegrationLink(integrationDetailsUninstalled, 'http://localhost'); + const { container } = render(link); + + expect(container.firstChild).toHaveProperty( + 'href', + 'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration' + ); + }); + + test('given an installed integrationDetails', () => { + const link = getIntegrationLink(integrationDetailsInstalled, 'http://localhost'); + const { container } = render(link); + + expect(container.firstChild).toHaveProperty( + 'href', + 'http://localhost/app/integrations/detail/test-1.2.3/overview?integration=integration' + ); + }); + + test('given an enabled integrationDetails with an unsatisfied version', () => { + const link = getIntegrationLink(integrationDetailsEnabled, 'http://localhost'); + const { container } = render(link); + + expect(container.firstChild).toHaveProperty( + 'href', + 'http://localhost/app/integrations/detail/test-1.3.3/overview?integration=integration' + ); + }); + }); + }); + + describe('#getInstalledRelatedIntegrations', () => { + test('it returns a the correct integrationDetails', () => { + const integrationDetails = getInstalledRelatedIntegrations([], []); + + expect(integrationDetails.length).toEqual(0); + }); + + describe('version is correctly computed', () => { + test('Unknown integration that does not exist', () => { + const integrationDetails = getInstalledRelatedIntegrations( + [ + { + package: 'foo1', + version: '~1.2.3', + }, + { + package: 'foo2', + version: '^1.2.3', + }, + { + package: 'foo3', + version: '1.2.x', + }, + ], + [] + ); + + expect(integrationDetails[0].target_version).toEqual('1.2.3'); + expect(integrationDetails[1].target_version).toEqual('1.2.3'); + expect(integrationDetails[2].target_version).toEqual('1.2.0'); + }); + + test('Integration that is not installed', () => { + const integrationDetails = getInstalledRelatedIntegrations( + [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], + [] + ); + + expect(integrationDetails[0].target_version).toEqual('1.2.3'); + expect(integrationDetails[1].target_version).toEqual('1.2.3'); + }); + + test('Integration that is installed, and its version matches required version', () => { + const integrationDetails = getInstalledRelatedIntegrations( + [ + { + package: 'aws', + integration: 'route53', + version: '^1.2.3', + }, + { + package: 'system', + version: '~1.2.3', + }, + ], + [ + { + package_name: 'aws', + package_title: 'AWS', + package_version: '1.3.0', + integration_name: 'route53', + integration_title: 'AWS Route 53', + is_enabled: false, + }, + { + package_name: 'system', + package_title: 'System', + package_version: '1.2.5', + is_enabled: true, + }, + ] + ); + + // Since version is satisfied, we check `package_version` + expect(integrationDetails[0].version_satisfied).toEqual(true); + expect(integrationDetails[0].package_version).toEqual('1.3.0'); + expect(integrationDetails[1].version_satisfied).toEqual(true); + expect(integrationDetails[1].package_version).toEqual('1.2.5'); + }); + + test('Integration that is installed, and its version is less than required version', () => { + const integrationDetails = getInstalledRelatedIntegrations( + [ + { + package: 'aws', + integration: 'route53', + version: '~1.2.3', + }, + { + package: 'system', + version: '^1.2.3', + }, + ], + [ + { + package_name: 'aws', + package_title: 'AWS', + package_version: '1.2.0', + integration_name: 'route53', + integration_title: 'AWS Route 53', + is_enabled: false, + }, + { + package_name: 'system', + package_title: 'System', + package_version: '1.2.2', + is_enabled: true, + }, + ] + ); + + expect(integrationDetails[0].target_version).toEqual('1.2.3'); + expect(integrationDetails[1].target_version).toEqual('1.2.3'); + }); + + test('Integration that is installed, and its version is greater than required version', () => { + const integrationDetails = getInstalledRelatedIntegrations( + [ + { + package: 'aws', + integration: 'route53', + version: '^1.2.3', + }, + { + package: 'system', + version: '~1.2.3', + }, + ], + [ + { + package_name: 'aws', + package_title: 'AWS', + package_version: '2.0.1', + integration_name: 'route53', + integration_title: 'AWS Route 53', + is_enabled: false, + }, + { + package_name: 'system', + package_title: 'System', + package_version: '1.3.0', + is_enabled: true, + }, + ] + ); + + expect(integrationDetails[0].target_version).toEqual('1.2.3'); + expect(integrationDetails[1].target_version).toEqual('1.2.3'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx new file mode 100644 index 0000000000000..bc52c12f31c3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/related_integrations/utils.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiLink } from '@elastic/eui'; +import { capitalize } from 'lodash'; +import React from 'react'; +import semver from 'semver'; +import { + InstalledIntegration, + InstalledIntegrationArray, + RelatedIntegration, + RelatedIntegrationArray, +} from '../../../../../common/detection_engine/schemas/common'; + +/** + * Returns an `EuiLink` that will link to a given package/integration/version page within fleet + * + * @param integration IntegrationDetails describing a package/integrations installed state + * @param basePath kbn basepath for composing the fleet URL + */ +export const getIntegrationLink = (integration: IntegrationDetails, basePath: string) => { + const packageName = integration.package_name; + const integrationName = integration.integration_name; + const integrationTitle = integration.integration_title ?? integration.package_title; + const version = integration.version_satisfied + ? integration.package_version + : integration.target_version; + + const integrationURL = + version !== '' + ? `${basePath}/app/integrations/detail/${packageName}-${version}/overview${ + integrationName ? `?integration=${integrationName}` : '' + }` + : `${basePath}/app/integrations/detail/${packageName}`; + return ( + + {integrationTitle} + + ); +}; + +export interface IntegrationDetails extends InstalledIntegration { + target_version: string; + version_satisfied: boolean; + is_installed: boolean; +} + +/** + * Given an array of integrations and an array of installed integrations this will return an + * array of integrations augmented with install details like targetVersion, and `version_satisfied` + * has + * @param integrations + * @param installedIntegrations + */ +export const getInstalledRelatedIntegrations = ( + integrations: RelatedIntegrationArray, + installedIntegrations: InstalledIntegrationArray | undefined +): IntegrationDetails[] => { + const integrationDetails: IntegrationDetails[] = []; + + integrations.forEach((i: RelatedIntegration) => { + const match = installedIntegrations?.find( + (installed) => + installed.package_name === i.package && installed?.integration_name === i?.integration + ); + + if (match != null) { + // Version check e.g. fleet match `1.2.3` satisfies rule dependency `~1.2.1` + const versionSatisfied = semver.satisfies(match.package_version, i.version); + const packageVersion = versionSatisfied + ? i.version + : semver.valid(semver.coerce(i.version)) ?? ''; + integrationDetails.push({ + ...match, + target_version: packageVersion, + version_satisfied: versionSatisfied, + is_installed: true, + }); + } else { + const packageVersion = semver.valid(semver.coerce(i.version)) ?? ''; + // TODO: Add `title` to RelatedIntegration (or fetch from Fleet API) so we can accurately display the integration pretty name + const integrationTitle = + i.integration != null ? `${capitalize(i.package)} ${capitalize(i.integration)}` : undefined; + integrationDetails.push({ + package_name: i.package, + package_title: capitalize(i.package), + package_version: packageVersion, + integration_name: i.integration, + integration_title: integrationTitle, + target_version: packageVersion, + version_satisfied: false, + is_enabled: false, + is_installed: false, + }); + } + }); + + return integrationDetails.sort((a, b) => { + if (a.integration_title != null && b.integration_title != null) { + return a.integration_title.localeCompare(b.integration_title); + } else if (a.integration_title != null) { + return a.integration_title.localeCompare(b.package_title); + } else if (b.integration_title != null) { + return a.package_title.localeCompare(b.integration_title); + } else { + return a.package_title.localeCompare(b.package_title); + } + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index 59f79b294d7fa..4a31022a3b9ca 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -7,6 +7,7 @@ import { GetAggregateRuleExecutionEventsResponse, + GetInstalledIntegrationsResponse, RulesSchema, } from '../../../../../../common/detection_engine/schemas/response'; @@ -104,3 +105,30 @@ export const fetchRuleExecutionEvents = async ({ export const fetchTags = async ({ signal }: { signal: AbortSignal }): Promise => Promise.resolve(['elastic', 'love', 'quality', 'code']); + +export const fetchInstalledIntegrations = async ({ + packages, + signal, +}: { + packages?: string[]; + signal?: AbortSignal; +}): Promise => { + return Promise.resolve({ + installed_integrations: [ + { + package_name: 'atlassian_bitbucket', + package_title: 'Atlassian Bitbucket', + package_version: '1.0.1', + integration_name: 'audit', + integration_title: 'Audit Logs', + is_enabled: true, + }, + { + package_name: 'system', + package_title: 'System', + package_version: '1.6.4', + is_enabled: true, + }, + ], + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts index 89d6332c9caaa..5d2bac9e8b501 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts @@ -123,10 +123,3 @@ export const RULE_EXECUTION_EVENTS_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch rule execution events', } ); - -export const INSTALLED_INTEGRATIONS_FETCH_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.installedIntegrationsFetchFailDescription', - { - defaultMessage: 'Failed to fetch installed integrations', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_installed_integrations.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_installed_integrations.tsx deleted file mode 100644 index caf46ddc8f210..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_installed_integrations.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useQuery } from 'react-query'; -import { RelatedIntegrationArray } from '../../../../../common/detection_engine/schemas/common'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import * as i18n from './translations'; - -export interface UseInstalledIntegrationsArgs { - packages?: string[]; -} - -export const useInstalledIntegrations = ({ packages }: UseInstalledIntegrationsArgs) => { - const { addError } = useAppToasts(); - - // TODO: Once API is merged update return type: - // See: https://github.com/elastic/kibana/pull/132667/files#diff-f9d9583d37123ed28fd08fc153eb06026e7ee0c3241364656fb707dcbc0a4872R58-R65 - return useQuery( - [ - 'installedIntegrations', - { - packages, - }, - ], - async ({ signal }) => { - return undefined; - - // Mock data -- uncomment to test full UI - // const mockInstalledIntegrations = [ - // { - // package: 'system', - // version: '1.7.4', - // }, - // // { - // // package: 'aws', - // // integration: 'cloudtrail', - // // version: '1.11.0', - // // }, - // ]; - // return mockInstalledIntegrations; - - // Or fetch from new API - // return fetchInstalledIntegrations({ - // packages, - // signal, - // }); - }, - { - keepPreviousData: true, - onError: (e) => { - addError(e, { title: i18n.INSTALLED_INTEGRATIONS_FETCH_FAILURE }); - }, - } - ); -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx index 602ff0eee932e..d46b0878b309a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/use_columns.tsx @@ -15,11 +15,12 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useMemo } from 'react'; -import { IntegrationsPopover } from '../../../../../common/components/integrations_popover'; +import { IntegrationsPopover } from '../../../../components/rules/related_integrations/integrations_popover'; import { APP_UI_ID, DEFAULT_RELATIVE_DATE_THRESHOLD, SecurityPageName, + SHOW_RELATED_INTEGRATIONS_SETTING, } from '../../../../../../common/constants'; import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { getEmptyTagValue } from '../../../../../common/components/empty_value'; @@ -28,7 +29,7 @@ import { LinkAnchor } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { PopoverItems } from '../../../../../common/components/popover_items'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; import { canEditRuleWithActions, getToolTipContent } from '../../../../../common/utils/privileges'; import { RuleSwitch } from '../../../../components/rules/rule_switch'; import { SeverityBadge } from '../../../../components/rules/severity_badge'; @@ -163,7 +164,7 @@ const INTEGRATIONS_COLUMN: TableColumn = { name: null, align: 'center', render: (integrations: Rule['related_integrations']) => { - if (integrations?.length === 0) { + if (integrations == null || integrations.length === 0) { return null; } @@ -201,11 +202,12 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] const enabledColumn = useEnabledColumn({ hasPermissions }); const ruleNameColumn = useRuleNameColumn(); const { isInMemorySorting } = useRulesTableContext().state; + const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); return useMemo( () => [ ruleNameColumn, - INTEGRATIONS_COLUMN, + ...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []), TAGS_COLUMN, { field: 'risk_score', @@ -294,7 +296,14 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] enabledColumn, ...(hasPermissions ? [actionsColumn] : []), ], - [actionsColumn, enabledColumn, hasPermissions, isInMemorySorting, ruleNameColumn] + [ + actionsColumn, + enabledColumn, + hasPermissions, + isInMemorySorting, + ruleNameColumn, + showRelatedIntegrations, + ] ); }; @@ -304,6 +313,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol const enabledColumn = useEnabledColumn({ hasPermissions }); const ruleNameColumn = useRuleNameColumn(); const { isInMemorySorting } = useRulesTableContext().state; + const [showRelatedIntegrations] = useUiSetting$(SHOW_RELATED_INTEGRATIONS_SETTING); return useMemo( () => [ @@ -311,7 +321,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol ...ruleNameColumn, width: '28%', }, - INTEGRATIONS_COLUMN, + ...(showRelatedIntegrations ? [INTEGRATIONS_COLUMN] : []), TAGS_COLUMN, { field: 'execution_summary.last_execution.metrics.total_indexing_duration_ms', @@ -426,6 +436,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol hasPermissions, isInMemorySorting, ruleNameColumn, + showRelatedIntegrations, ] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 7d5a9db2d6842..faaa1a95016f4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -1085,53 +1085,3 @@ export const RULES_BULK_EDIT_FAILURE_DESCRIPTION = (rulesCount: number) => defaultMessage: '{rulesCount, plural, =1 {# rule} other {# rules}} failed to update.', } ); - -export const INTEGRATIONS_BADGE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.integrations.badgeTitle', - { - defaultMessage: 'integrations', - } -); - -export const INTEGRATIONS_POPOVER_TITLE = (integrationsCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverTitle', - { - values: { integrationsCount }, - defaultMessage: - 'You have [{integrationsCount}] related {integrationsCount, plural, =1 {integration} other {integrations}} to your prebuilt rule', - } - ); - -export const INTEGRATIONS_POPOVER_DESCRIPTION_INSTALLED = (installedCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionInstalledTitle', - { - values: { installedCount }, - defaultMessage: - 'You have [{installedCount}] related {installedCount, plural, =1 {integration} other {integrations}} installed, click the link below to view the integration:', - } - ); - -export const INTEGRATIONS_POPOVER_DESCRIPTION_UNINSTALLED = (uninstalledCount: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionUninstalledTitle', - { - values: { uninstalledCount }, - defaultMessage: - 'You have [{uninstalledCount}] related {uninstalledCount, plural, =1 {integration} other {integrations}} uninstalled, click the link to add integration:', - } - ); - -export const INTEGRATIONS_INSTALLED_VERSION_TOOLTIP = ( - installedVersion: string, - targetVersion: string -) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.integrations.popoverDescriptionInstalledVersionTooltip', - { - values: { installedVersion, targetVersion }, - defaultMessage: - 'Version mismatch -- please resolve! Installed version `{installedVersion}` when target version `{targetVersion}`', - } - ); diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 592837c2e20dd..b2a86bc3ef058 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -32,6 +32,7 @@ import { NEWS_FEED_URL_SETTING, NEWS_FEED_URL_SETTING_DEFAULT, ENABLE_CCS_READ_WARNING_SETTING, + SHOW_RELATED_INTEGRATIONS_SETTING, } from '../common/constants'; import { ExperimentalFeatures } from '../common/experimental_features'; @@ -186,7 +187,7 @@ export const initUiSettings = ( 'xpack.securitySolution.uiSettings.rulesTableRefreshDescription', { defaultMessage: - '

Enables auto refresh on the all rules and monitoring tables, in milliseconds

', + '

Enables auto refresh on the rules and monitoring tables, in milliseconds

', } ), type: 'json', @@ -250,6 +251,22 @@ export const initUiSettings = ( requiresPageReload: false, schema: schema.boolean(), }, + [SHOW_RELATED_INTEGRATIONS_SETTING]: { + name: i18n.translate('xpack.securitySolution.uiSettings.showRelatedIntegrationsLabel', { + defaultMessage: 'Related integrations', + }), + value: true, + description: i18n.translate( + 'xpack.securitySolution.uiSettings.showRelatedIntegrationsDescription', + { + defaultMessage: '

Shows related integrations on the rules and monitoring tables

', + } + ), + type: 'boolean', + category: [APP_ID], + requiresPageReload: true, + schema: schema.boolean(), + }, }; uiSettings.register(orderSettings(securityUiSettings));