[Cloud Security] Host Name Misconfiguration Datagrid & Refactor CSP Plugin PHASE 2 (#192535)

In an attempt to make Reviewing easier and more accurate, the
implementation of Misconfiguration Data grid on Host.name flyout in
Alerts Page will be split into 2 Phases

Phase 1: Move Functions, Utils or Helpers, Hooks, constants to Package
Phase 2: Implementing the feature

This is **Phase 2** of the process
<img width="1712" alt="Screenshot 2024-09-11 at 2 16 20 PM"
src="https://github.com/user-attachments/assets/29ab56db-8561-486c-ae8d-c254b932cea4">

How to test:
Pre req: In order to test this, you need to generate some fake alerts.
This [repo](https://github.com/elastic/security-documents-generator)
will help you do that
1. Generate Some Alerts
2. Use the Reindex API to get some Findings data in (change the
host.name field to match the host.name from alerts generated if you want
to test Findings table in the left panel flyout)
3. Turn on Risky Entity Score if you want to test if both Risk
Contribution and Insights tabs shows up, follow this
[guide](https://www.elastic.co/guide/en/security/current/turn-on-risk-engine.html)
to turn on Risk Entity Score
This commit is contained in:
Rickyanto Ang 2024-09-13 21:41:41 -07:00 committed by GitHub
parent b5abc4ec7e
commit 28becfdce9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 636 additions and 134 deletions

View file

@ -17,7 +17,7 @@ export type {
BaseCspSetupStatus,
CspSetupStatus,
} from './types/status';
export type { CspFinding } from './types/findings';
export type { CspFinding, CspFindingResult } from './types/findings';
export type { BenchmarksCisId } from './types/benchmark';
export * from './constants';
export {

View file

@ -41,7 +41,7 @@ interface CspFindingCloud {
region?: string;
}
interface CspFindingResult {
export interface CspFindingResult {
evaluation: 'passed' | 'failed';
expected?: Record<string, unknown>;
evidence: Record<string, unknown>;

View file

@ -0,0 +1,60 @@
/*
* 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 '@tanstack/react-query';
import { lastValueFrom } from 'rxjs';
import { CspFinding } from '@kbn/cloud-security-posture-common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { showErrorToast } from '../..';
import type {
CspClientPluginStartDeps,
LatestFindingsRequest,
LatestFindingsResponse,
UseMisconfigurationOptions,
} from '../../type';
import { useGetCspBenchmarkRulesStatesApi } from './use_get_benchmark_rules_state_api';
import {
buildMisconfigurationsFindingsQuery,
getMisconfigurationAggregationCount,
} from '../utils/hooks_utils';
export const useMisconfigurationFindings = (options: UseMisconfigurationOptions) => {
const {
data,
notifications: { toasts },
} = useKibana<CoreStart & CspClientPluginStartDeps>().services;
const { data: rulesStates } = useGetCspBenchmarkRulesStatesApi();
return useQuery(
['csp_misconfiguration_findings', { params: options }, rulesStates],
async () => {
const {
rawResponse: { hits, aggregations },
} = await lastValueFrom(
data.search.search<LatestFindingsRequest, LatestFindingsResponse>({
params: buildMisconfigurationsFindingsQuery(options, rulesStates!),
})
);
if (!aggregations) throw new Error('expected aggregations to be defined');
return {
count: getMisconfigurationAggregationCount(aggregations.count.buckets),
rows: hits.hits.map((finding) => ({
result: finding._source?.result,
rule: finding?._source?.rule,
resource: finding?._source?.resource,
})) as Array<Pick<CspFinding, 'result' | 'rule' | 'resource'>>,
};
},
{
enabled: options.enabled && !!rulesStates,
keepPreviousData: true,
onError: (err: Error) => showErrorToast(toasts, err),
}
);
};

View file

@ -6,118 +6,22 @@
*/
import { useQuery } from '@tanstack/react-query';
import { lastValueFrom } from 'rxjs';
import type { IKibanaSearchResponse, IKibanaSearchRequest } from '@kbn/search-types';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
CDR_MISCONFIGURATIONS_INDEX_PATTERN,
LATEST_FINDINGS_RETENTION_POLICY,
CspFinding,
} from '@kbn/cloud-security-posture-common';
import type { CspBenchmarkRulesStates } from '@kbn/cloud-security-posture-common/schema/rules/latest';
import { buildMutedRulesFilter } from '@kbn/cloud-security-posture-common';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { showErrorToast } from '../..';
import type { CspClientPluginStartDeps } from '../../type';
import type {
CspClientPluginStartDeps,
LatestFindingsRequest,
LatestFindingsResponse,
UseMisconfigurationOptions,
} from '../../type';
import { useGetCspBenchmarkRulesStatesApi } from './use_get_benchmark_rules_state_api';
import {
buildMisconfigurationsFindingsQuery,
getMisconfigurationAggregationCount,
} from '../utils/hooks_utils';
interface MisconfigurationPreviewBaseEsQuery {
query?: {
bool: {
filter: estypes.QueryDslQueryContainer[];
};
};
}
interface UseMisconfigurationPreviewOptions extends MisconfigurationPreviewBaseEsQuery {
sort: string[][];
enabled: boolean;
pageSize: number;
}
type LatestFindingsRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type LatestFindingsResponse = IKibanaSearchResponse<
estypes.SearchResponse<CspFinding, FindingsAggs>
>;
interface FindingsAggs {
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}
const RESULT_EVALUATION = {
PASSED: 'passed',
FAILED: 'failed',
UNKNOWN: 'unknown',
};
export const getFindingsCountAggQueryMisconfigurationPreview = () => ({
count: {
filters: {
other_bucket_key: RESULT_EVALUATION.UNKNOWN,
filters: {
[RESULT_EVALUATION.PASSED]: { match: { 'result.evaluation': RESULT_EVALUATION.PASSED } },
[RESULT_EVALUATION.FAILED]: { match: { 'result.evaluation': RESULT_EVALUATION.FAILED } },
},
},
},
});
export const getMisconfigurationAggregationCount = (
buckets: estypes.AggregationsBuckets<estypes.AggregationsStringRareTermsBucketKeys>
) => {
return Object.entries(buckets).reduce(
(evaluation, [key, value]) => {
evaluation[key] = (evaluation[key] || 0) + (value.doc_count || 0);
return evaluation;
},
{
[RESULT_EVALUATION.PASSED]: 0,
[RESULT_EVALUATION.FAILED]: 0,
[RESULT_EVALUATION.UNKNOWN]: 0,
}
);
};
export const buildMisconfigurationsFindingsQuery = (
{ query }: UseMisconfigurationPreviewOptions,
rulesStates: CspBenchmarkRulesStates
) => {
const mutedRulesFilterQuery = buildMutedRulesFilter(rulesStates);
return {
index: CDR_MISCONFIGURATIONS_INDEX_PATTERN,
size: 0,
aggs: getFindingsCountAggQueryMisconfigurationPreview(),
ignore_unavailable: false,
query: buildMisconfigurationsFindingsQueryWithFilters(query, mutedRulesFilterQuery),
};
};
const buildMisconfigurationsFindingsQueryWithFilters = (
query: UseMisconfigurationPreviewOptions['query'],
mutedRulesFilterQuery: estypes.QueryDslQueryContainer[]
) => {
return {
...query,
bool: {
...query?.bool,
filter: [
...(query?.bool?.filter ?? []),
{
range: {
'@timestamp': {
gte: `now-${LATEST_FINDINGS_RETENTION_POLICY}`,
lte: 'now',
},
},
},
],
must_not: [...mutedRulesFilterQuery],
},
};
};
export const useMisconfigurationPreview = (options: UseMisconfigurationPreviewOptions) => {
export const useMisconfigurationPreview = (options: UseMisconfigurationOptions) => {
const {
data,
notifications: { toasts },
@ -134,10 +38,10 @@ export const useMisconfigurationPreview = (options: UseMisconfigurationPreviewOp
params: buildMisconfigurationsFindingsQuery(options, rulesStates!),
})
);
if (!aggregations) throw new Error('expected aggregations to be defined');
if (!aggregations && !options.ignore_unavailable)
throw new Error('expected aggregations to be defined');
return {
count: getMisconfigurationAggregationCount(aggregations.count.buckets),
count: getMisconfigurationAggregationCount(aggregations?.count?.buckets),
};
},
{

View file

@ -0,0 +1,105 @@
/*
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
CDR_MISCONFIGURATIONS_INDEX_PATTERN,
LATEST_FINDINGS_RETENTION_POLICY,
} from '@kbn/cloud-security-posture-common';
import type { CspBenchmarkRulesStates } from '@kbn/cloud-security-posture-common/schema/rules/latest';
import { buildMutedRulesFilter } from '@kbn/cloud-security-posture-common';
import type { UseMisconfigurationOptions } from '../../type';
const MISCONFIGURATIONS_SOURCE_FIELDS = ['result.*', 'rule.*', 'resource.*'];
interface AggregationBucket {
doc_count?: number;
}
type AggregationBuckets = Record<string, AggregationBucket>;
const RESULT_EVALUATION = {
PASSED: 'passed',
FAILED: 'failed',
UNKNOWN: 'unknown',
};
export const getFindingsCountAggQueryMisconfiguration = () => ({
count: {
filters: {
other_bucket_key: RESULT_EVALUATION.UNKNOWN,
filters: {
[RESULT_EVALUATION.PASSED]: { match: { 'result.evaluation': RESULT_EVALUATION.PASSED } },
[RESULT_EVALUATION.FAILED]: { match: { 'result.evaluation': RESULT_EVALUATION.FAILED } },
},
},
},
});
export const getMisconfigurationAggregationCount = (
buckets?: estypes.AggregationsBuckets<estypes.AggregationsStringRareTermsBucketKeys>
) => {
const defaultBuckets: AggregationBuckets = {
[RESULT_EVALUATION.PASSED]: { doc_count: 0 },
[RESULT_EVALUATION.FAILED]: { doc_count: 0 },
[RESULT_EVALUATION.UNKNOWN]: { doc_count: 0 },
};
// if buckets are undefined we will use default buckets
const usedBuckets = buckets || defaultBuckets;
return Object.entries(usedBuckets).reduce(
(evaluation, [key, value]) => {
evaluation[key] = (evaluation[key] || 0) + (value.doc_count || 0);
return evaluation;
},
{
[RESULT_EVALUATION.PASSED]: 0,
[RESULT_EVALUATION.FAILED]: 0,
[RESULT_EVALUATION.UNKNOWN]: 0,
}
);
};
export const buildMisconfigurationsFindingsQuery = (
{ query }: UseMisconfigurationOptions,
rulesStates: CspBenchmarkRulesStates,
isPreview = false
) => {
const mutedRulesFilterQuery = buildMutedRulesFilter(rulesStates);
return {
index: CDR_MISCONFIGURATIONS_INDEX_PATTERN,
size: isPreview ? 0 : 500,
aggs: getFindingsCountAggQueryMisconfiguration(),
ignore_unavailable: true,
query: buildMisconfigurationsFindingsQueryWithFilters(query, mutedRulesFilterQuery),
_source: MISCONFIGURATIONS_SOURCE_FIELDS,
};
};
const buildMisconfigurationsFindingsQueryWithFilters = (
query: UseMisconfigurationOptions['query'],
mutedRulesFilterQuery: estypes.QueryDslQueryContainer[]
) => {
return {
...query,
bool: {
...query?.bool,
filter: [
...(query?.bool?.filter ?? []),
{
range: {
'@timestamp': {
gte: `now-${LATEST_FINDINGS_RETENTION_POLICY}`,
lte: 'now',
},
},
},
],
must_not: [...mutedRulesFilterQuery],
},
};
};

View file

@ -22,6 +22,9 @@ import type { FleetStart } from '@kbn/fleet-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { CspFinding } from '@kbn/cloud-security-posture-common';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { IKibanaSearchResponse, IKibanaSearchRequest } from '@kbn/search-types';
import type { BoolQuery } from '@kbn/es-query';
export interface FindingsBaseEsQuery {
@ -51,3 +54,27 @@ export interface CspClientPluginStartDeps {
// optional
usageCollection?: UsageCollectionStart;
}
export interface MisconfigurationBaseEsQuery {
query?: {
bool: {
filter: estypes.QueryDslQueryContainer[];
};
};
}
export interface UseMisconfigurationOptions extends MisconfigurationBaseEsQuery {
sort: string[][];
enabled: boolean;
pageSize: number;
ignore_unavailable?: boolean;
}
export type LatestFindingsRequest = IKibanaSearchRequest<estypes.SearchRequest>;
export type LatestFindingsResponse = IKibanaSearchResponse<
estypes.SearchResponse<CspFinding, FindingsAggs>
>;
export interface FindingsAggs {
count: estypes.AggregationsMultiBucketAggregateBase<estypes.AggregationsStringRareTermsBucketKeys>;
}

View file

@ -0,0 +1,65 @@
/*
* 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, { memo } from 'react';
import { EuiButtonGroup, EuiSpacer } from '@elastic/eui';
import type { EuiButtonGroupOptionProps } from '@elastic/eui/src/components/button/button_group/button_group';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useExpandableFlyoutState } from '@kbn/expandable-flyout';
import { MisconfigurationFindingsDetailsTable } from './misconfiguration_findings_details_table';
enum InsightsTabCspTab {
MISCONFIGURATION = 'misconfigurationTabId',
}
const insightsButtons: EuiButtonGroupOptionProps[] = [
{
id: InsightsTabCspTab.MISCONFIGURATION,
label: (
<FormattedMessage
id="xpack.securitySolution.flyout.left.insights.misconfigurationButtonLabel"
defaultMessage="Misconfiguration"
/>
),
'data-test-subj': 'misconfigurationTabDataTestId',
},
];
/**
* Insights view displayed in the document details expandable flyout left section
*/
export const InsightsTabCsp = memo(
({ name, fieldName }: { name: string; fieldName: 'host.name' | 'user.name' }) => {
const panels = useExpandableFlyoutState();
const activeInsightsId = panels.left?.path?.subTab ?? 'misconfigurationTabId';
return (
<>
<EuiButtonGroup
color="primary"
legend={i18n.translate(
'xpack.securitySolution.flyout.left.insights.optionsButtonGroups',
{
defaultMessage: 'Insights options',
}
)}
options={insightsButtons}
idSelected={activeInsightsId}
onChange={() => {}}
buttonSize="compressed"
isFullWidth
data-test-subj={'insightButtonGroupsTestId'}
/>
<EuiSpacer size="xl" />
<MisconfigurationFindingsDetailsTable fieldName={fieldName} queryName={name} />
</>
);
}
);
InsightsTabCsp.displayName = 'InsightsTab';

View file

@ -0,0 +1,184 @@
/*
* 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, { memo, useState } from 'react';
import type { Criteria, EuiBasicTableColumn } from '@elastic/eui';
import { EuiSpacer, EuiIcon, EuiPanel, EuiLink, EuiText, EuiBasicTable } from '@elastic/eui';
import { useMisconfigurationFindings } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_findings';
import { i18n } from '@kbn/i18n';
import type { CspFinding, CspFindingResult } from '@kbn/cloud-security-posture-common';
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { euiThemeVars } from '@kbn/ui-theme';
import { DistributionBar } from '@kbn/security-solution-distribution-bar';
import { useNavigateFindings } from '@kbn/cloud-security-posture/src/hooks/use_navigate_findings';
import type { CspBenchmarkRuleMetadata } from '@kbn/cloud-security-posture-common/schema/rules/latest';
import { CspEvaluationBadge } from '@kbn/cloud-security-posture';
type MisconfigurationFindingDetailFields = Pick<CspFinding, 'result' | 'rule' | 'resource'>;
const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => {
if (passedFindingsStats === 0 && failedFindingsStats === 0) return [];
return [
{
key: i18n.translate(
'xpack.securitySolution.flyout.right.insights.misconfigurations.passedFindingsText',
{
defaultMessage: 'Passed findings',
}
),
count: passedFindingsStats,
color: euiThemeVars.euiColorSuccess,
},
{
key: i18n.translate(
'xpack.securitySolution.flyout.right.insights.misconfigurations.failedFindingsText',
{
defaultMessage: 'Failed findings',
}
),
count: failedFindingsStats,
color: euiThemeVars.euiColorVis9,
},
];
};
/**
* Insights view displayed in the document details expandable flyout left section
*/
export const MisconfigurationFindingsDetailsTable = memo(
({ fieldName, queryName }: { fieldName: 'host.name' | 'user.name'; queryName: string }) => {
const { data } = useMisconfigurationFindings({
query: buildEntityFlyoutPreviewQuery(fieldName, queryName),
sort: [],
enabled: true,
pageSize: 1,
});
const passedFindings = data?.count.passed || 0;
const failedFindings = data?.count.failed || 0;
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const findingsPagination = (findings: MisconfigurationFindingDetailFields[]) => {
let pageOfItems;
if (!pageIndex && !pageSize) {
pageOfItems = findings;
} else {
const startIndex = pageIndex * pageSize;
pageOfItems = findings?.slice(
startIndex,
Math.min(startIndex + pageSize, findings?.length)
);
}
return {
pageOfItems,
totalItemCount: findings?.length,
};
};
const { pageOfItems, totalItemCount } = findingsPagination(data?.rows || []);
const pagination = {
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions: [10, 25, 100],
};
const onTableChange = ({ page }: Criteria<MisconfigurationFindingDetailFields>) => {
if (page) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
};
const navToFindings = useNavigateFindings();
const navToFindingsByHostName = (hostName: string) => {
navToFindings({ 'host.name': hostName }, ['rule.name']);
};
const navToFindingsByRuleAndResourceId = (ruleId: string, resourceId: string) => {
navToFindings({ 'rule.id': ruleId, 'resource.id': resourceId });
};
const columns: Array<EuiBasicTableColumn<MisconfigurationFindingDetailFields>> = [
{
field: 'rule',
name: '',
width: '5%',
render: (rule: CspBenchmarkRuleMetadata, finding: MisconfigurationFindingDetailFields) => (
<EuiLink
onClick={() => {
navToFindingsByRuleAndResourceId(rule?.id, finding?.resource?.id);
}}
>
<EuiIcon type={'popout'} />
</EuiLink>
),
},
{
field: 'result',
render: (result: CspFindingResult) => <CspEvaluationBadge type={result?.evaluation} />,
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.misconfigurations.table.resultColumnName',
{
defaultMessage: 'Result',
}
),
width: '10%',
},
{
field: 'rule',
render: (rule: CspBenchmarkRuleMetadata) => <EuiText size="s">{rule?.name}</EuiText>,
name: i18n.translate(
'xpack.securitySolution.flyout.left.insights.misconfigurations.table.ruleColumnName',
{
defaultMessage: 'Rule',
}
),
width: '90%',
},
];
return (
<>
<EuiPanel hasShadow={false}>
<EuiLink
onClick={() => {
navToFindingsByHostName(queryName);
}}
>
{i18n.translate(
'xpack.securitySolution.flyout.left.insights.misconfigurations.tableTitle',
{
defaultMessage: 'Misconfigurations',
}
)}
<EuiIcon type={'popout'} />
</EuiLink>
<EuiSpacer size="xl" />
<DistributionBar stats={getFindingsStats(passedFindings, failedFindings)} />
<EuiSpacer size="l" />
<EuiBasicTable
items={pageOfItems || []}
rowHeader="result"
columns={columns}
pagination={pagination}
onChange={onTableChange}
/>
</EuiPanel>
</>
);
}
);
MisconfigurationFindingsDetailsTable.displayName = 'MisconfigurationFindingsDetailsTable';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { css } from '@emotion/react';
import type { EuiThemeComputed } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme, EuiTitle } from '@elastic/eui';
@ -16,6 +16,16 @@ import { euiThemeVars } from '@kbn/ui-theme';
import { i18n } from '@kbn/i18n';
import { ExpandablePanel } from '@kbn/security-solution-common';
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { HostDetailsPanelKey } from '../../../flyout/entity_details/host_details_left';
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine';
import { buildHostNamesFilter } from '../../../../common/search_strategy';
const FIRST_RECORD_PAGINATION = {
cursorStart: 0,
querySize: 1,
};
const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => {
if (passedFindingsStats === 0 && failedFindingsStats === 0) return [];
@ -75,10 +85,14 @@ const MisconfigurationPreviewScore = ({
passedFindings,
failedFindings,
euiTheme,
numberOfPassedFindings,
numberOfFailedFindings,
}: {
passedFindings: number;
failedFindings: number;
euiTheme: EuiThemeComputed<{}>;
numberOfPassedFindings?: number;
numberOfFailedFindings?: number;
}) => {
return (
<EuiFlexItem>
@ -119,9 +133,52 @@ export const MisconfigurationsPreview = ({ hostName }: { hostName: string }) =>
const { euiTheme } = useEuiTheme();
const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0;
const hostNameFilterQuery = useMemo(
() => (hostName ? buildHostNamesFilter([hostName]) : undefined),
[hostName]
);
const riskScoreState = useRiskScore({
riskEntity: RiskScoreEntity.host,
filterQuery: hostNameFilterQuery,
onlyLatest: false,
pagination: FIRST_RECORD_PAGINATION,
});
const { data: hostRisk } = riskScoreState;
const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined;
const isRiskScoreExist = !!hostRiskData?.host.risk;
const { openLeftPanel } = useExpandableFlyoutApi();
const isPreviewMode = false;
const goToEntityInsightTab = useCallback(() => {
openLeftPanel({
id: HostDetailsPanelKey,
params: {
name: hostName,
isRiskScoreExist,
hasMisconfigurationFindings,
path: { tab: 'csp_insights' },
},
});
}, [hasMisconfigurationFindings, hostName, isRiskScoreExist, openLeftPanel]);
const link = useMemo(
() =>
!isPreviewMode
? {
callback: goToEntityInsightTab,
tooltip: (
<FormattedMessage
id="xpack.securitySolution.flyout.right.insights.misconfiguration.misconfigurationTooltip"
defaultMessage="Show all misconfiguration findings"
/>
),
}
: undefined,
[isPreviewMode, goToEntityInsightTab]
);
return (
<ExpandablePanel
header={{
iconType: hasMisconfigurationFindings ? 'arrowStart' : '',
title: (
<EuiText
size="xs"
@ -135,6 +192,7 @@ export const MisconfigurationsPreview = ({ hostName }: { hostName: string }) =>
/>
</EuiText>
),
link: hasMisconfigurationFindings ? link : undefined,
}}
data-test-subj={'securitySolutionFlyoutInsightsMisconfigurations'}
>

View file

@ -11,8 +11,10 @@ import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared
import { PREFIX } from '../../../flyout/shared/test_ids';
import type { RiskInputsTabProps } from './tabs/risk_inputs/risk_inputs_tab';
import { RiskInputsTab } from './tabs/risk_inputs/risk_inputs_tab';
import { InsightsTabCsp } from '../../../cloud_security_posture/components/csp_details/insights_tab_csp';
export const RISK_INPUTS_TAB_TEST_ID = `${PREFIX}RiskInputsTab` as const;
export const INSIGHTS_TAB_TEST_ID = `${PREFIX}InsightInputsTab` as const;
export const getRiskInputTab = ({ entityType, entityName, scopeId }: RiskInputsTabProps) => ({
id: EntityDetailsLeftPanelTab.RISK_INPUTS,
@ -25,3 +27,21 @@ export const getRiskInputTab = ({ entityType, entityName, scopeId }: RiskInputsT
),
content: <RiskInputsTab entityType={entityType} entityName={entityName} scopeId={scopeId} />,
});
export const getInsightsInputTab = ({
name,
fieldName,
}: {
name: string;
fieldName: 'host.name' | 'user.name';
}) => ({
id: EntityDetailsLeftPanelTab.CSP_INSIGHTS,
'data-test-subj': INSIGHTS_TAB_TEST_ID,
name: (
<FormattedMessage
id="xpack.securitySolution.flyout.entityDetails.insightsDetails.insights.tabLabel"
defaultMessage="Insights"
/>
),
content: <InsightsTabCsp name={name} fieldName={fieldName} />,
});

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import { RISK_INPUTS_TAB_TEST_ID } from '../../../entity_analytics/components/entity_details_flyout';
import {
RISK_INPUTS_TAB_TEST_ID,
INSIGHTS_TAB_TEST_ID,
} from '../../../entity_analytics/components/entity_details_flyout';
import { render } from '@testing-library/react';
import React from 'react';
import { HostDetailsPanel } from '.';
@ -59,4 +62,34 @@ describe('HostDetailsPanel', () => {
);
expect(queryByTestId(RISK_INPUTS_TAB_TEST_ID)).not.toBeInTheDocument();
});
it("doesn't render insights panel when there no misconfiguration findings", () => {
const { queryByTestId } = render(
<HostDetailsPanel
name="elastic"
isRiskScoreExist={false}
scopeId={'scopeId'}
hasMisconfigurationFindings={false}
/>,
{
wrapper: TestProviders,
}
);
expect(queryByTestId(INSIGHTS_TAB_TEST_ID)).not.toBeInTheDocument();
});
it('render insights panel when there are misconfiguration findings', () => {
const { queryByTestId } = render(
<HostDetailsPanel
name="elastic"
isRiskScoreExist={false}
scopeId={'scopeId'}
hasMisconfigurationFindings={true}
/>,
{
wrapper: TestProviders,
}
);
expect(queryByTestId(INSIGHTS_TAB_TEST_ID)).toBeInTheDocument();
});
});

View file

@ -5,9 +5,12 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { getRiskInputTab } from '../../../entity_analytics/components/entity_details_flyout';
import {
getRiskInputTab,
getInsightsInputTab,
} from '../../../entity_analytics/components/entity_details_flyout';
import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
import {
EntityDetailsLeftPanelTab,
@ -19,6 +22,10 @@ export interface HostDetailsPanelProps extends Record<string, unknown> {
isRiskScoreExist: boolean;
name: string;
scopeId: string;
hasMisconfigurationFindings?: boolean;
path?: {
tab?: EntityDetailsLeftPanelTab;
};
}
export interface HostDetailsExpandableFlyoutProps extends FlyoutPanelProps {
key: 'host_details';
@ -26,18 +33,31 @@ export interface HostDetailsExpandableFlyoutProps extends FlyoutPanelProps {
}
export const HostDetailsPanelKey: HostDetailsExpandableFlyoutProps['key'] = 'host_details';
export const HostDetailsPanel = ({ name, isRiskScoreExist, scopeId }: HostDetailsPanelProps) => {
// Temporary implementation while Host details left panel don't have Asset tabs
const [tabs, selectedTabId, setSelectedTabId] = useMemo(() => {
export const HostDetailsPanel = ({
name,
isRiskScoreExist,
scopeId,
path,
hasMisconfigurationFindings,
}: HostDetailsPanelProps) => {
const [selectedTabId, setSelectedTabId] = useState(
path?.tab === EntityDetailsLeftPanelTab.CSP_INSIGHTS
? EntityDetailsLeftPanelTab.CSP_INSIGHTS
: EntityDetailsLeftPanelTab.RISK_INPUTS
);
const [tabs] = useMemo(() => {
const isRiskScoreTabAvailable = isRiskScoreExist && name;
return [
isRiskScoreTabAvailable
? [getRiskInputTab({ entityName: name, entityType: RiskScoreEntity.host, scopeId })]
: [],
EntityDetailsLeftPanelTab.RISK_INPUTS,
() => {},
];
}, [name, isRiskScoreExist, scopeId]);
const riskScoreTab = isRiskScoreTabAvailable
? [getRiskInputTab({ entityName: name, entityType: RiskScoreEntity.host, scopeId })]
: [];
// Determine if the Insights tab should be included
const insightsTab = hasMisconfigurationFindings
? [getInsightsInputTab({ name, fieldName: 'host.name' })]
: [];
return [[...riskScoreTab, ...insightsTab], EntityDetailsLeftPanelTab.RISK_INPUTS, () => {}];
}, [isRiskScoreExist, name, scopeId, hasMisconfigurationFindings]);
return (
<>

View file

@ -10,6 +10,8 @@ import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { FlyoutLoading, FlyoutNavigation } from '@kbn/security-solution-common';
import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common';
import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview';
import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id';
import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab';
import type { Refetch } from '../../../common/types';
@ -28,7 +30,7 @@ import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anom
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
import { useObservedHost } from './hooks/use_observed_host';
import { HostDetailsPanelKey } from '../host_details_left';
import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
import { HostPreviewPanelFooter } from '../host_preview/footer';
export interface HostPanelProps extends Record<string, unknown> {
@ -92,6 +94,19 @@ export const HostPanel = ({
{ onSuccess: refetchRiskScore }
);
const { data } = useMisconfigurationPreview({
query: buildEntityFlyoutPreviewQuery('host.name', hostName),
sort: [],
enabled: true,
pageSize: 1,
ignore_unavailable: true,
});
const passedFindings = data?.count.passed || 0;
const failedFindings = data?.count.failed || 0;
const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0;
useQueryInspector({
deleteQuery,
inspect: inspectRiskScore,
@ -114,13 +129,23 @@ export const HostPanel = ({
scopeId,
isRiskScoreExist,
path: tab ? { tab } : undefined,
hasMisconfigurationFindings,
},
});
},
[telemetry, openLeftPanel, hostName, isRiskScoreExist, scopeId]
[telemetry, openLeftPanel, hostName, scopeId, isRiskScoreExist, hasMisconfigurationFindings]
);
const openDefaultPanel = useCallback(
() =>
openTabPanel(
isRiskScoreExist
? EntityDetailsLeftPanelTab.RISK_INPUTS
: EntityDetailsLeftPanelTab.CSP_INSIGHTS
),
[isRiskScoreExist, openTabPanel]
);
const openDefaultPanel = useCallback(() => openTabPanel(), [openTabPanel]);
const observedHost = useObservedHost(hostName, scopeId);
if (observedHost.isLoading) {
@ -147,7 +172,9 @@ export const HostPanel = ({
return (
<>
<FlyoutNavigation
flyoutIsExpandable={!isPreviewMode && isRiskScoreExist}
flyoutIsExpandable={
!isPreviewMode && (isRiskScoreExist || hasMisconfigurationFindings)
}
expandDetails={openDefaultPanel}
/>
<HostPanelHeader hostName={hostName} observedHost={observedHostWithAnomalies} />

View file

@ -22,6 +22,7 @@ export enum EntityDetailsLeftPanelTab {
RISK_INPUTS = 'risk_inputs',
OKTA = 'okta_document',
ENTRA = 'entra_document',
CSP_INSIGHTS = 'csp_insights',
}
export interface PanelHeaderProps {
@ -65,9 +66,7 @@ export const LeftPanelHeader: VFC<PanelHeaderProps> = memo(
border-block-end: none !important;
`}
>
<EuiTabs size="l" expand>
{renderTabs}
</EuiTabs>
<EuiTabs size="l">{renderTabs}</EuiTabs>
</FlyoutHeader>
);
}