Skip to content

Commit

Permalink
[Security Solution] Flyout Insights - threats (#155647)
Browse files Browse the repository at this point in the history
## Summary

This PR adds the following panel to expandable flyout:


![image](https://user-images.githubusercontent.com/11671118/234833889-2e09f4bb-eda6-4f63-ba79-654648dda2ed.png)

### How to test this
add `xpack.securitySolution.enableExperimental:
['securityFlyoutEnabled']` to the kibana.json file
run `yarn es snapshot --license trial, yarn test:generate and yarn start
--no-base-path`

Navigate to alerts (see the data setup here
elastic/security-team#6422)
and expand the flyout for an event created with threat match rule.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
2 people authored and jasonrhodes committed May 17, 2023
1 parent 753a52e commit 7f92fd6
Show file tree
Hide file tree
Showing 9 changed files with 376 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 { cleanKibana } from '../../../tasks/common';
import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule';
import { expandFirstAlertExpandableFlyout } from '../../../tasks/document_expandable_flyout';
import { login, visit } from '../../../tasks/login';
import { ALERTS_URL } from '../../../urls/navigation';

// Skipping these for now as the feature is protected behind a feature flag set to false by default
// To run the tests locally, add 'securityFlyoutEnabled' in the Cypress config.ts here https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50
describe.skip('Expandable flyout left panel threat intelligence', { testIsolation: false }, () => {
before(() => {
cleanKibana();
login();
visit(ALERTS_URL);
waitForAlertsToPopulate();
expandFirstAlertExpandableFlyout();
});

it('should serialize its state to url', () => {
cy.url().should('include', 'eventFlyout');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,14 @@ const ThreatDetailsViewComponent: React.FC<{
enrichments: CtiEnrichment[];
showInvestigationTimeEnrichments: boolean;
loading: boolean;
/**
* Slot to render something before the beforeHeader.
* NOTE: this was introduced to avoid alterting existing flyout and will be removed after
* new flyout implementation is ready (Expandable Flyout owned by the Investigations Team)
*/
before?: React.ReactNode;
children?: React.ReactNode;
}> = ({ enrichments, showInvestigationTimeEnrichments, loading, children }) => {
}> = ({ enrichments, before = null, showInvestigationTimeEnrichments, loading, children }) => {
const {
[ENRICHMENT_TYPES.IndicatorMatchRule]: indicatorMatches,
[ENRICHMENT_TYPES.InvestigationTime]: threatIntelEnrichments,
Expand All @@ -86,7 +92,7 @@ const ThreatDetailsViewComponent: React.FC<{

return (
<>
<EuiSpacer size="m" />
{before}
<EnrichmentSection
dataTestSubj="threat-match-detected"
enrichments={indicatorMatches}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ const EventDetailsComponent: React.FC<Props> = ({
),
content: (
<ThreatDetailsView
before={<EuiSpacer size="m" />}
loading={isEnrichmentsLoading}
enrichments={allEnrichments}
showInvestigationTimeEnrichments={!isEmpty(eventFields)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { useThreatIntelligenceDetails } from './use_threat_intelligence_details';
import { renderHook } from '@testing-library/react-hooks';

import { useTimelineEventsDetails } from '../../../../timelines/containers/details';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../../context';
import { useInvestigationTimeEnrichment } from '../../../../common/containers/cti/event_enrichment';
import { SecurityPageName } from '../../../../../common/constants';
import type { RouteSpyState } from '../../../../common/utils/route/types';
import {
type GetBasicDataFromDetailsData,
useBasicDataFromDetailsData,
} from '../../../../timelines/components/side_panel/event_details/helpers';

jest.mock('../../../../timelines/containers/details');
jest.mock('../../../../common/containers/sourcerer');
jest.mock('../../../../common/utils/route/use_route_spy');
jest.mock('../../context');
jest.mock('../../../../common/containers/cti/event_enrichment');
jest.mock('../../../../timelines/components/side_panel/event_details/helpers');

describe('useThreatIntelligenceDetails', () => {
beforeEach(() => {
jest.mocked(useInvestigationTimeEnrichment).mockReturnValue({
result: { enrichments: [] },
loading: false,
setRange: jest.fn(),
range: { from: '2023-04-27T00:00:00Z', to: '2023-04-27T23:59:59Z' },
});

jest
.mocked(useTimelineEventsDetails)
.mockReturnValue([false, [], undefined, null, async () => {}]);

jest
.mocked(useBasicDataFromDetailsData)
.mockReturnValue({ isAlert: true } as unknown as GetBasicDataFromDetailsData);

jest.mocked(useSourcererDataView).mockReturnValue({
runtimeMappings: {},
browserFields: {},
dataViewId: '',
loading: false,
indicesExist: true,
patternList: [],
selectedPatterns: [],
indexPattern: { fields: [], title: '' },
sourcererDataView: undefined,
});

jest
.mocked(useRouteSpy)
.mockReturnValue([
{ pageName: SecurityPageName.detections } as unknown as RouteSpyState,
() => {},
]);

jest.mocked(useLeftPanelContext).mockReturnValue({
indexName: 'test-index',
eventId: 'test-event-id',
getFieldsData: () => {},
});
});

afterEach(() => {
jest.clearAllMocks();
});

it('returns the expected values', () => {
const { result } = renderHook(() => useThreatIntelligenceDetails());

expect(result.current.enrichments).toEqual([]);
expect(result.current.eventFields).toEqual({});
expect(result.current.isEnrichmentsLoading).toBe(false);
expect(result.current.isEventDataLoading).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.range).toEqual({
from: '2023-04-27T00:00:00Z',
to: '2023-04-27T23:59:59Z',
});
expect(typeof result.current.setRange).toBe('function');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* 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 type { CtiEnrichment, EventFields } 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 { SecurityPageName } from '../../../../../common/constants';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';

import { useInvestigationTimeEnrichment } from '../../../../common/containers/cti/event_enrichment';
import { useTimelineEventsDetails } from '../../../../timelines/containers/details';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useRouteSpy } from '../../../../common/utils/route/use_route_spy';
import { useLeftPanelContext } from '../../context';

export interface ThreatIntelligenceDetailsValue {
enrichments: CtiEnrichment[];
eventFields: EventFields;
isEnrichmentsLoading: boolean;
isEventDataLoading: boolean;
isLoading: boolean;
range: {
from: string;
to: string;
};
setRange: (range: { from: string; to: string }) => void;
}

/**
* A hook that returns data necessary strictly to render Threat Intel Insights.
* Reusing a bunch of hooks scattered across kibana, it makes it easier to mock the data layer
* for component testing.
*/
export const useThreatIntelligenceDetails = (): ThreatIntelligenceDetailsValue => {
const { indexName, eventId } = useLeftPanelContext();
const [{ pageName }] = useRouteSpy();
const sourcererScope =
pageName === SecurityPageName.detections
? SourcererScopeName.detections
: SourcererScopeName.default;
const sourcererDataView = useSourcererDataView(sourcererScope);

const [isEventDataLoading, eventData] = useTimelineEventsDetails({
indexName,
eventId,
runtimeMappings: sourcererDataView.runtimeMappings,
skip: !eventId,
});

const { isAlert } = useBasicDataFromDetailsData(eventData);

const data = useMemo(() => eventData || [], [eventData]);
const eventFields = useMemo(() => getEnrichmentFields(data || []), [data]);

const {
result: enrichmentsResponse,
loading: isEnrichmentsLoading,
setRange,
range,
} = useInvestigationTimeEnrichment(eventFields);

const existingEnrichments = useMemo(
() =>
isAlert
? parseExistingEnrichments(data).map((enrichmentData) =>
timelineDataToEnrichment(enrichmentData)
)
: [],
[data, isAlert]
);

const allEnrichments = useMemo(() => {
if (isEnrichmentsLoading || !enrichmentsResponse?.enrichments) {
return existingEnrichments;
}
return filterDuplicateEnrichments([...existingEnrichments, ...enrichmentsResponse.enrichments]);
}, [isEnrichmentsLoading, enrichmentsResponse, existingEnrichments]);

const isLoading = isEnrichmentsLoading || isEventDataLoading;

return {
enrichments: allEnrichments,
eventFields,
isEnrichmentsLoading,
isEventDataLoading,
isLoading,
range,
setRange,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
* 2.0.
*/

export const ANALYZER_GRAPH_TEST_ID = 'securitySolutionDocumentDetailsFlyoutAnalyzerGraph';
export const ANALYZE_GRAPH_ERROR_TEST_ID =
'securitySolutionDocumentDetailsFlyoutAnalyzerGraphError';
export const SESSION_VIEW_TEST_ID = 'securitySolutionDocumentDetailsFlyoutSessionView';
export const SESSION_VIEW_ERROR_TEST_ID = 'securitySolutionDocumentDetailsFlyoutSessionViewError';
export const ENTITIES_DETAILS_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesDetails';
export const THREAT_INTELLIGENCE_DETAILS_TEST_ID =
'securitySolutionDocumentDetailsFlyoutThreatIntelligenceDetails';
export const PREVALENCE_DETAILS_TEST_ID = 'securitySolutionDocumentDetailsFlyoutPrevalenceDetails';
export const CORRELATIONS_DETAILS_TEST_ID =
'securitySolutionDocumentDetailsFlyoutCorrelationsDetails';
const PREFIX = 'securitySolutionDocumentDetailsFlyout' as const;

export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const;
export const ANALYZE_GRAPH_ERROR_TEST_ID = `${PREFIX}AnalyzerGraphError`;
export const SESSION_VIEW_TEST_ID = `${PREFIX}SessionView` as const;
export const SESSION_VIEW_ERROR_TEST_ID = `${PREFIX}SessionViewError` as const;
export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const;
export const THREAT_INTELLIGENCE_DETAILS_TEST_ID = `${PREFIX}ThreatIntelligenceDetails` as const;
export const PREVALENCE_DETAILS_TEST_ID = `${PREFIX}PrevalenceDetails` as const;
export const CORRELATIONS_DETAILS_TEST_ID = `${PREFIX}CorrelationsDetails` as const;

export const THREAT_INTELLIGENCE_DETAILS_ENRICHMENTS_TEST_ID = `threat-match-detected` as const;
export const THREAT_INTELLIGENCE_DETAILS_SPINNER_TEST_ID =
`${PREFIX}ThreatIntelligenceDetailsLoadingSpinner` as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 '@testing-library/jest-dom';
import type { LeftPanelContext } from '../context';
import { LeftFlyoutContext } from '../context';
import { TestProviders } from '../../../common/mock';
import {
THREAT_INTELLIGENCE_DETAILS_ENRICHMENTS_TEST_ID,
THREAT_INTELLIGENCE_DETAILS_SPINNER_TEST_ID,
} from './test_ids';
import { ThreatIntelligenceDetails } from './threat_intelligence_details';
import { useThreatIntelligenceDetails } from './hooks/use_threat_intelligence_details';

jest.mock('../../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../../common/lib/kibana');
return {
...originalModule,
useKibana: jest.fn().mockReturnValue({
services: {
sessionView: {
getSessionView: jest.fn().mockReturnValue(<div />),
},
},
}),
};
});

jest.mock('./hooks/use_threat_intelligence_details');

const defaultContextValue = {
getFieldsData: () => 'id',
} as unknown as LeftPanelContext;

// Renders System Under Test
const renderSUT = (contextValue: LeftPanelContext) =>
render(
<TestProviders>
<LeftFlyoutContext.Provider value={contextValue}>
<ThreatIntelligenceDetails />
</LeftFlyoutContext.Provider>
</TestProviders>
);

describe('<ThreatIntelligenceDetails />', () => {
it('should render the view', () => {
jest.mocked(useThreatIntelligenceDetails).mockReturnValue({
isLoading: true,
enrichments: [],
isEventDataLoading: false,
isEnrichmentsLoading: true,
range: { from: '', to: '' },
setRange: () => {},
eventFields: {},
});

const wrapper = renderSUT(defaultContextValue);

expect(
wrapper.getByTestId(THREAT_INTELLIGENCE_DETAILS_ENRICHMENTS_TEST_ID)
).toBeInTheDocument();

expect(useThreatIntelligenceDetails).toHaveBeenCalled();
});

it('should render loading spinner when event details are pending', () => {
jest.mocked(useThreatIntelligenceDetails).mockReturnValue({
isLoading: true,
enrichments: [],
isEventDataLoading: true,
isEnrichmentsLoading: true,
range: { from: '', to: '' },
setRange: () => {},
eventFields: {},
});

const wrapper = renderSUT(defaultContextValue);

expect(wrapper.getByTestId(THREAT_INTELLIGENCE_DETAILS_SPINNER_TEST_ID)).toBeInTheDocument();

expect(useThreatIntelligenceDetails).toHaveBeenCalled();
});
});
Loading

0 comments on commit 7f92fd6

Please sign in to comment.