[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;
text: string;
renderContent: () => ReactNode;
extraAction?: EuiAccordionProps['extraAction'];
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.
*/
export const InsightAccordion = React.memo<Props>(
({ prefix, state, text, renderContent, onToggle = noop }) => {
({ prefix, state, text, renderContent, onToggle = noop, extraAction }) => {
const accordionId = useGeneratedHtmlId({ prefix });
switch (state) {
@ -54,6 +55,7 @@ export const InsightAccordion = React.memo<Props>(
</span>
}
onToggle={onToggle}
extraAction={extraAction}
/>
);
case 'success':
@ -64,6 +66,7 @@ export const InsightAccordion = React.memo<Props>(
buttonContent={text}
onToggle={onToggle}
paddingSize="l"
extraAction={extraAction}
>
{renderContent()}
</StyledAccordion>

View file

@ -10,8 +10,11 @@ import React from 'react';
import { TestProviders } from '../../../mock';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
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 { Insights } from './insights';
import * as i18n from './translations';
@ -39,6 +42,46 @@ jest.mock('../../../lib/kibana', () => {
});
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', () => {
beforeEach(() => {
mockUseGetUserCasesPermissions.mockReturnValue(noCasesPermissions());
@ -77,4 +120,57 @@ describe('Insights', () => {
})
).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 { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { hasData } from './helpers';
import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
import { useLicense } from '../../../hooks/use_license';
import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry';
import { RelatedCases } from './related_cases';
import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event';
import { RelatedAlertsBySession } from './related_alerts_by_session';
import { RelatedAlertsUpsell } from './related_alerts_upsell';
interface Props {
browserFields: BrowserFields;
@ -36,6 +39,7 @@ export const Insights = React.memo<Props>(
const isRelatedAlertsByProcessAncestryEnabled = useIsExperimentalFeatureEnabled(
'insightsRelatedAlertsByProcessAncestry'
);
const hasAtLeastPlatinum = useLicense().isPlatinumPlus();
const processEntityField = find({ category: 'process', field: 'process.entity_id' }, data);
const originalDocumentId = find(
{ 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' },
data
);
const hasProcessEntityInfo =
isRelatedAlertsByProcessAncestryEnabled && processEntityField && processEntityField.values;
const hasProcessEntityInfo = hasData(processEntityField);
const processSessionField = find(
{ category: 'process', field: 'process.entry_leader.entity_id' },
data
);
const hasProcessSessionInfo = processSessionField && processSessionField.values;
const hasProcessSessionInfo =
isRelatedAlertsByProcessAncestryEnabled && hasData(processSessionField);
const sourceEventField = find(
{ category: 'kibana', field: 'kibana.alert.original_event.id' },
data
);
const hasSourceEventInfo = sourceEventField && sourceEventField.values;
const hasSourceEventInfo = hasData(sourceEventField);
const userCasesPermissions = useGetUserCasesPermissions();
const hasCasesReadPermissions = userCasesPermissions.read;
@ -74,8 +78,7 @@ export const Insights = React.memo<Props>(
const canShowAncestryInsight =
isRelatedAlertsByProcessAncestryEnabled &&
processEntityField &&
processEntityField.values &&
hasProcessEntityInfo &&
originalDocumentId &&
originalDocumentIndex;
@ -122,17 +125,22 @@ export const Insights = React.memo<Props>(
</EuiFlexItem>
)}
{canShowAncestryInsight && (
<EuiFlexItem data-test-subj="related-alerts-by-ancestry">
<RelatedAlertsByProcessAncestry
data={processEntityField}
originalDocumentId={originalDocumentId}
index={originalDocumentIndex}
eventId={eventId}
timelineId={timelineId}
/>
</EuiFlexItem>
)}
{canShowAncestryInsight &&
(hasAtLeastPlatinum ? (
<EuiFlexItem data-test-subj="related-alerts-by-ancestry">
<RelatedAlertsByProcessAncestry
data={processEntityField}
originalDocumentId={originalDocumentId}
index={originalDocumentIndex}
eventId={eventId}
timelineId={timelineId}
/>
</EuiFlexItem>
) : (
<EuiFlexItem>
<RelatedAlertsUpsell />
</EuiFlexItem>
))}
</EuiFlexGroup>
</div>
);

View file

@ -6,7 +6,7 @@
*/
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 { TimelineId } from '../../../../../common/types/timeline';
@ -23,6 +23,7 @@ import {
PROCESS_ANCESTRY_EMPTY,
PROCESS_ANCESTRY_ERROR,
} from './translations';
import { BETA } from '../../../translations';
interface Props {
data: TimelineEventsDetailsItem;
@ -112,6 +113,7 @@ export const RelatedAlertsByProcessAncestry = React.memo<Props>(
}
renderContent={renderContent}
onToggle={onToggle}
extraAction={<EuiBetaBadge size="s" label={BETA} color="subdued" />}
/>
);
}

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { EuiBetaBadge, EuiSpacer } from '@elastic/eui';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
@ -19,6 +19,7 @@ import { SimpleAlertTable } from './simple_alert_table';
import { getEnrichedFieldInfo } from '../helpers';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { SESSION_LOADING, SESSION_EMPTY, SESSION_ERROR, SESSION_COUNT } from './translations';
import { BETA } from '../../../translations';
interface Props {
browserFields: BrowserFields;
@ -99,6 +100,7 @@ export const RelatedAlertsBySession = React.memo<Props>(
state={state}
text={getTextFromState(state, count)}
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.',
}
);
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>,
config: ConfigType
) => {
const [, { ruleRegistry }] = await startServices();
const [, { ruleRegistry, licensing }] = await startServices();
router.post(
{
path: '/api/endpoint/resolver/tree',
validate: validateTree,
options: { authRequired: true },
},
handleTree(ruleRegistry, config)
handleTree(ruleRegistry, config, licensing)
);
router.post(

View file

@ -5,23 +5,37 @@
* 2.0.
*/
import { firstValueFrom } from 'rxjs';
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
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 { validateTree } from '../../../../../common/endpoint/schema/resolver';
import { featureUsageService } from '../../../services/feature_usage';
import { Fetcher } from './utils/fetch';
export function handleTree(
ruleRegistry: RuleRegistryPluginStartContract,
config: ConfigType
config: ConfigType,
licensing: LicensingPluginStart
): RequestHandler<unknown, unknown, TypeOf<typeof validateTree.body>> {
return async (context, req, res) => {
const client = (await context.core).elasticsearch.client;
const {
experimentalFeatures: { insightsRelatedAlertsByProcessAncestry },
} = 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)
: undefined;
const fetcher = new Fetcher(client, alertsClient);

View file

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