[Security Solution] Create new host flyout (#173392)

## Summary

Create the Expandable Host flyout with the risk inputs panel.

![Screenshot 2023-12-22 at 16 56
12](a1e14232-620d-4109-a1b6-04e45513e3ee)


<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:
Pablo Machado 2024-01-04 15:59:57 +01:00 committed by GitHub
parent 488edebdb2
commit effd9e7a19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2649 additions and 861 deletions

View file

@ -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
*/

View file

@ -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;

View file

@ -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>

View file

@ -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

View file

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

View file

@ -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';

View file

@ -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];
};

View file

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

View file

@ -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';

View file

@ -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={() => {}}
/>
));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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';

View file

@ -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';

View file

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

View file

@ -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>

View file

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

View file

@ -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;

View file

@ -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: {

View file

@ -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

View file

@ -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'),

View file

@ -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

View file

@ -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',

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

@ -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: {

View file

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

View file

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

View file

@ -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],

View file

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

View file

@ -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]
);

View file

@ -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];

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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',
{

View file

@ -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'];
}

View file

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

View file

@ -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

View file

@ -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",

View file

@ -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": "タイムラインイベント検索でエラーが発生しました",

View file

@ -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": "搜索时间线事件时发生错误",