diff --git a/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap b/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap index 7328e2c61b961..acb1f152e738b 100644 --- a/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/packages/kbn-react-field/src/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -135,6 +135,16 @@ exports[`FieldIcon renders known field types keyword is rendered 1`] = ` /> `; +exports[`FieldIcon renders known field types match_only_text is rendered 1`] = ` + +`; + exports[`FieldIcon renders known field types murmur3 is rendered 1`] = ` { | 'geo_shape' | 'ip' | 'ip_range' + | 'match_only_text' | 'murmur3' | 'number' | 'number_range' @@ -45,6 +46,7 @@ export const typeToEuiIconMap: Partial> = { geo_shape: { iconType: 'tokenGeo' }, ip: { iconType: 'tokenIP' }, ip_range: { iconType: 'tokenIP' }, + match_only_text: { iconType: 'tokenString' }, // is a plugin's data type https://www.elastic.co/guide/en/elasticsearch/plugins/current/mapper-murmur3-usage.html murmur3: { iconType: 'tokenSearchType' }, number: { iconType: 'tokenNumber' }, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index f8c159241d00e..35da825087b15 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -258,6 +258,8 @@ export const DETECTION_ENGINE_RULE_EXECUTION_EVENTS_URL = `${INTERNAL_DETECTION_ENGINE_URL}/rules/{ruleId}/execution/events` as const; export const detectionEngineRuleExecutionEventsUrl = (ruleId: string) => `${INTERNAL_DETECTION_ENGINE_URL}/rules/${ruleId}/execution/events` as const; +export const DETECTION_ENGINE_INSTALLED_INTEGRATIONS_URL = + `${INTERNAL_DETECTION_ENGINE_URL}/fleet/integrations/installed` as const; /** * Telemetry detection endpoint for any previews requested of what data we are 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 new file mode 100644 index 0000000000000..65854b0cc1fb5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/integrations_popover/helpers.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 + * 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 new file mode 100644 index 0000000000000..8574a96ed9516 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/integrations_popover/index.tsx @@ -0,0 +1,143 @@ +/* + * 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/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap index 962f6f1a8dc42..4c0a74d3426a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/__snapshots__/jobs_table.test.tsx.snap @@ -11,7 +11,7 @@ exports[`JobsTableComponent renders correctly against snapshot 1`] = ` Object { "name": "Groups", "render": [Function], - "width": "140px", + "width": "80px", }, Object { "align": "center", diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx index 842b199e4b7aa..ab86093243d5c 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.tsx @@ -13,8 +13,6 @@ import { EuiBasicTable, EuiButton, EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, EuiIcon, EuiLink, EuiText, @@ -22,6 +20,7 @@ import { import styled from 'styled-components'; import { useMlHref, ML_PAGES } from '@kbn/ml-plugin/public'; +import { PopoverItems } from '../../popover_items'; import { useBasePath, useKibana } from '../../../lib/kibana'; import * as i18n from './translations'; import { JobSwitch } from './job_switch'; @@ -82,16 +81,24 @@ const getJobsTableColumns = ( }, { name: i18n.COLUMN_GROUPS, - render: ({ groups }: SecurityJob) => ( - - {groups.map((group) => ( - - {group} - - ))} - - ), - width: '140px', + render: ({ groups }: SecurityJob) => { + const renderItem = (group: string, i: number) => ( + + {group} + + ); + + return ( + + ); + }, + width: '80px', }, { diff --git a/x-pack/plugins/security_solution/public/common/components/popover_items/index.tsx b/x-pack/plugins/security_solution/public/common/components/popover_items/index.tsx index d0c806e7cae98..bd4bce2ded380 100644 --- a/x-pack/plugins/security_solution/public/common/components/popover_items/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/popover_items/index.tsx @@ -76,9 +76,11 @@ const PopoverItemsComponent = ({ return ( - - - + {numberOfItemsToDisplay !== 0 && ( + + + + )} theme.eui.euiFontSizeXS}; + font-family: ${({ theme }) => theme.eui.euiCodeFontFamily}; + display: inline; +`; + +export const buildRequiredFieldsDescription = ( + label: string, + requiredFields: RequiredFieldArray +): ListItems[] => { + if (requiredFields == null) { + return []; + } + + return [ + { + title: label, + description: ( + + {requiredFields.map((rF, index) => ( + + + + + + + + {` ${rF.name}${index + 1 !== requiredFields.length ? ', ' : ''}`} + + + + + ))} + + ), + }, + ]; +}; 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 c72b96242df07..af3f739fec044 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,6 +13,11 @@ 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 type { + RelatedIntegrationArray, + RequiredFieldArray, +} from '../../../../../common/detection_engine/schemas/common'; import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useKibana } from '../../../../common/lib/kibana'; import { AboutStepRiskScore, AboutStepSeverity } from '../../../pages/detection_engine/rules/types'; @@ -31,6 +36,7 @@ import { buildRuleTypeDescription, buildThresholdDescription, buildThreatMappingDescription, + buildRequiredFieldsDescription, } from './helpers'; import { buildMlJobsDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; @@ -151,7 +157,7 @@ export const addFilterStateIfNotThere = (filters: Filter[]): Filter[] => { }); }; -/* eslint complexity: ["error", 21]*/ +/* eslint complexity: ["error", 25]*/ export const getDescriptionItem = ( field: string, label: string, @@ -183,15 +189,18 @@ export const getDescriptionItem = ( } else if (field === 'falsePositives') { const values: string[] = get(field, data); return buildUnorderedListArrayDescription(label, field, values); - } else if (Array.isArray(get(field, data)) && field !== 'threatMapping') { - const values: string[] = get(field, data); - return buildStringArrayDescription(label, field, values); } else if (field === 'riskScore') { const values: AboutStepRiskScore = get(field, data); return buildRiskScoreDescription(values); } else if (field === 'severity') { const values: AboutStepSeverity = get(field, data); return buildSeverityDescription(values); + } else if (field === 'requiredFields') { + const requiredFields: RequiredFieldArray = get(field, data); + return buildRequiredFieldsDescription(label, requiredFields); + } else if (field === 'relatedIntegrations') { + const relatedIntegrations: RelatedIntegrationArray = get(field, data); + return buildRelatedIntegrationsDescription(label, relatedIntegrations); } else if (field === 'timeline') { const timeline = get(field, data) as FieldValueTimeline; return [ @@ -224,6 +233,9 @@ export const getDescriptionItem = ( } else if (field === 'threatMapping') { const threatMap: ThreatMapping = get(field, data); return buildThreatMappingDescription(label, threatMap); + } else if (Array.isArray(get(field, data)) && field !== 'threatMapping') { + const values: string[] = get(field, data); + return buildStringArrayDescription(label, field, values); } const description: string = get(field, data); 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 new file mode 100644 index 0000000000000..3e07ccd17dfa8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/required_integrations_description.tsx @@ -0,0 +1,95 @@ +/* + * 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 1b0d906528b95..949d5b32e88de 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 @@ -97,3 +97,17 @@ export const THRESHOLD_RESULTS_AGGREGATED_BY = i18n.translate( defaultMessage: 'Results aggregated by', } ); + +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/step_about_rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx index d2003c411f05c..8164c5099db4a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx @@ -37,6 +37,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: mockRule.note, description: mockRule.description, + setup: '', }} stepData={mockRule} /> @@ -61,6 +62,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: '', description: '', + setup: '', }} stepData={null} /> @@ -81,6 +83,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: '', description: mockRule.description, + setup: '', }} stepData={mockAboutStepWithoutNote} /> @@ -88,6 +91,7 @@ describe('StepAboutRuleToggleDetails', () => { expect(wrapper.find('[data-test-subj="stepAboutDetailsToggle"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="stepAboutDetailsNoteContent"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="stepAboutDetailsSetupContent"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="stepAboutDetailsContent"]').exists()).toBeTruthy(); }); }); @@ -101,6 +105,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: mockRule.note, description: mockRule.description, + setup: '', }} stepData={mockRule} /> @@ -120,6 +125,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: mockRule.note, description: mockRule.description, + setup: '', }} stepData={mockRule} /> @@ -147,6 +153,7 @@ describe('StepAboutRuleToggleDetails', () => { stepDataDetails={{ note: mockRule.note, description: mockRule.description, + setup: '', }} stepData={mockRule} /> @@ -165,4 +172,107 @@ describe('StepAboutRuleToggleDetails', () => { ); }); }); + + describe('setup value is empty string', () => { + test('it does render toggle buttons if note is not empty', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(EuiButtonGroup).exists()).toBeTruthy(); + expect(wrapper.find('#details').at(0).prop('isSelected')).toBeTruthy(); + expect(wrapper.find('#notes').at(0).prop('isSelected')).toBeFalsy(); + expect(wrapper.find('[data-test-subj="stepAboutDetailsSetupContent"]').exists()).toBeFalsy(); + }); + }); + + describe('setup value does exist', () => { + test('it renders toggle buttons, defaulted to "details"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(EuiButtonGroup).exists()).toBeTruthy(); + expect(wrapper.find('#details').at(0).prop('isSelected')).toBeTruthy(); + expect(wrapper.find('#notes').at(0).prop('isSelected')).toBeFalsy(); + expect(wrapper.find('#setup').at(0).prop('isSelected')).toBeFalsy(); + }); + + test('it allows users to toggle between "details" and "setup"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[idSelected="details"]').exists()).toBeTruthy(); + expect(wrapper.find('[idSelected="notes"]').exists()).toBeFalsy(); + expect(wrapper.find('[idSelected="setup"]').exists()).toBeFalsy(); + + wrapper + .find('[title="Setup guide"]') + .at(0) + .find('input') + .simulate('change', { target: { value: 'setup' } }); + + expect(wrapper.find('[idSelected="details"]').exists()).toBeFalsy(); + expect(wrapper.find('[idSelected="notes"]').exists()).toBeFalsy(); + expect(wrapper.find('[idSelected="setup"]').exists()).toBeTruthy(); + }); + + test('it displays notes markdown when user toggles to "setup"', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[title="Setup guide"]') + .at(0) + .find('input') + .simulate('change', { target: { value: 'setup' } }); + + expect(wrapper.find('EuiButtonGroup[idSelected="setup"]').exists()).toBeTruthy(); + expect(wrapper.find('div.euiMarkdownFormat').text()).toEqual( + 'this is some markdown documentation' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx index f400887f43927..439e981059a6b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx @@ -16,9 +16,9 @@ import { EuiFlexGroup, EuiResizeObserver, } from '@elastic/eui'; -import React, { memo, useCallback, useState } from 'react'; +import { isEmpty } from 'lodash'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { isEmpty } from 'lodash/fp'; import { HeaderSection } from '../../../../common/components/header_section'; import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; @@ -48,18 +48,21 @@ const AboutContent = styled.div` height: 100%; `; -const toggleOptions: EuiButtonGroupOptionProps[] = [ - { - id: 'details', - label: i18n.ABOUT_PANEL_DETAILS_TAB, - 'data-test-subj': 'stepAboutDetailsToggle-details', - }, - { - id: 'notes', - label: i18n.ABOUT_PANEL_NOTES_TAB, - 'data-test-subj': 'stepAboutDetailsToggle-notes', - }, -]; +const detailsOption: EuiButtonGroupOptionProps = { + id: 'details', + label: i18n.ABOUT_PANEL_DETAILS_TAB, + 'data-test-subj': 'stepAboutDetailsToggle-details', +}; +const notesOption: EuiButtonGroupOptionProps = { + id: 'notes', + label: i18n.ABOUT_PANEL_NOTES_TAB, + 'data-test-subj': 'stepAboutDetailsToggle-notes', +}; +const setupOption: EuiButtonGroupOptionProps = { + id: 'setup', + label: i18n.ABOUT_PANEL_SETUP_TAB, + 'data-test-subj': 'stepAboutDetailsToggle-setup', +}; interface StepPanelProps { stepData: AboutStepRule | null; @@ -82,6 +85,16 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ [setAboutPanelHeight] ); + const toggleOptions: EuiButtonGroupOptionProps[] = useMemo(() => { + const notesExist = !isEmpty(stepDataDetails?.note) && stepDataDetails?.note.trim() !== ''; + const setupExists = !isEmpty(stepDataDetails?.setup) && stepDataDetails?.setup.trim() !== ''; + return [ + ...(notesExist || setupExists ? [detailsOption] : []), + ...(notesExist ? [notesOption] : []), + ...(setupExists ? [setupOption] : []), + ]; + }, [stepDataDetails]); + return ( {loading && ( @@ -94,7 +107,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ - {!isEmpty(stepDataDetails.note) && stepDataDetails.note.trim() !== '' && ( + {toggleOptions.length > 0 && ( = ({ - {selectedToggleOption === 'details' ? ( + {selectedToggleOption === 'details' && ( {(resizeRef) => ( @@ -132,7 +145,8 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ )} - ) : ( + )} + {selectedToggleOption === 'notes' && ( = ({ )} + {selectedToggleOption === 'setup' && ( + + + {stepDataDetails.setup} + + + )} )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/translations.ts index e479cad3151a5..40a30dffb6021 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/translations.ts @@ -28,6 +28,13 @@ export const ABOUT_PANEL_NOTES_TAB = i18n.translate( } ); +export const ABOUT_PANEL_SETUP_TAB = i18n.translate( + 'xpack.securitySolution.detectionEngine.details.stepAboutRule.setupGuideLabel', + { + defaultMessage: 'Setup guide', + } +); + export const ABOUT_CONTROL_LEGEND = i18n.translate( 'xpack.securitySolution.detectionEngine.details.stepAboutRule.controlLegend', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 2113af02d0d06..21074226d92c6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -80,6 +80,8 @@ export const stepDefineDefaultValue: DefineStepRule = { filters: [], saved_id: undefined, }, + requiredFields: [], + relatedIntegrations: [], threatMapping: [], threshold: { field: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index a2018280bebc6..f066b5dd97fce 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -175,6 +175,34 @@ export const schema: FormSchema = { }, ], }, + relatedIntegrations: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsLabel', + { + defaultMessage: 'Related integrations', + } + ), + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRelatedIntegrationsHelpText', + { + defaultMessage: 'Integration related to this Rule.', + } + ), + }, + requiredFields: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsLabel', + { + defaultMessage: 'Required fields', + } + ), + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldRequiredFieldsHelpText', + { + defaultMessage: 'Fields required for this Rule to function.', + } + ), + }, timeline: { label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 220926ebc1722..177b4daf9fdfd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -18,10 +18,12 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_PREVIEW, detectionEngineRuleExecutionEventsUrl, + DETECTION_ENGINE_INSTALLED_INTEGRATIONS_URL, } from '../../../../../common/constants'; import { AggregateRuleExecutionEvent, BulkAction, + RelatedIntegrationArray, RuleExecutionStatus, } from '../../../../../common/detection_engine/schemas/common'; import { @@ -408,3 +410,29 @@ export const getPrePackagedRulesStatus = async ({ signal, } ); + +/** + * Fetch all installed integrations + * + * @param packages array of packages to filter for + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const fetchInstalledIntegrations = async ({ + packages, + signal, +}: { + packages?: string[]; + signal?: AbortSignal; +}): Promise => + KibanaServices.get().http.fetch( + DETECTION_ENGINE_INSTALLED_INTEGRATIONS_URL, + { + method: 'GET', + query: { + packages: packages?.sort()?.join(','), + }, + signal, + } + ); 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 5d2bac9e8b501..89d6332c9caaa 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,3 +123,10 @@ 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 new file mode 100644 index 0000000000000..caf46ddc8f210 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_installed_integrations.tsx @@ -0,0 +1,59 @@ +/* + * 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/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index d9f16242a544a..ddc651b696dde 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -195,6 +195,8 @@ export const mockDefineStepRule = (): DefineStepRule => ({ index: ['filebeat-'], queryBar: mockQueryBar, threatQueryBar: mockQueryBar, + requiredFields: [], + relatedIntegrations: [], threatMapping: [], timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', 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 a6891af1cc7da..c28776d97d683 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,6 +15,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useMemo } from 'react'; +import { IntegrationsPopover } from '../../../../../common/components/integrations_popover'; import { APP_UI_ID, DEFAULT_RELATIVE_DATE_THRESHOLD, @@ -157,6 +158,21 @@ const TAGS_COLUMN: TableColumn = { truncateText: true, }; +const INTEGRATIONS_COLUMN: TableColumn = { + field: 'related_integrations', + name: null, + align: 'center', + render: (integrations: Rule['related_integrations']) => { + if (integrations?.length === 0) { + return null; + } + + return ; + }, + width: '143px', + truncateText: true, +}; + const useActionsColumn = (): EuiTableActionsColumnType => { const { navigateToApp } = useKibana().services.application; const hasActionsPrivileges = useHasActionsPrivileges(); @@ -187,6 +203,7 @@ export const useRulesColumns = ({ hasPermissions }: ColumnsProps): TableColumn[] return useMemo( () => [ ruleNameColumn, + INTEGRATIONS_COLUMN, TAGS_COLUMN, { field: 'risk_score', @@ -292,6 +309,7 @@ export const useMonitoringColumns = ({ hasPermissions }: ColumnsProps): TableCol ...ruleNameColumn, width: '28%', }, + INTEGRATIONS_COLUMN, TAGS_COLUMN, { field: 'execution_summary.last_execution.metrics.total_indexing_duration_ms', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 166395673e726..5f32f7f4b4600 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -81,6 +81,8 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + relatedIntegrations: [], + requiredFields: [], threshold: { field: ['host.name'], value: '50', @@ -131,6 +133,7 @@ describe('rule helpers', () => { const aboutRuleDataDetailsData = { note: '# this is some markdown documentation', description: '24/7', + setup: '', }; expect(defineRuleData).toEqual(defineRuleStepData); @@ -214,6 +217,8 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + relatedIntegrations: [], + requiredFields: [], threshold: { field: [], value: '100', @@ -256,6 +261,8 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + relatedIntegrations: [], + requiredFields: [], threshold: { field: [], value: '100', @@ -388,6 +395,7 @@ describe('rule helpers', () => { const aboutRuleDataDetailsData = { note: '# this is some markdown documentation', description: '24/7', + setup: '', }; expect(result).toEqual(aboutRuleDataDetailsData); @@ -397,7 +405,11 @@ describe('rule helpers', () => { const { note, ...mockRuleWithoutNote } = { ...mockRuleWithEverything('test-id') }; const result: AboutStepRuleDetails = getModifiedAboutDetailsData(mockRuleWithoutNote); - const aboutRuleDetailsData = { note: '', description: mockRuleWithoutNote.description }; + const aboutRuleDetailsData = { + note: '', + description: mockRuleWithoutNote.description, + setup: '', + }; expect(result).toEqual(aboutRuleDetailsData); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index a8037ea9216a3..7e446416cfaa9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -95,6 +95,8 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ filters: (rule.filters ?? []) as Filter[], saved_id: rule.saved_id, }, + relatedIntegrations: rule.related_integrations ?? [], + requiredFields: rule.required_fields ?? [], timeline: { id: rule.timeline_id ?? null, title: rule.timeline_title ?? null, @@ -227,6 +229,7 @@ export const determineDetailsValue = ( export const getModifiedAboutDetailsData = (rule: Rule): AboutStepRuleDetails => ({ note: rule.note ?? '', description: rule.description, + setup: rule.setup ?? '', }); export const useQuery = () => new URLSearchParams(useLocation().search); 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 7071ef95c8c7e..e5e005f8d6b9b 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,3 +1085,53 @@ 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/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index e94365da0a3a7..5c962ed9e7960 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -21,14 +21,17 @@ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; import { FieldValueThreshold } from '../../../components/rules/threshold_input'; -import { +import type { Author, BuildingBlockType, License, + RelatedIntegrationArray, + RequiredFieldArray, RuleNameOverride, SortOrder, + SetupGuide, TimestampOverride, -} from '../../../../../common/detection_engine/schemas/common/schemas'; +} from '../../../../../common/detection_engine/schemas/common'; export interface EuiBasicTableSortTypes { field: string; @@ -109,6 +112,7 @@ export interface AboutStepRule { export interface AboutStepRuleDetails { note: string; description: string; + setup: SetupGuide; } export interface AboutStepSeverity { @@ -128,6 +132,8 @@ export interface DefineStepRule { index: string[]; machineLearningJobId: string[]; queryBar: FieldValueQueryBar; + relatedIntegrations: RelatedIntegrationArray; + requiredFields: RequiredFieldArray; ruleType: Type; timeline: FieldValueTimeline; threshold: FieldValueThreshold;