Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cloud Security] Alerts Preview Contextual Flyout #197102

Merged
40 changes: 40 additions & 0 deletions x-pack/packages/kbn-cloud-security-posture/common/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,43 @@ export const buildEntityFlyoutPreviewQuery = (field: string, queryValue?: string
},
};
};

export const buildEntityAlertsQuery = (field: string, queryValue?: string, size?: number) => {
maxcold marked this conversation as resolved.
Show resolved Hide resolved
return {
size: size || 0,
_source: false,
fields: [
maxcold marked this conversation as resolved.
Show resolved Hide resolved
'kibana.alert.rule.uuid',
'signal.rule.name',
'signal.rule.severity',
'kibana.alert.reason',
],
query: {
bool: {
filter: [
{
bool: {
must: [],
filter: [
{
match_phrase: {
[field]: {
query: queryValue,
},
},
},
],
should: [],
must_not: [],
},
},
{
terms: {
'kibana.alert.workflow_status': ['open', 'acknowledged'],
maxcold marked this conversation as resolved.
Show resolved Hide resolved
},
},
],
},
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 { render } from '@testing-library/react';
import { AlertsPreview } from './alerts_preview';
import { TestProviders } from '../../../common/mock/test_providers';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { AlertSearchResponse } from '../../../detections/containers/detection_engine/alerts/types';

const mockAlertsData: AlertSearchResponse<unknown, unknown> = {
took: 0,
timeout: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 2,
relation: 'eq',
},
max_score: 0,
hits: [
{
fields: {
'signal.rule.name': ['Low Alert'],
'kibana.alert.reason': ['Low Alert Reason'],
'kibana.alert.rule.uuid': ['Low Alert UUID'],
'signal.rule.severity': ['low'],
},
},
{
fields: {
'signal.rule.name': ['Medium Alert'],
'kibana.alert.reason': ['Medium Alert Reason'],
'kibana.alert.rule.uuid': ['Medium Alert UUID'],
'signal.rule.severity': ['medium'],
},
},
],
},
};

jest.mock(
'../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'
);
jest.mock('@kbn/expandable-flyout');

describe('AlertsPreview', () => {
const mockOpenLeftPanel = jest.fn();

beforeEach(() => {
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel });
});
afterEach(() => {
jest.clearAllMocks();
});

it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<AlertsPreview alertsData={mockAlertsData} alertsCount={1} />
</TestProviders>
);

expect(getByTestId('securitySolutionFlyoutInsightsAlertsTitleText')).toBeInTheDocument();
maxcold marked this conversation as resolved.
Show resolved Hide resolved
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we shouldn't be the owners of this component, it has nothing to do with Cloud Security Posture. I understand that this is the outcome of this unclear ownership, but I would try to find a more suitable place for the alerts component. @PhilippeOberti wdyt about the code ownership? right now it's a bit like "who wrote the code owns it", but I don't think it is a good idea longer term

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's a bit tricky... While I agree that the logic isn't related to Cloud Security, if the component is only used within another component owned by Cloud Security it's difficult for other teams to know how to properly test it (where it is, how to generate data to have things show correctly...).
A few times I've had situation where I didn't even know that one of our components was used by another team and didn't test things when I made changes to it...

Also, if that component is only used in one place, it's hard to justify having us own it. What if we need to make changes to it? We would still need a way to have you guys review it to make sure we don't brake your functionality?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on the other side, we are the ones that are supposed to know how this thing works, so I totally get your point of us owning it. I just feel like if we want to do this, we then need to have a good look at it before it's being merged to make sure we understand what it does, we should have a say on how it's being written and place it in a folder that makes sense

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, let's discuss it in the sync as proposed. Right now we have the situation that we've built misconfig, vuln and alert previews on the entity flyout and you folks built the same but on document flyout. For me the ideal situation would be to split the ownership by the business domain: we own everything related to cloud security (misconfiguration and vulnerability) and alerts should be owned by the team owning the business domain of alerts. Which team is the owner of the alerts logic in the Security Org is unclear to me tbh. But if we continue to own based on who wrote the code, we have the risk of things being broken (no necessarily technically but rather in business sense) without the owners of the domain logic realising that

* 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 { css } from '@emotion/react';
import type { EuiThemeComputed } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DistributionBar } from '@kbn/security-solution-distribution-bar';
import { getAbbreviatedNumber } from '@kbn/cloud-security-posture-common';
import type { AlertSearchResponse } from '../../../detections/containers/detection_engine/alerts/types';
import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel';
import { getSeverityColor } from '../../../detections/components/alerts_kpis/severity_level_panel/helpers';

interface CspAlertsField {
maxcold marked this conversation as resolved.
Show resolved Hide resolved
'kibana.alert.rule.uuid': string[];
'kibana.alert.reason': string[];
'signal.rule.name': string[];
'signal.rule.severity': string[];
}

interface AlertsDetailsFields {
fields: CspAlertsField;
}

const AlertsCount = ({
alertsTotal,
euiTheme,
}: {
alertsTotal: string | number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this truly be a string? We can use only a number instead of a union.

euiTheme: EuiThemeComputed<{}>;
}) => {
return (
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="s">
<h1>{alertsTotal}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText
size="m"
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
css={{
fontWeight: euiTheme.font.weight.semiBold,
}}
>

>
<FormattedMessage
id="xpack.securitySolution.flyout.right.insights.alerts.alertsCountDescription"
defaultMessage="Alerts"
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
};

export const AlertsPreview = ({
alertsData,
alertsCount,
isPreviewMode,
}: {
alertsData: AlertSearchResponse<unknown, unknown> | null;
alertsCount: number;
isPreviewMode?: boolean;
}) => {
const { euiTheme } = useEuiTheme();

const resultX = (alertsData?.hits?.hits as AlertsDetailsFields[])?.map(
maxcold marked this conversation as resolved.
Show resolved Hide resolved
(item: AlertsDetailsFields) => {
return { fields: item.fields };
}
);

const severities = resultX?.map((item) => item.fields['signal.rule.severity'][0]) || [];
const alertStats = Object.entries(
severities.reduce((acc: Record<string, number>, item) => {
acc[item] = (acc[item] || 0) + 1;
return acc;
}, {})
).map(([key, count]) => ({
key,
count,
color: getSeverityColor(key),
}));

return (
<ExpandablePanel
header={{
title: (
<EuiText
size="xs"
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
>
<FormattedMessage
id="xpack.securitySolution.flyout.right.insights.alerts.alertsTitle"
defaultMessage="Alerts"
/>
</EuiText>
),
}}
data-test-subj={'securitySolutionFlyoutInsightsAlerts'}
>
<EuiFlexGroup gutterSize="none">
<AlertsCount alertsTotal={getAbbreviatedNumber(alertsCount)} euiTheme={euiTheme} />
<EuiFlexItem grow={2}>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem />
<EuiFlexItem>
<EuiSpacer />
<DistributionBar stats={alertStats.reverse()} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</ExpandablePanel>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hook
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview';
import { hasVulnerabilitiesData } from '@kbn/cloud-security-posture';
import { buildEntityAlertsQuery } from '@kbn/cloud-security-posture-common/utils/helpers';
import { MisconfigurationsPreview } from './misconfiguration/misconfiguration_preview';
import { VulnerabilitiesPreview } from './vulnerabilities/vulnerabilities_preview';
import { AlertsPreview } from './alerts/alerts_preview';
import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index';
import { ALERTS_QUERY_NAMES } from '../../detections/containers/detection_engine/alerts/constants';
import { useQueryAlerts } from '../../detections/containers/detection_engine/alerts/use_query';

export const EntityInsight = <T,>({
name,
Expand Down Expand Up @@ -60,6 +65,28 @@ export const EntityInsight = <T,>({

const isVulnerabilitiesFindingForHost = hasVulnerabilitiesFindings && fieldName === 'host.name';

const { signalIndexName } = useSignalIndex();

const { data: alertsData } = useQueryAlerts({
query: buildEntityAlertsQuery(fieldName, name, 500),
maxcold marked this conversation as resolved.
Show resolved Hide resolved
queryName: ALERTS_QUERY_NAMES.ALERTS_COUNT_BY_STATUS,
indexName: signalIndexName,
});

const alertsCount = alertsData?.hits?.total.value || 0;
if (alertsCount > 0) {
insightContent.push(
<>
<AlertsPreview
alertsData={alertsData}
maxcold marked this conversation as resolved.
Show resolved Hide resolved
alertsCount={alertsCount}
isPreviewMode={isPreviewMode}
/>
<EuiSpacer size="s" />
</>
);
}

if (hasMisconfigurationFindings)
insightContent.push(
<>
Expand All @@ -76,7 +103,8 @@ export const EntityInsight = <T,>({
);
return (
<>
{(hasMisconfigurationFindings ||
{(insightContent.length > 0 ||
hasMisconfigurationFindings ||
(isVulnerabilitiesFindingForHost && hasVulnerabilitiesFindings)) && (
<>
<EuiAccordion
Expand Down