Skip to content

Commit

Permalink
[Security Solutions] Add risk tab to the user details page (#130256)
Browse files Browse the repository at this point in the history
* Remove traces of host from risk_score_over_time

* Remove traces of host from risk_score_contributor and add query toggle

* Add user details risk tab

* Improve unit test coverage

* Create User Risk Information flyout

* run i18n_check.js --fix

* Update users by risk table to link to users details risk tab

* Improve Host Risk Flyout test coverage

* Rename user and host risk tabs

* Fix user risk CallOut showing when riskyUsersEnabled FF is disabled

* Fix eslint warning
  • Loading branch information
machadoum authored May 6, 2022
1 parent 8539a91 commit 2fd0e55
Show file tree
Hide file tree
Showing 38 changed files with 1,247 additions and 643 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('risk tab', () => {
cy.get('[data-test-subj="navigation-hostRisk"]').click();
waitForTableToLoad();

cy.get('[data-test-subj="topHostScoreContributors"]')
cy.get('[data-test-subj="topRiskScoreContributors"]')
.find(TABLE_ROWS)
.within(() => {
cy.get(TABLE_CELL).contains('Unusual Linux Username');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

export const RULE_NAME = '[data-test-subj="topHostScoreContributors"] .euiTableCellContent';
export const RULE_NAME = '[data-test-subj="topRiskScoreContributors"] .euiTableCellContent';

export const RISK_FLYOUT = '[data-test-subj="open-risk-information-flyout"] .euiFlyoutHeader';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
{
id: SecurityPageName.hostsRisk,
title: i18n.translate('xpack.securitySolution.search.hosts.risk', {
defaultMessage: 'Hosts by risk',
defaultMessage: 'Host risk',
}),
path: `${HOSTS_PATH}/hostRisk`,
experimentalKey: 'riskyHostsEnabled',
Expand Down Expand Up @@ -355,7 +355,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
{
id: SecurityPageName.usersRisk,
title: i18n.translate('xpack.securitySolution.search.users.risk', {
defaultMessage: 'Users by risk',
defaultMessage: 'User risk',
}),
path: `${USERS_PATH}/userRisk`,
experimentalKey: 'riskyUsersEnabled',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ import { isUrlInvalid } from '../../utils/validators';

import * as i18n from './translations';
import { SecurityPageName } from '../../../app/types';
import { getUsersDetailsUrl } from '../link_to/redirect_to_users';
import { getTabsOnUsersDetailsUrl, getUsersDetailsUrl } from '../link_to/redirect_to_users';
import { LinkAnchor, GenericLinkButton, PortContainer, Comma, LinkButton } from './helpers';
import { HostsTableType } from '../../../hosts/store/model';
import { UsersTableType } from '../../../users/store/model';

export { LinkButton, LinkAnchor } from './helpers';

Expand All @@ -52,10 +53,11 @@ const UserDetailsLinkComponent: React.FC<{
/** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */
Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon;
userName: string;
userTab?: UsersTableType;
title?: string;
isButton?: boolean;
onClick?: (e: SyntheticEvent) => void;
}> = ({ children, Component, userName, isButton, onClick, title }) => {
}> = ({ children, Component, userName, isButton, onClick, title, userTab }) => {
const encodedUserName = encodeURIComponent(userName);

const { formatUrl, search } = useFormatUrl(SecurityPageName.users);
Expand All @@ -65,17 +67,29 @@ const UserDetailsLinkComponent: React.FC<{
ev.preventDefault();
navigateToApp(APP_UI_ID, {
deepLinkId: SecurityPageName.users,
path: getUsersDetailsUrl(encodedUserName, search),
path: userTab
? getTabsOnUsersDetailsUrl(encodedUserName, userTab, search)
: getUsersDetailsUrl(encodedUserName, search),
});
},
[encodedUserName, navigateToApp, search]
[encodedUserName, navigateToApp, search, userTab]
);

const href = useMemo(
() =>
formatUrl(
userTab
? getTabsOnUsersDetailsUrl(encodedUserName, userTab)
: getUsersDetailsUrl(encodedUserName)
),
[formatUrl, encodedUserName, userTab]
);

return isButton ? (
<GenericLinkButton
Component={Component}
dataTestSubj="data-grid-user-details"
href={formatUrl(getUsersDetailsUrl(encodedUserName))}
href={href}
onClick={onClick ?? goToUsersDetails}
title={title ?? userName}
>
Expand All @@ -85,7 +99,7 @@ const UserDetailsLinkComponent: React.FC<{
<LinkAnchor
data-test-subj="users-link-anchor"
onClick={onClick ?? goToUsersDetails}
href={formatUrl(getUsersDetailsUrl(encodedUserName))}
href={href}
>
{children ? children : userName}
</LinkAnchor>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { render } from '@testing-library/react';
import React from 'react';
import { RiskScoreOverTime, scoreFormatter } from '.';
import { TestProviders } from '../../mock';
import { LineSeries } from '@elastic/charts';

const mockLineSeries = LineSeries as jest.Mock;

jest.mock('@elastic/charts', () => {
const original = jest.requireActual('@elastic/charts');
return {
...original,
LineSeries: jest.fn().mockImplementation(() => <></>),
};
});

describe('Risk Score Over Time', () => {
it('renders', () => {
const { queryByTestId } = render(
<TestProviders>
<RiskScoreOverTime
riskScore={[]}
loading={false}
from={'2020-07-07T08:20:18.966Z'}
to={'2020-07-08T08:20:18.966Z'}
queryId={'test_query_id'}
title={'test_query_title'}
toggleStatus={true}
/>
</TestProviders>
);

expect(queryByTestId('RiskScoreOverTime')).toBeInTheDocument();
});

it('renders loader when loading', () => {
const { queryByTestId } = render(
<TestProviders>
<RiskScoreOverTime
loading={true}
from={'2020-07-07T08:20:18.966Z'}
to={'2020-07-08T08:20:18.966Z'}
queryId={'test_query_id'}
title={'test_query_title'}
toggleStatus={true}
/>
</TestProviders>
);

expect(queryByTestId('RiskScoreOverTime-loading')).toBeInTheDocument();
});

describe('scoreFormatter', () => {
it('renders score formatted', () => {
render(
<TestProviders>
<RiskScoreOverTime
riskScore={[]}
loading={false}
from={'2020-07-07T08:20:18.966Z'}
to={'2020-07-08T08:20:18.966Z'}
queryId={'test_query_id'}
title={'test_query_title'}
toggleStatus={true}
/>
</TestProviders>
);

const tickFormat = mockLineSeries.mock.calls[0][0].tickFormat;

expect(tickFormat).toBe(scoreFormatter);
});

it('renders a formatted score', () => {
expect(scoreFormatter(3.000001)).toEqual('3');
expect(scoreFormatter(3.4999)).toEqual('3');
expect(scoreFormatter(3.51111)).toEqual('4');
expect(scoreFormatter(3.9999)).toEqual('4');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* 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, useCallback } from 'react';
import {
Chart,
LineSeries,
ScaleType,
Settings,
Axis,
Position,
AnnotationDomainType,
LineAnnotation,
TooltipValue,
} from '@elastic/charts';
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiText, EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
import { chartDefaultSettings, useTheme } from '../charts/common';
import { useTimeZone } from '../../lib/kibana';
import { histogramDateTimeFormatter } from '../utils';
import { HeaderSection } from '../header_section';
import { InspectButton, InspectButtonContainer } from '../inspect';
import * as i18n from './translations';
import { PreferenceFormattedDate } from '../formatted_date';
import { RiskScore } from '../../../../common/search_strategy';

export interface RiskScoreOverTimeProps {
from: string;
to: string;
loading: boolean;
riskScore?: RiskScore[];
queryId: string;
title: string;
toggleStatus: boolean;
toggleQuery?: (status: boolean) => void;
}

const RISKY_THRESHOLD = 70;
const DEFAULT_CHART_HEIGHT = 250;

const StyledEuiText = styled(EuiText)`
font-size: 9px;
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
margin-right: ${({ theme }) => theme.eui.paddingSizes.xs};
`;

const LoadingChart = styled(EuiLoadingChart)`
display: block;
text-align: center;
`;

export const scoreFormatter = (d: number) => Math.round(d).toString();

const RiskScoreOverTimeComponent: React.FC<RiskScoreOverTimeProps> = ({
from,
to,
riskScore,
loading,
queryId,
title,
toggleStatus,
toggleQuery,
}) => {
const timeZone = useTimeZone();

const dataTimeFormatter = useMemo(() => histogramDateTimeFormatter([from, to]), [from, to]);
const headerFormatter = useCallback(
(tooltip: TooltipValue) => <PreferenceFormattedDate value={tooltip.value} />,
[]
);

const theme = useTheme();

const graphData = useMemo(
() =>
riskScore
?.map((data) => ({
x: data['@timestamp'],
y: data.risk_stats.risk_score,
}))
.reverse() ?? [],
[riskScore]
);

return (
<InspectButtonContainer>
<EuiPanel hasBorder data-test-subj="RiskScoreOverTime">
<EuiFlexGroup gutterSize={'none'}>
<EuiFlexItem grow={1}>
<HeaderSection
title={title}
hideSubtitle
toggleQuery={toggleQuery}
toggleStatus={toggleStatus}
/>
</EuiFlexItem>
{toggleStatus && (
<EuiFlexItem grow={false}>
<InspectButton queryId={queryId} title={title} />
</EuiFlexItem>
)}
</EuiFlexGroup>

{toggleStatus && (
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem grow={1}>
<div style={{ height: DEFAULT_CHART_HEIGHT }}>
{loading ? (
<LoadingChart size="l" data-test-subj="RiskScoreOverTime-loading" />
) : (
<Chart>
<Settings
{...chartDefaultSettings}
theme={theme}
tooltip={{
headerFormatter,
}}
/>
<Axis
id="bottom"
position={Position.Bottom}
tickFormat={dataTimeFormatter}
showGridLines
gridLine={{
strokeWidth: 1,
opacity: 1,
dash: [3, 5],
}}
/>
<Axis
domain={{
min: 0,
max: 100,
}}
id="left"
position={Position.Left}
ticks={3}
style={{
tickLine: {
visible: false,
},
tickLabel: {
padding: 10,
},
}}
/>
<LineSeries
id="RiskOverTime"
name={i18n.RISK_SCORE}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="x"
yAccessors={['y']}
timeZone={timeZone}
data={graphData}
tickFormat={scoreFormatter}
/>
<LineAnnotation
id="RiskOverTime_annotation"
domainType={AnnotationDomainType.YDomain}
dataValues={[
{
dataValue: RISKY_THRESHOLD,
details: `${RISKY_THRESHOLD}`,
header: i18n.RISK_THRESHOLD,
},
]}
markerPosition="left"
style={{
line: {
strokeWidth: 1,
stroke: euiThemeVars.euiColorDanger,
opacity: 1,
},
}}
marker={
<StyledEuiText color={euiThemeVars.euiColorDarkestShade}>
{i18n.RISKY}
</StyledEuiText>
}
/>
</Chart>
)}
</div>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
</InspectButtonContainer>
);
};

RiskScoreOverTimeComponent.displayName = 'RiskScoreOverTimeComponent';
export const RiskScoreOverTime = React.memo(RiskScoreOverTimeComponent);
RiskScoreOverTime.displayName = 'RiskScoreOverTime';
Loading

0 comments on commit 2fd0e55

Please sign in to comment.