[Cloud Security]Detection Rules counter on Rules Flyout (#176041)

## Summary

This PR adds a Detection Rules Counter on Rules Flyout, users are also
able to add Detection Rule on Flyout


a000c1d5-3cec-4631-b255-745547e93c38

When Rule is disabled, the text 'Disabled' will be rendered instead of
Detection Rule Counter

<img width="1423" alt="Screenshot 2024-02-01 at 8 09 04 PM"
src="aad119d4-75a3-4407-8155-fb68d7d06f38">
This commit is contained in:
Rickyanto Ang 2024-02-06 13:04:12 -08:00 committed by GitHub
parent 1c7d089162
commit a50f0ae1a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 276 additions and 108 deletions

View file

@ -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: <CspFlyoutMarkdown>{rule.description}</CspFlyoutMarkdown>,
},
{
title: i18n.translate('xpack.csp.findings.findingsFlyout.ruleTab.AlertsTitle', {
defaultMessage: 'Alerts',
}),
description:
ruleState === 'unmuted' ? (
<RulesDetectionRuleCounter benchmarkRule={rule} />
) : (
<FormattedMessage
id="xpack.csp.findings.findingsFlyout.ruleTab.disabledRuleText"
defaultMessage="Disabled"
/>
),
},
{
title: i18n.translate('xpack.csp.findings.findingsFlyout.ruleTab.tagsTitle', {
defaultMessage: 'Tags',

View file

@ -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,
},
});
};

View file

@ -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 (
<DetectionRuleCounter
tags={getFindingsDetectionRuleSearchTags(benchmarkRule)}
createRuleFn={createBenchmarkRuleFn}
/>
);
};

View file

@ -128,10 +128,25 @@ export const RuleFlyout = ({ onClose, rule, refetchRulesStates }: RuleFlyoutProp
);
};
const getRuleStateSwitch = (
rule: CspBenchmarkRulesWithStates,
switchRuleStates: () => Promise<void>
) => [
const RuleOverviewTab = ({
rule,
ruleData,
switchRuleStates,
}: {
rule: CspBenchmarkRuleMetadata;
ruleData: CspBenchmarkRulesWithStates;
switchRuleStates: () => Promise<void>;
}) => (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiDescriptionList
listItems={[...ruleState(ruleData, switchRuleStates), ...getRuleList(rule, ruleData.state)]}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
const ruleState = (rule: CspBenchmarkRulesWithStates, switchRuleStates: () => Promise<void>) => [
{
title: (
<EuiFlexGroup gutterSize="xs" alignItems="center">
@ -172,21 +187,3 @@ const getRuleStateSwitch = (
),
},
];
const RuleOverviewTab = ({
rule,
ruleData,
switchRuleStates,
}: {
rule: CspBenchmarkRuleMetadata;
ruleData: CspBenchmarkRulesWithStates;
switchRuleStates: () => Promise<void>;
}) => (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiDescriptionList
listItems={[...getRuleStateSwitch(ruleData, switchRuleStates), ...getRuleList(rule)]}
/>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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) => {

View file

@ -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,20 +71,79 @@ 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 () => {
await rule.rulePage.toggleBulkActionButton();
expect(
(await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_ENABLE)) ===
'true'
).to.be(true);
expect(
(await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_DISABLE)) ===
'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('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();
@ -99,6 +157,18 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
).to.be(false);
});
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)) ===
'true'
).to.be(true);
expect(
(await rule.rulePage.isBulkActionOptionDisabled(RULES_BULK_ACTION_OPTION_DISABLE)) ===
'true'
).to.be(true);
});
it('It should disable Disable option when there are all rules selected are already Disabled', async () => {
await rule.rulePage.clickSelectAllRules();
await rule.rulePage.toggleBulkActionButton();
@ -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);
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 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);
expect(
(await rule.rulePage.doesElementExist(
'csp:findings-flyout-create-detection-rule-link'
)) === true
).to.be(true);
});
});
});