mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
74804fc775
commit
dba180eb6f
11 changed files with 227 additions and 25 deletions
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue