[SecuritySolution] Move related alerts by process ancestry behind platinum subscription (#138419) (#138605)

* feat: show an upsell for users without platinum license

* test: add license-related tests

* feat: re-add the feature flag

* nit: use EuiLink

* fix: check for length as well

* chore: move data check to helper

This reduced the overall complexity of the module, which made the eslint complexity exception obsolete

* tests:

* tests: fix tests

* tests: fix integration tests

(cherry picked from commit 785532eab6)

Co-authored-by: Jan Monschke <jan.monschke@elastic.co>
This commit is contained in:
Kibana Machine 2022-08-11 09:23:13 -04:00 committed by GitHub
parent 74804fc775
commit dba180eb6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 227 additions and 25 deletions

View file

@ -0,0 +1,21 @@
/*
* 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 { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
type TimelineEventsDetailsItemWithValues = TimelineEventsDetailsItem & {
values: string[];
};
/**
* Checks if the `item` has a non-empty `values` array
*/
export function hasData(
item?: TimelineEventsDetailsItem
): item is TimelineEventsDetailsItemWithValues {
return Boolean(item && item.values && item.values.length);
}

View file

@ -25,6 +25,7 @@ interface Props {
state: InsightAccordionState; state: InsightAccordionState;
text: string; text: string;
renderContent: () => ReactNode; renderContent: () => ReactNode;
extraAction?: EuiAccordionProps['extraAction'];
onToggle?: EuiAccordionProps['onToggle']; onToggle?: EuiAccordionProps['onToggle'];
} }
@ -33,7 +34,7 @@ interface Props {
* It wraps logic and custom styling around the loading, error and success states of an insight section. * It wraps logic and custom styling around the loading, error and success states of an insight section.
*/ */
export const InsightAccordion = React.memo<Props>( export const InsightAccordion = React.memo<Props>(
({ prefix, state, text, renderContent, onToggle = noop }) => { ({ prefix, state, text, renderContent, onToggle = noop, extraAction }) => {
const accordionId = useGeneratedHtmlId({ prefix }); const accordionId = useGeneratedHtmlId({ prefix });
switch (state) { switch (state) {
@ -54,6 +55,7 @@ export const InsightAccordion = React.memo<Props>(
</span> </span>
} }
onToggle={onToggle} onToggle={onToggle}
extraAction={extraAction}
/> />
); );
case 'success': case 'success':
@ -64,6 +66,7 @@ export const InsightAccordion = React.memo<Props>(
buttonContent={text} buttonContent={text}
onToggle={onToggle} onToggle={onToggle}
paddingSize="l" paddingSize="l"
extraAction={extraAction}
> >
{renderContent()} {renderContent()}
</StyledAccordion> </StyledAccordion>

View file

@ -10,8 +10,11 @@ import React from 'react';
import { TestProviders } from '../../../mock'; import { TestProviders } from '../../../mock';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__'; import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import { licenseService } from '../../../hooks/use_license';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils'; import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { Insights } from './insights'; import { Insights } from './insights';
import * as i18n from './translations'; import * as i18n from './translations';
@ -39,6 +42,46 @@ jest.mock('../../../lib/kibana', () => {
}); });
const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock; const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock;
jest.mock('../../../hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
isEnterprise: jest.fn(() => true),
};
return {
licenseService: licenseServiceInstance,
useLicense: () => {
return licenseServiceInstance;
},
};
});
const licenseServiceMock = licenseService as jest.Mocked<typeof licenseService>;
jest.mock('../../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
const data: TimelineEventsDetailsItem[] = [
{
category: 'process',
field: 'process.entity_id',
isObjectArray: false,
values: ['32082y34028u34'],
},
{
category: 'kibana',
field: 'kibana.alert.ancestors.id',
isObjectArray: false,
values: ['woeurhw98rhwr'],
},
{
category: 'kibana',
field: 'kibana.alert.rule.parameters.index',
isObjectArray: false,
values: ['fakeindex'],
},
];
describe('Insights', () => { describe('Insights', () => {
beforeEach(() => { beforeEach(() => {
mockUseGetUserCasesPermissions.mockReturnValue(noCasesPermissions()); mockUseGetUserCasesPermissions.mockReturnValue(noCasesPermissions());
@ -77,4 +120,57 @@ describe('Insights', () => {
}) })
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
describe('with feature flag enabled', () => {
describe('with platinum license', () => {
it('should show insights for related alerts by process ancestry', () => {
licenseServiceMock.isPlatinumPlus.mockReturnValue(true);
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={data} timelineId="" />
</TestProviders>
);
expect(screen.getByTestId('related-alerts-by-ancestry')).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) })
).not.toBeInTheDocument();
});
});
describe('without platinum license', () => {
it('should show an upsell for related alerts by process ancestry', () => {
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={data} timelineId="" />
</TestProviders>
);
expect(
screen.getByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) })
).toBeInTheDocument();
expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument();
});
});
});
describe('with feature flag disabled', () => {
it('should not render neither the upsell, nor the insights for alerts by process ancestry', () => {
useIsExperimentalFeatureEnabledMock.mockReturnValue(false);
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={data} timelineId="" />
</TestProviders>
);
expect(screen.queryByTestId('related-alerts-by-ancestry')).not.toBeInTheDocument();
expect(
screen.queryByRole('link', { name: new RegExp(i18n.ALERT_UPSELL) })
).not.toBeInTheDocument();
});
});
}); });

View file

@ -13,12 +13,15 @@ import * as i18n from './translations';
import type { BrowserFields } from '../../../containers/source'; import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { hasData } from './helpers';
import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import { useLicense } from '../../../hooks/use_license';
import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry'; import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry';
import { RelatedCases } from './related_cases'; import { RelatedCases } from './related_cases';
import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event'; import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event';
import { RelatedAlertsBySession } from './related_alerts_by_session'; import { RelatedAlertsBySession } from './related_alerts_by_session';
import { RelatedAlertsUpsell } from './related_alerts_upsell';
interface Props { interface Props {
browserFields: BrowserFields; browserFields: BrowserFields;
@ -36,6 +39,7 @@ export const Insights = React.memo<Props>(
const isRelatedAlertsByProcessAncestryEnabled = useIsExperimentalFeatureEnabled( const isRelatedAlertsByProcessAncestryEnabled = useIsExperimentalFeatureEnabled(
'insightsRelatedAlertsByProcessAncestry' 'insightsRelatedAlertsByProcessAncestry'
); );
const hasAtLeastPlatinum = useLicense().isPlatinumPlus();
const processEntityField = find({ category: 'process', field: 'process.entity_id' }, data); const processEntityField = find({ category: 'process', field: 'process.entity_id' }, data);
const originalDocumentId = find( const originalDocumentId = find(
{ category: 'kibana', field: 'kibana.alert.ancestors.id' }, { category: 'kibana', field: 'kibana.alert.ancestors.id' },
@ -45,20 +49,20 @@ export const Insights = React.memo<Props>(
{ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, { category: 'kibana', field: 'kibana.alert.rule.parameters.index' },
data data
); );
const hasProcessEntityInfo = const hasProcessEntityInfo = hasData(processEntityField);
isRelatedAlertsByProcessAncestryEnabled && processEntityField && processEntityField.values;
const processSessionField = find( const processSessionField = find(
{ category: 'process', field: 'process.entry_leader.entity_id' }, { category: 'process', field: 'process.entry_leader.entity_id' },
data data
); );
const hasProcessSessionInfo = processSessionField && processSessionField.values; const hasProcessSessionInfo =
isRelatedAlertsByProcessAncestryEnabled && hasData(processSessionField);
const sourceEventField = find( const sourceEventField = find(
{ category: 'kibana', field: 'kibana.alert.original_event.id' }, { category: 'kibana', field: 'kibana.alert.original_event.id' },
data data
); );
const hasSourceEventInfo = sourceEventField && sourceEventField.values; const hasSourceEventInfo = hasData(sourceEventField);
const userCasesPermissions = useGetUserCasesPermissions(); const userCasesPermissions = useGetUserCasesPermissions();
const hasCasesReadPermissions = userCasesPermissions.read; const hasCasesReadPermissions = userCasesPermissions.read;
@ -74,8 +78,7 @@ export const Insights = React.memo<Props>(
const canShowAncestryInsight = const canShowAncestryInsight =
isRelatedAlertsByProcessAncestryEnabled && isRelatedAlertsByProcessAncestryEnabled &&
processEntityField && hasProcessEntityInfo &&
processEntityField.values &&
originalDocumentId && originalDocumentId &&
originalDocumentIndex; originalDocumentIndex;
@ -122,17 +125,22 @@ export const Insights = React.memo<Props>(
</EuiFlexItem> </EuiFlexItem>
)} )}
{canShowAncestryInsight && ( {canShowAncestryInsight &&
<EuiFlexItem data-test-subj="related-alerts-by-ancestry"> (hasAtLeastPlatinum ? (
<RelatedAlertsByProcessAncestry <EuiFlexItem data-test-subj="related-alerts-by-ancestry">
data={processEntityField} <RelatedAlertsByProcessAncestry
originalDocumentId={originalDocumentId} data={processEntityField}
index={originalDocumentIndex} originalDocumentId={originalDocumentId}
eventId={eventId} index={originalDocumentIndex}
timelineId={timelineId} eventId={eventId}
/> timelineId={timelineId}
</EuiFlexItem> />
)} </EuiFlexItem>
) : (
<EuiFlexItem>
<RelatedAlertsUpsell />
</EuiFlexItem>
))}
</EuiFlexGroup> </EuiFlexGroup>
</div> </div>
); );

View file

@ -6,7 +6,7 @@
*/ */
import React, { useMemo, useCallback, useEffect, useState } from 'react'; import React, { useMemo, useCallback, useEffect, useState } from 'react';
import { EuiSpacer, EuiLoadingSpinner } from '@elastic/eui'; import { EuiBetaBadge, EuiSpacer, EuiLoadingSpinner } from '@elastic/eui';
import type { DataProvider } from '../../../../../common/types'; import type { DataProvider } from '../../../../../common/types';
import { TimelineId } from '../../../../../common/types/timeline'; import { TimelineId } from '../../../../../common/types/timeline';
@ -23,6 +23,7 @@ import {
PROCESS_ANCESTRY_EMPTY, PROCESS_ANCESTRY_EMPTY,
PROCESS_ANCESTRY_ERROR, PROCESS_ANCESTRY_ERROR,
} from './translations'; } from './translations';
import { BETA } from '../../../translations';
interface Props { interface Props {
data: TimelineEventsDetailsItem; data: TimelineEventsDetailsItem;
@ -112,6 +113,7 @@ export const RelatedAlertsByProcessAncestry = React.memo<Props>(
} }
renderContent={renderContent} renderContent={renderContent}
onToggle={onToggle} onToggle={onToggle}
extraAction={<EuiBetaBadge size="s" label={BETA} color="subdued" />}
/> />
); );
} }

View file

@ -6,7 +6,7 @@
*/ */
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui'; import { EuiBetaBadge, EuiSpacer } from '@elastic/eui';
import type { BrowserFields } from '../../../containers/source'; import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
@ -19,6 +19,7 @@ import { SimpleAlertTable } from './simple_alert_table';
import { getEnrichedFieldInfo } from '../helpers'; import { getEnrichedFieldInfo } from '../helpers';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations'; import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { SESSION_LOADING, SESSION_EMPTY, SESSION_ERROR, SESSION_COUNT } from './translations'; import { SESSION_LOADING, SESSION_EMPTY, SESSION_ERROR, SESSION_COUNT } from './translations';
import { BETA } from '../../../translations';
interface Props { interface Props {
browserFields: BrowserFields; browserFields: BrowserFields;
@ -99,6 +100,7 @@ export const RelatedAlertsBySession = React.memo<Props>(
state={state} state={state}
text={getTextFromState(state, count)} text={getTextFromState(state, count)}
renderContent={renderContent} renderContent={renderContent}
extraAction={<EuiBetaBadge size="s" label={BETA} color="subdued" />}
/> />
); );
} }

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiLink, EuiIcon, EuiText } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { ALERT_UPSELL } from './translations';
const UpsellContainer = euiStyled.div`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
padding: 12px;
border-radius: 6px;
`;
const StyledIcon = euiStyled(EuiIcon)`
margin-right: 10px;
`;
export const RelatedAlertsUpsell = React.memo(() => {
return (
<UpsellContainer>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<StyledIcon size="m" type="lock" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<EuiLink
color="subdued"
href="https://www.elastic.co/pricing/"
rel="noopener noreferrer"
target="_blank"
>
{ALERT_UPSELL}
</EuiLink>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</UpsellContainer>
);
});
RelatedAlertsUpsell.displayName = 'RelatedAlertsUpsell';

View file

@ -135,3 +135,10 @@ export const SIMPLE_ALERT_TABLE_LIMITED = i18n.translate(
defaultMessage: 'Showing only the latest 10 alerts. View the rest of alerts in timeline.', defaultMessage: 'Showing only the latest 10 alerts. View the rest of alerts in timeline.',
} }
); );
export const ALERT_UPSELL = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.alertUpsellTitle',
{
defaultMessage: 'Get more insights with a subscription',
}
);

View file

@ -23,14 +23,14 @@ export const registerResolverRoutes = async (
startServices: StartServicesAccessor<StartPlugins>, startServices: StartServicesAccessor<StartPlugins>,
config: ConfigType config: ConfigType
) => { ) => {
const [, { ruleRegistry }] = await startServices(); const [, { ruleRegistry, licensing }] = await startServices();
router.post( router.post(
{ {
path: '/api/endpoint/resolver/tree', path: '/api/endpoint/resolver/tree',
validate: validateTree, validate: validateTree,
options: { authRequired: true }, options: { authRequired: true },
}, },
handleTree(ruleRegistry, config) handleTree(ruleRegistry, config, licensing)
); );
router.post( router.post(

View file

@ -5,23 +5,37 @@
* 2.0. * 2.0.
*/ */
import { firstValueFrom } from 'rxjs';
import type { RequestHandler } from '@kbn/core/server'; import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema'; import type { TypeOf } from '@kbn/config-schema';
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server'; import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import type { validateTree } from '../../../../../common/endpoint/schema/resolver'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import type { ConfigType } from '../../../../config'; import type { ConfigType } from '../../../../config';
import type { validateTree } from '../../../../../common/endpoint/schema/resolver';
import { featureUsageService } from '../../../services/feature_usage';
import { Fetcher } from './utils/fetch'; import { Fetcher } from './utils/fetch';
export function handleTree( export function handleTree(
ruleRegistry: RuleRegistryPluginStartContract, ruleRegistry: RuleRegistryPluginStartContract,
config: ConfigType config: ConfigType,
licensing: LicensingPluginStart
): RequestHandler<unknown, unknown, TypeOf<typeof validateTree.body>> { ): RequestHandler<unknown, unknown, TypeOf<typeof validateTree.body>> {
return async (context, req, res) => { return async (context, req, res) => {
const client = (await context.core).elasticsearch.client; const client = (await context.core).elasticsearch.client;
const { const {
experimentalFeatures: { insightsRelatedAlertsByProcessAncestry }, experimentalFeatures: { insightsRelatedAlertsByProcessAncestry },
} = config; } = config;
const alertsClient = insightsRelatedAlertsByProcessAncestry const license = await firstValueFrom(licensing.license$);
const hasAccessToInsightsRelatedByProcessAncestry =
insightsRelatedAlertsByProcessAncestry && license.hasAtLeast('platinum');
if (hasAccessToInsightsRelatedByProcessAncestry) {
featureUsageService.notifyUsage('ALERTS_BY_PROCESS_ANCESTRY');
}
const alertsClient = hasAccessToInsightsRelatedByProcessAncestry
? await ruleRegistry.getRacClientWithRequest(req) ? await ruleRegistry.getRacClientWithRequest(req)
: undefined; : undefined;
const fetcher = new Fetcher(client, alertsClient); const fetcher = new Fetcher(client, alertsClient);

View file

@ -21,6 +21,7 @@ const FEATURES = {
KILL_PROCESS: 'Kill process', KILL_PROCESS: 'Kill process',
SUSPEND_PROCESS: 'Suspend process', SUSPEND_PROCESS: 'Suspend process',
RUNNING_PROCESSES: 'Get running processes', RUNNING_PROCESSES: 'Get running processes',
ALERTS_BY_PROCESS_ANCESTRY: 'Get related alerts by process ancestry',
} as const; } as const;
export type FeatureKeys = keyof typeof FEATURES; export type FeatureKeys = keyof typeof FEATURES;