diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/rule_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/rule_tab.tsx index 63822a185fe312..9b7b400a581964 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/rule_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/rule_tab.tsx @@ -8,10 +8,16 @@ import { EuiBadge, EuiDescriptionList, EuiLink, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; import { CspFinding } from '../../../../common/schemas/csp_finding'; +import { RulesDetectionRuleCounter } from '../../rules/rules_detection_rule_counter'; import { CisKubernetesIcons, CspFlyoutMarkdown } from './findings_flyout'; -export const getRuleList = (rule: CspFinding['rule'], ruleFlyoutLink?: string) => [ +export const getRuleList = ( + rule: CspFinding['rule'], + ruleState = 'unmuted', + ruleFlyoutLink?: string +) => [ { title: i18n.translate('xpack.csp.findings.findingsFlyout.ruleTab.nameTitle', { defaultMessage: 'Name', @@ -35,6 +41,20 @@ export const getRuleList = (rule: CspFinding['rule'], ruleFlyoutLink?: string) = }), description: {rule.description}, }, + { + title: i18n.translate('xpack.csp.findings.findingsFlyout.ruleTab.AlertsTitle', { + defaultMessage: 'Alerts', + }), + description: + ruleState === 'unmuted' ? ( + + ) : ( + + ), + }, { title: i18n.translate('xpack.csp.findings.findingsFlyout.ruleTab.tagsTitle', { defaultMessage: 'Tags', diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts new file mode 100644 index 00000000000000..f445761ed84473 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts @@ -0,0 +1,103 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { CspBenchmarkRule } from '../../../../common/types/latest'; +import { + FINDINGS_INDEX_PATTERN, + LATEST_FINDINGS_RETENTION_POLICY, +} from '../../../../common/constants'; +import { createDetectionRule } from '../../../common/api/create_detection_rule'; +import { generateBenchmarkRuleTags } from '../../../../common/utils/detection_rules'; + +const DEFAULT_RULE_RISK_SCORE = 0; +const DEFAULT_RULE_SEVERITY = 'low'; +const DEFAULT_RULE_ENABLED = true; +const DEFAULT_RULE_AUTHOR = 'Elastic'; +const DEFAULT_RULE_LICENSE = 'Elastic License v2'; +const DEFAULT_MAX_ALERTS_PER_RULE = 100; +const ALERT_SUPPRESSION_FIELD = 'resource.id'; +const ALERT_TIMESTAMP_FIELD = 'event.ingested'; +const DEFAULT_INVESTIGATION_FIELDS = { + field_names: ['resource.name', 'resource.id', 'resource.type', 'resource.sub_type'], +}; + +enum AlertSuppressionMissingFieldsStrategy { + // per each document a separate alert will be created + DoNotSuppress = 'doNotSuppress', + // only one alert will be created per suppress by bucket + Suppress = 'suppress', +} + +const convertReferencesLinksToArray = (input: string | undefined) => { + if (!input) { + return []; + } + // Match all URLs in the input string using a regular expression + const matches = input.match(/(https?:\/\/\S+)/g); + + if (!matches) { + return []; + } + + // Remove the numbers and new lines + return matches.map((link) => link.replace(/^\d+\. /, '').replace(/\n/g, '')); +}; + +const generateFindingsRuleQuery = (benchmarkRule: CspBenchmarkRule['metadata']) => { + const currentTimestamp = new Date().toISOString(); + + return `rule.benchmark.rule_number: "${benchmarkRule.benchmark.rule_number}" + AND rule.benchmark.id: "${benchmarkRule.benchmark.id}" + AND result.evaluation: "failed" + AND event.ingested >= "${currentTimestamp}"`; +}; + +/* + * Creates a detection rule from a Benchmark rule + */ +export const createDetectionRuleFromBenchmark = async ( + http: HttpSetup, + benchmarkRule: CspBenchmarkRule['metadata'] +) => { + return await createDetectionRule({ + http, + rule: { + type: 'query', + language: 'kuery', + license: DEFAULT_RULE_LICENSE, + author: [DEFAULT_RULE_AUTHOR], + filters: [], + false_positives: [], + risk_score: DEFAULT_RULE_RISK_SCORE, + risk_score_mapping: [], + severity: DEFAULT_RULE_SEVERITY, + severity_mapping: [], + threat: [], + interval: '1h', + from: `now-${LATEST_FINDINGS_RETENTION_POLICY}`, + to: 'now', + max_signals: DEFAULT_MAX_ALERTS_PER_RULE, + timestamp_override: ALERT_TIMESTAMP_FIELD, + timestamp_override_fallback_disabled: false, + actions: [], + enabled: DEFAULT_RULE_ENABLED, + alert_suppression: { + group_by: [ALERT_SUPPRESSION_FIELD], + missing_fields_strategy: AlertSuppressionMissingFieldsStrategy.Suppress, + }, + index: [FINDINGS_INDEX_PATTERN], + query: generateFindingsRuleQuery(benchmarkRule), + references: convertReferencesLinksToArray(benchmarkRule.references), + name: benchmarkRule.name, + description: benchmarkRule.rationale, + tags: generateBenchmarkRuleTags(benchmarkRule), + investigation_fields: DEFAULT_INVESTIGATION_FIELDS, + note: benchmarkRule.remediation, + }, + }); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_detection_rule_counter.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_detection_rule_counter.tsx new file mode 100644 index 00000000000000..04b6a4ab835977 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_detection_rule_counter.tsx @@ -0,0 +1,29 @@ +/* + * 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 type { HttpSetup } from '@kbn/core/public'; +import React from 'react'; +import { CspBenchmarkRule } from '../../../common/types/latest'; +import { getFindingsDetectionRuleSearchTags } from '../../../common/utils/detection_rules'; +import { DetectionRuleCounter } from '../../components/detection_rule_counter'; +import { createDetectionRuleFromBenchmark } from '../configurations/utils/create_detection_rule_from_benchmark'; + +export const RulesDetectionRuleCounter = ({ + benchmarkRule, +}: { + benchmarkRule: CspBenchmarkRule['metadata']; +}) => { + const createBenchmarkRuleFn = async (http: HttpSetup) => + await createDetectionRuleFromBenchmark(http, benchmarkRule); + + return ( + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx index 33215bf80d30bc..333f958de6fb1d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/rules_flyout.tsx @@ -128,10 +128,25 @@ export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProp ); }; -const getRuleStateSwitch = ( - rule: CspBenchmarkRulesWithStates, - switchRuleStates: () => Promise -) => [ +const RuleOverviewTab = ({ + rule, + ruleData, + switchRuleStates, +}: { + rule: CspBenchmarkRuleMetadata; + ruleData: CspBenchmarkRulesWithStates; + switchRuleStates: () => Promise; +}) => ( + + + + + +); + +const ruleState = (rule: CspBenchmarkRulesWithStates, switchRuleStates: () => Promise) => [ { title: ( @@ -172,21 +187,3 @@ const getRuleStateSwitch = ( ), }, ]; - -const RuleOverviewTab = ({ - rule, - ruleData, - switchRuleStates, -}: { - rule: CspBenchmarkRuleMetadata; - ruleData: CspBenchmarkRulesWithStates; - switchRuleStates: () => Promise; -}) => ( - - - - - -); diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/rule_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/rule_page.ts index 8ab9afd818146f..3cf0810c7a03ee 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/rule_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/rule_page.ts @@ -181,6 +181,10 @@ export function RulePagePageProvider({ getService, getPageObjects }: FtrProvider const disabledRulesButton = await testSubjects.find('rules-counters-disabled-rules-button'); await disabledRulesButton.click(); }, + + doesElementExist: async (selector: string) => { + return await testSubjects.exists(selector); + }, }; const navigateToRulePage = async (benchmarkCisId: string, benchmarkCisVersion: string) => { diff --git a/x-pack/test/cloud_security_posture_functional/pages/rules.ts b/x-pack/test/cloud_security_posture_functional/pages/rules.ts index 81629da46439c6..b734c391bf27c7 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/rules.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/rules.ts @@ -28,8 +28,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'findings', ]); - // Failing: See https://github.com/elastic/kibana/issues/175905 - describe.skip('Cloud Posture Rules Page', function () { + describe('Cloud Posture Rules Page', function () { this.tags(['cloud_security_posture_rules_page']); let rule: typeof pageObjects.rule; let findings: typeof pageObjects.findings; @@ -72,9 +71,81 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await findings.index.remove(); }); - // FLAKY: https://github.com/elastic/kibana/issues/175614 - describe.skip('Rules Page - Bulk Action buttons', () => { - it('It should disable both Enable and Disable options when there are no rules selected', async () => { + describe('Rules Page - Rules Counters', () => { + it('Shows posture score when there are findings', async () => { + const isEmptyStateVisible = await rule.rulePage.getCountersEmptyState(); + expect(isEmptyStateVisible).to.be(false); + + const postureScoreCounter = await rule.rulePage.getPostureScoreCounter(); + expect((await postureScoreCounter.getVisibleText()).includes('33%')).to.be(true); + }); + + it('Clicking the posture score button leads to the dashboard', async () => { + await rule.rulePage.clickPostureScoreButton(); + await pageObjects.common.waitUntilUrlIncludes('cloud_security_posture/dashboard'); + }); + + it('Shows integrations count when there are findings', async () => { + const integrationsCounter = await rule.rulePage.getIntegrationsEvaluatedCounter(); + expect((await integrationsCounter.getVisibleText()).includes('1')).to.be(true); + }); + + it('Clicking the integrations counter button leads to the integration page', async () => { + await rule.rulePage.clickIntegrationsEvaluatedButton(); + await pageObjects.common.waitUntilUrlIncludes('add-integration/kspm'); + }); + + it('Shows the failed findings counter when there are findings', async () => { + const failedFindingsCounter = await rule.rulePage.getFailedFindingsCounter(); + expect((await failedFindingsCounter.getVisibleText()).includes('2')).to.be(true); + }); + + it('Clicking the failed findings button leads to the findings page', async () => { + await rule.rulePage.clickFailedFindingsButton(); + await pageObjects.common.waitUntilUrlIncludes( + 'cloud_security_posture/findings/configurations' + ); + }); + + it('Shows the disabled rules count', async () => { + const disabledRulesCounter = await rule.rulePage.getDisabledRulesCounter(); + expect((await disabledRulesCounter.getVisibleText()).includes('0')).to.be(true); + + // disable rule 1.1.1 (k8s findings mock contains a findings from that rule) + await rule.rulePage.clickEnableRulesRowSwitchButton(0); + await pageObjects.header.waitUntilLoadingHasFinished(); + expect((await disabledRulesCounter.getVisibleText()).includes('1')).to.be(true); + + const postureScoreCounter = await rule.rulePage.getPostureScoreCounter(); + expect((await postureScoreCounter.getVisibleText()).includes('0%')).to.be(true); + + // enable rule back + await rule.rulePage.clickEnableRulesRowSwitchButton(0); + }); + + it('Clicking the disabled rules button shows enables the disabled filter', async () => { + await rule.rulePage.clickEnableRulesRowSwitchButton(0); + await pageObjects.header.waitUntilLoadingHasFinished(); + + await rule.rulePage.clickDisabledRulesButton(); + await pageObjects.header.waitUntilLoadingHasFinished(); + expect((await rule.rulePage.getEnableRulesRowSwitchButton()) === 1).to.be(true); + }); + + it('Shows empty state when there are no findings', async () => { + // Ensure there are no findings initially + await findings.index.remove(); + await rule.navigateToRulePage('cis_k8s', '1.0.1'); + + const isEmptyStateVisible = await rule.rulePage.getCountersEmptyState(); + expect(isEmptyStateVisible).to.be(true); + await rule.rulePage.clickEnableRulesRowSwitchButton(0); + }); + }); + + describe('Rules Page - Bulk Action buttons', () => { + it('It should disable Enable option when there are all rules selected are already enabled ', async () => { + await rule.rulePage.clickSelectAllRules(); await rule.rulePage.toggleBulkActionButton(); expect( (await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_ENABLE)) === @@ -83,11 +154,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect( (await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_DISABLE)) === 'true' - ).to.be(true); + ).to.be(false); }); - it('It should disable Enable option when there are all rules selected are already enabled ', async () => { - await rule.rulePage.clickSelectAllRules(); + it('It should disable both Enable and Disable options when there are no rules selected', async () => { await rule.rulePage.toggleBulkActionButton(); expect( (await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_ENABLE)) === @@ -96,7 +166,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect( (await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_DISABLE)) === 'true' - ).to.be(false); + ).to.be(true); }); it('It should disable Disable option when there are all rules selected are already Disabled', async () => { @@ -178,6 +248,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.header.waitUntilLoadingHasFinished(); expect((await rule.rulePage.getEnableSwitchButtonState()) === 'false').to.be(true); }); + it('Alerts section of Rules Flyout shows Disabled text when Rules are disabled', async () => { + await rule.rulePage.clickRulesNames(0); + await pageObjects.header.waitUntilLoadingHasFinished(); + expect( + (await rule.rulePage.doesElementExist( + 'csp:findings-flyout-create-detection-rule-link' + )) === false + ).to.be(true); + }); it('Users are able to Enable/Disable Rule from Take Action on Rule Flyout', async () => { await rule.rulePage.clickRulesNames(0); await rule.rulePage.clickTakeActionButton(); @@ -185,78 +264,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.header.waitUntilLoadingHasFinished(); expect((await rule.rulePage.getEnableSwitchButtonState()) === 'true').to.be(true); }); - }); - - describe('Rules Page - Rules Counters', () => { - it('Shows posture score when there are findings', async () => { - const isEmptyStateVisible = await rule.rulePage.getCountersEmptyState(); - expect(isEmptyStateVisible).to.be(false); - - const postureScoreCounter = await rule.rulePage.getPostureScoreCounter(); - expect((await postureScoreCounter.getVisibleText()).includes('33%')).to.be(true); - }); - - it('Clicking the posture score button leads to the dashboard', async () => { - await rule.rulePage.clickPostureScoreButton(); - await pageObjects.common.waitUntilUrlIncludes('cloud_security_posture/dashboard'); - }); - - it('Shows integrations count when there are findings', async () => { - const integrationsCounter = await rule.rulePage.getIntegrationsEvaluatedCounter(); - expect((await integrationsCounter.getVisibleText()).includes('1')).to.be(true); - }); - - it('Clicking the integrations counter button leads to the integration page', async () => { - await rule.rulePage.clickIntegrationsEvaluatedButton(); - await pageObjects.common.waitUntilUrlIncludes( - 'cloud_security_posture/add-integration/kspm' - ); - }); - - it('Shows the failed findings counter when there are findings', async () => { - const failedFindingsCounter = await rule.rulePage.getFailedFindingsCounter(); - expect((await failedFindingsCounter.getVisibleText()).includes('2')).to.be(true); - }); - - it('Clicking the failed findings button leads to the findings page', async () => { - await rule.rulePage.clickFailedFindingsButton(); - await pageObjects.common.waitUntilUrlIncludes( - 'cloud_security_posture/findings/configurations' - ); - }); - - it('Shows the disabled rules count', async () => { - const disabledRulesCounter = await rule.rulePage.getDisabledRulesCounter(); - expect((await disabledRulesCounter.getVisibleText()).includes('0')).to.be(true); - - // disable rule 1.1.1 (k8s findings mock contains a findings from that rule) - await rule.rulePage.clickEnableRulesRowSwitchButton(0); - await pageObjects.header.waitUntilLoadingHasFinished(); - expect((await disabledRulesCounter.getVisibleText()).includes('1')).to.be(true); - - const postureScoreCounter = await rule.rulePage.getPostureScoreCounter(); - expect((await postureScoreCounter.getVisibleText()).includes('0%')).to.be(true); - - // enable rule back - await rule.rulePage.clickEnableRulesRowSwitchButton(0); - }); - - it('Clicking the disabled rules button shows enables the disabled filter', async () => { - await rule.rulePage.clickEnableRulesRowSwitchButton(0); - await pageObjects.header.waitUntilLoadingHasFinished(); - - await rule.rulePage.clickDisabledRulesButton(); + it('Alerts section of Rules Flyout shows Detection Rule Counter component when Rules are enabled', async () => { + await rule.rulePage.clickRulesNames(0); await pageObjects.header.waitUntilLoadingHasFinished(); - expect((await rule.rulePage.getEnableRulesRowSwitchButton()) === 1).to.be(true); - }); - - it('Shows empty state when there are no findings', async () => { - // Ensure there are no findings initially - await findings.index.remove(); - await rule.navigateToRulePage('cis_k8s', '1.0.1'); - - const isEmptyStateVisible = await rule.rulePage.getCountersEmptyState(); - expect(isEmptyStateVisible).to.be(true); + expect( + (await rule.rulePage.doesElementExist( + 'csp:findings-flyout-create-detection-rule-link' + )) === true + ).to.be(true); }); }); });