mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution] Create new host flyout (#173392)
## Summary Create the Expandable Host flyout with the risk inputs panel.  <img src="b7443ca0
-c369-4b53-99b4-68810d4adab1" width="400" /> <img src="961e37b0
-e2bc-48d0-90f3-55226498529c" width="400" /> ### What is included * Host panel creation * Left risk inputs panel * Refactor user panel component to be reused by Host * Risk score section ### What is not included * Asset integration section * Asset criticality ### How to test it? * Enable experimental flag `newHostDetailsFlyout` * Create alerts with `host.name` field * Open the alerts page and click on the `host.name` field * It should not display the expandable risk input panel * Enable Risk engine * make sure it generates data for the host * Open the flyout once more and check if the expandable risk input panel opens --------- Co-authored-by: Tiago Vila Verde <tiago.vilaverde@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
488edebdb2
commit
effd9e7a19
67 changed files with 2649 additions and 861 deletions
|
@ -100,11 +100,17 @@ export const allowedExperimentalValues = Object.freeze({
|
|||
assistantModelEvaluation: false,
|
||||
|
||||
/*
|
||||
* Enables the new user details flyout displayed on the Alerts page and timeline.
|
||||
* Enables the new user details flyout displayed on the Alerts table.
|
||||
*
|
||||
**/
|
||||
newUserDetailsFlyout: false,
|
||||
|
||||
/*
|
||||
* Enables the new host details flyout displayed on the Alerts table.
|
||||
*
|
||||
**/
|
||||
newHostDetailsFlyout: false,
|
||||
|
||||
/**
|
||||
* Enable risk engine client and initialisation of datastream, component templates and mappings
|
||||
*/
|
||||
|
|
|
@ -71,3 +71,10 @@ export const assertUnreachable = (
|
|||
): never => {
|
||||
throw new Error(`${message}: ${x}`);
|
||||
};
|
||||
|
||||
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
|
||||
/**
|
||||
* The XOR (exclusive OR) allows to ensure that a variable conforms to only one of several possible types.
|
||||
* Read more: https://medium.com/@aeron169/building-a-xor-type-in-typescript-5f4f7e709a9d
|
||||
*/
|
||||
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
|
||||
|
|
|
@ -21,6 +21,7 @@ import { mockGlobalState } from './global_state';
|
|||
import { SUB_PLUGINS_REDUCER } from './utils';
|
||||
import { createSecuritySolutionStorageMock } from './mock_local_storage';
|
||||
import type { StartServices } from '../../types';
|
||||
import { ReactQueryClientProvider } from '../containers/query_client/query_client_provider';
|
||||
|
||||
export const kibanaObservable = new BehaviorSubject({} as unknown as StartServices);
|
||||
|
||||
|
@ -106,13 +107,15 @@ export const StorybookProviders: React.FC = ({ children }) => {
|
|||
<I18nProvider>
|
||||
<KibanaReactContext.Provider>
|
||||
<NavigationProvider core={coreMock}>
|
||||
<CellActionsProvider getTriggerCompatibleActions={() => Promise.resolve([])}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</ReduxStoreProvider>
|
||||
</CellActionsProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<CellActionsProvider getTriggerCompatibleActions={() => Promise.resolve([])}>
|
||||
<ReduxStoreProvider store={store}>
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</ReduxStoreProvider>
|
||||
</CellActionsProvider>
|
||||
</ReactQueryClientProvider>
|
||||
</NavigationProvider>
|
||||
</KibanaReactContext.Provider>
|
||||
</I18nProvider>
|
||||
|
|
|
@ -7,14 +7,14 @@
|
|||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
|
||||
import { PREFIX } from '../../../flyout/shared/test_ids';
|
||||
import { UserDetailsLeftPanelTab } from '../../../flyout/entity_details/user_details_left/tabs';
|
||||
import { RiskInputsTab } from './tabs/risk_inputs';
|
||||
|
||||
export const RISK_INPUTS_TAB_TEST_ID = `${PREFIX}RiskInputsTab` as const;
|
||||
|
||||
export const getRiskInputTab = (alertIds: string[]) => ({
|
||||
id: UserDetailsLeftPanelTab.RISK_INPUTS,
|
||||
id: EntityDetailsLeftPanelTab.RISK_INPUTS,
|
||||
'data-test-subj': RISK_INPUTS_TAB_TEST_ID,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
|
|
|
@ -5,20 +5,38 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
mockHostRiskScoreState,
|
||||
mockUserRiskScoreState,
|
||||
} from '../../../flyout/entity_details/mocks';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { mockRiskScoreState } from '../../../flyout/entity_details/user_right/mocks';
|
||||
import { RiskSummary } from './risk_summary';
|
||||
import type {
|
||||
LensAttributes,
|
||||
VisualizationEmbeddableProps,
|
||||
} from '../../../common/components/visualization_actions/types';
|
||||
|
||||
jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
|
||||
const mockVisualizationEmbeddable = jest
|
||||
.fn()
|
||||
.mockReturnValue(<div data-test-subj="visualization-embeddable" />);
|
||||
|
||||
jest.mock('../../../common/components/visualization_actions/visualization_embeddable', () => ({
|
||||
VisualizationEmbeddable: (props: VisualizationEmbeddableProps) =>
|
||||
mockVisualizationEmbeddable(props),
|
||||
}));
|
||||
|
||||
describe('RiskSummary', () => {
|
||||
beforeEach(() => {
|
||||
mockVisualizationEmbeddable.mockClear();
|
||||
});
|
||||
|
||||
it('renders risk summary table', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={mockRiskScoreState}
|
||||
riskScoreData={mockHostRiskScoreState}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
|
@ -34,7 +52,7 @@ describe('RiskSummary', () => {
|
|||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={{ ...mockRiskScoreState, data: undefined }}
|
||||
riskScoreData={{ ...mockHostRiskScoreState, data: undefined }}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
|
@ -47,7 +65,7 @@ describe('RiskSummary', () => {
|
|||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={mockRiskScoreState}
|
||||
riskScoreData={mockHostRiskScoreState}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
|
@ -61,7 +79,7 @@ describe('RiskSummary', () => {
|
|||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={mockRiskScoreState}
|
||||
riskScoreData={mockHostRiskScoreState}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
|
@ -70,4 +88,52 @@ describe('RiskSummary', () => {
|
|||
|
||||
expect(getByTestId('risk-summary-updatedAt')).toHaveTextContent('Updated Nov 8, 1989');
|
||||
});
|
||||
|
||||
it('builds lens attributes for host risk score', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={mockHostRiskScoreState}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const lensAttributes: LensAttributes =
|
||||
mockVisualizationEmbeddable.mock.calls[0][0].lensAttributes;
|
||||
const datasourceLayers = Object.values(lensAttributes.state.datasourceStates.formBased.layers);
|
||||
const firstColumn = Object.values(datasourceLayers[0].columns)[0];
|
||||
|
||||
expect(lensAttributes.state.query.query).toEqual('host.name: test');
|
||||
expect(firstColumn).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceField: 'host.risk.calculated_score_norm',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('builds lens attributes for user risk score', () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<RiskSummary
|
||||
riskScoreData={mockUserRiskScoreState}
|
||||
queryId={'testQuery'}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const lensAttributes: LensAttributes =
|
||||
mockVisualizationEmbeddable.mock.calls[0][0].lensAttributes;
|
||||
const datasourceLayers = Object.values(lensAttributes.state.datasourceStates.formBased.layers);
|
||||
const firstColumn = Object.values(datasourceLayers[0].columns)[0];
|
||||
|
||||
expect(lensAttributes.state.query.query).toEqual('user.name: test');
|
||||
expect(firstColumn).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceField: 'user.risk.calculated_score_norm',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,7 +21,11 @@ import { css } from '@emotion/react';
|
|||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { UserDetailsLeftPanelTab } from '../../../flyout/entity_details/user_details_left/tabs';
|
||||
import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
|
||||
import type {
|
||||
HostRiskScore,
|
||||
UserRiskScore,
|
||||
} from '../../../../common/search_strategy/security_solution/risk_score';
|
||||
import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect';
|
||||
import { ONE_WEEK_IN_HOURS } from '../../../timelines/components/side_panel/new_user_detail/constants';
|
||||
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
|
||||
|
@ -31,10 +35,10 @@ import { ExpandablePanel } from '../../../flyout/shared/components/expandable_pa
|
|||
import type { RiskScoreState } from '../../api/hooks/use_risk_score';
|
||||
import { getRiskScoreSummaryAttributes } from '../../lens_attributes/risk_score_summary';
|
||||
|
||||
export interface RiskSummaryProps {
|
||||
riskScoreData: RiskScoreState<RiskScoreEntity.user>;
|
||||
export interface RiskSummaryProps<T extends RiskScoreEntity> {
|
||||
riskScoreData: RiskScoreState<T>;
|
||||
queryId: string;
|
||||
openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
|
||||
openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
|
||||
}
|
||||
|
||||
interface TableItem {
|
||||
|
@ -44,203 +48,229 @@ interface TableItem {
|
|||
const LENS_VISUALIZATION_HEIGHT = 126; // Static height in pixels specified by design
|
||||
const LAST_30_DAYS = { from: 'now-30d', to: 'now' };
|
||||
|
||||
export const RiskSummary = React.memo(
|
||||
({ riskScoreData, queryId, openDetailsPanel }: RiskSummaryProps) => {
|
||||
const { data: userRisk } = riskScoreData;
|
||||
const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
function isUserRiskData(
|
||||
riskData: UserRiskScore | HostRiskScore | undefined
|
||||
): riskData is UserRiskScore {
|
||||
return !!riskData && (riskData as UserRiskScore).user !== undefined;
|
||||
}
|
||||
|
||||
const lensAttributes = useMemo(() => {
|
||||
return getRiskScoreSummaryAttributes({
|
||||
severity: userRiskData?.user?.risk?.calculated_level,
|
||||
query: `user.name: ${userRiskData?.user?.name}`,
|
||||
spaceId: 'default',
|
||||
riskEntity: RiskScoreEntity.user,
|
||||
});
|
||||
}, [userRiskData]);
|
||||
const getEntityData = (riskData: UserRiskScore | HostRiskScore | undefined) => {
|
||||
if (!riskData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<TableItem>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'category',
|
||||
name: (
|
||||
if (isUserRiskData(riskData)) {
|
||||
return riskData.user;
|
||||
}
|
||||
|
||||
return riskData.host;
|
||||
};
|
||||
|
||||
const RiskSummaryComponent = <T extends RiskScoreEntity>({
|
||||
riskScoreData,
|
||||
queryId,
|
||||
openDetailsPanel,
|
||||
}: RiskSummaryProps<T>) => {
|
||||
const { data } = riskScoreData;
|
||||
const riskData = data && data.length > 0 ? data[0] : undefined;
|
||||
const entityData = getEntityData(riskData);
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
const lensAttributes = useMemo(() => {
|
||||
const entityName = entityData?.name ?? '';
|
||||
const fieldName = isUserRiskData(riskData) ? 'user.name' : 'host.name';
|
||||
|
||||
return getRiskScoreSummaryAttributes({
|
||||
severity: entityData?.risk?.calculated_level,
|
||||
query: `${fieldName}: ${entityName}`,
|
||||
spaceId: 'default',
|
||||
riskEntity: isUserRiskData(riskData) ? RiskScoreEntity.user : RiskScoreEntity.host,
|
||||
});
|
||||
}, [entityData?.name, entityData?.risk?.calculated_level, riskData]);
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<TableItem>> = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'category',
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.categoryColumnLabel"
|
||||
defaultMessage="Category"
|
||||
/>
|
||||
),
|
||||
truncateText: false,
|
||||
mobileOptions: { show: true },
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'count',
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.inputsColumnLabel"
|
||||
defaultMessage="Inputs"
|
||||
/>
|
||||
),
|
||||
truncateText: false,
|
||||
mobileOptions: { show: true },
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const xsFontSize = useEuiFontSize('xxs').fontSize;
|
||||
|
||||
const items: TableItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
category: i18n.translate('xpack.securitySolution.flyout.entityDetails.alertsGroupLabel', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
count: entityData?.risk.inputs?.length ?? 0,
|
||||
},
|
||||
],
|
||||
[entityData?.risk.inputs?.length]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen
|
||||
id={'risk_summary'}
|
||||
buttonProps={{
|
||||
css: css`
|
||||
color: ${euiTheme.colors.primary};
|
||||
`,
|
||||
}}
|
||||
buttonContent={
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.categoryColumnLabel"
|
||||
defaultMessage="Category"
|
||||
id="xpack.securitySolution.flyout.entityDetails.title"
|
||||
defaultMessage="Risk summary"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
}
|
||||
extraAction={
|
||||
<span
|
||||
data-test-subj="risk-summary-updatedAt"
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
`}
|
||||
>
|
||||
{riskData && (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.riskUpdatedTime"
|
||||
defaultMessage="Updated {time}"
|
||||
values={{
|
||||
time: (
|
||||
<FormattedRelativePreferenceDate
|
||||
value={riskData['@timestamp']}
|
||||
dateFormat="MMM D, YYYY"
|
||||
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<ExpandablePanel
|
||||
header={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.riskInputs"
|
||||
defaultMessage="Risk inputs"
|
||||
/>
|
||||
),
|
||||
truncateText: false,
|
||||
mobileOptions: { show: true },
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'count',
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.inputsColumnLabel"
|
||||
defaultMessage="Inputs"
|
||||
/>
|
||||
),
|
||||
truncateText: false,
|
||||
mobileOptions: { show: true },
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const xsFontSize = useEuiFontSize('xxs').fontSize;
|
||||
|
||||
const items: TableItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
category: i18n.translate('xpack.securitySolution.flyout.entityDetails.alertsGroupLabel', {
|
||||
defaultMessage: 'Alerts',
|
||||
}),
|
||||
count: userRiskData?.user.risk.inputs?.length ?? 0,
|
||||
},
|
||||
],
|
||||
[userRiskData?.user.risk.inputs?.length]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen
|
||||
id={'risk_summary'}
|
||||
buttonProps={{
|
||||
css: css`
|
||||
color: ${euiTheme.colors.primary};
|
||||
`,
|
||||
}}
|
||||
buttonContent={
|
||||
<EuiTitle size="xs">
|
||||
<h3>
|
||||
link: {
|
||||
callback: () => openDetailsPanel(EntityDetailsLeftPanelTab.RISK_INPUTS),
|
||||
tooltip: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.title"
|
||||
defaultMessage="Risk summary"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
}
|
||||
extraAction={
|
||||
<span
|
||||
data-test-subj="risk-summary-updatedAt"
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
`}
|
||||
>
|
||||
{userRiskData && (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.riskUpdatedTime"
|
||||
defaultMessage="Updated {time}"
|
||||
values={{
|
||||
time: (
|
||||
<FormattedRelativePreferenceDate
|
||||
value={userRiskData['@timestamp']}
|
||||
dateFormat="MMM D, YYYY"
|
||||
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<ExpandablePanel
|
||||
header={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.riskInputs"
|
||||
defaultMessage="Risk inputs"
|
||||
id="xpack.securitySolution.flyout.entityDetails.showAllRiskInputs"
|
||||
defaultMessage="Show all risk inputs"
|
||||
/>
|
||||
),
|
||||
link: {
|
||||
callback: () => openDetailsPanel(UserDetailsLeftPanelTab.RISK_INPUTS),
|
||||
tooltip: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.showAllRiskInputs"
|
||||
defaultMessage="Show all risk inputs"
|
||||
},
|
||||
iconType: 'arrowStart',
|
||||
}}
|
||||
expand={{
|
||||
expandable: false,
|
||||
}}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="m" direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
<div
|
||||
// Improve Visualization loading state by predefining the size
|
||||
css={css`
|
||||
height: ${LENS_VISUALIZATION_HEIGHT}px;
|
||||
`}
|
||||
>
|
||||
{riskData && (
|
||||
<VisualizationEmbeddable
|
||||
applyGlobalQueriesAndFilters={false}
|
||||
lensAttributes={lensAttributes}
|
||||
id={`RiskSummary-risk_score_metric`}
|
||||
timerange={LAST_30_DAYS}
|
||||
width={'100%'}
|
||||
height={LENS_VISUALIZATION_HEIGHT}
|
||||
disableOnClickFilter
|
||||
inspectTitle={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.inspectVisualizationTitle"
|
||||
defaultMessage="Risk Summary Visualization"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
iconType: 'arrowStart',
|
||||
}}
|
||||
expand={{
|
||||
expandable: false,
|
||||
}}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="m" direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<InspectButtonContainer>
|
||||
<div
|
||||
// Improve Visualization loading state by predefining the size
|
||||
// Anchors the position absolute inspect button (nearest positioned ancestor)
|
||||
css={css`
|
||||
height: ${LENS_VISUALIZATION_HEIGHT}px;
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
{userRiskData && (
|
||||
<VisualizationEmbeddable
|
||||
applyGlobalQueriesAndFilters={false}
|
||||
lensAttributes={lensAttributes}
|
||||
id={`RiskSummary-risk_score_metric`}
|
||||
timerange={LAST_30_DAYS}
|
||||
width={'100%'}
|
||||
height={LENS_VISUALIZATION_HEIGHT}
|
||||
disableOnClickFilter
|
||||
inspectTitle={
|
||||
<div
|
||||
// Position the inspect button above the table
|
||||
css={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -${euiThemeVars.euiSize};
|
||||
`}
|
||||
>
|
||||
<InspectButton
|
||||
queryId={queryId}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.inspectVisualizationTitle"
|
||||
defaultMessage="Risk Summary Visualization"
|
||||
id="xpack.securitySolution.flyout.entityDetails.inspectTableTitle"
|
||||
defaultMessage="Risk Summary Table"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<InspectButtonContainer>
|
||||
<div
|
||||
// Anchors the position absolute inspect button (nearest positioned ancestor)
|
||||
css={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
// Position the inspect button above the table
|
||||
css={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -${euiThemeVars.euiSize};
|
||||
`}
|
||||
>
|
||||
<InspectButton
|
||||
queryId={queryId}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.inspectTableTitle"
|
||||
defaultMessage="Risk Summary Table"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<EuiBasicTable
|
||||
data-test-subj="risk-summary-table"
|
||||
responsive={false}
|
||||
columns={columns}
|
||||
items={items}
|
||||
compressed
|
||||
/>
|
||||
</div>
|
||||
</InspectButtonContainer>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</ExpandablePanel>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiAccordion>
|
||||
);
|
||||
}
|
||||
);
|
||||
<EuiBasicTable
|
||||
data-test-subj="risk-summary-table"
|
||||
responsive={false}
|
||||
columns={columns}
|
||||
items={items}
|
||||
compressed
|
||||
/>
|
||||
</div>
|
||||
</InspectButtonContainer>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</ExpandablePanel>
|
||||
<EuiSpacer size="s" />
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
||||
export const RiskSummary = React.memo(RiskSummaryComponent);
|
||||
RiskSummary.displayName = 'RiskSummary';
|
||||
|
|
|
@ -42,7 +42,7 @@ export const useHostDetails = ({
|
|||
id = ID,
|
||||
skip = false,
|
||||
startDate,
|
||||
}: UseHostDetails): [boolean, HostDetailsArgs] => {
|
||||
}: UseHostDetails): [boolean, HostDetailsArgs, inputsModel.Refetch] => {
|
||||
const {
|
||||
loading,
|
||||
result: response,
|
||||
|
@ -91,5 +91,5 @@ export const useHostDetails = ({
|
|||
}
|
||||
}, [hostDetailsRequest, search, skip]);
|
||||
|
||||
return [loading, hostDetailsResponse];
|
||||
return [loading, hostDetailsResponse, refetch];
|
||||
};
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { RISK_INPUTS_TAB_TEST_ID } from '../../../entity_analytics/components/entity_details_flyout';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { HostDetailsPanel } from '.';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
|
||||
describe('HostDetailsPanel', () => {
|
||||
it('render risk inputs panel', () => {
|
||||
const { getByTestId } = render(
|
||||
<HostDetailsPanel
|
||||
riskInputs={{
|
||||
alertIds: ['test-id-1', 'test-id-2'],
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: TestProviders }
|
||||
);
|
||||
expect(getByTestId(RISK_INPUTS_TAB_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("doesn't render risk inputs panel when no alerts ids are provided", () => {
|
||||
const { queryByTestId } = render(
|
||||
<HostDetailsPanel
|
||||
riskInputs={{
|
||||
alertIds: [],
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: TestProviders }
|
||||
);
|
||||
expect(queryByTestId(RISK_INPUTS_TAB_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
|
||||
import { getRiskInputTab } from '../../../entity_analytics/components/entity_details_flyout';
|
||||
import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
|
||||
import {
|
||||
EntityDetailsLeftPanelTab,
|
||||
LeftPanelHeader,
|
||||
} from '../shared/components/left_panel/left_panel_header';
|
||||
|
||||
interface RiskInputsParam {
|
||||
alertIds: string[];
|
||||
}
|
||||
|
||||
export interface HostDetailsPanelProps extends Record<string, unknown> {
|
||||
riskInputs: RiskInputsParam;
|
||||
}
|
||||
export interface HostDetailsExpandableFlyoutProps extends FlyoutPanelProps {
|
||||
key: 'host_details';
|
||||
params: HostDetailsPanelProps;
|
||||
}
|
||||
export const HostDetailsPanelKey: HostDetailsExpandableFlyoutProps['key'] = 'host_details';
|
||||
|
||||
export const HostDetailsPanel = ({ riskInputs }: HostDetailsPanelProps) => {
|
||||
// Temporary implementation while Host details left panel don't have Asset tabs
|
||||
const [tabs, selectedTabId, setSelectedTabId] = useMemo(() => {
|
||||
return [
|
||||
riskInputs.alertIds.length > 0 ? [getRiskInputTab(riskInputs.alertIds)] : [],
|
||||
EntityDetailsLeftPanelTab.RISK_INPUTS,
|
||||
() => {},
|
||||
];
|
||||
}, [riskInputs.alertIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeftPanelHeader
|
||||
selectedTabId={selectedTabId}
|
||||
setSelectedTabId={setSelectedTabId}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<LeftPanelContent selectedTabId={selectedTabId} tabs={tabs} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
HostDetailsPanel.displayName = 'HostDetailsPanel';
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 { storiesOf } from '@storybook/react';
|
||||
import { EuiFlyout } from '@elastic/eui';
|
||||
import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/context';
|
||||
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
|
||||
import { StorybookProviders } from '../../../common/mock/storybook_providers';
|
||||
import { mockRiskScoreState } from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
|
||||
import { HostPanelContent } from './content';
|
||||
import { mockObservedHostData } from '../mocks';
|
||||
|
||||
const flyoutContextValue = {
|
||||
openLeftPanel: () => window.alert('openLeftPanel called'),
|
||||
panels: {},
|
||||
} as unknown as ExpandableFlyoutContextValue;
|
||||
|
||||
const riskScoreData = { ...mockRiskScoreState, data: [] };
|
||||
|
||||
storiesOf('Components/HostPanelContent', module)
|
||||
.addDecorator((storyFn) => (
|
||||
<StorybookProviders>
|
||||
<ExpandableFlyoutContext.Provider value={flyoutContextValue}>
|
||||
<EuiFlyout size="m" onClose={() => {}}>
|
||||
{storyFn()}
|
||||
</EuiFlyout>
|
||||
</ExpandableFlyoutContext.Provider>
|
||||
</StorybookProviders>
|
||||
))
|
||||
.add('default', () => (
|
||||
<HostPanelContent
|
||||
observedHost={mockObservedHostData}
|
||||
riskScoreState={riskScoreData}
|
||||
contextID={'test-host-details'}
|
||||
scopeId={'test-scopeId'}
|
||||
isDraggable={false}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
))
|
||||
.add('no observed data', () => (
|
||||
<HostPanelContent
|
||||
observedHost={{
|
||||
details: {},
|
||||
isLoading: false,
|
||||
firstSeen: {
|
||||
isLoading: false,
|
||||
date: undefined,
|
||||
},
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: undefined,
|
||||
},
|
||||
anomalies: { isLoading: false, anomalies: null, jobNameById: {} },
|
||||
}}
|
||||
riskScoreState={riskScoreData}
|
||||
contextID={'test-host-details'}
|
||||
scopeId={'test-scopeId'}
|
||||
isDraggable={false}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
))
|
||||
.add('loading', () => (
|
||||
<HostPanelContent
|
||||
observedHost={{
|
||||
details: {},
|
||||
isLoading: true,
|
||||
firstSeen: {
|
||||
isLoading: true,
|
||||
date: undefined,
|
||||
},
|
||||
lastSeen: {
|
||||
isLoading: true,
|
||||
date: undefined,
|
||||
},
|
||||
anomalies: { isLoading: true, anomalies: null, jobNameById: {} },
|
||||
}}
|
||||
riskScoreState={riskScoreData}
|
||||
contextID={'test-host-details'}
|
||||
scopeId={'test-scopeId'}
|
||||
isDraggable={false}
|
||||
openDetailsPanel={() => {}}
|
||||
/>
|
||||
));
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { EuiHorizontalRule } from '@elastic/eui';
|
||||
|
||||
import React from 'react';
|
||||
import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
|
||||
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import type { RiskScoreEntity, HostItem } from '../../../../common/search_strategy';
|
||||
import { FlyoutBody } from '../../shared/components/flyout_body';
|
||||
import { ObservedEntity } from '../shared/components/observed_entity';
|
||||
import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '.';
|
||||
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
|
||||
import { useObservedHostFields } from './hooks/use_observed_host_fields';
|
||||
import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
|
||||
|
||||
interface HostPanelContentProps {
|
||||
observedHost: ObservedEntityData<HostItem>;
|
||||
riskScoreState: RiskScoreState<RiskScoreEntity.user>;
|
||||
contextID: string;
|
||||
scopeId: string;
|
||||
isDraggable: boolean;
|
||||
openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
|
||||
}
|
||||
|
||||
export const HostPanelContent = ({
|
||||
observedHost,
|
||||
riskScoreState,
|
||||
contextID,
|
||||
scopeId,
|
||||
isDraggable,
|
||||
openDetailsPanel,
|
||||
}: HostPanelContentProps) => {
|
||||
const observedFields = useObservedHostFields(observedHost);
|
||||
|
||||
return (
|
||||
<FlyoutBody>
|
||||
{riskScoreState.isModuleEnabled && riskScoreState.data?.length !== 0 && (
|
||||
<>
|
||||
{
|
||||
<RiskSummary
|
||||
riskScoreData={riskScoreState}
|
||||
queryId={HOST_PANEL_RISK_SCORE_QUERY_ID}
|
||||
openDetailsPanel={openDetailsPanel}
|
||||
/>
|
||||
}
|
||||
<EuiHorizontalRule margin="m" />
|
||||
</>
|
||||
)}
|
||||
<ObservedEntity
|
||||
observedData={observedHost}
|
||||
contextID={contextID}
|
||||
scopeId={scopeId}
|
||||
isDraggable={isDraggable}
|
||||
observedFields={observedFields}
|
||||
queryId={HOST_PANEL_OBSERVED_HOST_QUERY_ID}
|
||||
/>
|
||||
</FlyoutBody>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 { getEmptyTagValue } from '../../../../common/components/empty_value';
|
||||
import type { HostItem } from '../../../../../common/search_strategy';
|
||||
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
|
||||
import { NetworkDetailsLink } from '../../../../common/components/links';
|
||||
import * as i18n from './translations';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import type { EntityTableRows } from '../../shared/components/entity_table/types';
|
||||
|
||||
export const basicHostFields: EntityTableRows<ObservedEntityData<HostItem>> = [
|
||||
{
|
||||
label: i18n.HOST_ID,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.id,
|
||||
field: 'host.id',
|
||||
},
|
||||
{
|
||||
label: i18n.FIRST_SEEN,
|
||||
render: (hostData: ObservedEntityData<HostItem>) =>
|
||||
hostData.firstSeen.date ? (
|
||||
<FormattedRelativePreferenceDate value={hostData.firstSeen.date} />
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
),
|
||||
},
|
||||
{
|
||||
label: i18n.LAST_SEEN,
|
||||
render: (hostData: ObservedEntityData<HostItem>) =>
|
||||
hostData.lastSeen.date ? (
|
||||
<FormattedRelativePreferenceDate value={hostData.lastSeen.date} />
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
),
|
||||
},
|
||||
{
|
||||
label: i18n.IP_ADDRESSES,
|
||||
field: 'host.ip',
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.ip,
|
||||
renderField: (ip: string) => {
|
||||
return <NetworkDetailsLink ip={ip} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: i18n.MAC_ADDRESSES,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.mac,
|
||||
field: 'host.mac',
|
||||
},
|
||||
{
|
||||
label: i18n.PLATFORM,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.os?.platform,
|
||||
field: 'host.os.platform',
|
||||
},
|
||||
{
|
||||
label: i18n.OS,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.os?.name,
|
||||
field: 'host.os.name',
|
||||
},
|
||||
{
|
||||
label: i18n.FAMILY,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.os?.family,
|
||||
field: 'host.os.family',
|
||||
},
|
||||
{
|
||||
label: i18n.VERSION,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.os?.version,
|
||||
field: 'host.os.version',
|
||||
},
|
||||
{
|
||||
label: i18n.ARCHITECTURE,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.host?.architecture,
|
||||
field: 'host.architecture',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { HostItem } from '../../../../../common/search_strategy';
|
||||
import type { EntityTableRows } from '../../shared/components/entity_table/types';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const cloudFields: EntityTableRows<ObservedEntityData<HostItem>> = [
|
||||
{
|
||||
label: i18n.CLOUD_PROVIDER,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.cloud?.provider,
|
||||
field: 'cloud.provider',
|
||||
},
|
||||
{
|
||||
label: i18n.REGION,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.cloud?.region,
|
||||
field: 'cloud.region',
|
||||
},
|
||||
{
|
||||
label: i18n.INSTANCE_ID,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.cloud?.instance?.id,
|
||||
field: 'cloud.instance.id',
|
||||
},
|
||||
{
|
||||
label: i18n.MACHINE_TYPE,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) => hostData.details.cloud?.machine?.type,
|
||||
field: 'cloud.machine.type',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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 { TestProviders } from '../../../../common/mock';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { mockObservedHostData } from '../../mocks';
|
||||
import { policyFields } from './endpoint_policy_fields';
|
||||
|
||||
const TestWrapper = ({ el }: { el: JSX.Element | undefined }) => <>{el}</>;
|
||||
|
||||
jest.mock(
|
||||
'../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary',
|
||||
() => {
|
||||
const original = jest.requireActual(
|
||||
'../../../../management/hooks/response_actions/use_get_endpoint_pending_actions_summary'
|
||||
);
|
||||
return {
|
||||
...original,
|
||||
useGetEndpointPendingActionsSummary: () => ({
|
||||
pendingActions: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isTimeout: false,
|
||||
fetch: jest.fn(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('Endpoint Policy Fields', () => {
|
||||
it('renders policy name', () => {
|
||||
const policyName = policyFields[0];
|
||||
|
||||
const { container } = render(<TestWrapper el={policyName.render?.(mockObservedHostData)} />);
|
||||
|
||||
expect(container).toHaveTextContent('policy-name');
|
||||
});
|
||||
|
||||
it('renders policy status', () => {
|
||||
const policyStatus = policyFields[1];
|
||||
|
||||
const { container } = render(<TestWrapper el={policyStatus.render?.(mockObservedHostData)} />);
|
||||
|
||||
expect(container).toHaveTextContent('failure');
|
||||
});
|
||||
|
||||
it('renders agent status', () => {
|
||||
const agentStatus = policyFields[3];
|
||||
|
||||
const { container } = render(<TestWrapper el={agentStatus.render?.(mockObservedHostData)} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(container).toHaveTextContent('Healthy');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 { EuiHealth } from '@elastic/eui';
|
||||
|
||||
import type { EntityTableRows } from '../../shared/components/entity_table/types';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import { EndpointAgentStatus } from '../../../../common/components/endpoint/endpoint_agent_status';
|
||||
import { getEmptyTagValue } from '../../../../common/components/empty_value';
|
||||
import type { HostItem } from '../../../../../common/search_strategy';
|
||||
import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const policyFields: EntityTableRows<ObservedEntityData<HostItem>> = [
|
||||
{
|
||||
label: i18n.ENDPOINT_POLICY,
|
||||
render: (hostData: ObservedEntityData<HostItem>) => {
|
||||
const appliedPolicy = hostData.details.endpoint?.hostInfo?.metadata.Endpoint.policy.applied;
|
||||
return appliedPolicy?.name ? <>{appliedPolicy.name}</> : getEmptyTagValue();
|
||||
},
|
||||
isVisible: (hostData: ObservedEntityData<HostItem>) => hostData.details.endpoint != null,
|
||||
},
|
||||
{
|
||||
label: i18n.POLICY_STATUS,
|
||||
render: (hostData: ObservedEntityData<HostItem>) => {
|
||||
const appliedPolicy = hostData.details.endpoint?.hostInfo?.metadata.Endpoint.policy.applied;
|
||||
const policyColor =
|
||||
appliedPolicy?.status === HostPolicyResponseActionStatus.failure
|
||||
? 'danger'
|
||||
: appliedPolicy?.status;
|
||||
|
||||
return appliedPolicy?.status ? (
|
||||
<EuiHealth aria-label={appliedPolicy?.status} color={policyColor}>
|
||||
{appliedPolicy?.status}
|
||||
</EuiHealth>
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
);
|
||||
},
|
||||
isVisible: (hostData: ObservedEntityData<HostItem>) => hostData.details.endpoint != null,
|
||||
},
|
||||
{
|
||||
label: i18n.SENSORVERSION,
|
||||
getValues: (hostData: ObservedEntityData<HostItem>) =>
|
||||
hostData.details.endpoint?.hostInfo?.metadata.agent.version
|
||||
? [hostData.details.endpoint?.hostInfo?.metadata.agent.version]
|
||||
: undefined,
|
||||
field: 'agent.version',
|
||||
isVisible: (hostData: ObservedEntityData<HostItem>) => hostData.details.endpoint != null,
|
||||
},
|
||||
{
|
||||
label: i18n.FLEET_AGENT_STATUS,
|
||||
render: (hostData: ObservedEntityData<HostItem>) =>
|
||||
hostData.details.endpoint?.hostInfo ? (
|
||||
<EndpointAgentStatus
|
||||
endpointHostInfo={hostData.details.endpoint?.hostInfo}
|
||||
data-test-subj="endpointHostAgentStatus"
|
||||
/>
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
),
|
||||
isVisible: (hostData: ObservedEntityData<HostItem>) => hostData.details.endpoint != null,
|
||||
},
|
||||
];
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const HOST_ID = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.hostIdTitle',
|
||||
{
|
||||
defaultMessage: 'Host ID',
|
||||
}
|
||||
);
|
||||
|
||||
export const FIRST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.firstSeenTitle',
|
||||
{
|
||||
defaultMessage: 'First seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const LAST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.lastSeenTitle',
|
||||
{
|
||||
defaultMessage: 'Last seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const IP_ADDRESSES = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.ipAddressesTitle',
|
||||
{
|
||||
defaultMessage: 'IP addresses',
|
||||
}
|
||||
);
|
||||
|
||||
export const MAC_ADDRESSES = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.macAddressesTitle',
|
||||
{
|
||||
defaultMessage: 'MAC addresses',
|
||||
}
|
||||
);
|
||||
|
||||
export const PLATFORM = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.platformTitle',
|
||||
{
|
||||
defaultMessage: 'Platform',
|
||||
}
|
||||
);
|
||||
|
||||
export const OS = i18n.translate('xpack.securitySolution.flyout.entityDetails.host.osTitle', {
|
||||
defaultMessage: 'Operating system',
|
||||
});
|
||||
|
||||
export const FAMILY = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.familyTitle',
|
||||
{
|
||||
defaultMessage: 'Family',
|
||||
}
|
||||
);
|
||||
|
||||
export const VERSION = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.versionLabel',
|
||||
{
|
||||
defaultMessage: 'Version',
|
||||
}
|
||||
);
|
||||
|
||||
export const ARCHITECTURE = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.architectureLabel',
|
||||
{
|
||||
defaultMessage: 'Architecture',
|
||||
}
|
||||
);
|
||||
|
||||
export const CLOUD_PROVIDER = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.cloudProviderTitle',
|
||||
{
|
||||
defaultMessage: 'Cloud provider',
|
||||
}
|
||||
);
|
||||
|
||||
export const REGION = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.regionTitle',
|
||||
{
|
||||
defaultMessage: 'Region',
|
||||
}
|
||||
);
|
||||
|
||||
export const INSTANCE_ID = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.instanceIdTitle',
|
||||
{
|
||||
defaultMessage: 'Instance ID',
|
||||
}
|
||||
);
|
||||
|
||||
export const MACHINE_TYPE = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.machineTypeTitle',
|
||||
{
|
||||
defaultMessage: 'Machine type',
|
||||
}
|
||||
);
|
||||
|
||||
export const ENDPOINT_POLICY = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.endpoint.endpointPolicy',
|
||||
{
|
||||
defaultMessage: 'Endpoint integration policy',
|
||||
}
|
||||
);
|
||||
|
||||
export const POLICY_STATUS = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.endpoint.policyStatus',
|
||||
{
|
||||
defaultMessage: 'Policy Status',
|
||||
}
|
||||
);
|
||||
|
||||
export const SENSORVERSION = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.endpoint.sensorversion',
|
||||
{
|
||||
defaultMessage: 'Endpoint version',
|
||||
}
|
||||
);
|
||||
|
||||
export const FLEET_AGENT_STATUS = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.host.endpoint.fleetAgentStatus',
|
||||
{
|
||||
defaultMessage: 'Agent status',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { HostPanelHeader } from './header';
|
||||
import { mockObservedHostData } from '../mocks';
|
||||
|
||||
const mockProps = {
|
||||
hostName: 'test',
|
||||
observedHost: mockObservedHostData,
|
||||
};
|
||||
|
||||
jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
|
||||
|
||||
describe('HostPanelHeader', () => {
|
||||
it('renders', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanelHeader {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('host-panel-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders observed date', () => {
|
||||
const futureDay = '2989-03-07T20:00:00.000Z';
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanelHeader
|
||||
{...{
|
||||
...mockProps,
|
||||
observedHost: {
|
||||
...mockObservedHostData,
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: futureDay,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('host-panel-header-lastSeen').textContent).toContain('Mar 7, 2989');
|
||||
});
|
||||
|
||||
it('renders observed badge when lastSeen is defined', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanelHeader {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('host-panel-header-observed-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render observed badge when lastSeen date is undefined', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanelHeader
|
||||
{...{
|
||||
...mockProps,
|
||||
observedHost: {
|
||||
...mockObservedHostData,
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(queryByTestId('host-panel-header-observed-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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 { EuiSpacer, EuiBadge, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SecurityPageName } from '@kbn/security-solution-navigation';
|
||||
import type { HostItem } from '../../../../common/search_strategy';
|
||||
import { getHostDetailsUrl } from '../../../common/components/link_to';
|
||||
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
|
||||
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
|
||||
import { FlyoutHeader } from '../../shared/components/flyout_header';
|
||||
import { FlyoutTitle } from '../../shared/components/flyout_title';
|
||||
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
|
||||
|
||||
interface HostPanelHeaderProps {
|
||||
hostName: string;
|
||||
observedHost: ObservedEntityData<HostItem>;
|
||||
}
|
||||
|
||||
export const HostPanelHeader = ({ hostName, observedHost }: HostPanelHeaderProps) => {
|
||||
const lastSeenDate = useMemo(
|
||||
() => observedHost.lastSeen.date && new Date(observedHost.lastSeen.date),
|
||||
[observedHost.lastSeen.date]
|
||||
);
|
||||
|
||||
return (
|
||||
<FlyoutHeader data-test-subj="host-panel-header">
|
||||
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs" data-test-subj={'host-panel-header-lastSeen'}>
|
||||
{lastSeenDate && <PreferenceFormattedDate value={lastSeenDate} />}
|
||||
<EuiSpacer size="xs" />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<SecuritySolutionLinkAnchor
|
||||
deepLinkId={SecurityPageName.hosts}
|
||||
path={getHostDetailsUrl(hostName)}
|
||||
target={'_blank'}
|
||||
external={false}
|
||||
>
|
||||
<FlyoutTitle title={hostName} iconType={'storage'} isLink />
|
||||
</SecuritySolutionLinkAnchor>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{observedHost.lastSeen.date && (
|
||||
<EuiBadge data-test-subj="host-panel-header-observed-badge" color="hollow">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.host.observedBadge"
|
||||
defaultMessage="Observed"
|
||||
/>
|
||||
</EuiBadge>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FlyoutHeader>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { useHostDetails } from '../../../../explore/hosts/containers/hosts/details';
|
||||
import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import type { HostItem } from '../../../../../common/search_strategy';
|
||||
import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy';
|
||||
import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '..';
|
||||
import { useQueryInspector } from '../../../../common/components/page/manage_query';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
|
||||
export const useObservedHost = (
|
||||
hostName: string
|
||||
): Omit<ObservedEntityData<HostItem>, 'anomalies'> => {
|
||||
const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime();
|
||||
const { selectedPatterns } = useSourcererDataView();
|
||||
|
||||
const [isLoading, { hostDetails, inspect: inspectObservedHost }, refetch] = useHostDetails({
|
||||
endDate: to,
|
||||
hostName,
|
||||
indexNames: selectedPatterns,
|
||||
id: HOST_PANEL_RISK_SCORE_QUERY_ID,
|
||||
skip: isInitializing,
|
||||
startDate: from,
|
||||
});
|
||||
|
||||
useQueryInspector({
|
||||
deleteQuery,
|
||||
inspect: inspectObservedHost,
|
||||
loading: isLoading,
|
||||
queryId: HOST_PANEL_OBSERVED_HOST_QUERY_ID,
|
||||
refetch,
|
||||
setQuery,
|
||||
});
|
||||
|
||||
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
|
||||
field: 'host.name',
|
||||
value: hostName,
|
||||
defaultIndex: selectedPatterns,
|
||||
order: Direction.asc,
|
||||
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
|
||||
});
|
||||
|
||||
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
|
||||
field: 'host.name',
|
||||
value: hostName,
|
||||
defaultIndex: selectedPatterns,
|
||||
order: Direction.desc,
|
||||
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
details: hostDetails,
|
||||
isLoading: isLoading || loadingLastSeen || loadingFirstSeen,
|
||||
firstSeen: {
|
||||
date: firstSeen,
|
||||
isLoading: loadingFirstSeen,
|
||||
},
|
||||
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
|
||||
}),
|
||||
[firstSeen, hostDetails, isLoading, lastSeen, loadingFirstSeen, loadingLastSeen]
|
||||
);
|
||||
};
|
|
@ -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 { renderHook } from '@testing-library/react-hooks';
|
||||
import { useObservedHostFields } from './use_observed_host_fields';
|
||||
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import { mockObservedHostData } from '../../mocks';
|
||||
|
||||
describe('useManagedUserItems', () => {
|
||||
it('returns managed user items for Entra user', () => {
|
||||
const { result } = renderHook(() => useObservedHostFields(mockObservedHostData), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"field": "host.id",
|
||||
"getValues": [Function],
|
||||
"label": "Host ID",
|
||||
},
|
||||
Object {
|
||||
"label": "First seen",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"label": "Last seen",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "host.ip",
|
||||
"getValues": [Function],
|
||||
"label": "IP addresses",
|
||||
"renderField": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "host.mac",
|
||||
"getValues": [Function],
|
||||
"label": "MAC addresses",
|
||||
},
|
||||
Object {
|
||||
"field": "host.os.platform",
|
||||
"getValues": [Function],
|
||||
"label": "Platform",
|
||||
},
|
||||
Object {
|
||||
"field": "host.os.name",
|
||||
"getValues": [Function],
|
||||
"label": "Operating system",
|
||||
},
|
||||
Object {
|
||||
"field": "host.os.family",
|
||||
"getValues": [Function],
|
||||
"label": "Family",
|
||||
},
|
||||
Object {
|
||||
"field": "host.os.version",
|
||||
"getValues": [Function],
|
||||
"label": "Version",
|
||||
},
|
||||
Object {
|
||||
"field": "host.architecture",
|
||||
"getValues": [Function],
|
||||
"label": "Architecture",
|
||||
},
|
||||
Object {
|
||||
"isVisible": [Function],
|
||||
"label": "Max anomaly score by job",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "cloud.provider",
|
||||
"getValues": [Function],
|
||||
"label": "Cloud provider",
|
||||
},
|
||||
Object {
|
||||
"field": "cloud.region",
|
||||
"getValues": [Function],
|
||||
"label": "Region",
|
||||
},
|
||||
Object {
|
||||
"field": "cloud.instance.id",
|
||||
"getValues": [Function],
|
||||
"label": "Instance ID",
|
||||
},
|
||||
Object {
|
||||
"field": "cloud.machine.type",
|
||||
"getValues": [Function],
|
||||
"label": "Machine type",
|
||||
},
|
||||
Object {
|
||||
"isVisible": [Function],
|
||||
"label": "Endpoint integration policy",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"isVisible": [Function],
|
||||
"label": "Policy Status",
|
||||
"render": [Function],
|
||||
},
|
||||
Object {
|
||||
"field": "agent.version",
|
||||
"getValues": [Function],
|
||||
"isVisible": [Function],
|
||||
"label": "Endpoint version",
|
||||
},
|
||||
Object {
|
||||
"isVisible": [Function],
|
||||
"label": "Agent status",
|
||||
"render": [Function],
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
expect(
|
||||
result.current.map(({ getValues }) => getValues && getValues(mockObservedHostData))
|
||||
).toEqual([
|
||||
['host-id'],
|
||||
undefined, // First seen doesn't implement getValues
|
||||
undefined, // Last seen doesn't implement getValues
|
||||
['host-ip'],
|
||||
['host-mac'],
|
||||
['host-platform'],
|
||||
['os-name'],
|
||||
['host-family'],
|
||||
['host-version'],
|
||||
['host-architecture'],
|
||||
undefined, // Max anomaly score by job doesn't implement getValues
|
||||
['cloud-provider'],
|
||||
['cloud-region'],
|
||||
['cloud-instance-id'],
|
||||
['cloud-machine-type'],
|
||||
undefined, // Endpoint integration policy doesn't implement getValues
|
||||
undefined, // Policy Status doesn't implement getValues
|
||||
['endpoint-agent-version'],
|
||||
undefined, // Agent status doesn't implement getValues
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
|
||||
import type { HostItem } from '../../../../../common/search_strategy';
|
||||
import { getAnomaliesFields } from '../../shared/common';
|
||||
import type { EntityTableRows } from '../../shared/components/entity_table/types';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import { policyFields } from '../fields/endpoint_policy_fields';
|
||||
import { basicHostFields } from '../fields/basic_host_fields';
|
||||
import { cloudFields } from '../fields/cloud_fields';
|
||||
|
||||
export const useObservedHostFields = (
|
||||
hostData: ObservedEntityData<HostItem>
|
||||
): EntityTableRows<ObservedEntityData<HostItem>> => {
|
||||
const mlCapabilities = useMlCapabilities();
|
||||
|
||||
return useMemo(() => {
|
||||
if (hostData == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...basicHostFields,
|
||||
...getAnomaliesFields(mlCapabilities),
|
||||
...cloudFields,
|
||||
...policyFields,
|
||||
];
|
||||
}, [hostData, mlCapabilities]);
|
||||
};
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { mockHostRiskScoreState, mockObservedHostData } from '../mocks';
|
||||
|
||||
import type { HostPanelProps } from '.';
|
||||
import { HostPanel } from '.';
|
||||
|
||||
const mockProps: HostPanelProps = {
|
||||
hostName: 'test',
|
||||
contextID: 'test-host -panel',
|
||||
scopeId: 'test-scope-id',
|
||||
isDraggable: false,
|
||||
};
|
||||
|
||||
jest.mock('../../../common/components/visualization_actions/visualization_embeddable');
|
||||
|
||||
const mockedHostRiskScore = jest.fn().mockReturnValue(mockHostRiskScoreState);
|
||||
jest.mock('../../../entity_analytics/api/hooks/use_risk_score', () => ({
|
||||
useRiskScore: () => mockedHostRiskScore(),
|
||||
}));
|
||||
|
||||
const mockedUseObservedHost = jest.fn().mockReturnValue(mockObservedHostData);
|
||||
|
||||
jest.mock('./hooks/use_observed_host', () => ({
|
||||
useObservedHost: () => mockedUseObservedHost(),
|
||||
}));
|
||||
|
||||
describe('HostPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockedHostRiskScore.mockReturnValue(mockHostRiskScoreState);
|
||||
mockedUseObservedHost.mockReturnValue(mockObservedHostData);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanel {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('host-panel-header')).toBeInTheDocument();
|
||||
expect(queryByTestId('securitySolutionFlyoutLoading')).not.toBeInTheDocument();
|
||||
expect(getByTestId('securitySolutionFlyoutNavigationExpandDetailButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state when risk score is loading', () => {
|
||||
mockedHostRiskScore.mockReturnValue({
|
||||
...mockHostRiskScoreState,
|
||||
data: undefined,
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanel {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state when observed host is loading', () => {
|
||||
mockedUseObservedHost.mockReturnValue({
|
||||
...mockObservedHostData,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<HostPanel {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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, { useCallback, useMemo } from 'react';
|
||||
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
|
||||
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
|
||||
|
||||
import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria';
|
||||
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import { useQueryInspector } from '../../../common/components/page/manage_query';
|
||||
import { useGlobalTime } from '../../../common/containers/use_global_time';
|
||||
import type { HostItem } from '../../../../common/search_strategy';
|
||||
import { buildHostNamesFilter } from '../../../../common/search_strategy';
|
||||
import { RiskScoreEntity } from '../../../../common/entity_analytics/risk_engine';
|
||||
import { FlyoutLoading } from '../../shared/components/flyout_loading';
|
||||
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
|
||||
import { HostPanelContent } from './content';
|
||||
import { HostPanelHeader } from './header';
|
||||
import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider';
|
||||
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';
|
||||
|
||||
export interface HostPanelProps extends Record<string, unknown> {
|
||||
contextID: string;
|
||||
scopeId: string;
|
||||
hostName: string;
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export interface HostPanelExpandableFlyoutProps extends FlyoutPanelProps {
|
||||
key: 'host-panel';
|
||||
params: HostPanelProps;
|
||||
}
|
||||
|
||||
export const HostPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-panel';
|
||||
export const HOST_PANEL_RISK_SCORE_QUERY_ID = 'HostPanelRiskScoreQuery';
|
||||
export const HOST_PANEL_OBSERVED_HOST_QUERY_ID = 'HostPanelObservedHostQuery';
|
||||
|
||||
const FIRST_RECORD_PAGINATION = {
|
||||
cursorStart: 0,
|
||||
querySize: 1,
|
||||
};
|
||||
|
||||
export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPanelProps) => {
|
||||
const { openLeftPanel } = useExpandableFlyoutContext();
|
||||
const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime();
|
||||
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, inspect: inspectRiskScore, refetch, loading } = riskScoreState;
|
||||
const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined;
|
||||
|
||||
useQueryInspector({
|
||||
deleteQuery,
|
||||
inspect: inspectRiskScore,
|
||||
loading,
|
||||
queryId: HOST_PANEL_RISK_SCORE_QUERY_ID,
|
||||
refetch,
|
||||
setQuery,
|
||||
});
|
||||
|
||||
const openTabPanel = useCallback(
|
||||
(tab?: EntityDetailsLeftPanelTab) => {
|
||||
openLeftPanel({
|
||||
id: HostDetailsPanelKey,
|
||||
params: {
|
||||
riskInputs: {
|
||||
alertIds: hostRiskData?.host.risk.inputs?.map(({ id }) => id) ?? [],
|
||||
host: {
|
||||
name: hostName,
|
||||
},
|
||||
},
|
||||
path: tab ? { tab } : undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
[openLeftPanel, hostRiskData?.host.risk.inputs, hostName]
|
||||
);
|
||||
|
||||
const openDefaultPanel = useCallback(() => openTabPanel(), [openTabPanel]);
|
||||
const observedHost = useObservedHost(hostName);
|
||||
|
||||
if (riskScoreState.loading || observedHost.isLoading) {
|
||||
return <FlyoutLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnomalyTableProvider
|
||||
criteriaFields={hostToCriteria(observedHost.details)}
|
||||
startDate={from}
|
||||
endDate={to}
|
||||
skip={isInitializing}
|
||||
>
|
||||
{({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => {
|
||||
const observedHostWithAnomalies: ObservedEntityData<HostItem> = {
|
||||
...observedHost,
|
||||
anomalies: {
|
||||
isLoading: isLoadingAnomaliesData,
|
||||
anomalies: anomaliesData,
|
||||
jobNameById,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlyoutNavigation
|
||||
flyoutIsExpandable={!!hostRiskData?.host.risk}
|
||||
expandDetails={openDefaultPanel}
|
||||
/>
|
||||
<HostPanelHeader hostName={hostName} observedHost={observedHostWithAnomalies} />
|
||||
<HostPanelContent
|
||||
observedHost={observedHostWithAnomalies}
|
||||
riskScoreState={riskScoreState}
|
||||
contextID={contextID}
|
||||
scopeId={scopeId}
|
||||
isDraggable={!!isDraggable}
|
||||
openDetailsPanel={openTabPanel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AnomalyTableProvider>
|
||||
);
|
||||
};
|
||||
|
||||
HostPanel.displayName = 'HostPanel';
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* 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 { HostMetadataInterface } from '../../../../common/endpoint/types';
|
||||
import { EndpointStatus, HostStatus } from '../../../../common/endpoint/types';
|
||||
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import type {
|
||||
HostItem,
|
||||
HostRiskScore,
|
||||
RiskScoreEntity,
|
||||
UserRiskScore,
|
||||
} from '../../../../common/search_strategy';
|
||||
import { HostPolicyResponseActionStatus, RiskSeverity } from '../../../../common/search_strategy';
|
||||
import { RiskCategories } from '../../../../common/entity_analytics/risk_engine';
|
||||
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
|
||||
|
||||
const userRiskScore: UserRiskScore = {
|
||||
'@timestamp': '1989-11-08T23:00:00.000Z',
|
||||
user: {
|
||||
name: 'test',
|
||||
risk: {
|
||||
rule_risks: [],
|
||||
calculated_score_norm: 70,
|
||||
multipliers: [],
|
||||
calculated_level: RiskSeverity.high,
|
||||
inputs: [
|
||||
{
|
||||
id: '_id',
|
||||
index: '_index',
|
||||
category: RiskCategories.category_1,
|
||||
description: 'Alert from Rule: My rule',
|
||||
risk_score: 30,
|
||||
timestamp: '2021-08-19T18:55:59.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
alertsCount: 0,
|
||||
oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
|
||||
};
|
||||
|
||||
const hostRiskScore: HostRiskScore = {
|
||||
'@timestamp': '1989-11-08T23:00:00.000Z',
|
||||
host: {
|
||||
name: 'test',
|
||||
risk: {
|
||||
rule_risks: [],
|
||||
calculated_score_norm: 70,
|
||||
multipliers: [],
|
||||
calculated_level: RiskSeverity.high,
|
||||
inputs: [
|
||||
{
|
||||
id: '_id',
|
||||
index: '_index',
|
||||
category: RiskCategories.category_1,
|
||||
description: 'Alert from Rule: My rule',
|
||||
risk_score: 30,
|
||||
timestamp: '2021-08-19T18:55:59.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
alertsCount: 0,
|
||||
oldestAlertTimestamp: '1989-11-08T23:00:00.000Z',
|
||||
};
|
||||
|
||||
export const mockUserRiskScoreState: RiskScoreState<RiskScoreEntity.user> = {
|
||||
data: [userRiskScore],
|
||||
inspect: {
|
||||
dsl: [],
|
||||
response: [],
|
||||
},
|
||||
isInspected: false,
|
||||
refetch: () => {},
|
||||
totalCount: 0,
|
||||
isModuleEnabled: true,
|
||||
isAuthorized: true,
|
||||
isDeprecated: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const mockHostRiskScoreState: RiskScoreState<RiskScoreEntity.host> = {
|
||||
data: [hostRiskScore],
|
||||
inspect: {
|
||||
dsl: [],
|
||||
response: [],
|
||||
},
|
||||
isInspected: false,
|
||||
refetch: () => {},
|
||||
totalCount: 0,
|
||||
isModuleEnabled: true,
|
||||
isAuthorized: true,
|
||||
isDeprecated: false,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const hostMetadata: HostMetadataInterface = {
|
||||
'@timestamp': 1036358673463478,
|
||||
|
||||
agent: {
|
||||
id: 'endpoint-agent-id',
|
||||
version: 'endpoint-agent-version',
|
||||
type: 'endpoint-agent-type',
|
||||
},
|
||||
Endpoint: {
|
||||
status: EndpointStatus.enrolled,
|
||||
policy: {
|
||||
applied: {
|
||||
name: 'policy-name',
|
||||
id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A',
|
||||
endpoint_policy_version: 3,
|
||||
version: 5,
|
||||
status: HostPolicyResponseActionStatus.failure,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as HostMetadataInterface;
|
||||
|
||||
export const mockObservedHost: HostItem = {
|
||||
host: {
|
||||
id: ['host-id'],
|
||||
mac: ['host-mac'],
|
||||
architecture: ['host-architecture'],
|
||||
os: {
|
||||
platform: ['host-platform'],
|
||||
name: ['os-name'],
|
||||
version: ['host-version'],
|
||||
family: ['host-family'],
|
||||
},
|
||||
ip: ['host-ip'],
|
||||
name: ['host-name'],
|
||||
},
|
||||
cloud: {
|
||||
instance: {
|
||||
id: ['cloud-instance-id'],
|
||||
},
|
||||
provider: ['cloud-provider'],
|
||||
region: ['cloud-region'],
|
||||
machine: {
|
||||
type: ['cloud-machine-type'],
|
||||
},
|
||||
},
|
||||
endpoint: {
|
||||
hostInfo: {
|
||||
metadata: hostMetadata,
|
||||
host_status: HostStatus.HEALTHY,
|
||||
last_checkin: 'host-last-checkin',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockObservedHostData: ObservedEntityData<HostItem> = {
|
||||
details: mockObservedHost,
|
||||
isLoading: false,
|
||||
firstSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
anomalies: { isLoading: false, anomalies: null, jobNameById: {} },
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { getAnomaliesFields } from './common';
|
||||
import { emptyMlCapabilities } from '../../../../common/machine_learning/empty_ml_capabilities';
|
||||
|
||||
const emptyMlCapabilitiesProvider = {
|
||||
...emptyMlCapabilities,
|
||||
capabilitiesFetched: false,
|
||||
};
|
||||
|
||||
describe('getAnomaliesFields', () => {
|
||||
it('returns max anomaly score', () => {
|
||||
const field = getAnomaliesFields(emptyMlCapabilitiesProvider);
|
||||
|
||||
expect(field[0].label).toBe('Max anomaly score by job');
|
||||
});
|
||||
|
||||
it('hides anomalies field when user has no permissions', () => {
|
||||
const field = getAnomaliesFields(emptyMlCapabilitiesProvider);
|
||||
|
||||
expect(field[0].isVisible()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows anomalies field when user has permissions', () => {
|
||||
const mlCapabilitiesProvider = {
|
||||
...emptyMlCapabilities,
|
||||
capabilitiesFetched: false,
|
||||
capabilities: {
|
||||
...emptyMlCapabilities.capabilities,
|
||||
canGetJobs: true,
|
||||
canGetDatafeeds: true,
|
||||
canGetCalendars: true,
|
||||
},
|
||||
};
|
||||
|
||||
const field = getAnomaliesFields(mlCapabilitiesProvider);
|
||||
|
||||
expect(field[0].isVisible()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import type { ObservedEntityData } from './components/observed_entity/types';
|
||||
import type { MlCapabilitiesProvider } from '../../../common/components/ml/permissions/ml_capabilities_provider';
|
||||
import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions';
|
||||
import { getEmptyTagValue } from '../../../common/components/empty_value';
|
||||
import type { HostItem } from '../../../../common/search_strategy';
|
||||
import { AnomaliesField } from './components/anomalies_field';
|
||||
|
||||
export const getAnomaliesFields = (mlCapabilities: MlCapabilitiesProvider) => [
|
||||
{
|
||||
label: i18n.translate('xpack.securitySolution.timeline.sidePanel.maxAnomalyScoreByJobTitle', {
|
||||
defaultMessage: 'Max anomaly score by job',
|
||||
}),
|
||||
render: (hostData: ObservedEntityData<HostItem>) =>
|
||||
hostData.anomalies ? <AnomaliesField anomalies={hostData.anomalies} /> : getEmptyTagValue(),
|
||||
isVisible: () => hasMlUserPermissions(mlCapabilities),
|
||||
},
|
||||
];
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 { mockAnomalies } from '../../../../common/components/ml/mock';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { AnomaliesField } from './anomalies_field';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
|
||||
jest.mock('../../../../common/components/cell_actions', () => {
|
||||
const actual = jest.requireActual('../../../../common/components/cell_actions');
|
||||
return {
|
||||
...actual,
|
||||
SecurityCellActions: () => <></>,
|
||||
};
|
||||
});
|
||||
|
||||
describe('getAnomaliesFields', () => {
|
||||
it('returns max anomaly score', () => {
|
||||
const { getByTestId } = render(
|
||||
<AnomaliesField
|
||||
anomalies={{ isLoading: false, anomalies: mockAnomalies, jobNameById: {} }}
|
||||
/>,
|
||||
{
|
||||
wrapper: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getByTestId('anomaly-scores')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { EntityAnomalies } from './observed_entity/types';
|
||||
import { AnomalyScores } from '../../../../common/components/ml/score/anomaly_scores';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
|
||||
|
||||
export const AnomaliesField = ({ anomalies }: { anomalies: EntityAnomalies }) => {
|
||||
const { to, from } = useGlobalTime();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const narrowDateRange = useCallback(
|
||||
(score, interval) => {
|
||||
const fromTo = scoreIntervalToDateTime(score, interval);
|
||||
dispatch(
|
||||
setAbsoluteRangeDatePicker({
|
||||
id: InputsModelId.global,
|
||||
from: fromTo.from,
|
||||
to: fromTo.to,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<AnomalyScores
|
||||
anomalies={anomalies.anomalies}
|
||||
startDate={from}
|
||||
endDate={to}
|
||||
isLoading={anomalies.isLoading}
|
||||
narrowDateRange={narrowDateRange}
|
||||
jobNameById={anomalies.jobNameById}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 { css } from '@emotion/react';
|
||||
import React from 'react';
|
||||
import { euiLightVars } from '@kbn/ui-theme';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { DefaultFieldRenderer } from '../../../../../timelines/components/field_renderers/field_renderers';
|
||||
import { getEmptyTagValue } from '../../../../../common/components/empty_value';
|
||||
import { getSourcererScopeId } from '../../../../../helpers';
|
||||
import type { BasicEntityData, EntityTableColumns } from './types';
|
||||
|
||||
export const getEntityTableColumns = <T extends BasicEntityData>(
|
||||
contextID: string,
|
||||
scopeId: string,
|
||||
isDraggable: boolean,
|
||||
data: T
|
||||
): EntityTableColumns<T> => [
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.fieldColumnTitle"
|
||||
defaultMessage="Field"
|
||||
/>
|
||||
),
|
||||
field: 'label',
|
||||
render: (label: string, { field }) => (
|
||||
<span
|
||||
data-test-subj="entity-table-label"
|
||||
css={css`
|
||||
font-weight: ${euiLightVars.euiFontWeightMedium};
|
||||
color: ${euiLightVars.euiTitleColor};
|
||||
`}
|
||||
>
|
||||
{label ?? field}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.valuesColumnTitle"
|
||||
defaultMessage="Values"
|
||||
/>
|
||||
),
|
||||
field: 'field',
|
||||
render: (field: string | undefined, { getValues, render, renderField }) => {
|
||||
const values = getValues && getValues(data);
|
||||
|
||||
if (field) {
|
||||
return (
|
||||
<DefaultFieldRenderer
|
||||
rowItems={values}
|
||||
attrName={field}
|
||||
idPrefix={contextID ? `entityTable-${contextID}` : 'entityTable'}
|
||||
isDraggable={isDraggable}
|
||||
sourcererScopeId={getSourcererScopeId(scopeId)}
|
||||
render={renderField}
|
||||
data-test-subj="entity-table-value"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (render) {
|
||||
return render(data);
|
||||
}
|
||||
|
||||
return getEmptyTagValue();
|
||||
},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { EntityTable } from '.';
|
||||
import { TestProviders } from '../../../../../common/mock';
|
||||
import type { BasicEntityData, EntityTableRow } from './types';
|
||||
|
||||
const renderedFieldValue = 'testValue1';
|
||||
|
||||
const testField: EntityTableRow<BasicEntityData> = {
|
||||
label: 'testLabel',
|
||||
field: 'testField',
|
||||
getValues: (data: unknown) => [renderedFieldValue],
|
||||
renderField: (field: string) => <>{field}</>,
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
contextID: 'testContextID',
|
||||
scopeId: 'testScopeId',
|
||||
isDraggable: false,
|
||||
data: { isLoading: false },
|
||||
entityFields: [testField],
|
||||
};
|
||||
|
||||
describe('EntityTable', () => {
|
||||
it('renders correctly', () => {
|
||||
const { queryByTestId, queryAllByTestId } = render(<EntityTable {...mockProps} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(queryByTestId('entity-table')).toBeInTheDocument();
|
||||
expect(queryAllByTestId('entity-table-label')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("it doesn't render fields when isVisible returns false", () => {
|
||||
const props = {
|
||||
...mockProps,
|
||||
entityFields: [
|
||||
{
|
||||
...testField,
|
||||
isVisible: () => false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { queryAllByTestId } = render(<EntityTable {...props} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(queryAllByTestId('entity-table-label')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('it renders the field label', () => {
|
||||
const { queryByTestId } = render(<EntityTable {...mockProps} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(queryByTestId('entity-table-label')).toHaveTextContent('testLabel');
|
||||
});
|
||||
|
||||
it('it renders the field value', () => {
|
||||
const { queryByTestId } = render(<EntityTable {...mockProps} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(queryByTestId('DefaultFieldRendererComponent')).toHaveTextContent(renderedFieldValue);
|
||||
});
|
||||
|
||||
it('it call render function when field is undefined', () => {
|
||||
const props = {
|
||||
...mockProps,
|
||||
entityFields: [
|
||||
{
|
||||
label: 'testLabel',
|
||||
render: (data: unknown) => (
|
||||
<span data-test-subj="test-custom-render">{'test-custom-render'}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<EntityTable {...props} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(queryByTestId('test-custom-render')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import { BasicTable } from '../../../../../common/components/ml/tables/basic_table';
|
||||
import { getEntityTableColumns } from './columns';
|
||||
import type { BasicEntityData, EntityTableRows } from './types';
|
||||
|
||||
interface EntityTableProps<T extends BasicEntityData> {
|
||||
contextID: string;
|
||||
scopeId: string;
|
||||
isDraggable: boolean;
|
||||
data: T;
|
||||
entityFields: EntityTableRows<T>;
|
||||
}
|
||||
|
||||
export const EntityTable = <T extends BasicEntityData>({
|
||||
contextID,
|
||||
scopeId,
|
||||
isDraggable,
|
||||
data,
|
||||
entityFields,
|
||||
}: EntityTableProps<T>) => {
|
||||
const items = useMemo(
|
||||
() => entityFields.filter(({ isVisible }) => (isVisible ? isVisible(data) : true)),
|
||||
[data, entityFields]
|
||||
);
|
||||
|
||||
const entityTableColumns = useMemo(
|
||||
() => getEntityTableColumns<T>(contextID, scopeId, isDraggable, data),
|
||||
[contextID, scopeId, isDraggable, data]
|
||||
);
|
||||
return (
|
||||
<BasicTable
|
||||
loading={data.isLoading}
|
||||
data-test-subj="entity-table"
|
||||
columns={entityTableColumns}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { XOR } from '../../../../../../common/utility_types';
|
||||
|
||||
export type EntityTableRow<T extends BasicEntityData> = XOR<
|
||||
{
|
||||
label: string;
|
||||
/**
|
||||
* The field name. It is used for displaying CellActions.
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* It extracts an array of strings from the data. Each element is a valid field value.
|
||||
* It is used for displaying MoreContainer.
|
||||
*/
|
||||
getValues: (data: T) => string[] | null | undefined;
|
||||
/**
|
||||
* It allows the customization of the rendered field.
|
||||
* The element is still rendered inside `DefaultFieldRenderer` getting `CellActions` and `MoreContainer` capabilities.
|
||||
*/
|
||||
renderField?: (value: string) => JSX.Element;
|
||||
/**
|
||||
* It hides the row when `isVisible` returns false.
|
||||
*/
|
||||
isVisible?: (data: T) => boolean;
|
||||
},
|
||||
{
|
||||
label: string;
|
||||
/**
|
||||
* It takes complete control over the rendering.
|
||||
* `getValues` and `renderField` are not called when this property is used.
|
||||
*/
|
||||
render: (data: T) => JSX.Element;
|
||||
/**
|
||||
* It hides the row when `isVisible` returns false.
|
||||
*/
|
||||
isVisible?: (data: T) => boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export type EntityTableColumns<T extends BasicEntityData> = Array<
|
||||
EuiBasicTableColumn<EntityTableRow<T>>
|
||||
>;
|
||||
export type EntityTableRows<T extends BasicEntityData> = Array<EntityTableRow<T>>;
|
||||
|
||||
export interface BasicEntityData {
|
||||
isLoading: boolean;
|
||||
}
|
|
@ -9,19 +9,19 @@ import { useEuiBackgroundColor } from '@elastic/eui';
|
|||
import type { VFC } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
|
||||
import { FlyoutBody } from '../../shared/components/flyout_body';
|
||||
import { FlyoutBody } from '../../../../shared/components/flyout_body';
|
||||
import type { EntityDetailsLeftPanelTab, LeftPanelTabsType } from './left_panel_header';
|
||||
|
||||
export interface PanelContentProps {
|
||||
selectedTabId: UserDetailsLeftPanelTab;
|
||||
selectedTabId: EntityDetailsLeftPanelTab;
|
||||
tabs: LeftPanelTabsType;
|
||||
}
|
||||
|
||||
/**
|
||||
* User details expandable flyout left section.
|
||||
* Content for a entity left panel.
|
||||
* Appears after the user clicks on the expand details button in the right section.
|
||||
*/
|
||||
export const PanelContent: VFC<PanelContentProps> = ({ selectedTabId, tabs }) => {
|
||||
export const LeftPanelContent: VFC<PanelContentProps> = ({ selectedTabId, tabs }) => {
|
||||
const selectedTabContent = useMemo(() => {
|
||||
return tabs.find((tab) => tab.id === selectedTabId)?.content;
|
||||
}, [selectedTabId, tabs]);
|
||||
|
@ -37,4 +37,4 @@ export const PanelContent: VFC<PanelContentProps> = ({ selectedTabId, tabs }) =>
|
|||
);
|
||||
};
|
||||
|
||||
PanelContent.displayName = 'PanelContent';
|
||||
LeftPanelContent.displayName = 'LeftPanelContent';
|
|
@ -6,21 +6,33 @@
|
|||
*/
|
||||
|
||||
import { EuiTab, EuiTabs, useEuiBackgroundColor } from '@elastic/eui';
|
||||
import type { VFC } from 'react';
|
||||
import type { ReactElement, VFC } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
|
||||
import { FlyoutHeader } from '../../shared/components/flyout_header';
|
||||
import { FlyoutHeader } from '../../../../shared/components/flyout_header';
|
||||
|
||||
export type LeftPanelTabsType = Array<{
|
||||
id: EntityDetailsLeftPanelTab;
|
||||
'data-test-subj': string;
|
||||
name: ReactElement;
|
||||
content: React.ReactElement;
|
||||
}>;
|
||||
|
||||
export enum EntityDetailsLeftPanelTab {
|
||||
RISK_INPUTS = 'risk_inputs',
|
||||
OKTA = 'okta_document',
|
||||
ENTRA = 'entra_document',
|
||||
}
|
||||
|
||||
export interface PanelHeaderProps {
|
||||
/**
|
||||
* Id of the tab selected in the parent component to display its content
|
||||
*/
|
||||
selectedTabId: UserDetailsLeftPanelTab;
|
||||
selectedTabId: EntityDetailsLeftPanelTab;
|
||||
/**
|
||||
* Callback to set the selected tab id in the parent component
|
||||
*/
|
||||
setSelectedTabId: (selected: UserDetailsLeftPanelTab) => void;
|
||||
setSelectedTabId: (selected: EntityDetailsLeftPanelTab) => void;
|
||||
/**
|
||||
* List of tabs to display in the header
|
||||
*/
|
||||
|
@ -31,9 +43,9 @@ export interface PanelHeaderProps {
|
|||
* Header at the top of the left section.
|
||||
* Displays the investigation and insights tabs (visualize is hidden for 8.9).
|
||||
*/
|
||||
export const PanelHeader: VFC<PanelHeaderProps> = memo(
|
||||
export const LeftPanelHeader: VFC<PanelHeaderProps> = memo(
|
||||
({ selectedTabId, setSelectedTabId, tabs }) => {
|
||||
const onSelectedTabChanged = (id: UserDetailsLeftPanelTab) => setSelectedTabId(id);
|
||||
const onSelectedTabChanged = (id: EntityDetailsLeftPanelTab) => setSelectedTabId(id);
|
||||
const renderTabs = tabs.map((tab, index) => (
|
||||
<EuiTab
|
||||
onClick={() => onSelectedTabChanged(tab.id)}
|
||||
|
@ -61,4 +73,4 @@ export const PanelHeader: VFC<PanelHeaderProps> = memo(
|
|||
}
|
||||
);
|
||||
|
||||
PanelHeader.displayName = 'PanelHeader';
|
||||
LeftPanelHeader.displayName = 'LeftPanelHeader';
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ObservedEntity } from '.';
|
||||
import { TestProviders } from '../../../../../common/mock';
|
||||
import { mockObservedHostData } from '../../../mocks';
|
||||
|
||||
describe('ObservedHost', () => {
|
||||
const mockProps = {
|
||||
observedData: mockObservedHostData,
|
||||
contextID: '',
|
||||
scopeId: '',
|
||||
isDraggable: false,
|
||||
queryId: 'TEST_QUERY_ID',
|
||||
observedFields: [],
|
||||
};
|
||||
|
||||
it('renders', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<ObservedEntity {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('observedEntity-accordion')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the formatted date', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<ObservedEntity {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('observedEntity-accordion')).toHaveTextContent('Updated Feb 23, 2023');
|
||||
});
|
||||
});
|
|
@ -5,39 +5,34 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiAccordion, EuiSpacer, EuiTitle, useEuiTheme, useEuiFontSize } from '@elastic/eui';
|
||||
import { EuiAccordion, EuiSpacer, EuiTitle, useEuiFontSize, useEuiTheme } from '@elastic/eui';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import * as i18n from './translations';
|
||||
import type { ObservedUserData } from './types';
|
||||
import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
|
||||
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
|
||||
import { getObservedUserTableColumns } from './columns';
|
||||
import { ONE_WEEK_IN_HOURS } from './constants';
|
||||
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
|
||||
import { OBSERVED_USER_QUERY_ID } from '../../../../explore/users/containers/users/observed_details';
|
||||
import { useObservedUserItems } from './hooks/use_observed_user_items';
|
||||
import { EntityTable } from '../entity_table';
|
||||
import { FormattedRelativePreferenceDate } from '../../../../../common/components/formatted_date';
|
||||
import { InspectButton, InspectButtonContainer } from '../../../../../common/components/inspect';
|
||||
import type { EntityTableRows } from '../entity_table/types';
|
||||
import { ONE_WEEK_IN_HOURS } from '../../constants';
|
||||
import type { ObservedEntityData } from './types';
|
||||
|
||||
export const ObservedUser = ({
|
||||
observedUser,
|
||||
export const ObservedEntity = <T,>({
|
||||
observedData,
|
||||
contextID,
|
||||
scopeId,
|
||||
isDraggable,
|
||||
observedFields,
|
||||
queryId,
|
||||
}: {
|
||||
observedUser: ObservedUserData;
|
||||
observedData: ObservedEntityData<T>;
|
||||
contextID: string;
|
||||
scopeId: string;
|
||||
isDraggable: boolean;
|
||||
observedFields: EntityTableRows<ObservedEntityData<T>>;
|
||||
queryId: string;
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const observedItems = useObservedUserItems(observedUser);
|
||||
|
||||
const observedUserTableColumns = useMemo(
|
||||
() => getObservedUserTableColumns(contextID, scopeId, isDraggable),
|
||||
[contextID, scopeId, isDraggable]
|
||||
);
|
||||
const xsFontSize = useEuiFontSize('xxs').fontSize;
|
||||
|
||||
return (
|
||||
|
@ -45,18 +40,23 @@ export const ObservedUser = ({
|
|||
<InspectButtonContainer>
|
||||
<EuiAccordion
|
||||
initialIsOpen={true}
|
||||
isLoading={observedUser.isLoading}
|
||||
id="observedUser-data"
|
||||
data-test-subj="observedUser-data"
|
||||
isLoading={observedData.isLoading}
|
||||
id="observedEntity-accordion"
|
||||
data-test-subj="observedEntity-accordion"
|
||||
buttonProps={{
|
||||
'data-test-subj': 'observedUser-accordion-button',
|
||||
'data-test-subj': 'observedEntity-accordion-button',
|
||||
css: css`
|
||||
color: ${euiTheme.colors.primary};
|
||||
`,
|
||||
}}
|
||||
buttonContent={
|
||||
<EuiTitle size="xs">
|
||||
<h3>{i18n.OBSERVED_DATA_TITLE}</h3>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.observedDataTitle"
|
||||
defaultMessage="Observed data"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
}
|
||||
extraAction={
|
||||
|
@ -67,23 +67,28 @@ export const ObservedUser = ({
|
|||
`}
|
||||
>
|
||||
<InspectButton
|
||||
queryId={OBSERVED_USER_QUERY_ID}
|
||||
title={i18n.OBSERVED_USER_INSPECT_TITLE}
|
||||
queryId={queryId}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.entityDetails.observedDataInspectTitle"
|
||||
defaultMessage="Observed data"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{observedUser.lastSeen.date && (
|
||||
{observedData.lastSeen.date && (
|
||||
<span
|
||||
css={css`
|
||||
font-size: ${xsFontSize};
|
||||
`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime"
|
||||
id="xpack.securitySolution.flyout.entityDetails.observedEntityUpdatedTime"
|
||||
defaultMessage="Updated {time}"
|
||||
values={{
|
||||
time: (
|
||||
<FormattedRelativePreferenceDate
|
||||
value={observedUser.lastSeen.date}
|
||||
value={observedData.lastSeen.date}
|
||||
dateFormat="MMM D, YYYY"
|
||||
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
|
||||
/>
|
||||
|
@ -101,17 +106,12 @@ export const ObservedUser = ({
|
|||
`}
|
||||
>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<BasicTable
|
||||
loading={
|
||||
observedUser.isLoading ||
|
||||
observedUser.firstSeen.isLoading ||
|
||||
observedUser.lastSeen.isLoading ||
|
||||
observedUser.anomalies.isLoading
|
||||
}
|
||||
data-test-subj="observedUser-table"
|
||||
columns={observedUserTableColumns}
|
||||
items={observedItems}
|
||||
<EntityTable
|
||||
contextID={contextID}
|
||||
scopeId={scopeId}
|
||||
isDraggable={isDraggable}
|
||||
data={observedData}
|
||||
entityFields={observedFields}
|
||||
/>
|
||||
</EuiAccordion>
|
||||
</InspectButtonContainer>
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 { BasicEntityData } from '../entity_table/types';
|
||||
import type { AnomalyTableProviderChildrenProps } from '../../../../../common/components/ml/anomaly/anomaly_table_provider';
|
||||
|
||||
export interface FirstLastSeenData {
|
||||
date: string | null | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface EntityAnomalies {
|
||||
isLoading: AnomalyTableProviderChildrenProps['isLoadingAnomaliesData'];
|
||||
anomalies: AnomalyTableProviderChildrenProps['anomaliesData'];
|
||||
jobNameById: AnomalyTableProviderChildrenProps['jobNameById'];
|
||||
}
|
||||
|
||||
export interface ObservedEntityData<T> extends BasicEntityData {
|
||||
firstSeen: FirstLastSeenData;
|
||||
lastSeen: FirstLastSeenData;
|
||||
anomalies: EntityAnomalies;
|
||||
details: T;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const ONE_WEEK_IN_HOURS = 24 * 7;
|
|
@ -9,11 +9,14 @@ import React, { useMemo } from 'react';
|
|||
import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout';
|
||||
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
|
||||
import { useManagedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user';
|
||||
import { PanelHeader } from './header';
|
||||
import { PanelContent } from './content';
|
||||
import type { LeftPanelTabsType, UserDetailsLeftPanelTab } from './tabs';
|
||||
import { useTabs } from './tabs';
|
||||
import { FlyoutLoading } from '../../shared/components/flyout_loading';
|
||||
import type {
|
||||
EntityDetailsLeftPanelTab,
|
||||
LeftPanelTabsType,
|
||||
} from '../shared/components/left_panel/left_panel_header';
|
||||
import { LeftPanelHeader } from '../shared/components/left_panel/left_panel_header';
|
||||
import { LeftPanelContent } from '../shared/components/left_panel/left_panel_content';
|
||||
|
||||
interface RiskInputsParam {
|
||||
alertIds: string[];
|
||||
|
@ -44,8 +47,12 @@ export const UserDetailsPanel = ({ riskInputs, user, path }: UserDetailsPanelPro
|
|||
|
||||
return (
|
||||
<>
|
||||
<PanelHeader selectedTabId={selectedTabId} setSelectedTabId={setSelectedTabId} tabs={tabs} />
|
||||
<PanelContent selectedTabId={selectedTabId} tabs={tabs} />
|
||||
<LeftPanelHeader
|
||||
selectedTabId={selectedTabId}
|
||||
setSelectedTabId={setSelectedTabId}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<LeftPanelContent selectedTabId={selectedTabId} tabs={tabs} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -65,7 +72,7 @@ const useSelectedTab = (
|
|||
return tabs.find((tab) => tab.id === path.tab)?.id ?? defaultTab;
|
||||
}, [path, tabs]);
|
||||
|
||||
const setSelectedTabId = (tabId: UserDetailsLeftPanelTab) => {
|
||||
const setSelectedTabId = (tabId: EntityDetailsLeftPanelTab) => {
|
||||
openLeftPanel({
|
||||
id: UserDetailsPanelKey,
|
||||
path: {
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
|
@ -19,19 +18,8 @@ import type {
|
|||
import { ENTRA_TAB_TEST_ID, OKTA_TAB_TEST_ID } from './test_ids';
|
||||
import { AssetDocumentTab } from './tabs/asset_document';
|
||||
import { RightPanelProvider } from '../../document_details/right/context';
|
||||
|
||||
export type LeftPanelTabsType = Array<{
|
||||
id: UserDetailsLeftPanelTab;
|
||||
'data-test-subj': string;
|
||||
name: ReactElement;
|
||||
content: React.ReactElement;
|
||||
}>;
|
||||
|
||||
export enum UserDetailsLeftPanelTab {
|
||||
RISK_INPUTS = 'risk_inputs',
|
||||
OKTA = 'okta_document',
|
||||
ENTRA = 'entra_document',
|
||||
}
|
||||
import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header';
|
||||
import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
|
||||
|
||||
export const useTabs = (managedUser: ManagedUserHits, alertIds: string[]): LeftPanelTabsType =>
|
||||
useMemo(() => {
|
||||
|
@ -55,7 +43,7 @@ export const useTabs = (managedUser: ManagedUserHits, alertIds: string[]): LeftP
|
|||
}, [alertIds, managedUser]);
|
||||
|
||||
const getOktaTab = (oktaManagedUser: ManagedUserHit) => ({
|
||||
id: UserDetailsLeftPanelTab.OKTA,
|
||||
id: EntityDetailsLeftPanelTab.OKTA,
|
||||
'data-test-subj': OKTA_TAB_TEST_ID,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
|
@ -76,7 +64,7 @@ const getOktaTab = (oktaManagedUser: ManagedUserHit) => ({
|
|||
|
||||
const getEntraTab = (entraManagedUser: ManagedUserHit) => {
|
||||
return {
|
||||
id: UserDetailsLeftPanelTab.ENTRA,
|
||||
id: EntityDetailsLeftPanelTab.ENTRA,
|
||||
'data-test-subj': ENTRA_TAB_TEST_ID,
|
||||
name: (
|
||||
<FormattedMessage
|
||||
|
|
|
@ -13,10 +13,10 @@ import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
|
|||
import { StorybookProviders } from '../../../common/mock/storybook_providers';
|
||||
import {
|
||||
mockManagedUserData,
|
||||
mockObservedUser,
|
||||
mockRiskScoreState,
|
||||
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
|
||||
import { UserPanelContent } from './content';
|
||||
import { mockObservedUser } from './mocks';
|
||||
|
||||
const flyoutContextValue = {
|
||||
openLeftPanel: () => window.alert('openLeftPanel called'),
|
||||
|
|
|
@ -8,27 +8,27 @@
|
|||
import { EuiHorizontalRule } from '@elastic/eui';
|
||||
|
||||
import React from 'react';
|
||||
import { OBSERVED_USER_QUERY_ID } from '../../../explore/users/containers/users/observed_details';
|
||||
import { RiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary';
|
||||
import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import { ManagedUser } from '../../../timelines/components/side_panel/new_user_detail/managed_user';
|
||||
import type {
|
||||
ManagedUserData,
|
||||
ObservedUserData,
|
||||
} from '../../../timelines/components/side_panel/new_user_detail/types';
|
||||
import { ObservedUser } from '../../../timelines/components/side_panel/new_user_detail/observed_user';
|
||||
import type { RiskScoreEntity } from '../../../../common/search_strategy';
|
||||
import type { ManagedUserData } from '../../../timelines/components/side_panel/new_user_detail/types';
|
||||
import type { RiskScoreEntity, UserItem } from '../../../../common/search_strategy';
|
||||
import { USER_PANEL_RISK_SCORE_QUERY_ID } from '.';
|
||||
import { FlyoutBody } from '../../shared/components/flyout_body';
|
||||
import type { UserDetailsLeftPanelTab } from '../user_details_left/tabs';
|
||||
import { ObservedEntity } from '../shared/components/observed_entity';
|
||||
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
|
||||
import { useObservedUserItems } from './hooks/use_observed_user_items';
|
||||
import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
|
||||
|
||||
interface UserPanelContentProps {
|
||||
observedUser: ObservedUserData;
|
||||
observedUser: ObservedEntityData<UserItem>;
|
||||
managedUser: ManagedUserData;
|
||||
riskScoreState: RiskScoreState<RiskScoreEntity.user>;
|
||||
contextID: string;
|
||||
scopeId: string;
|
||||
isDraggable: boolean;
|
||||
openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
|
||||
openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
|
||||
}
|
||||
|
||||
export const UserPanelContent = ({
|
||||
|
@ -40,6 +40,8 @@ export const UserPanelContent = ({
|
|||
isDraggable,
|
||||
openDetailsPanel,
|
||||
}: UserPanelContentProps) => {
|
||||
const observedFields = useObservedUserItems(observedUser);
|
||||
|
||||
return (
|
||||
<FlyoutBody>
|
||||
{riskScoreState.isModuleEnabled && riskScoreState.data?.length !== 0 && (
|
||||
|
@ -52,11 +54,13 @@ export const UserPanelContent = ({
|
|||
<EuiHorizontalRule margin="m" />
|
||||
</>
|
||||
)}
|
||||
<ObservedUser
|
||||
observedUser={observedUser}
|
||||
<ObservedEntity
|
||||
observedData={observedUser}
|
||||
contextID={contextID}
|
||||
scopeId={scopeId}
|
||||
isDraggable={isDraggable}
|
||||
observedFields={observedFields}
|
||||
queryId={OBSERVED_USER_QUERY_ID}
|
||||
/>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
<ManagedUser
|
||||
|
|
|
@ -12,9 +12,9 @@ import { TestProviders } from '../../../common/mock';
|
|||
import {
|
||||
managedUserDetails,
|
||||
mockManagedUserData,
|
||||
mockObservedUser,
|
||||
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
|
||||
import { UserPanelHeader } from './header';
|
||||
import { mockObservedUser } from './mocks';
|
||||
|
||||
const mockProps = {
|
||||
userName: 'test',
|
||||
|
|
|
@ -10,21 +10,20 @@ import { FormattedMessage } from '@kbn/i18n-react';
|
|||
import React, { useMemo } from 'react';
|
||||
import { max } from 'lodash/fp';
|
||||
import { SecurityPageName } from '@kbn/security-solution-navigation';
|
||||
import type { UserItem } from '../../../../common/search_strategy';
|
||||
import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import { getUsersDetailsUrl } from '../../../common/components/link_to/redirect_to_users';
|
||||
import type {
|
||||
ManagedUserData,
|
||||
ObservedUserData,
|
||||
} from '../../../timelines/components/side_panel/new_user_detail/types';
|
||||
import type { ManagedUserData } from '../../../timelines/components/side_panel/new_user_detail/types';
|
||||
|
||||
import { SecuritySolutionLinkAnchor } from '../../../common/components/links';
|
||||
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
|
||||
import { FlyoutHeader } from '../../shared/components/flyout_header';
|
||||
import { FlyoutTitle } from '../../shared/components/flyout_title';
|
||||
import type { ObservedEntityData } from '../shared/components/observed_entity/types';
|
||||
|
||||
interface UserPanelHeaderProps {
|
||||
userName: string;
|
||||
observedUser: ObservedUserData;
|
||||
observedUser: ObservedEntityData<UserItem>;
|
||||
managedUser: ManagedUserData;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const USER_ID = i18n.translate('xpack.securitySolution.flyout.entityDetails.user.idLabel', {
|
||||
defaultMessage: 'User ID',
|
||||
});
|
||||
|
||||
export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.maxAnomalyScoreByJobLabel',
|
||||
{
|
||||
defaultMessage: 'Max anomaly score by job',
|
||||
}
|
||||
);
|
||||
|
||||
export const FIRST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.firstSeenLabel',
|
||||
{
|
||||
defaultMessage: 'First seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const LAST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.lastSeenLabel',
|
||||
{
|
||||
defaultMessage: 'Last seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPERATING_SYSTEM_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.hostOsNameLabel',
|
||||
{
|
||||
defaultMessage: 'Operating system',
|
||||
}
|
||||
);
|
||||
|
||||
export const FAMILY = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.familyLabel',
|
||||
{
|
||||
defaultMessage: 'Family',
|
||||
}
|
||||
);
|
||||
|
||||
export const IP_ADDRESSES = i18n.translate(
|
||||
'xpack.securitySolution.flyout.entityDetails.user.ipAddressesLabel',
|
||||
{
|
||||
defaultMessage: 'IP addresses',
|
||||
}
|
||||
);
|
|
@ -6,28 +6,18 @@
|
|||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useObservedUserDetails } from '../../../../../explore/users/containers/users/observed_details';
|
||||
import type { UserItem } from '../../../../../../common/search_strategy';
|
||||
import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy';
|
||||
import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
|
||||
import { useGlobalTime } from '../../../../../common/containers/use_global_time';
|
||||
import { useFirstLastSeen } from '../../../../../common/containers/use_first_last_seen';
|
||||
import { useQueryInspector } from '../../../../../common/components/page/manage_query';
|
||||
import { useQueryInspector } from '../../../../common/components/page/manage_query';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
|
||||
import type { UserItem } from '../../../../../common/search_strategy';
|
||||
import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy';
|
||||
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
|
||||
|
||||
export interface ObserverUser {
|
||||
details: UserItem;
|
||||
isLoading: boolean;
|
||||
firstSeen: {
|
||||
date: string | null | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
lastSeen: {
|
||||
date: string | null | undefined;
|
||||
isLoading: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const useObservedUser = (userName: string): ObserverUser => {
|
||||
export const useObservedUser = (
|
||||
userName: string
|
||||
): Omit<ObservedEntityData<UserItem>, 'anomalies'> => {
|
||||
const { selectedPatterns } = useSourcererDataView();
|
||||
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
|
||||
|
||||
|
@ -68,7 +58,7 @@ export const useObservedUser = (userName: string): ObserverUser => {
|
|||
return useMemo(
|
||||
() => ({
|
||||
details: observedUserDetails,
|
||||
isLoading: loadingObservedUser,
|
||||
isLoading: loadingObservedUser || loadingLastSeen || loadingFirstSeen,
|
||||
firstSeen: {
|
||||
date: firstSeen,
|
||||
isLoading: loadingFirstSeen,
|
|
@ -5,13 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { TestProviders } from '../../../../../common/mock';
|
||||
import { mockObservedUser } from '../__mocks__';
|
||||
import { mockObservedUser } from '../mocks';
|
||||
import { useObservedUserItems } from './use_observed_user_items';
|
||||
|
||||
describe('useManagedUserItems', () => {
|
||||
it('returns managed user items for Entra user', () => {
|
||||
it('returns observed user fields', () => {
|
||||
const { result } = renderHook(() => useObservedUserItems(mockObservedUser), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
@ -20,43 +20,58 @@ describe('useManagedUserItems', () => {
|
|||
{
|
||||
field: 'user.id',
|
||||
label: 'User ID',
|
||||
values: ['1234', '321'],
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: 'user.domain',
|
||||
label: 'Domain',
|
||||
values: ['test domain', 'another test domain'],
|
||||
},
|
||||
{
|
||||
field: 'anomalies',
|
||||
label: 'Max anomaly score by job',
|
||||
values: mockObservedUser.anomalies,
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: '@timestamp',
|
||||
label: 'First seen',
|
||||
values: ['2023-02-23T20:03:17.489Z'],
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: '@timestamp',
|
||||
label: 'Last seen',
|
||||
values: ['2023-02-23T20:03:17.489Z'],
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: 'host.os.name',
|
||||
label: 'Operating system',
|
||||
values: ['testOs'],
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: 'host.os.family',
|
||||
label: 'Family',
|
||||
values: ['testFamily'],
|
||||
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
field: 'host.ip',
|
||||
label: 'IP addresses',
|
||||
values: ['10.0.0.1', '127.0.0.1'],
|
||||
|
||||
getValues: expect.any(Function),
|
||||
},
|
||||
{
|
||||
label: 'Max anomaly score by job',
|
||||
isVisible: expect.any(Function),
|
||||
render: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.current.map(({ getValues }) => getValues && getValues(mockObservedUser))).toEqual(
|
||||
[
|
||||
['1234', '321'], // id
|
||||
['test domain', 'another test domain'], // domain
|
||||
['2023-02-23T20:03:17.489Z'], // First seen
|
||||
['2023-02-23T20:03:17.489Z'], // Last seen
|
||||
['testOs'], // OS name
|
||||
['testFamily'], // os family
|
||||
['10.0.0.1', '127.0.0.1'], // IP addresses
|
||||
undefined, // Max anomaly score by job doesn't implement getValues
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import type { UserItem } from '../../../../../common/search_strategy';
|
||||
import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities';
|
||||
import { getAnomaliesFields } from '../../shared/common';
|
||||
import * as i18n from './translations';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
import type { EntityTableRows } from '../../shared/components/entity_table/types';
|
||||
|
||||
const basicUserFields: EntityTableRows<ObservedEntityData<UserItem>> = [
|
||||
{
|
||||
label: i18n.USER_ID,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) => userData.details.user?.id,
|
||||
field: 'user.id',
|
||||
},
|
||||
{
|
||||
label: 'Domain',
|
||||
getValues: (userData: ObservedEntityData<UserItem>) => userData.details.user?.domain,
|
||||
field: 'user.domain',
|
||||
},
|
||||
{
|
||||
label: i18n.FIRST_SEEN,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) =>
|
||||
userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
label: i18n.LAST_SEEN,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) =>
|
||||
userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
label: i18n.OPERATING_SYSTEM_TITLE,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) => userData.details.host?.os?.name,
|
||||
field: 'host.os.name',
|
||||
},
|
||||
{
|
||||
label: i18n.FAMILY,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) => userData.details.host?.os?.family,
|
||||
field: 'host.os.family',
|
||||
},
|
||||
{
|
||||
label: i18n.IP_ADDRESSES,
|
||||
getValues: (userData: ObservedEntityData<UserItem>) => userData.details.host?.ip,
|
||||
field: 'host.ip',
|
||||
},
|
||||
];
|
||||
|
||||
export const useObservedUserItems = (
|
||||
userData: ObservedEntityData<UserItem>
|
||||
): EntityTableRows<ObservedEntityData<UserItem>> => {
|
||||
const mlCapabilities = useMlCapabilities();
|
||||
|
||||
const fields: EntityTableRows<ObservedEntityData<UserItem>> = useMemo(
|
||||
() => [...basicUserFields, ...getAnomaliesFields(mlCapabilities)],
|
||||
[mlCapabilities]
|
||||
);
|
||||
|
||||
if (!userData.details) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
|
@ -10,12 +10,12 @@ import React from 'react';
|
|||
import { TestProviders } from '../../../common/mock';
|
||||
import type { UserPanelProps } from '.';
|
||||
import { UserPanel } from '.';
|
||||
import { mockRiskScoreState } from './mocks';
|
||||
|
||||
import {
|
||||
mockManagedUserData,
|
||||
mockObservedUser,
|
||||
mockRiskScoreState,
|
||||
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
|
||||
import { mockObservedUser } from './mocks';
|
||||
|
||||
const mockProps: UserPanelProps = {
|
||||
userName: 'test',
|
||||
|
@ -41,12 +41,9 @@ jest.mock(
|
|||
})
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user',
|
||||
() => ({
|
||||
useObservedUser: () => mockedUseObservedUser(),
|
||||
})
|
||||
);
|
||||
jest.mock('./hooks/use_observed_user', () => ({
|
||||
useObservedUser: () => mockedUseObservedUser(),
|
||||
}));
|
||||
|
||||
describe('UserPanel', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -11,7 +11,6 @@ import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
|
|||
import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import { useManagedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user';
|
||||
import { useObservedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user';
|
||||
import { useQueryInspector } from '../../../common/components/page/manage_query';
|
||||
import { UsersType } from '../../../explore/users/store/model';
|
||||
import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type';
|
||||
|
@ -24,7 +23,8 @@ import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
|
|||
import { UserPanelContent } from './content';
|
||||
import { UserPanelHeader } from './header';
|
||||
import { UserDetailsPanelKey } from '../user_details_left';
|
||||
import type { UserDetailsLeftPanelTab } from '../user_details_left/tabs';
|
||||
import { useObservedUser } from './hooks/use_observed_user';
|
||||
import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header';
|
||||
|
||||
export interface UserPanelProps extends Record<string, unknown> {
|
||||
contextID: string;
|
||||
|
@ -79,7 +79,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan
|
|||
|
||||
const { openLeftPanel } = useExpandableFlyoutContext();
|
||||
const openPanelTab = useCallback(
|
||||
(tab?: UserDetailsLeftPanelTab) => {
|
||||
(tab?: EntityDetailsLeftPanelTab) => {
|
||||
openLeftPanel({
|
||||
id: UserDetailsPanelKey,
|
||||
params: {
|
||||
|
|
|
@ -5,47 +5,43 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RiskScoreState } from '../../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import type { RiskScoreEntity, UserRiskScore } from '../../../../../common/search_strategy';
|
||||
import { RiskSeverity } from '../../../../../common/search_strategy';
|
||||
import { RiskCategories } from '../../../../../common/entity_analytics/risk_engine';
|
||||
import { mockAnomalies } from '../../../../common/components/ml/mock';
|
||||
import type { UserItem } from '../../../../../common/search_strategy';
|
||||
import type { ObservedEntityData } from '../../shared/components/observed_entity/types';
|
||||
|
||||
const userRiskScore: UserRiskScore = {
|
||||
'@timestamp': '626569200000',
|
||||
const anomaly = mockAnomalies.anomalies[0];
|
||||
|
||||
const observedUserDetails = {
|
||||
user: {
|
||||
name: 'test',
|
||||
risk: {
|
||||
rule_risks: [],
|
||||
calculated_score_norm: 70,
|
||||
multipliers: [],
|
||||
calculated_level: RiskSeverity.high,
|
||||
inputs: [
|
||||
{
|
||||
id: '_id',
|
||||
index: '_index',
|
||||
category: RiskCategories.category_1,
|
||||
description: 'Alert from Rule: My rule',
|
||||
risk_score: 30,
|
||||
timestamp: '2021-08-19T18:55:59.000Z',
|
||||
},
|
||||
],
|
||||
id: ['1234', '321'],
|
||||
domain: ['test domain', 'another test domain'],
|
||||
},
|
||||
host: {
|
||||
ip: ['10.0.0.1', '127.0.0.1'],
|
||||
os: {
|
||||
name: ['testOs'],
|
||||
family: ['testFamily'],
|
||||
},
|
||||
},
|
||||
alertsCount: 0,
|
||||
oldestAlertTimestamp: '626569200000',
|
||||
};
|
||||
|
||||
export const mockRiskScoreState: RiskScoreState<RiskScoreEntity.user> = {
|
||||
data: [userRiskScore],
|
||||
inspect: {
|
||||
dsl: [],
|
||||
response: [],
|
||||
export const mockObservedUser: ObservedEntityData<UserItem> = {
|
||||
details: observedUserDetails,
|
||||
isLoading: false,
|
||||
firstSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
anomalies: {
|
||||
isLoading: false,
|
||||
anomalies: {
|
||||
anomalies: [anomaly],
|
||||
interval: '',
|
||||
},
|
||||
jobNameById: { [anomaly.jobId]: 'job_name' },
|
||||
},
|
||||
isInspected: false,
|
||||
refetch: () => {},
|
||||
totalCount: 0,
|
||||
isModuleEnabled: true,
|
||||
isAuthorized: true,
|
||||
isDeprecated: false,
|
||||
loading: false,
|
||||
};
|
||||
|
|
|
@ -26,6 +26,11 @@ import type { UserPanelExpandableFlyoutProps } from './entity_details/user_right
|
|||
import { UserPanel, UserPanelKey } from './entity_details/user_right';
|
||||
import type { UserDetailsPanelProps } from './entity_details/user_details_left';
|
||||
import { UserDetailsPanel, UserDetailsPanelKey } from './entity_details/user_details_left';
|
||||
import type { HostPanelExpandableFlyoutProps } from './entity_details/host_right';
|
||||
import { HostPanel, HostPanelKey } from './entity_details/host_right';
|
||||
import type { HostDetailsExpandableFlyoutProps } from './entity_details/host_details_left';
|
||||
import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_details_left';
|
||||
|
||||
/**
|
||||
* List of all panels that will be used within the document details expandable flyout.
|
||||
* This needs to be passed to the expandable flyout registeredPanels property.
|
||||
|
@ -73,6 +78,16 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
|
|||
<UserDetailsPanel {...({ ...props.params, path: props.path } as UserDetailsPanelProps)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: HostPanelKey,
|
||||
component: (props) => <HostPanel {...(props as HostPanelExpandableFlyoutProps).params} />,
|
||||
},
|
||||
{
|
||||
key: HostDetailsPanelKey,
|
||||
component: (props) => (
|
||||
<HostDetailsPanel {...(props as HostDetailsExpandableFlyoutProps).params} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const SecuritySolutionFlyout = memo(() => (
|
||||
|
|
|
@ -11,8 +11,7 @@ import type {
|
|||
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import { RiskSeverity } from '../../../../../../common/search_strategy';
|
||||
import { mockAnomalies } from '../../../../../common/components/ml/mock';
|
||||
import type { ManagedUserData, ObservedUserData } from '../types';
|
||||
import type { ManagedUserData } from '../types';
|
||||
|
||||
const userRiskScore = {
|
||||
'@timestamp': '123456',
|
||||
|
@ -44,43 +43,6 @@ export const mockRiskScoreState = {
|
|||
loading: false,
|
||||
};
|
||||
|
||||
const anomaly = mockAnomalies.anomalies[0];
|
||||
|
||||
export const observedUserDetails = {
|
||||
user: {
|
||||
id: ['1234', '321'],
|
||||
domain: ['test domain', 'another test domain'],
|
||||
},
|
||||
host: {
|
||||
ip: ['10.0.0.1', '127.0.0.1'],
|
||||
os: {
|
||||
name: ['testOs'],
|
||||
family: ['testFamily'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockObservedUser: ObservedUserData = {
|
||||
details: observedUserDetails,
|
||||
isLoading: false,
|
||||
firstSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
lastSeen: {
|
||||
isLoading: false,
|
||||
date: '2023-02-23T20:03:17.489Z',
|
||||
},
|
||||
anomalies: {
|
||||
isLoading: false,
|
||||
anomalies: {
|
||||
anomalies: [anomaly],
|
||||
interval: '',
|
||||
},
|
||||
jobNameById: { [anomaly.jobId]: 'job_name' },
|
||||
},
|
||||
};
|
||||
|
||||
export const mockOktaUserFields: ManagedUserFields = {
|
||||
'@timestamp': ['2023-11-16T13:42:23.074Z'],
|
||||
'event.dataset': [ManagedUserDatasetKey.OKTA],
|
||||
|
|
|
@ -6,31 +6,16 @@
|
|||
*/
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { head } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { euiLightVars } from '@kbn/ui-theme';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
|
||||
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
|
||||
import type {
|
||||
ManagedUsersTableColumns,
|
||||
ManagedUserTable,
|
||||
ObservedUsersTableColumns,
|
||||
ObservedUserTable,
|
||||
UserAnomalies,
|
||||
} from './types';
|
||||
import type { ManagedUsersTableColumns, ManagedUserTable } from './types';
|
||||
import * as i18n from './translations';
|
||||
import { defaultToEmptyTag } from '../../../../common/components/empty_value';
|
||||
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
|
||||
import { AnomalyScores } from '../../../../common/components/ml/score/anomaly_scores';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime';
|
||||
import { InputsModelId } from '../../../../common/store/inputs/constants';
|
||||
import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions';
|
||||
import { getSourcererScopeId } from '../../../../helpers';
|
||||
|
||||
const fieldColumn: EuiBasicTableColumn<ObservedUserTable | ManagedUserTable> = {
|
||||
const fieldColumn: EuiBasicTableColumn<ManagedUserTable> = {
|
||||
name: i18n.FIELD_COLUMN_TITLE,
|
||||
field: 'label',
|
||||
render: (label: string, { field }) => (
|
||||
|
@ -68,71 +53,3 @@ export const getManagedUserTableColumns = (
|
|||
},
|
||||
},
|
||||
];
|
||||
|
||||
function isAnomalies(
|
||||
field: string | undefined,
|
||||
values: UserAnomalies | unknown
|
||||
): values is UserAnomalies {
|
||||
return field === 'anomalies';
|
||||
}
|
||||
|
||||
export const getObservedUserTableColumns = (
|
||||
contextID: string,
|
||||
scopeId: string,
|
||||
isDraggable: boolean
|
||||
): ObservedUsersTableColumns => [
|
||||
fieldColumn,
|
||||
{
|
||||
name: i18n.VALUES_COLUMN_TITLE,
|
||||
field: 'values',
|
||||
render: (values: ObservedUserTable['values'], { field }) => {
|
||||
if (isAnomalies(field, values) && values) {
|
||||
return <AnomaliesField anomalies={values} />;
|
||||
}
|
||||
|
||||
if (field === '@timestamp') {
|
||||
return <FormattedRelativePreferenceDate value={head(values)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultFieldRenderer
|
||||
rowItems={values}
|
||||
attrName={field}
|
||||
idPrefix={contextID ? `observedUser-${contextID}` : 'observedUser'}
|
||||
isDraggable={isDraggable}
|
||||
sourcererScopeId={getSourcererScopeId(scopeId)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const AnomaliesField = ({ anomalies }: { anomalies: UserAnomalies }) => {
|
||||
const { to, from } = useGlobalTime();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const narrowDateRange = useCallback(
|
||||
(score, interval) => {
|
||||
const fromTo = scoreIntervalToDateTime(score, interval);
|
||||
dispatch(
|
||||
setAbsoluteRangeDatePicker({
|
||||
id: InputsModelId.global,
|
||||
from: fromTo.from,
|
||||
to: fromTo.to,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<AnomalyScores
|
||||
anomalies={anomalies.anomalies}
|
||||
startDate={from}
|
||||
endDate={to}
|
||||
isLoading={anomalies.isLoading}
|
||||
narrowDateRange={narrowDateRange}
|
||||
jobNameById={anomalies.jobNameById}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import * as i18n from '../translations';
|
||||
import type { ObservedUserData, ObservedUserTable } from '../types';
|
||||
|
||||
export const useObservedUserItems = (userData: ObservedUserData): ObservedUserTable[] =>
|
||||
useMemo(
|
||||
() =>
|
||||
!userData.details
|
||||
? []
|
||||
: [
|
||||
{ label: i18n.USER_ID, values: userData.details.user?.id, field: 'user.id' },
|
||||
{ label: 'Domain', values: userData.details.user?.domain, field: 'user.domain' },
|
||||
{
|
||||
label: i18n.MAX_ANOMALY_SCORE_BY_JOB,
|
||||
field: 'anomalies',
|
||||
values: userData.anomalies,
|
||||
},
|
||||
{
|
||||
label: i18n.FIRST_SEEN,
|
||||
values: userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
label: i18n.LAST_SEEN,
|
||||
values: userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
label: i18n.OPERATING_SYSTEM_TITLE,
|
||||
values: userData.details.host?.os?.name,
|
||||
field: 'host.os.name',
|
||||
},
|
||||
{
|
||||
label: i18n.FAMILY,
|
||||
values: userData.details.host?.os?.family,
|
||||
field: 'host.os.family',
|
||||
},
|
||||
{ label: i18n.IP_ADDRESSES, values: userData.details.host?.ip, field: 'host.ip' },
|
||||
],
|
||||
[userData.details, userData.anomalies, userData.firstSeen, userData.lastSeen]
|
||||
);
|
|
@ -18,7 +18,7 @@ import {
|
|||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { css } from '@emotion/css';
|
||||
import type { UserDetailsLeftPanelTab } from '../../../../flyout/entity_details/user_details_left/tabs';
|
||||
import type { EntityDetailsLeftPanelTab } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
|
||||
import { UserAssetTableType } from '../../../../explore/users/store/model';
|
||||
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import { ManagedUserDatasetKey } from '../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
|
@ -47,7 +47,7 @@ export const ManagedUser = ({
|
|||
managedUser: ManagedUserData;
|
||||
contextID: string;
|
||||
isDraggable: boolean;
|
||||
openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
|
||||
openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
|
||||
}) => {
|
||||
const entraManagedUser = managedUser.data?.[ManagedUserDatasetKey.ENTRA];
|
||||
const oktaManagedUser = managedUser.data?.[ManagedUserDatasetKey.OKTA];
|
||||
|
|
|
@ -11,7 +11,7 @@ import React from 'react';
|
|||
import { css } from '@emotion/react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { get } from 'lodash/fp';
|
||||
import { UserDetailsLeftPanelTab } from '../../../../flyout/entity_details/user_details_left/tabs';
|
||||
import { EntityDetailsLeftPanelTab } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header';
|
||||
import { ExpandablePanel } from '../../../../flyout/shared/components/expandable_panel';
|
||||
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
|
||||
|
@ -23,7 +23,7 @@ interface ManagedUserAccordionProps {
|
|||
title: string;
|
||||
managedUser: ManagedUserFields;
|
||||
tableType: UserAssetTableType;
|
||||
openDetailsPanel: (tab: UserDetailsLeftPanelTab) => void;
|
||||
openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void;
|
||||
}
|
||||
|
||||
export const ManagedUserAccordion: React.FC<ManagedUserAccordionProps> = ({
|
||||
|
@ -66,8 +66,8 @@ export const ManagedUserAccordion: React.FC<ManagedUserAccordionProps> = ({
|
|||
callback: () =>
|
||||
openDetailsPanel(
|
||||
tableType === UserAssetTableType.assetOkta
|
||||
? UserDetailsLeftPanelTab.OKTA
|
||||
: UserDetailsLeftPanelTab.ENTRA
|
||||
? EntityDetailsLeftPanelTab.OKTA
|
||||
: EntityDetailsLeftPanelTab.ENTRA
|
||||
),
|
||||
tooltip: (
|
||||
<FormattedMessage
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { mockObservedUser } from './__mocks__';
|
||||
import { ObservedUser } from './observed_user';
|
||||
|
||||
describe('ObservedUser', () => {
|
||||
const mockProps = {
|
||||
observedUser: mockObservedUser,
|
||||
contextID: '',
|
||||
scopeId: '',
|
||||
isDraggable: false,
|
||||
};
|
||||
|
||||
it('renders', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<ObservedUser {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('observedUser-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the formatted date', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<ObservedUser {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('observedUser-data')).toHaveTextContent('Updated Feb 23, 2023');
|
||||
});
|
||||
|
||||
it('renders anomaly score', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<ObservedUser {...mockProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('anomaly-score')).toHaveTextContent('17');
|
||||
});
|
||||
});
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { RiskScoreField } from './risk_score_field';
|
||||
import { mockRiskScoreState } from './__mocks__';
|
||||
import { getEmptyValue } from '../../../../common/components/empty_value';
|
||||
|
||||
describe('RiskScoreField', () => {
|
||||
it('renders', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskScoreField riskScoreState={mockRiskScoreState} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('user-details-risk-score')).toBeInTheDocument();
|
||||
expect(getByTestId('user-details-risk-score')).toHaveTextContent('70');
|
||||
});
|
||||
|
||||
it('does not render content when the license is invalid', () => {
|
||||
const { queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskScoreField riskScoreState={{ ...mockRiskScoreState, isAuthorized: false }} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(queryByTestId('user-details-risk-score')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty tag when risk score is undefined', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<RiskScoreField riskScoreState={{ ...mockRiskScoreState, data: [] }} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId('user-details-risk-score')).toHaveTextContent(getEmptyValue());
|
||||
});
|
||||
});
|
|
@ -1,79 +0,0 @@
|
|||
/*
|
||||
* 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 { EuiFlexItem, EuiFlexGroup, useEuiFontSize, useEuiTheme } from '@elastic/eui';
|
||||
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import * as i18n from './translations';
|
||||
|
||||
import { RiskScoreEntity } from '../../../../../common/search_strategy';
|
||||
import { getEmptyTagValue } from '../../../../common/components/empty_value';
|
||||
import { RiskScoreLevel } from '../../../../entity_analytics/components/severity/common';
|
||||
import type { RiskScoreState } from '../../../../entity_analytics/api/hooks/use_risk_score';
|
||||
import { RiskScoreDocTooltip } from '../../../../overview/components/common';
|
||||
|
||||
export const TooltipContainer = styled.div`
|
||||
padding: ${({ theme }) => theme.eui.euiSizeS};
|
||||
`;
|
||||
|
||||
export const RiskScoreField = ({
|
||||
riskScoreState,
|
||||
}: {
|
||||
riskScoreState: RiskScoreState<RiskScoreEntity.user>;
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { fontSize: xsFontSize } = useEuiFontSize('xs');
|
||||
const { data: userRisk, isAuthorized: isRiskScoreAuthorized } = riskScoreState;
|
||||
const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;
|
||||
|
||||
if (!isRiskScoreAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
responsive={false}
|
||||
data-test-subj="user-details-risk-score"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<span
|
||||
css={css`
|
||||
font-weight: ${euiTheme.font.weight.bold};
|
||||
font-size: ${xsFontSize};
|
||||
margin-right: ${euiTheme.size.xs};
|
||||
`}
|
||||
>
|
||||
{i18n.RISK_SCORE}
|
||||
{': '}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
{userRiskData ? (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
{Math.round(userRiskData.user.risk.calculated_score_norm)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RiskScoreLevel
|
||||
severity={userRiskData.user.risk.calculated_level}
|
||||
hideBackgroundColor
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RiskScoreDocTooltip riskScoreEntity={RiskScoreEntity.user} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
getEmptyTagValue()
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -81,49 +81,6 @@ export const FIELD_COLUMN_TITLE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const USER_ID = i18n.translate('xpack.securitySolution.timeline.userDetails.userIdLabel', {
|
||||
defaultMessage: 'User ID',
|
||||
});
|
||||
|
||||
export const MAX_ANOMALY_SCORE_BY_JOB = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel',
|
||||
{
|
||||
defaultMessage: 'Max anomaly score by job',
|
||||
}
|
||||
);
|
||||
|
||||
export const FIRST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.firstSeenLabel',
|
||||
{
|
||||
defaultMessage: 'First seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const LAST_SEEN = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.lastSeenLabel',
|
||||
{
|
||||
defaultMessage: 'Last seen',
|
||||
}
|
||||
);
|
||||
|
||||
export const OPERATING_SYSTEM_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.hostOsNameLabel',
|
||||
{
|
||||
defaultMessage: 'Operating system',
|
||||
}
|
||||
);
|
||||
|
||||
export const FAMILY = i18n.translate('xpack.securitySolution.timeline.userDetails.familyLabel', {
|
||||
defaultMessage: 'Family',
|
||||
});
|
||||
|
||||
export const IP_ADDRESSES = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.ipAddressesLabel',
|
||||
{
|
||||
defaultMessage: 'IP addresses',
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_ACTIVE_INTEGRATION_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle',
|
||||
{
|
||||
|
@ -168,13 +125,6 @@ export const CLOSE_BUTTON = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const OBSERVED_USER_INSPECT_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.observedUserInspectTitle',
|
||||
{
|
||||
defaultMessage: 'Observed user',
|
||||
}
|
||||
);
|
||||
|
||||
export const MANAGED_USER_INSPECT_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.timeline.userDetails.managedUserInspectTitle',
|
||||
{
|
||||
|
|
|
@ -7,44 +7,17 @@
|
|||
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { SearchTypes } from '../../../../../common/detection_engine/types';
|
||||
import type { UserItem } from '../../../../../common/search_strategy';
|
||||
import type { ManagedUserHits } from '../../../../../common/search_strategy/security_solution/users/managed_details';
|
||||
import type { AnomalyTableProviderChildrenProps } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
|
||||
|
||||
export interface ObservedUserTable {
|
||||
values: string[] | null | undefined | UserAnomalies;
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface ManagedUserTable {
|
||||
value: SearchTypes[];
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export type ObservedUsersTableColumns = Array<EuiBasicTableColumn<ObservedUserTable>>;
|
||||
export type ManagedUsersTableColumns = Array<EuiBasicTableColumn<ManagedUserTable>>;
|
||||
|
||||
export interface ObservedUserData {
|
||||
isLoading: boolean;
|
||||
details: UserItem;
|
||||
firstSeen: FirstLastSeenData;
|
||||
lastSeen: FirstLastSeenData;
|
||||
anomalies: UserAnomalies;
|
||||
}
|
||||
|
||||
export interface ManagedUserData {
|
||||
isLoading: boolean;
|
||||
data: ManagedUserHits | undefined;
|
||||
isIntegrationEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface FirstLastSeenData {
|
||||
date: string | null | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface UserAnomalies {
|
||||
isLoading: AnomalyTableProviderChildrenProps['isLoadingAnomaliesData'];
|
||||
anomalies: AnomalyTableProviderChildrenProps['anomaliesData'];
|
||||
jobNameById: AnomalyTableProviderChildrenProps['jobNameById'];
|
||||
}
|
||||
|
|
|
@ -17,6 +17,23 @@ import { StatefulEventContext } from '../../../../../common/components/events_vi
|
|||
import { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
|
||||
|
||||
const mockedTelemetry = createTelemetryServiceMock();
|
||||
const mockUseIsExperimentalFeatureEnabled = jest.fn();
|
||||
const mockOpenRightPanel = jest.fn();
|
||||
|
||||
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: () => mockUseIsExperimentalFeatureEnabled,
|
||||
}));
|
||||
|
||||
jest.mock('@kbn/expandable-flyout/src/context', () => {
|
||||
const original = jest.requireActual('@kbn/expandable-flyout/src/context');
|
||||
|
||||
return {
|
||||
...original,
|
||||
useExpandableFlyoutContext: () => ({
|
||||
openRightPanel: mockOpenRightPanel,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => {
|
||||
const origin = jest.requireActual('react-redux');
|
||||
|
@ -197,4 +214,27 @@ describe('HostName', () => {
|
|||
expect(toggleExpandedDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('it should open expandable flyout if timeline is not in context and experimental flag is enabled', async () => {
|
||||
mockUseIsExperimentalFeatureEnabled.mockReturnValue(true);
|
||||
const context = {
|
||||
enableHostDetailsFlyout: true,
|
||||
enableIpDetailsFlyout: true,
|
||||
timelineID: 'fake-timeline',
|
||||
tabType: TimelineTabs.query,
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<StatefulEventContext.Provider value={context}>
|
||||
<HostName {...props} />
|
||||
</StatefulEventContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="host-details-button"]').last().simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(mockOpenRightPanel).toHaveBeenCalled();
|
||||
expect(toggleExpandedDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,9 +9,13 @@ import React, { useCallback, useContext, useMemo } from 'react';
|
|||
import type { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isString } from 'lodash/fp';
|
||||
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { HostPanelKey } from '../../../../../flyout/entity_details/host_right';
|
||||
import type { ExpandedDetailType } from '../../../../../../common/types';
|
||||
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
|
||||
import { getScopedActions } from '../../../../../helpers';
|
||||
import { getScopedActions, isTimelineScope } from '../../../../../helpers';
|
||||
import { HostDetailsLink } from '../../../../../common/components/links';
|
||||
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
|
||||
import { DefaultDraggable } from '../../../../../common/components/draggables';
|
||||
|
@ -46,6 +50,9 @@ const HostNameComponent: React.FC<Props> = ({
|
|||
title,
|
||||
value,
|
||||
}) => {
|
||||
const isNewHostDetailsFlyoutEnabled = useIsExperimentalFeatureEnabled('newHostDetailsFlyout');
|
||||
const { openRightPanel } = useExpandableFlyoutContext();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const eventContext = useContext(StatefulEventContext);
|
||||
const hostName = `${value}`;
|
||||
|
@ -58,31 +65,55 @@ const HostNameComponent: React.FC<Props> = ({
|
|||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
|
||||
if (eventContext && isInTimelineContext) {
|
||||
const { timelineID, tabType } = eventContext;
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'hostDetail',
|
||||
params: {
|
||||
hostName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (timelineID === TimelineId.active && tabType === TimelineTabs.query) {
|
||||
activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
|
||||
if (isNewHostDetailsFlyoutEnabled && !isTimelineScope(timelineID)) {
|
||||
openRightPanel({
|
||||
id: HostPanelKey,
|
||||
params: {
|
||||
hostName,
|
||||
contextID: contextId,
|
||||
scopeId: TableId.alertsOnAlertsPage,
|
||||
isDraggable,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const updatedExpandedDetail: ExpandedDetailType = {
|
||||
panelView: 'hostDetail',
|
||||
params: {
|
||||
hostName,
|
||||
},
|
||||
};
|
||||
const scopedActions = getScopedActions(timelineID);
|
||||
if (scopedActions) {
|
||||
dispatch(
|
||||
scopedActions.toggleDetailPanel({
|
||||
...updatedExpandedDetail,
|
||||
id: timelineID,
|
||||
tabType: tabType as TimelineTabs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (timelineID === TimelineId.active && tabType === TimelineTabs.query) {
|
||||
activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClick, eventContext, isInTimelineContext, hostName, dispatch]
|
||||
[
|
||||
onClick,
|
||||
eventContext,
|
||||
isInTimelineContext,
|
||||
isNewHostDetailsFlyoutEnabled,
|
||||
openRightPanel,
|
||||
hostName,
|
||||
contextId,
|
||||
isDraggable,
|
||||
dispatch,
|
||||
]
|
||||
);
|
||||
|
||||
// The below is explicitly defined this way as the onClick takes precedence when it and the href are both defined
|
||||
|
|
|
@ -32020,7 +32020,6 @@
|
|||
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "{isOpen, select, false {Ouvrir} true {Fermer} other {Bascule}} la chronologie {title}",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "Vous avez une {timeline} non enregistrée. Voulez-vous l'enregistrer ?",
|
||||
"xpack.securitySolution.timeline.searchBoxPlaceholder": "par ex. nom ou description de {timeline}",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "Mis à jour {time}",
|
||||
"xpack.securitySolution.timeline.userDetails.updatedTime": "Mis à jour {time}",
|
||||
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "Vous êtes dans un outil de rendu d'événement pour la ligne : {row}. Appuyez sur la touche fléchée vers le haut pour quitter et revenir à la ligne en cours, ou sur la touche fléchée vers le bas pour quitter et passer à la ligne suivante.",
|
||||
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "Vous êtes dans une cellule de tableau. Ligne : {row}, colonne : {column}",
|
||||
|
@ -36361,25 +36360,17 @@
|
|||
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "Ajouter des intégrations externes",
|
||||
"xpack.securitySolution.timeline.userDetails.closeButton": "fermer",
|
||||
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "Impossible de lancer la recherche sur des données gérées par l'utilisateur",
|
||||
"xpack.securitySolution.timeline.userDetails.familyLabel": "Famille",
|
||||
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "Champ",
|
||||
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "Vu en premier",
|
||||
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "Système d'exploitation",
|
||||
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "Adresses IP",
|
||||
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "Vu en dernier",
|
||||
"xpack.securitySolution.timeline.userDetails.managedBadge": "GÉRÉ",
|
||||
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "Données gérées",
|
||||
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "Géré par l'utilisateur",
|
||||
"xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "Score maximal d'anomalie par tâche",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "Les intégrations externes peuvent fournir des métadonnées supplémentaires et vous aider à gérer les utilisateurs.",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "Vous n'avez aucune intégration active.",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "Si vous vous attendiez à voir des métadonnées pour cet utilisateur, assurez-vous d'avoir correctement configuré vos intégrations.",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "Métadonnées introuvables pour cet utilisateur",
|
||||
"xpack.securitySolution.timeline.userDetails.observedBadge": "OBSERVÉ",
|
||||
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "Données observées",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "Utilisateur observé",
|
||||
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "Score de risque",
|
||||
"xpack.securitySolution.timeline.userDetails.userIdLabel": "ID utilisateur",
|
||||
"xpack.securitySolution.timeline.userDetails.userLabel": "Utilisateur",
|
||||
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "Valeurs",
|
||||
"xpack.securitySolution.timelineEvents.errorSearchDescription": "Une erreur s'est produite lors de la recherche d'événements de la chronologie",
|
||||
|
|
|
@ -32019,7 +32019,6 @@
|
|||
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "タイムライン\"{title}\"を{isOpen, select, false {開く} true {閉じる} other {切り替え}}",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "保存されていない{timeline}があります。保存しますか?",
|
||||
"xpack.securitySolution.timeline.searchBoxPlaceholder": "例:{timeline}名または説明",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "{time}を更新しました",
|
||||
"xpack.securitySolution.timeline.userDetails.updatedTime": "{time}を更新しました",
|
||||
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "行{row}のイベントレンダラーを表示しています。上矢印キーを押すと、終了して現在の行に戻ります。下矢印キーを押すと、終了して次の行に進みます。",
|
||||
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "表セルの行{row}、列{column}にいます",
|
||||
|
@ -36360,25 +36359,17 @@
|
|||
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "外部統合を追加",
|
||||
"xpack.securitySolution.timeline.userDetails.closeButton": "閉じる",
|
||||
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "ユーザーが管理するデータで検索を実行できませんでした",
|
||||
"xpack.securitySolution.timeline.userDetails.familyLabel": "ファミリー",
|
||||
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "フィールド",
|
||||
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "初回の認識",
|
||||
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "オペレーティングシステム",
|
||||
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP アドレス",
|
||||
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "前回の認識",
|
||||
"xpack.securitySolution.timeline.userDetails.managedBadge": "管理対象",
|
||||
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "管理対象のデータ",
|
||||
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "管理対象のユーザー",
|
||||
"xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "ジョブ別の最高異常スコア",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "外部統合は追加のメタデータを提供し、ユーザーの管理を支援できます。",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "アクティブな統合がありません",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "このユーザーのメタデータが表示されることが想定される場合は、統合を正しく構成したことを確認してください。",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "このユーザーのメタデータが見つかりません",
|
||||
"xpack.securitySolution.timeline.userDetails.observedBadge": "観測済み",
|
||||
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "観測されたデータ",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "観測されたユーザー",
|
||||
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "リスクスコア",
|
||||
"xpack.securitySolution.timeline.userDetails.userIdLabel": "ユーザーID",
|
||||
"xpack.securitySolution.timeline.userDetails.userLabel": "ユーザー",
|
||||
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "値",
|
||||
"xpack.securitySolution.timelineEvents.errorSearchDescription": "タイムラインイベント検索でエラーが発生しました",
|
||||
|
|
|
@ -32001,7 +32001,6 @@
|
|||
"xpack.securitySolution.timeline.properties.timelineToggleButtonAriaLabel": "{isOpen, select, false {打开} true {关闭} other {切换}}时间线 {title}",
|
||||
"xpack.securitySolution.timeline.saveTimeline.modal.warning.title": "您的 {timeline} 未保存。是否保存?",
|
||||
"xpack.securitySolution.timeline.searchBoxPlaceholder": "例如 {timeline} 名称或描述",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserUpdatedTime": "已更新 {time}",
|
||||
"xpack.securitySolution.timeline.userDetails.updatedTime": "已更新 {time}",
|
||||
"xpack.securitySolution.timeline.youAreInAnEventRendererScreenReaderOnly": "您正处于第 {row} 行的事件呈现器中。按向上箭头键退出并返回当前行,或按向下箭头键退出并前进到下一行。",
|
||||
"xpack.securitySolution.timeline.youAreInATableCellScreenReaderOnly": "您处在表单元格中。行:{row},列:{column}",
|
||||
|
@ -36342,25 +36341,17 @@
|
|||
"xpack.securitySolution.timeline.userDetails.addExternalIntegrationButton": "添加外部集成",
|
||||
"xpack.securitySolution.timeline.userDetails.closeButton": "关闭",
|
||||
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "无法对用户托管数据执行搜索",
|
||||
"xpack.securitySolution.timeline.userDetails.familyLabel": "系列",
|
||||
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "字段",
|
||||
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "首次看到时间",
|
||||
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "操作系统",
|
||||
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP 地址",
|
||||
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "最后看到时间",
|
||||
"xpack.securitySolution.timeline.userDetails.managedBadge": "托管",
|
||||
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "托管数据",
|
||||
"xpack.securitySolution.timeline.userDetails.managedUserInspectTitle": "托管用户",
|
||||
"xpack.securitySolution.timeline.userDetails.maxAnomalyScoreByJobLabel": "最大异常分数(按作业)",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationText": "外部集成可提供其他元数据并帮助您管理用户。",
|
||||
"xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle": "您没有任何活动集成",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataText": "如果计划查看此用户的元数据,请确保已正确配置集成。",
|
||||
"xpack.securitySolution.timeline.userDetails.noAzureDataTitle": "找不到此用户的元数据",
|
||||
"xpack.securitySolution.timeline.userDetails.observedBadge": "已观察",
|
||||
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "观察数据",
|
||||
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "已观察用户",
|
||||
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "风险分数",
|
||||
"xpack.securitySolution.timeline.userDetails.userIdLabel": "用户 ID",
|
||||
"xpack.securitySolution.timeline.userDetails.userLabel": "用户",
|
||||
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "值",
|
||||
"xpack.securitySolution.timelineEvents.errorSearchDescription": "搜索时间线事件时发生错误",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue