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;