[Fleet][Integrations] Add callout to Microsoft Defender integrations showing support for bi-directional response actions (#207861)

## Summary

- Add dismissible callout indicating support for bi-directional response
actions to the following integration: Overview page:
    - Microsoft Defender for Endpoint
    - Microsoft M365 Defender
This commit is contained in:
Paul Tavares 2025-01-23 08:30:40 -05:00 committed by GitHub
parent a69236d048
commit cc38fbea29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 79 deletions

View file

@ -6,7 +6,9 @@
*/
import React from 'react';
import { type RenderResult } from '@testing-library/react';
import { type RenderResult, fireEvent, waitFor } from '@testing-library/react';
import type { FleetStartServices } from '../../../../../../..';
import { createFleetTestRendererMock } from '../../../../../../../mock';
@ -18,33 +20,61 @@ import {
jest.mock('react-use/lib/useLocalStorage');
describe('BidirectionalIntegrationsBanner', () => {
let formProps: BidirectionalIntegrationsBannerProps;
let componentProps: BidirectionalIntegrationsBannerProps;
let renderResult: RenderResult;
let render: () => RenderResult;
let storageMock: jest.Mocked<FleetStartServices['storage']>;
beforeEach(() => {
formProps = {
onDismiss: jest.fn(),
componentProps = { integrationPackageName: 'sentinel_one' };
const testRunner = createFleetTestRendererMock();
storageMock = testRunner.startServices.storage;
render = () => {
renderResult = testRunner.render(<BidirectionalIntegrationsBanner {...componentProps} />);
return renderResult;
};
const renderer = createFleetTestRendererMock();
renderResult = renderer.render(<BidirectionalIntegrationsBanner {...formProps} />);
});
it('should render bidirectional integrations banner', () => {
render();
expect(renderResult.getByTestId('bidirectionalIntegrationsCallout')).toBeInTheDocument();
});
it('should contain a link to documentation', () => {
render();
const docLink = renderResult.getByTestId('bidirectionalIntegrationDocLink');
expect(docLink).toBeInTheDocument();
expect(docLink.getAttribute('href')).toContain('third-party-actions.html');
});
it('should call `onDismiss` callback when user clicks dismiss', () => {
renderResult.getByTestId('euiDismissCalloutButton').click();
it('should remove the callout when the dismiss button is clicked', async () => {
render();
fireEvent.click(renderResult.getByTestId('euiDismissCalloutButton'));
expect(formProps.onDismiss).toBeCalled();
await waitFor(() => {
expect(storageMock.store.setItem).toHaveBeenCalledWith(
'fleet.showSOReponseSupportBanner',
'false'
);
expect(renderResult.queryByTestId('bidirectionalIntegrationsCallout')).toBeFalsy();
});
});
it('should render nothing if integration is not supported', () => {
componentProps.integrationPackageName = 'foo';
render();
expect(renderResult.queryByTestId('bidirectionalIntegrationsCallout')).toBeFalsy();
});
it('should render nothing if user had dismissed the callout in the past', () => {
(storageMock.store.getItem as jest.Mock).mockReturnValue('false');
render();
expect(renderResult.queryByTestId('bidirectionalIntegrationsCallout')).toBeFalsy();
});
});

View file

@ -5,12 +5,26 @@
* 2.0.
*/
import React, { memo } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { EuiCallOut, EuiLink, EuiTextColor } from '@elastic/eui';
import { EuiCallOut, EuiLink, EuiSpacer, EuiTextColor } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { FleetStartServices } from '../../../../../../..';
/**
* A list of Integration `package.name`'s that support security's bi-directional response actions
* along with its corresponding storage (local storage) key for persisting the user's dismissal of
* the callout
*/
const SUPPORTED_INTEGRATIONS_STORAGE_KEY: Readonly<Record<string, string>> = Object.freeze({
sentinel_one: 'fleet.showSOReponseSupportBanner',
crowdstrike: 'fleet.showCSResponseSupportBanner',
microsoft_defender_endpoint: 'fleet.showMSDefenderResponseSupportBanner',
m365_defender: 'fleet.showMSDefenderResponseSupportBanner', // Same key as the one above
});
const AccentCallout = styled(EuiCallOut)`
.euiCallOutHeader__title {
color: ${(props) => props.theme.eui.euiColorAccent};
@ -19,47 +33,69 @@ const AccentCallout = styled(EuiCallOut)`
`;
export interface BidirectionalIntegrationsBannerProps {
onDismiss: () => void;
integrationPackageName: string;
}
export const BidirectionalIntegrationsBanner = memo<BidirectionalIntegrationsBannerProps>(
({ onDismiss }) => {
const { docLinks } = useKibana().services;
const bannerTitle = (
<EuiTextColor color="accent">
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrationsBanner.title"
defaultMessage={'NEW: Response enabled integration'}
/>
</EuiTextColor>
({ integrationPackageName }) => {
const { docLinks, storage } = useKibana<FleetStartServices>().services;
const storageKey = SUPPORTED_INTEGRATIONS_STORAGE_KEY[integrationPackageName];
const [showBanner, setShowBanner] = useState(
storageKey ? storage.get(storageKey) ?? true : false
);
const onDismissHandler = useCallback(() => {
setShowBanner(false);
if (storageKey) {
storage.set(storageKey, false);
}
}, [storage, storageKey]);
const bannerTitle = useMemo(
() => (
<EuiTextColor color="accent">
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrationsBanner.title"
defaultMessage={'NEW: Response enabled integration'}
/>
</EuiTextColor>
),
[]
);
if (!storageKey || !showBanner) {
return null;
}
return (
<AccentCallout
title={bannerTitle}
iconType="cheer"
onDismiss={onDismiss}
data-test-subj={'bidirectionalIntegrationsCallout'}
>
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrationsBanner.body"
defaultMessage="Orchestrate response actions across endpoint vendors with bidirectional integrations. {learnmore}."
values={{
learnmore: (
<EuiLink
href={docLinks?.links.securitySolution.bidirectionalIntegrations}
target="_blank"
data-test-subj="bidirectionalIntegrationDocLink"
>
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrations.doc.link"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</AccentCallout>
<>
<AccentCallout
title={bannerTitle}
iconType="cheer"
onDismiss={onDismissHandler}
data-test-subj={'bidirectionalIntegrationsCallout'}
>
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrationsBanner.body"
defaultMessage="Orchestrate response actions across endpoint vendors with bidirectional integrations. {learnmore}."
values={{
learnmore: (
<EuiLink
href={docLinks?.links.securitySolution.bidirectionalIntegrations}
target="_blank"
data-test-subj="bidirectionalIntegrationDocLink"
>
<FormattedMessage
id="xpack.fleet.bidirectionalIntegrations.doc.link"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</AccentCallout>
<EuiSpacer size="s" />
</>
);
}
);

View file

@ -175,8 +175,6 @@ export const OverviewPage: React.FC<Props> = memo(
const isUnverified = isPackageUnverified(packageInfo, packageVerificationKeyId);
const isPrerelease = isPackagePrerelease(packageInfo.version);
const isElasticDefend = packageInfo.name === 'endpoint';
const isSentinelOne = packageInfo.name === 'sentinel_one';
const isCrowdStrike = packageInfo.name === 'crowdstrike';
const [markdown, setMarkdown] = useState<string | undefined>(undefined);
const [selectedItemId, setSelectedItem] = useState<string | undefined>(undefined);
const [isSideNavOpenOnMobile, setIsSideNavOpenOnMobile] = useState(false);
@ -301,27 +299,11 @@ export const OverviewPage: React.FC<Props> = memo(
const [showAVCBanner, setShowAVCBanner] = useState(
storage.get('securitySolution.showAvcBanner') ?? true
);
const [showCSResponseSupportBanner, setShowCSResponseSupportBanner] = useState(
storage.get('fleet.showCSResponseSupportBanner') ?? true
);
const [showSOReponseSupportBanner, setShowSOResponseSupportBanner] = useState(
storage.get('fleet.showSOReponseSupportBanner') ?? true
);
const onAVCBannerDismiss = useCallback(() => {
setShowAVCBanner(false);
storage.set('securitySolution.showAvcBanner', false);
}, [storage]);
const onCSResponseSupportBannerDismiss = useCallback(() => {
setShowCSResponseSupportBanner(false);
storage.set('fleet.showCSResponseSupportBanner', false);
}, [storage]);
const onSOResponseSupportBannerDismiss = useCallback(() => {
setShowSOResponseSupportBanner(false);
storage.set('fleet.showSOReponseSupportBanner', false);
}, [storage]);
return (
<EuiFlexGroup alignItems="flexStart" data-test-subj="epm.OverviewPage">
<SideBar grow={2}>
@ -342,18 +324,9 @@ export const OverviewPage: React.FC<Props> = memo(
<EuiSpacer size="s" />
</>
)}
{isCrowdStrike && showCSResponseSupportBanner && (
<>
<BidirectionalIntegrationsBanner onDismiss={onCSResponseSupportBannerDismiss} />
<EuiSpacer size="s" />
</>
)}
{isSentinelOne && showSOReponseSupportBanner && (
<>
<BidirectionalIntegrationsBanner onDismiss={onSOResponseSupportBannerDismiss} />
<EuiSpacer size="s" />
</>
)}
<BidirectionalIntegrationsBanner integrationPackageName={packageInfo.name} />
<CloudPostureThirdPartySupportCallout packageInfo={packageInfo} />
{isPrerelease && (
<PrereleaseCallout