[Security Solution] add threat intelligence overview to expandable flyout (#155328)

This commit is contained in:
Philippe Oberti 2023-04-21 15:45:55 -05:00 committed by GitHub
parent 8a3f5ebbea
commit 4eeec1865f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1250 additions and 0 deletions

View file

@ -29,6 +29,10 @@ import {
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_VIEW_ALL_ENTITIES_BUTTON,
DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT,
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_ANALYZER_TREE,
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_HEADER,
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_CONTENT,
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VALUES,
DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON,
} from '../../../screens/document_expandable_flyout';
import {
expandFirstAlertExpandableFlyout,
@ -180,6 +184,38 @@ describe.skip(
.click();
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT).should('be.visible');
});
// TODO work on getting proper IoC data to make the threat intelligence section work here
it.skip('should display threat intelligence section', () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_HEADER)
.scrollIntoView()
.should('be.visible')
.and('have.text', 'Threat Intelligence');
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_CONTENT)
.should('be.visible')
.within(() => {
// threat match detected
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VALUES)
.eq(0)
.should('be.visible')
.and('have.text', '1 threat match detected'); // TODO
// field with threat enrichement
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VALUES)
.eq(1)
.should('be.visible')
.and('have.text', '1 field enriched with threat intelligence'); // TODO
});
});
// TODO work on getting proper IoC data to make the threat intelligence section work here
// and improve when we can navigate Threat Intelligence to sub tab directly
it.skip('should navigate to left panel, entities tab when view all fields of threat intelligence is clicked', () => {
cy.get(DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON)
.should('be.visible')
.click();
cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT).should('be.visible');
});
});
describe('visualizations section', () => {

View file

@ -73,6 +73,10 @@ import {
ENTITIES_VIEW_ALL_BUTTON_TEST_ID,
VISUALIZATIONS_SECTION_HEADER_TEST_ID,
ANALYZER_TREE_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID,
} from '../../public/flyout/right/components/test_ids';
import {
getClassSelector,
@ -303,6 +307,14 @@ export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_ENTITY_PANEL_HEADER =
getDataTestSubjectSelector(ENTITY_PANEL_HEADER_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_ENTITY_PANEL_CONTENT =
getDataTestSubjectSelector(ENTITY_PANEL_CONTENT_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_HEADER =
getDataTestSubjectSelector(INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_CONTENT =
getDataTestSubjectSelector(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VALUES =
getDataTestSubjectSelector(INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON =
getDataTestSubjectSelector(INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID);
export const DOCUMENT_DETAILS_FLYOUT_OVERVIEW_TAB_VISUALIZATIONS_SECTION_HEADER =
getDataTestSubjectSelector(VISUALIZATIONS_SECTION_HEADER_TEST_ID);

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import { ThreatIntelligenceOverview } from './threat_intelligence_overview';
import { INSIGHTS_TEST_ID } from './test_ids';
import { INSIGHTS_TITLE } from './translations';
import { EntitiesOverview } from './entities_overview';
@ -25,6 +26,7 @@ export const InsightsSection: React.FC<InsightsSectionProps> = ({ expanded = fal
return (
<ExpandableSection title={INSIGHTS_TITLE} expanded={expanded} data-test-subj={INSIGHTS_TEST_ID}>
<EntitiesOverview />
<ThreatIntelligenceOverview />
</ExpandableSection>
);
};

View file

@ -0,0 +1,38 @@
/*
* 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 type { Story } from '@storybook/react';
import { InsightsSubSection } from './insights_subsection';
export default {
component: InsightsSubSection,
title: 'Flyout/InsightsSubSection',
};
const title = 'Title';
const children = <div>{'hello'}</div>;
export const Basic: Story<void> = () => {
return <InsightsSubSection title={title}>{children}</InsightsSubSection>;
};
export const Loading: Story<void> = () => {
return (
<InsightsSubSection loading={true} title={title}>
{null}
</InsightsSubSection>
);
};
export const NoTitle: Story<void> = () => {
return <InsightsSubSection title={''}>{children}</InsightsSubSection>;
};
export const NoChildren: Story<void> = () => {
return <InsightsSubSection title={title}>{null}</InsightsSubSection>;
};

View file

@ -0,0 +1,67 @@
/*
* 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 { render } from '@testing-library/react';
import { InsightsSubSection } from './insights_subsection';
const title = 'Title';
const dataTestSubj = 'test';
const children = <div>{'hello'}</div>;
describe('<InsightsSubSection />', () => {
it('should render children component', () => {
const { getByTestId } = render(
<InsightsSubSection title={title} data-test-subj={dataTestSubj}>
{children}
</InsightsSubSection>
);
const titleDataTestSubj = `${dataTestSubj}Title`;
const contentDataTestSubj = `${dataTestSubj}Content`;
expect(getByTestId(titleDataTestSubj)).toHaveTextContent(title);
expect(getByTestId(contentDataTestSubj)).toBeInTheDocument();
});
it('should render loading component', () => {
const { getByTestId } = render(
<InsightsSubSection loading={true} title={title} data-test-subj={dataTestSubj}>
{children}
</InsightsSubSection>
);
const loadingDataTestSubj = `${dataTestSubj}Loading`;
expect(getByTestId(loadingDataTestSubj)).toBeInTheDocument();
});
it('should render null if error', () => {
const { container } = render(
<InsightsSubSection error={true} title={title}>
{children}
</InsightsSubSection>
);
expect(container).toBeEmptyDOMElement();
});
it('should render null if no title', () => {
const { container } = render(<InsightsSubSection title={''}>{children}</InsightsSubSection>);
expect(container).toBeEmptyDOMElement();
});
it('should render null if no children', () => {
const { container } = render(
<InsightsSubSection error={true} title={title}>
{null}
</InsightsSubSection>
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui';
export interface InsightsSectionProps {
/**
* Renders a loading spinner if true
*/
loading?: boolean;
/**
* Returns a null component if true
*/
error?: boolean;
/**
* Title at the top of the component
*/
title: string;
/**
* Content of the component
*/
children: React.ReactNode;
/**
* Prefix data-test-subj to use for the elements
*/
['data-test-subj']?: string;
}
/**
* Presentational component to handle loading and error in the subsections of the Insights section.
* Should be used for Entities, Threat Intelligence, Prevalence, Correlations and Results
*/
export const InsightsSubSection: React.FC<InsightsSectionProps> = ({
loading = false,
error = false,
title,
'data-test-subj': dataTestSubj,
children,
}) => {
const loadingDataTestSubj = `${dataTestSubj}Loading`;
// showing the loading in this component instead of SummaryPanel because we're hiding the entire section if no data
if (loading) {
return (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj={loadingDataTestSubj} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
// hide everything
if (error || !title || !children) {
return null;
}
const titleDataTestSubj = `${dataTestSubj}Title`;
const contentDataTestSubj = `${dataTestSubj}Content`;
return (
<>
<EuiTitle size="xxs" data-test-subj={titleDataTestSubj}>
<h5>{title}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup data-test-subj={contentDataTestSubj} direction="column" gutterSize="s">
{children}
</EuiFlexGroup>
</>
);
};
InsightsSubSection.displayName = 'InsightsSubSection';

View file

@ -0,0 +1,156 @@
/*
* 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 type { Story } from '@storybook/react';
import { css } from '@emotion/react';
import type { InsightsSummaryPanelData } from './insights_summary_panel';
import { InsightsSummaryPanel } from './insights_summary_panel';
export default {
component: InsightsSummaryPanel,
title: 'Flyout/InsightsSummaryPanel',
};
export const Default: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 1,
text: 'this is a test for red',
color: 'rgb(189,39,30)',
},
{
icon: 'warning',
value: 2,
text: 'this is test for orange',
color: 'rgb(255,126,98)',
},
{
icon: 'warning',
value: 3,
text: 'this is test for yellow',
color: 'rgb(241,216,11)',
},
];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};
export const InvalidColor: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 1,
text: 'this is a test for an invalid color (abc)',
color: 'abc',
},
];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};
export const NoColor: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 1,
text: 'this is a test for red',
},
{
icon: 'warning',
value: 2,
text: 'this is test for orange',
},
{
icon: 'warning',
value: 3,
text: 'this is test for yellow',
},
];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};
export const LongText: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 1,
text: 'this is an extremely long text to verify it is properly cut off and and we show three dots at the end',
color: 'abc',
},
];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};
export const LongNumber: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 160000,
text: 'this is an extremely long value to verify it is properly cut off and and we show three dots at the end',
color: 'abc',
},
];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};
export const NoData: Story<void> = () => {
const data: InsightsSummaryPanelData[] = [];
return (
<div
css={css`
width: 500px;
`}
>
<InsightsSummaryPanel data={data} />
</div>
);
};

View file

@ -0,0 +1,90 @@
/*
* 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 { render } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import {
INSIGHTS_THREAT_INTELLIGENCE_COLOR_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_ICON_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID,
} from './test_ids';
import type { InsightsSummaryPanelData } from './insights_summary_panel';
import { InsightsSummaryPanel } from './insights_summary_panel';
describe('<SummaryPanel />', () => {
it('should render by default', () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 1,
text: 'this is a test for red',
color: 'rgb(189,39,30)',
},
];
const { getByTestId } = render(
<IntlProvider locale="en">
<InsightsSummaryPanel data={data} data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} />
</IntlProvider>
);
const iconTestId = `${INSIGHTS_THREAT_INTELLIGENCE_ICON_TEST_ID}0`;
const valueTestId = `${INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID}0`;
const colorTestId = `${INSIGHTS_THREAT_INTELLIGENCE_COLOR_TEST_ID}0`;
expect(getByTestId(iconTestId)).toBeInTheDocument();
expect(getByTestId(valueTestId)).toHaveTextContent('1 this is a test for red');
expect(getByTestId(colorTestId)).toBeInTheDocument();
});
it('should only render null when data is null', () => {
const data = null as unknown as InsightsSummaryPanelData[];
const { container } = render(<InsightsSummaryPanel data={data} />);
expect(container).toBeEmptyDOMElement();
});
it('should handle big number in a compact notation', () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 160000,
text: 'this is a test for red',
color: 'rgb(189,39,30)',
},
];
const { getByTestId } = render(
<IntlProvider locale="en">
<InsightsSummaryPanel data={data} data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} />
</IntlProvider>
);
const valueTestId = `${INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID}0`;
expect(getByTestId(valueTestId)).toHaveTextContent('160k this is a test for red');
});
it(`should not show the colored dot if color isn't provided`, () => {
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: 160000,
text: 'this is a test for no color',
},
];
const { queryByTestId } = render(
<IntlProvider locale="en">
<InsightsSummaryPanel data={data} data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} />
</IntlProvider>
);
expect(queryByTestId(INSIGHTS_THREAT_INTELLIGENCE_COLOR_TEST_ID)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,106 @@
/*
* 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 { VFC } from 'react';
import React from 'react';
import { css } from '@emotion/react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHealth, EuiPanel } from '@elastic/eui';
import { FormattedCount } from '../../../common/components/formatted_number';
export interface InsightsSummaryPanelData {
/**
* Icon to display on the left side of each row
*/
icon: string;
/**
* Number of results/entries found
*/
value: number;
/**
* Text corresponding of the number of results/entries
*/
text: string;
/**
* Optional parameter for now, will be used to display a dot on the right side
* (corresponding to some sort of severity?)
*/
color?: string; // TODO remove optional when we have guidance on what the colors will actually be
}
export interface InsightsSummaryPanelProps {
/**
* Array of data to display in each row
*/
data: InsightsSummaryPanelData[];
/**
* Prefix data-test-subj because this component will be used in multiple places
*/
['data-test-subj']?: string;
}
/**
* Panel showing summary information as an icon, a count and text as well as a severity colored dot.
* Should be used for Entities, Threat Intelligence, Prevalence, Correlations and Results components under the Insights section.
* The colored dot is currently optional but will ultimately be mandatory (waiting on PM and UIUX).
*/
export const InsightsSummaryPanel: VFC<InsightsSummaryPanelProps> = ({
data,
'data-test-subj': dataTestSubj,
}) => {
if (!data || data.length === 0) {
return null;
}
const iconDataTestSubj = `${dataTestSubj}Icon`;
const valueDataTestSubj = `${dataTestSubj}Value`;
const colorDataTestSubj = `${dataTestSubj}Color`;
return (
<EuiPanel hasShadow={false} hasBorder={true} paddingSize="s">
<EuiFlexGroup direction="column" gutterSize="none">
{data.map((row, index) => (
<EuiFlexGroup
gutterSize="none"
justifyContent={'spaceBetween'}
alignItems={'center'}
key={index}
>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj={iconDataTestSubj + index}
aria-label={'entity-icon'}
color="text"
display="empty"
iconType={row.icon}
size="s"
/>
</EuiFlexItem>
<EuiFlexItem
data-test-subj={valueDataTestSubj + index}
css={css`
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
`}
>
<FormattedCount count={row.value} /> {row.text}
</EuiFlexItem>
{row.color && (
<EuiFlexItem grow={false} data-test-subj={colorDataTestSubj + index}>
<EuiHealth color={row.color} />
</EuiFlexItem>
)}
</EuiFlexGroup>
))}
</EuiFlexGroup>
</EuiPanel>
);
};
InsightsSummaryPanel.displayName = 'InsightsSummaryPanel';

View file

@ -84,6 +84,18 @@ export const ENTITIES_HOST_OVERVIEW_IP_TEST_ID =
export const ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID =
'securitySolutionDocumentDetailsFlyoutEntitiesHostOverviewRiskLevel';
/* Insights Threat Intelligence */
export const INSIGHTS_THREAT_INTELLIGENCE_TEST_ID =
'securitySolutionDocumentDetailsFlyoutInsightsThreatIntelligence';
export const INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Title`;
export const INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Content`;
export const INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}ViewAllButton`;
export const INSIGHTS_THREAT_INTELLIGENCE_LOADING_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Loading`;
export const INSIGHTS_THREAT_INTELLIGENCE_ICON_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Icon`;
export const INSIGHTS_THREAT_INTELLIGENCE_VALUE_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Value`;
export const INSIGHTS_THREAT_INTELLIGENCE_COLOR_TEST_ID = `${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}Color`;
/* Visualizations section*/
export const VISUALIZATIONS_SECTION_TEST_ID = 'securitySolutionDocumentDetailsVisualizationsTitle';
export const VISUALIZATIONS_SECTION_HEADER_TEST_ID =

View file

@ -0,0 +1,195 @@
/*
* 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 { render } from '@testing-library/react';
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { RightPanelContext } from '../context';
import {
INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_LOADING_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID,
INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID,
} from './test_ids';
import { TestProviders } from '../../../common/mock';
import { ThreatIntelligenceOverview } from './threat_intelligence_overview';
import { LeftPanelInsightsTabPath, LeftPanelKey } from '../../left';
import { useFetchThreatIntelligence } from '../hooks/use_fetch_threat_intelligence';
jest.mock('../hooks/use_fetch_threat_intelligence');
const panelContextValue = {
eventId: 'event id',
indexName: 'indexName',
dataFormattedForFieldBrowser: [],
} as unknown as RightPanelContext;
const renderThreatIntelligenceOverview = (contextValue: RightPanelContext) => (
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<ThreatIntelligenceOverview />
</RightPanelContext.Provider>
</TestProviders>
);
describe('<ThreatIntelligenceOverview />', () => {
it('should render 1 match detected and 1 field enriched', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 1,
threatEnrichmentsCount: 1,
});
const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue));
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID)).toHaveTextContent(
'Threat Intelligence'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'1 threat match detected'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'1 field enriched with threat intelligence'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render 2 matches detected and 2 fields enriched', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 2,
threatEnrichmentsCount: 2,
});
const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue));
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_TITLE_TEST_ID)).toHaveTextContent(
'Threat Intelligence'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'2 threat matches detected'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'2 fields enriched with threat intelligence'
);
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render 0 field enriched', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 1,
threatEnrichmentsCount: 0,
});
const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue));
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'0 field enriched with threat intelligence'
);
});
it('should render 0 match detected', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 0,
threatEnrichmentsCount: 2,
});
const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue));
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_CONTENT_TEST_ID)).toHaveTextContent(
'0 threat match detected'
);
});
it('should render loading', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: true,
});
const { getByTestId } = render(renderThreatIntelligenceOverview(panelContextValue));
expect(getByTestId(INSIGHTS_THREAT_INTELLIGENCE_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should render null when eventId is null', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
});
const contextValue = {
...panelContextValue,
eventId: null,
} as unknown as RightPanelContext;
const { container } = render(renderThreatIntelligenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should render null when dataFormattedForFieldBrowser is null', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
error: true,
});
const contextValue = {
...panelContextValue,
dataFormattedForFieldBrowser: null,
} as unknown as RightPanelContext;
const { container } = render(renderThreatIntelligenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should render null when no enrichment found is null', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 0,
threatEnrichmentsCount: 0,
});
const contextValue = {
...panelContextValue,
dataFormattedForFieldBrowser: [],
} as unknown as RightPanelContext;
const { container } = render(renderThreatIntelligenceOverview(contextValue));
expect(container).toBeEmptyDOMElement();
});
it('should navigate to left section Insights tab when clicking on button', () => {
(useFetchThreatIntelligence as jest.Mock).mockReturnValue({
loading: false,
threatMatchesCount: 1,
threatEnrichmentsCount: 1,
});
const flyoutContextValue = {
openLeftPanel: jest.fn(),
} as unknown as ExpandableFlyoutContext;
const { getByTestId } = render(
<TestProviders>
<ExpandableFlyoutContext.Provider value={flyoutContextValue}>
<RightPanelContext.Provider value={panelContextValue}>
<ThreatIntelligenceOverview />
</RightPanelContext.Provider>
</ExpandableFlyoutContext.Provider>
</TestProviders>
);
getByTestId(INSIGHTS_THREAT_INTELLIGENCE_VIEW_ALL_BUTTON_TEST_ID).click();
expect(flyoutContextValue.openLeftPanel).toHaveBeenCalledWith({
id: LeftPanelKey,
path: LeftPanelInsightsTabPath,
params: {
id: panelContextValue.eventId,
indexName: panelContextValue.indexName,
},
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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, { useCallback } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useFetchThreatIntelligence } from '../hooks/use_fetch_threat_intelligence';
import { InsightsSubSection } from './insights_subsection';
import type { InsightsSummaryPanelData } from './insights_summary_panel';
import { InsightsSummaryPanel } from './insights_summary_panel';
import { useRightPanelContext } from '../context';
import { INSIGHTS_THREAT_INTELLIGENCE_TEST_ID } from './test_ids';
import {
VIEW_ALL,
THREAT_INTELLIGENCE_TITLE,
THREAT_INTELLIGENCE_TEXT,
THREAT_MATCH_DETECTED,
THREAT_ENRICHMENT,
THREAT_MATCHES_DETECTED,
THREAT_ENRICHMENTS,
} from './translations';
import { LeftPanelKey, LeftPanelInsightsTabPath } from '../../left';
/**
* Threat Intelligence section under Insights section, overview tab.
* The component fetches the necessary data, then pass it down to the InsightsSubSection component for loading and error state,
* and the SummaryPanel component for data rendering.
*/
export const ThreatIntelligenceOverview: React.FC = () => {
const { eventId, indexName, dataFormattedForFieldBrowser } = useRightPanelContext();
const { openLeftPanel } = useExpandableFlyoutContext();
const goToThreatIntelligenceTab = useCallback(() => {
openLeftPanel({
id: LeftPanelKey,
path: LeftPanelInsightsTabPath,
params: {
id: eventId,
indexName,
},
});
}, [eventId, openLeftPanel, indexName]);
const { loading, threatMatchesCount, threatEnrichmentsCount } = useFetchThreatIntelligence({
dataFormattedForFieldBrowser,
});
const data: InsightsSummaryPanelData[] = [
{
icon: 'image',
value: threatMatchesCount,
text: threatMatchesCount <= 1 ? THREAT_MATCH_DETECTED : THREAT_MATCHES_DETECTED,
},
{
icon: 'warning',
value: threatEnrichmentsCount,
text: threatMatchesCount <= 1 ? THREAT_ENRICHMENT : THREAT_ENRICHMENTS,
},
];
const error: boolean =
!eventId ||
!dataFormattedForFieldBrowser ||
(threatMatchesCount === 0 && threatEnrichmentsCount === 0);
return (
<InsightsSubSection
loading={loading}
error={error}
title={THREAT_INTELLIGENCE_TITLE}
data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}
>
<InsightsSummaryPanel data={data} data-test-subj={INSIGHTS_THREAT_INTELLIGENCE_TEST_ID} />
<EuiButtonEmpty
onClick={goToThreatIntelligenceTab}
iconType="arrowStart"
iconSide="left"
size="s"
data-test-subj={`${INSIGHTS_THREAT_INTELLIGENCE_TEST_ID}ViewAllButton`}
>
{VIEW_ALL(THREAT_INTELLIGENCE_TEXT)}
</EuiButtonEmpty>
</InsightsSubSection>
);
};
ThreatIntelligenceOverview.displayName = 'ThreatIntelligenceOverview';

View file

@ -101,11 +101,18 @@ export const HIGHLIGHTED_FIELDS_TITLE = i18n.translate(
{ defaultMessage: 'Highlighted fields' }
);
/* Insights section */
export const ENTITIES_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.entitiesTitle',
{ defaultMessage: 'Entities' }
);
export const THREAT_INTELLIGENCE_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.threatIntelligenceTitle',
{ defaultMessage: 'Threat Intelligence' }
);
export const INSIGHTS_TITLE = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.insightsTitle',
{ defaultMessage: 'Insights' }
@ -131,6 +138,41 @@ export const ENTITIES_TEXT = i18n.translate(
}
);
export const THREAT_INTELLIGENCE_TEXT = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligenceText',
{
defaultMessage: 'fields of threat intelligence',
}
);
export const THREAT_MATCH_DETECTED = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligence.threatMatch',
{
defaultMessage: `threat match detected`,
}
);
export const THREAT_MATCHES_DETECTED = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligence.threatMatches',
{
defaultMessage: `threat matches detected`,
}
);
export const THREAT_ENRICHMENT = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligence.threatEnrichment',
{
defaultMessage: `field enriched with threat intelligence`,
}
);
export const THREAT_ENRICHMENTS = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.overviewTab.threatIntelligence.threatEnrichments',
{
defaultMessage: `fields enriched with threat intelligence`,
}
);
export const VIEW_ALL = (text: string) =>
i18n.translate('xpack.securitySolution.flyout.documentDetails.overviewTab.viewAllButton', {
values: { text },

View file

@ -0,0 +1,216 @@
/*
* 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 { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import type {
UseThreatIntelligenceParams,
UseThreatIntelligenceValue,
} from './use_fetch_threat_intelligence';
import { useFetchThreatIntelligence } from './use_fetch_threat_intelligence';
import { useInvestigationTimeEnrichment } from '../../../common/containers/cti/event_enrichment';
jest.mock('../../../common/containers/cti/event_enrichment');
const dataFormattedForFieldBrowser = [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
isObjectArray: false,
originalValue: ['uuid'],
values: ['uuid'],
},
{
category: 'threat',
field: 'threat.enrichments',
isObjectArray: true,
originalValue: ['{"indicator.file.hash.sha256":["sha256"]}'],
values: ['{"indicator.file.hash.sha256":["sha256"]}'],
},
{
category: 'threat',
field: 'threat.enrichments.indicator.file.hash.sha256',
isObjectArray: false,
originalValue: ['sha256'],
values: ['sha256'],
},
];
describe('useFetchThreatIntelligence', () => {
let hookResult: RenderHookResult<UseThreatIntelligenceParams, UseThreatIntelligenceValue>;
it('should render 1 match detected and 1 field enriched', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: {
enrichments: [
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.1'],
'matched.type': ['indicator_match_rule'],
},
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.2'],
'matched.type': ['investigation_time'],
'event.type': ['indicator'],
},
],
totalCount: 2,
},
loading: false,
});
hookResult = renderHook(() => useFetchThreatIntelligence({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.threatMatches).toHaveLength(1);
expect(hookResult.result.current.threatMatchesCount).toEqual(1);
expect(hookResult.result.current.threatEnrichments).toHaveLength(1);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(1);
});
it('should render 2 matches detected and 2 fields enriched', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: {
enrichments: [
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.1'],
'matched.type': ['indicator_match_rule'],
},
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.2'],
'matched.type': ['investigation_time'],
'event.type': ['indicator'],
},
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.3'],
'matched.type': ['indicator_match_rule'],
},
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.4'],
'matched.type': ['investigation_time'],
'event.type': ['indicator'],
},
],
totalCount: 4,
},
loading: false,
});
hookResult = renderHook(() => useFetchThreatIntelligence({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.threatMatches).toHaveLength(2);
expect(hookResult.result.current.threatMatchesCount).toEqual(2);
expect(hookResult.result.current.threatEnrichments).toHaveLength(2);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(2);
});
it('should render 0 field enriched', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: {
enrichments: [
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.1'],
'matched.type': ['indicator_match_rule'],
},
],
totalCount: 1,
},
loading: false,
});
hookResult = renderHook(() => useFetchThreatIntelligence({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.threatMatches).toHaveLength(1);
expect(hookResult.result.current.threatMatchesCount).toEqual(1);
expect(hookResult.result.current.threatEnrichments).toEqual(undefined);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(0);
});
it('should render 0 match detected', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: {
enrichments: [
{
'threat.indicator.file.hash.sha256': 'sha256',
'matched.atomic': ['sha256'],
'matched.field': ['file.hash.sha256'],
'matched.id': ['matched.id.2'],
'matched.type': ['investigation_time'],
'event.type': ['indicator'],
},
],
totalCount: 1,
},
loading: false,
});
hookResult = renderHook(() => useFetchThreatIntelligence({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.threatMatches).toEqual(undefined);
expect(hookResult.result.current.threatMatchesCount).toEqual(0);
expect(hookResult.result.current.threatEnrichments).toHaveLength(1);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(1);
});
it('should return loading true', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: undefined,
loading: true,
});
hookResult = renderHook(() => useFetchThreatIntelligence({ dataFormattedForFieldBrowser }));
expect(hookResult.result.current.loading).toEqual(true);
expect(hookResult.result.current.error).toEqual(false);
expect(hookResult.result.current.threatMatches).toEqual(undefined);
expect(hookResult.result.current.threatMatchesCount).toEqual(0);
expect(hookResult.result.current.threatEnrichments).toEqual(undefined);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(0);
});
it('should return error true', () => {
(useInvestigationTimeEnrichment as jest.Mock).mockReturnValue({
result: {
enrichments: [],
totalCount: 0,
},
loading: false,
});
hookResult = renderHook(() =>
useFetchThreatIntelligence({ dataFormattedForFieldBrowser: null })
);
expect(hookResult.result.current.loading).toEqual(false);
expect(hookResult.result.current.error).toEqual(true);
expect(hookResult.result.current.threatMatches).toEqual(undefined);
expect(hookResult.result.current.threatMatchesCount).toEqual(0);
expect(hookResult.result.current.threatEnrichments).toEqual(undefined);
expect(hookResult.result.current.threatEnrichmentsCount).toEqual(0);
});
});

View file

@ -0,0 +1,108 @@
/*
* 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 { useMemo } from 'react';
import { groupBy } from 'lodash';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { CtiEnrichment } from '../../../../common/search_strategy';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import {
filterDuplicateEnrichments,
getEnrichmentFields,
parseExistingEnrichments,
timelineDataToEnrichment,
} from '../../../common/components/event_details/cti_details/helpers';
import { useInvestigationTimeEnrichment } from '../../../common/containers/cti/event_enrichment';
import { ENRICHMENT_TYPES } from '../../../../common/cti/constants';
export interface UseThreatIntelligenceParams {
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
}
export interface UseThreatIntelligenceValue {
/**
* Returns true while the threat intelligence data is being queried
*/
loading: boolean;
/**
* Returns true if the dataFormattedForFieldBrowser property is null
*/
error: boolean;
/**
* Threat matches (from an indicator match rule)
*/
threatMatches: CtiEnrichment[];
/**
* Threat matches count
*/
threatMatchesCount: number;
/**
* Threat enrichments (from the real time query)
*/
threatEnrichments: CtiEnrichment[];
/**
* Threat enrichments count
*/
threatEnrichmentsCount: number;
}
/**
* Hook to retrieve threat intelligence data for the expandable flyout right and left sections.
*/
export const useFetchThreatIntelligence = ({
dataFormattedForFieldBrowser,
}: UseThreatIntelligenceParams): UseThreatIntelligenceValue => {
const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
// retrieve the threat enrichment fields with value for the current document
// (see https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/common/cti/constants.ts#L35)
const eventFields = useMemo(
() => getEnrichmentFields(dataFormattedForFieldBrowser || []),
[dataFormattedForFieldBrowser]
);
// retrieve existing enrichment fields and their value
const existingEnrichments = useMemo(
() =>
isAlert
? parseExistingEnrichments(dataFormattedForFieldBrowser || []).map((enrichmentData) =>
timelineDataToEnrichment(enrichmentData)
)
: [],
[dataFormattedForFieldBrowser, isAlert]
);
// api call to retrieve all documents that match the eventFields
const { result: response, loading } = useInvestigationTimeEnrichment(eventFields);
// combine existing enrichment and enrichment from the api response
// also removes the investigation-time enrichments if the exact indicator already exists
const allEnrichments = useMemo(() => {
if (loading || !response?.enrichments) {
return existingEnrichments;
}
return filterDuplicateEnrichments([...existingEnrichments, ...response.enrichments]);
}, [loading, response, existingEnrichments]);
// separate threat matches (from indicator-match rule) from threat enrichments (realtime query)
const {
[ENRICHMENT_TYPES.IndicatorMatchRule]: threatMatches,
[ENRICHMENT_TYPES.InvestigationTime]: threatEnrichments,
} = groupBy(allEnrichments, 'matched.type');
return {
loading,
error: !dataFormattedForFieldBrowser,
threatMatches,
threatMatchesCount: (threatMatches || []).length,
threatEnrichments,
threatEnrichmentsCount: (threatEnrichments || []).length,
};
};