[SecuritySolution] Introduce Insights and related alerts by process ancestry (#136009)

* feat: introduce related alerts to highlighted fields

* fix: `dataProvider` is `dataProviders` now

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* extend `_source` and add `fields` definition

* allow to include alert ids when fetching prevalence info

* fix: remove broken type import

* feat: add insight accordions

* feat: show investigate in TL buttons on related-x modules

* test: move related_cases tests to new location

* chore: remove unused translations

* test: remove unused tests

* chore: remove unused test data

* feat: show limited data note

* feat: add `sort` param

* test: fix props in test

* feat: add click-to-view to process ancestry accordion

* test: fix mock import

* chore: change order of insight items

* chore: use compressed table

* chore: update spacing

* chore: add empty state to InsightsAccordion

* chore: == > ===

* test: fix related cases test

* chore: comments

* test: remove unnecessary `describe`

* test: add more tests

* fix: remove unused props

* chore: simplify the interface of <InsightAccordion />

* chore: move translations to their own file

* feat: hide related alerts by process ancestry behind feature flag

* test: add insights tests

* test: add more tests

* test: add flyout tests

- Moves the old flyout test to a new file
- Adds a new test case for the related alerts by session use case

* chore: comments

* fix: typo

* feat: remove background color in empty state

* test: the `auditbeat` archive is loaded by default

- also: removing the `auditbeat` archive can lead to unwanted side-effects (TIL)

* test: refactor cypress test

- use `closeTimeline()`
- move `openOverview()` to beforeEach

* test: remove unnecessary call to `openOverview()`

* fix: make sure related cases has a loading state

* chore: remove unused file

* chore: remove unused translations

* chore: remove unused translations

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jan Monschke 2022-07-22 17:47:05 +02:00 committed by GitHub
parent a59ba34397
commit 7a36cf3ab5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2193 additions and 333 deletions

View file

@ -76,7 +76,19 @@ describe('query, aggs, size, _source and track_total_hits on signals index', ()
expect(message.schema).toEqual(payload);
});
test('_source only', () => {
test('_source only (as string)', () => {
const payload: QuerySignalsSchema = {
_source: 'field',
};
const decoded = querySignalsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('_source only (as string[])', () => {
const payload: QuerySignalsSchema = {
_source: ['field'],
};
@ -87,4 +99,42 @@ describe('query, aggs, size, _source and track_total_hits on signals index', ()
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('_source only (as boolean)', () => {
const payload: QuerySignalsSchema = {
_source: false,
};
const decoded = querySignalsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('fields only', () => {
const payload: QuerySignalsSchema = {
fields: ['test*'],
};
const decoded = querySignalsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('sort only', () => {
const payload: QuerySignalsSchema = {
sort: {
'@payload': 'desc',
},
};
const decoded = querySignalsSchema.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
});

View file

@ -14,8 +14,10 @@ export const querySignalsSchema = t.exact(
aggs: t.object,
size: PositiveInteger,
track_total_hits: t.boolean,
_source: t.array(t.string),
_source: t.union([t.boolean, t.string, t.array(t.string)]),
fields: t.array(t.string),
runtime_mappings: t.unknown,
sort: t.object,
})
);

View file

@ -41,6 +41,11 @@ export const allowedExperimentalValues = Object.freeze({
* Enables the cloud security posture navigation inside the security solution
*/
cloudSecurityPostureNavigation: false,
/**
* Enables the insights module for related alerts by process ancestry
*/
insightsRelatedAlertsByProcessAncestry: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -0,0 +1,80 @@
/*
* 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 {
ALERT_FLYOUT,
SUMMARY_VIEW_PREVALENCE_CELL,
SUMMARY_VIEW_INVESTIGATE_IN_TIMELINE_BUTTON,
INSIGHTS_RELATED_ALERTS_BY_SESSION,
INSIGHTS_INVESTIGATE_IN_TIMELINE_BUTTON,
} from '../../screens/alerts_details';
import { QUERY_TAB_BUTTON, TIMELINE_TITLE } from '../../screens/timeline';
import { expandFirstAlert } from '../../tasks/alerts';
import { closeTimeline } from '../../tasks/timeline';
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
import { cleanKibana } from '../../tasks/common';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import { login, visitWithoutDateRange } from '../../tasks/login';
import { getNewRule } from '../../objects/rule';
import { ALERTS_URL } from '../../urls/navigation';
describe('Alert Flyout', () => {
before(() => {
cleanKibana();
login();
createCustomRuleEnabled(getNewRule(), 'rule1');
visitWithoutDateRange(ALERTS_URL);
waitForAlertsToPopulate();
expandFirstAlert();
});
afterEach(() => {
closeTimeline();
});
it('Opens a new timeline investigation (from a prevalence field)', () => {
cy.get(SUMMARY_VIEW_PREVALENCE_CELL)
.first()
.invoke('text')
.then((alertCount) => {
// Click on the first button that lets us investigate in timeline
cy.get(ALERT_FLYOUT).find(SUMMARY_VIEW_INVESTIGATE_IN_TIMELINE_BUTTON).first().click();
// Make sure a new timeline is created and opened
cy.get(TIMELINE_TITLE).should('contain.text', 'Untitled timeline');
// The alert count in this timeline should match the count shown on the alert flyout
cy.get(QUERY_TAB_BUTTON).should('contain.text', alertCount);
});
});
it('Opens a new timeline investigation (from an insights module)', () => {
cy.get(INSIGHTS_RELATED_ALERTS_BY_SESSION)
.click()
.invoke('text')
.then((relatedAlertsBySessionText) => {
// Extract the count from the text
const alertCount = relatedAlertsBySessionText.match(/(\d)/);
const actualCount = alertCount && alertCount[0];
// Make sure we can see the table
cy.contains('New Rule Test').should('be.visible');
// Click on the first button that lets us investigate in timeline
cy.get(ALERT_FLYOUT).find(INSIGHTS_INVESTIGATE_IN_TIMELINE_BUTTON).click();
// Make sure a new timeline is created and opened
cy.get(TIMELINE_TITLE).should('contain.text', 'Untitled timeline');
// The alert count in this timeline should match the count shown on the alert flyout
cy.get(QUERY_TAB_BUTTON).should('contain.text', actualCount);
});
});
});

View file

@ -11,13 +11,10 @@ import {
JSON_TEXT,
TABLE_CONTAINER,
TABLE_ROWS,
SUMMARY_VIEW_PREVALENCE_CELL,
SUMMARY_VIEW_INVESTIGATE_IN_TIMELINE_BUTTON,
} from '../../screens/alerts_details';
import { QUERY_TAB_BUTTON, TIMELINE_TITLE } from '../../screens/timeline';
import { expandFirstAlert } from '../../tasks/alerts';
import { openJsonView, openOverview, openTable } from '../../tasks/alerts_details';
import { openJsonView, openTable } from '../../tasks/alerts_details';
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
import { cleanKibana } from '../../tasks/common';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
@ -87,21 +84,4 @@ describe('Alert details with unmapped fields', () => {
expect($tableContainer[0].scrollLeft).to.equal(4);
});
});
it('Opens a new timeline investigation', () => {
openOverview();
cy.get(SUMMARY_VIEW_PREVALENCE_CELL)
.invoke('text')
.then((alertCount) => {
// Click on the first button that lets us investigate in timeline
cy.get(ALERT_FLYOUT).find(SUMMARY_VIEW_INVESTIGATE_IN_TIMELINE_BUTTON).click();
// Make sure a new timeline is created and opened
cy.get(TIMELINE_TITLE).should('contain.text', 'Untitled timeline');
// The alert count in this timeline should match the count shown on the alert flyout
cy.get(QUERY_TAB_BUTTON).should('contain.text', alertCount);
});
});
});

View file

@ -73,3 +73,7 @@ export const OVERVIEW_TAB = '[data-test-subj="overviewTab"]';
export const SUMMARY_VIEW_PREVALENCE_CELL = `${SUMMARY_VIEW} [data-test-subj='alert-prevalence']`;
export const SUMMARY_VIEW_INVESTIGATE_IN_TIMELINE_BUTTON = `${SUMMARY_VIEW} [aria-label='Investigate in timeline']`;
export const INSIGHTS_RELATED_ALERTS_BY_SESSION = `[data-test-subj='related-alerts-by-session']`;
export const INSIGHTS_INVESTIGATE_IN_TIMELINE_BUTTON = `${INSIGHTS_RELATED_ALERTS_BY_SESSION} [aria-label='Investigate in timeline']`;

View file

@ -74,14 +74,7 @@ describe('AlertSummaryView', () => {
</TestProviders>
);
[
'host.name',
'user.name',
i18n.RULE_TYPE,
'query',
i18n.SOURCE_EVENT_ID,
i18n.SESSION_ID,
].forEach((fieldId) => {
['host.name', 'user.name', i18n.RULE_TYPE, 'query'].forEach((fieldId) => {
expect(getByText(fieldId));
});
});

View file

@ -41,7 +41,7 @@ import { Reason } from './reason';
import { InvestigationGuideView } from './investigation_guide_view';
import { Overview } from './overview';
import type { HostRisk } from '../../../risk_score/containers';
import { RelatedCases } from './related_cases';
import { Insights } from './insights/insights';
type EventViewTab = EuiTabbedContentTab;
@ -170,7 +170,6 @@ const EventDetailsComponent: React.FC<Props> = ({
/>
<EuiSpacer size="l" />
<Reason eventId={id} data={data} />
<RelatedCases eventId={id} isReadOnly={isReadOnly} />
<EuiHorizontalRule />
<AlertSummaryView
{...{
@ -185,6 +184,15 @@ const EventDetailsComponent: React.FC<Props> = ({
goToTable={goToTableTab}
/>
<EuiSpacer size="l" />
<Insights
browserFields={browserFields}
eventId={id}
data={data}
timelineId={timelineId}
isReadOnly={isReadOnly}
/>
{(enrichmentCount > 0 || hostRisk) && (
<ThreatSummaryView
isDraggable={isDraggable}

View file

@ -38,8 +38,6 @@ const alwaysDisplayedFields: EventSummaryField[] = [
{ id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS },
{ id: 'user.name' },
{ id: ALERT_RULE_TYPE, label: i18n.RULE_TYPE },
{ id: 'kibana.alert.original_event.id', label: i18n.SOURCE_EVENT_ID },
{ id: 'process.entry_leader.entity_id', label: i18n.SESSION_ID },
];
/**

View file

@ -0,0 +1,71 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { InsightAccordion } from './insight_accordion';
const noopRenderer = () => null;
describe('InsightAccordion', () => {
it("shows a loading indicator when it's in the loading state", () => {
const loadingText = 'loading text';
render(
<TestProviders>
<InsightAccordion
state="loading"
text={loadingText}
prefix=""
renderContent={noopRenderer}
/>
</TestProviders>
);
expect(screen.getByText(loadingText)).toBeInTheDocument();
});
it("shows an error when it's in the error state", () => {
const errorText = 'error text';
render(
<TestProviders>
<InsightAccordion state="error" text={errorText} prefix="" renderContent={noopRenderer} />
</TestProviders>
);
expect(screen.getByText(errorText)).toBeInTheDocument();
});
it("shows the text and a disabled button when it's in the empty state", () => {
const text = 'the text';
render(
<TestProviders>
<InsightAccordion state="empty" text={text} prefix="" renderContent={noopRenderer} />
</TestProviders>
);
const button = screen.getByRole('button', { name: text });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('aria-disabled');
});
it('shows the text and renders the correct content', () => {
const text = 'the text';
const contentText = 'content text';
const contentRenderer = () => <span>{contentText}</span>;
render(
<TestProviders>
<InsightAccordion state="success" text={text} prefix="" renderContent={contentRenderer} />
</TestProviders>
);
expect(screen.getByText(text)).toBeInTheDocument();
expect(screen.getByText(contentText)).toBeInTheDocument();
});
});

View file

@ -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 type { ReactNode } from 'react';
import React from 'react';
import { noop } from 'lodash/fp';
import type { EuiAccordionProps } from '@elastic/eui';
import { EuiAccordion, EuiIcon, useGeneratedHtmlId } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
const StyledAccordion = euiStyled(EuiAccordion)`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
padding: 10px 8px;
border-radius: 6px;
`;
const EmptyAccordion = euiStyled(StyledAccordion)`
color: ${({ theme }) => theme.eui.euiColorDisabledText};
pointer-events: none;
`;
export type InsightAccordionState = 'loading' | 'error' | 'success' | 'empty';
interface Props {
prefix: string;
state: InsightAccordionState;
text: string;
renderContent: () => ReactNode;
onToggle?: EuiAccordionProps['onToggle'];
}
/**
* A special accordion that is used in the Insights section on the alert flyout.
* 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 }) => {
const accordionId = useGeneratedHtmlId({ prefix });
switch (state) {
case 'loading':
// Don't render content when loading
return (
<StyledAccordion id={accordionId} buttonContent={text} onToggle={onToggle} isLoading />
);
case 'error':
// Display an alert icon and don't render content when there was an error
return (
<StyledAccordion
id={accordionId}
buttonContent={
<span>
<EuiIcon type="alert" color="danger" style={{ marginRight: '6px' }} />
{text}
</span>
}
onToggle={onToggle}
/>
);
case 'empty':
// Since EuiAccordions don't have an empty state and they don't allow to style the arrow
// we're using a custom styled Accordion here and we're adding the faded-out button manually.
return (
<EmptyAccordion
id={accordionId}
buttonContent={
<span>
<EuiIcon type="arrowRight" style={{ margin: '0px 8px 0 4px' }} />
{text}
</span>
}
buttonProps={{
'aria-disabled': true,
}}
arrowDisplay="none"
/>
);
case 'success':
// The accordion can display the content now
return (
<StyledAccordion
id={accordionId}
buttonContent={text}
onToggle={onToggle}
paddingSize="l"
>
{renderContent()}
</StyledAccordion>
);
default:
return null;
}
}
);
InsightAccordion.displayName = 'InsightAccordion';

View file

@ -0,0 +1,80 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { Insights } from './insights';
import * as i18n from './translations';
const mockedUseKibana = mockUseKibana();
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
return {
...original,
useGetUserCasesPermissions: jest.fn(),
useToasts: jest.fn().mockReturnValue({ addWarning: jest.fn() }),
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
cases: {
api: {
getRelatedCases: jest.fn(),
},
},
},
}),
};
});
const mockUseGetUserCasesPermissions = useGetUserCasesPermissions as jest.Mock;
describe('Insights', () => {
beforeEach(() => {
mockUseGetUserCasesPermissions.mockReturnValue(noCasesPermissions());
});
it('does not render when there is no content to show', () => {
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={[]} timelineId="" />
</TestProviders>
);
expect(
screen.queryByRole('heading', {
name: i18n.INSIGHTS,
})
).not.toBeInTheDocument();
});
it('renders when there is at least one insight element to show', () => {
// One of the insights modules is the module showing related cases.
// It will show for all users that are able to read case data.
// Enabling that permission, will show the case insight module which
// is necessary to pass this test.
mockUseGetUserCasesPermissions.mockReturnValue(readCasesPermissions());
render(
<TestProviders>
<Insights browserFields={{}} eventId="test" data={[]} timelineId="" />
</TestProviders>
);
expect(
screen.queryByRole('heading', {
name: i18n.INSIGHTS,
})
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,127 @@
/*
* 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, EuiTitle } from '@elastic/eui';
import { find } from 'lodash/fp';
import * as i18n from './translations';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
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';
interface Props {
browserFields: BrowserFields;
eventId: string;
data: TimelineEventsDetailsItem[];
timelineId: string;
isReadOnly?: boolean;
}
/**
* Displays several key insights for the associated alert.
*/
export const Insights = React.memo<Props>(
({ browserFields, eventId, data, isReadOnly, timelineId }) => {
const isRelatedAlertsByProcessAncestryEnabled = useIsExperimentalFeatureEnabled(
'insightsRelatedAlertsByProcessAncestry'
);
const processEntityField = find({ category: 'process', field: 'process.entity_id' }, data);
const hasProcessEntityInfo =
isRelatedAlertsByProcessAncestryEnabled && processEntityField && processEntityField.values;
const processSessionField = find(
{ category: 'process', field: 'process.entry_leader.entity_id' },
data
);
const hasProcessSessionInfo = processSessionField && processSessionField.values;
const sourceEventField = find(
{ category: 'kibana', field: 'kibana.alert.original_event.id' },
data
);
const hasSourceEventInfo = sourceEventField && sourceEventField.values;
const userCasesPermissions = useGetUserCasesPermissions();
const hasCasesReadPermissions = userCasesPermissions.read;
// Make sure that the alert has at least one of the associated fields
// or the user has the required permissions for features/fields that
// we can provide insights for
const canShowAtLeastOneInsight =
hasCasesReadPermissions ||
hasProcessEntityInfo ||
hasSourceEventInfo ||
hasProcessSessionInfo;
// If we're in read-only mode or don't have any insight-related data,
// don't render anything.
if (isReadOnly || !canShowAtLeastOneInsight) {
return null;
}
return (
<div>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiTitle size="xxxs">
<h5>{i18n.INSIGHTS}</h5>
</EuiTitle>
</EuiFlexItem>
{hasCasesReadPermissions && (
<EuiFlexItem>
<RelatedCases eventId={eventId} />
</EuiFlexItem>
)}
{sourceEventField && sourceEventField.values && (
<EuiFlexItem>
<RelatedAlertsBySourceEvent
browserFields={browserFields}
data={sourceEventField}
eventId={eventId}
timelineId={timelineId}
/>
</EuiFlexItem>
)}
{processSessionField && processSessionField.values && (
<EuiFlexItem data-test-subj="related-alerts-by-session">
<RelatedAlertsBySession
browserFields={browserFields}
data={processSessionField}
eventId={eventId}
timelineId={timelineId}
/>
</EuiFlexItem>
)}
{isRelatedAlertsByProcessAncestryEnabled &&
processEntityField &&
processEntityField.values && (
<EuiFlexItem>
<RelatedAlertsByProcessAncestry
data={processEntityField}
eventId={eventId}
timelineId={timelineId}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</div>
);
}
);
Insights.displayName = 'Insights';

View file

@ -0,0 +1,123 @@
/*
* 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 { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useAlertPrevalenceFromProcessTree } from '../../../containers/alerts/use_alert_prevalence_from_process_tree';
import { RelatedAlertsByProcessAncestry } from './related_alerts_by_process_ancestry';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { PROCESS_ANCESTRY, PROCESS_ANCESTRY_COUNT, PROCESS_ANCESTRY_ERROR } from './translations';
jest.mock('../../../containers/alerts/use_alert_prevalence_from_process_tree', () => ({
useAlertPrevalenceFromProcessTree: jest.fn(),
}));
const mockUseAlertPrevalenceFromProcessTree = useAlertPrevalenceFromProcessTree as jest.Mock;
describe('RelatedAlertsByProcessAncestry', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('shows an accordion and does not fetch data right away', () => {
render(
<TestProviders>
<RelatedAlertsByProcessAncestry
eventId="random"
data={{
field: 'testfield',
values: ['test value'],
isObjectArray: false,
}}
/>
</TestProviders>
);
expect(screen.getByText(PROCESS_ANCESTRY)).toBeInTheDocument();
expect(mockUseAlertPrevalenceFromProcessTree).not.toHaveBeenCalled();
});
it('shows a loading indicator and starts to fetch data when clicked', () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: true,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry
eventId="random"
data={{
field: 'testfield',
values: ['test value'],
isObjectArray: false,
}}
/>
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
expect(mockUseAlertPrevalenceFromProcessTree).toHaveBeenCalled();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows an error message when the request fails', () => {
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: true,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry
eventId="random"
data={{
field: 'testfield',
values: ['test value'],
isObjectArray: false,
}}
/>
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
expect(screen.getByText(PROCESS_ANCESTRY_ERROR)).toBeInTheDocument();
});
it('renders the text with a count and a timeline button when the request works', async () => {
const mockAlertIds = ['1', '2'];
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
error: false,
alertIds: mockAlertIds,
});
render(
<TestProviders>
<RelatedAlertsByProcessAncestry
eventId="random"
data={{
field: 'testfield',
values: ['test value'],
isObjectArray: false,
}}
/>
</TestProviders>
);
userEvent.click(screen.getByText(PROCESS_ANCESTRY));
await waitFor(() => {
expect(screen.getByText(PROCESS_ANCESTRY_COUNT(2))).toBeInTheDocument();
expect(
screen.getByRole('button', { name: ACTION_INVESTIGATE_IN_TIMELINE })
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,173 @@
/*
* 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, { useMemo, useCallback, useEffect, useState } from 'react';
import { EuiSpacer, EuiLoadingSpinner } from '@elastic/eui';
import type { DataProvider } from '../../../../../common/types';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { getDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalenceFromProcessTree } from '../../../containers/alerts/use_alert_prevalence_from_process_tree';
import { InsightAccordion } from './insight_accordion';
import { SimpleAlertTable } from './simple_alert_table';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { PROCESS_ANCESTRY, PROCESS_ANCESTRY_COUNT, PROCESS_ANCESTRY_ERROR } from './translations';
interface Props {
data: TimelineEventsDetailsItem;
eventId: string;
timelineId?: string;
}
interface Cache {
alertIds: string[];
}
/**
* Fetches and displays alerts that were generated in the associated process'
* process tree.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*
* In contrast to other insight accordions, this one does not fetch the
* count and alerts on mount since the call to fetch the process tree
* and its associated alerts is quite expensive.
* The component requires users to click on the accordion in order to
* initiate the fetch of the associated events.
*
* In order to achieve this, this component orchestrates two helper
* components:
*
* RelatedAlertsByProcessAncestry (empty cache)
* user clicks -->
* FetchAndNotifyCachedAlertsByProcessAncestry (fetches data, shows loading state)
* cache loaded -->
* ActualRelatedAlertsByProcessAncestry (displays data)
*
* The top-level component maintains a "cache" state that is used for
* state management and to prevent double-fetching in case the
* accordion is closed and re-opened.
*
* Due to the ephemeral nature of the data, it was decided to keep the
* state inside the component rather than to add it to Redux.
*/
export const RelatedAlertsByProcessAncestry = React.memo<Props>(({ data, eventId, timelineId }) => {
const [showContent, setShowContent] = useState(false);
const [cache, setCache] = useState<Partial<Cache>>({});
const onToggle = useCallback((isOpen: boolean) => setShowContent(isOpen), []);
// Makes sure the component is not fetching data before the accordion
// has been openend.
const renderContent = useCallback(() => {
if (!showContent) {
return null;
} else if (cache.alertIds) {
return (
<ActualRelatedAlertsByProcessAncestry
eventId={eventId}
timelineId={timelineId}
alertIds={cache.alertIds}
/>
);
}
return (
<FetchAndNotifyCachedAlertsByProcessAncestry
data={data}
timelineId={timelineId}
onCacheLoad={setCache}
/>
);
}, [showContent, cache, data, eventId, timelineId]);
const isEmpty = !!cache.alertIds && cache.alertIds.length === 0;
return (
<InsightAccordion
prefix="RelatedAlertsByProcessAncestry"
// `renderContent` and the associated sub-components are making sure to
// render the correct loading and error states so we can omit these states here
state={isEmpty ? 'empty' : 'success'}
text={
// If we have fetched the alerts, display the count here, otherwise omit the count
cache.alertIds ? PROCESS_ANCESTRY_COUNT(cache.alertIds.length) : PROCESS_ANCESTRY
}
renderContent={renderContent}
onToggle={onToggle}
/>
);
});
RelatedAlertsByProcessAncestry.displayName = 'RelatedAlertsByProcessAncestry';
/**
* Fetches data, displays a loading and error state and notifies about on success
*/
const FetchAndNotifyCachedAlertsByProcessAncestry: React.FC<{
data: TimelineEventsDetailsItem;
timelineId?: string;
onCacheLoad: (cache: Cache) => void;
}> = ({ data, timelineId, onCacheLoad }) => {
const { loading, error, alertIds } = useAlertPrevalenceFromProcessTree({
parentEntityId: data.values,
timelineId: timelineId ?? '',
signalIndexName: null,
});
useEffect(() => {
if (alertIds) {
onCacheLoad({ alertIds });
}
}, [alertIds, onCacheLoad]);
if (loading) {
return <EuiLoadingSpinner />;
} else if (error) {
return <>{PROCESS_ANCESTRY_ERROR}</>;
}
return null;
};
FetchAndNotifyCachedAlertsByProcessAncestry.displayName =
'FetchAndNotifyCachedAlertsByProcessAncestry';
/**
* Renders the alert table and the timeline button from a filled cache.
*/
const ActualRelatedAlertsByProcessAncestry: React.FC<{
alertIds: string[];
eventId: string;
timelineId?: string;
}> = ({ alertIds, eventId, timelineId }) => {
const dataProviders = useMemo(() => {
if (alertIds && alertIds.length) {
return alertIds.reduce<DataProvider[]>((result, alertId, index) => {
const id = `${timelineId}-${eventId}-event.id-${index}-${alertId}`;
result.push(getDataProvider('_id', id, alertId));
return result;
}, []);
}
return null;
}, [alertIds, eventId, timelineId]);
if (!dataProviders) {
return null;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton asEmptyButton={false} dataProviders={dataProviders}>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
};
ActualRelatedAlertsByProcessAncestry.displayName = 'ActualRelatedAlertsByProcessAncestry';

View file

@ -0,0 +1,122 @@
/*
* 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 { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import { RelatedAlertsBySession } from './related_alerts_by_session';
import { SESSION_LOADING, SESSION_ERROR, SESSION_COUNT } from './translations';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
jest.mock('../table/use_action_cell_data_provider', () => ({
useActionCellDataProvider: jest.fn(),
}));
const mockUseActionCellDataProvider = useActionCellDataProvider as jest.Mock;
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: jest.fn(),
}));
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
const testEventId = '20398h209482';
const testData = {
field: 'process.entry_leader.entity_id',
data: ['2938hr29348h9489r8'],
isObjectArray: false,
};
describe('RelatedAlertsBySession', () => {
it('shows a loading message when data is loading', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_LOADING)).toBeInTheDocument();
});
it('shows an error message when data failed to load', () => {
mockUseAlertPrevalence.mockReturnValue({
error: true,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_ERROR)).toBeInTheDocument();
});
it('shows an empty state when no alerts exist', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 0,
alertIds: [],
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SESSION_COUNT(0))).toBeInTheDocument();
});
it('shows the correct count and renders the timeline button', async () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 2,
alertIds: ['223', '2323'],
});
mockUseActionCellDataProvider.mockReturnValue({
dataProviders: [{}, {}],
});
render(
<TestProviders>
<RelatedAlertsBySession
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(SESSION_COUNT(2))).toBeInTheDocument();
expect(screen.getByText(ACTION_INVESTIGATE_IN_TIMELINE)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,119 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
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_ERROR, SESSION_COUNT } from './translations';
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem;
eventId: string;
timelineId: string;
}
/**
* Fetches the count of alerts that were generated in the same session
* and displays an accordion with a mini table representation of the
* related cases.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*/
export const RelatedAlertsBySession = React.memo<Props>(
({ browserFields, data, eventId, timelineId }) => {
const { field, values } = data;
const { error, count, alertIds } = useAlertPrevalence({
field,
value: values,
timelineId: timelineId ?? '',
signalIndexName: null,
includeAlertIds: true,
});
const { fieldFromBrowserField } = getEnrichedFieldInfo({
browserFields,
contextId: timelineId,
eventId,
field: { id: data.field },
timelineId,
item: data,
});
const cellData = useActionCellDataProvider({
field,
values,
contextId: timelineId,
eventId,
fieldFromBrowserField,
fieldFormat: fieldFromBrowserField?.format,
fieldType: fieldFromBrowserField?.type,
});
const renderContent = useCallback(() => {
if (!alertIds || !cellData?.dataProviders) {
return null;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton
asEmptyButton={false}
dataProviders={cellData?.dataProviders}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
}, [alertIds, cellData?.dataProviders]);
let state: InsightAccordionState = 'loading';
if (error) {
state = 'error';
} else if (count === 0) {
state = 'empty';
} else if (alertIds) {
state = 'success';
}
return (
<InsightAccordion
prefix="RelatedAlertsBySession"
state={state}
text={getTextFromState(state, count)}
renderContent={renderContent}
/>
);
}
);
RelatedAlertsBySession.displayName = 'RelatedAlertsBySession';
function getTextFromState(state: InsightAccordionState, count: number | undefined) {
switch (state) {
case 'loading':
return SESSION_LOADING;
case 'error':
return SESSION_ERROR;
case 'success':
case 'empty':
return SESSION_COUNT(count);
default:
return '';
}
}

View file

@ -0,0 +1,122 @@
/*
* 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 { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import { RelatedAlertsBySourceEvent } from './related_alerts_by_source_event';
import { SOURCE_EVENT_LOADING, SOURCE_EVENT_ERROR, SOURCE_EVENT_COUNT } from './translations';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
jest.mock('../table/use_action_cell_data_provider', () => ({
useActionCellDataProvider: jest.fn(),
}));
const mockUseActionCellDataProvider = useActionCellDataProvider as jest.Mock;
jest.mock('../../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: jest.fn(),
}));
const mockUseAlertPrevalence = useAlertPrevalence as jest.Mock;
const testEventId = '20398h209482';
const testData = {
field: 'kibana.alert.original_event.id',
data: ['2938hr29348h9489r8'],
isObjectArray: false,
};
describe('RelatedAlertsBySourceEvent', () => {
it('shows a loading message when data is loading', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_LOADING)).toBeInTheDocument();
});
it('shows an error message when data failed to load', () => {
mockUseAlertPrevalence.mockReturnValue({
error: true,
count: undefined,
alertIds: undefined,
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_ERROR)).toBeInTheDocument();
});
it('shows an empty state when no alerts exist', () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 0,
alertIds: [],
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
expect(screen.getByText(SOURCE_EVENT_COUNT(0))).toBeInTheDocument();
});
it('shows the correct count and renders the timeline button', async () => {
mockUseAlertPrevalence.mockReturnValue({
error: false,
count: 2,
alertIds: ['223', '2323'],
});
mockUseActionCellDataProvider.mockReturnValue({
dataProviders: [{}, {}],
});
render(
<TestProviders>
<RelatedAlertsBySourceEvent
browserFields={{}}
data={testData}
eventId={testEventId}
timelineId=""
/>
</TestProviders>
);
await waitFor(() => {
expect(screen.getByText(SOURCE_EVENT_COUNT(2))).toBeInTheDocument();
expect(screen.getByText(ACTION_INVESTIGATE_IN_TIMELINE)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,119 @@
/*
* 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 { EuiSpacer } from '@elastic/eui';
import type { BrowserFields } from '../../../containers/source';
import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline';
import { useActionCellDataProvider } from '../table/use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { InvestigateInTimelineButton } from '../table/investigate_in_timeline_button';
import { SimpleAlertTable } from './simple_alert_table';
import { getEnrichedFieldInfo } from '../helpers';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
import { SOURCE_EVENT_LOADING, SOURCE_EVENT_ERROR, SOURCE_EVENT_COUNT } from './translations';
interface Props {
browserFields: BrowserFields;
data: TimelineEventsDetailsItem;
eventId: string;
timelineId: string;
}
/**
* Fetches the count of alerts that were generated by the same source
* event and displays an accordion with a mini table representation of
* the related cases.
* Offers the ability to dive deeper into the investigation by opening
* the related alerts in a timeline investigation.
*/
export const RelatedAlertsBySourceEvent = React.memo<Props>(
({ browserFields, data, eventId, timelineId }) => {
const { field, values } = data;
const { error, count, alertIds } = useAlertPrevalence({
field,
value: values,
timelineId: timelineId ?? '',
signalIndexName: null,
includeAlertIds: true,
});
const { fieldFromBrowserField } = getEnrichedFieldInfo({
browserFields,
contextId: timelineId,
eventId,
field: { id: data.field },
timelineId,
item: data,
});
const cellData = useActionCellDataProvider({
field,
values,
contextId: timelineId,
eventId,
fieldFromBrowserField,
fieldFormat: fieldFromBrowserField?.format,
fieldType: fieldFromBrowserField?.type,
});
const renderContent = useCallback(() => {
if (!alertIds || !cellData?.dataProviders) {
return null;
}
return (
<>
<SimpleAlertTable alertIds={alertIds} />
<EuiSpacer />
<InvestigateInTimelineButton
asEmptyButton={false}
dataProviders={cellData?.dataProviders}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</InvestigateInTimelineButton>
</>
);
}, [alertIds, cellData?.dataProviders]);
let state: InsightAccordionState = 'loading';
if (error) {
state = 'error';
} else if (count === 0) {
state = 'empty';
} else if (alertIds) {
state = 'success';
}
return (
<InsightAccordion
prefix="RelatedAlertsBySourceEvent"
state={state}
text={getTextFromState(state, count)}
renderContent={renderContent}
/>
);
}
);
function getTextFromState(state: InsightAccordionState, count: number | undefined) {
switch (state) {
case 'loading':
return SOURCE_EVENT_LOADING;
case 'error':
return SOURCE_EVENT_ERROR;
case 'success':
case 'empty':
return SOURCE_EVENT_COUNT(count);
default:
return '';
}
}
RelatedAlertsBySourceEvent.displayName = 'RelatedAlertsBySourceEvent';

View file

@ -5,20 +5,21 @@
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../mock';
import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { TestProviders } from '../../../mock';
import { useKibana as mockUseKibana } from '../../../lib/kibana/__mocks__';
import { useGetUserCasesPermissions } from '../../../lib/kibana';
import { RelatedCases } from './related_cases';
import { noCasesPermissions, readCasesPermissions } from '../../../cases_test_utils';
import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils';
import { CASES_LOADING, CASES_COUNT } from './translations';
const mockedUseKibana = mockUseKibana();
const mockGetRelatedCases = jest.fn();
jest.mock('../../lib/kibana', () => {
const original = jest.requireActual('../../lib/kibana');
jest.mock('../../../lib/kibana', () => {
const original = jest.requireActual('../../../lib/kibana');
return {
...original,
@ -58,6 +59,19 @@ describe('Related Cases', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions());
});
describe('When related cases are loading', () => {
test('should show the loading message', () => {
mockGetRelatedCases.mockReturnValue([]);
render(
<TestProviders>
<RelatedCases eventId={eventId} />
</TestProviders>
);
expect(screen.getByText(CASES_LOADING)).toBeInTheDocument();
});
});
describe('When related cases are unable to be retrieved', () => {
test('should show 0 related cases when there are none', async () => {
mockGetRelatedCases.mockReturnValue([]);
@ -67,7 +81,9 @@ describe('Related Cases', () => {
</TestProviders>
);
expect(await screen.findByText('0 cases.')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument();
});
});
});
@ -79,8 +95,10 @@ describe('Related Cases', () => {
<RelatedCases eventId={eventId} />
</TestProviders>
);
expect(await screen.findByText('1 case:')).toBeInTheDocument();
expect(await screen.findByTestId('case-details-link')).toHaveTextContent('Test Case');
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument();
expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case');
});
});
});
@ -95,11 +113,14 @@ describe('Related Cases', () => {
<RelatedCases eventId={eventId} />
</TestProviders>
);
expect(await screen.findByText('2 cases:')).toBeInTheDocument();
const cases = await screen.findAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
expect(cases[0]).toHaveTextContent('Test Case 1');
expect(cases[1]).toHaveTextContent('Test Case 2');
await waitFor(() => {
expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument();
const cases = screen.getAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
expect(cases[0]).toHaveTextContent('Test Case 1');
expect(cases[1]).toHaveTextContent('Test Case 2');
});
});
});
});

View file

@ -0,0 +1,129 @@
/*
* 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, useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana, useToasts } from '../../../lib/kibana';
import { CaseDetailsLink } from '../../links';
import { APP_ID } from '../../../../../common/constants';
import type { InsightAccordionState } from './insight_accordion';
import { InsightAccordion } from './insight_accordion';
import { CASES_LOADING, CASES_ERROR, CASES_ERROR_TOAST, CASES_COUNT } from './translations';
type RelatedCaseList = Array<{ id: string; title: string }>;
interface Props {
eventId: string;
}
/**
* Fetches and displays case links of cases that include the associated event (id).
*/
export const RelatedCases = React.memo<Props>(({ eventId }) => {
const {
services: { cases },
} = useKibana();
const toasts = useToasts();
const [relatedCases, setRelatedCases] = useState<RelatedCaseList | undefined>(undefined);
const [areCasesLoading, setAreCasesLoading] = useState(true);
const [hasError, setHasError] = useState<boolean>(false);
const renderContent = useCallback(() => renderCaseContent(relatedCases), [relatedCases]);
const getRelatedCases = useCallback(async () => {
let relatedCaseList: RelatedCaseList = [];
try {
if (eventId) {
relatedCaseList =
(await cases.api.getRelatedCases(eventId, {
owner: APP_ID,
})) ?? [];
}
} catch (error) {
setHasError(true);
toasts.addWarning(CASES_ERROR_TOAST(error));
}
setRelatedCases(relatedCaseList);
setAreCasesLoading(false);
}, [eventId, cases.api, toasts]);
useEffect(() => {
getRelatedCases();
}, [eventId, getRelatedCases]);
let state: InsightAccordionState = 'loading';
if (hasError) {
state = 'error';
} else if (!areCasesLoading && relatedCases?.length === 0) {
state = 'empty';
} else if (relatedCases) {
state = 'success';
}
return (
<InsightAccordion
prefix="RelatedCases"
state={state}
text={getTextFromState(state, relatedCases?.length)}
renderContent={renderContent}
/>
);
});
function renderCaseContent(relatedCases: RelatedCaseList = []) {
const caseCount = relatedCases.length;
return (
<span>
<FormattedMessage
defaultMessage="This alert was found in {caseCount}"
id="xpack.securitySolution.alertDetails.overview.insights_related_cases_found_content"
values={{
caseCount: (
<strong>
<FormattedMessage
id="xpack.securitySolution.alertDetails.overview.insights_related_cases_found_content_count"
defaultMessage="{caseCount} {caseCount, plural, =0 {cases.} =1 {case:} other {cases:}}"
values={{ caseCount }}
/>
</strong>
),
}}
/>
{relatedCases.map(({ id, title }, index) =>
id && title ? (
<span key={id}>
{' '}
<CaseDetailsLink detailName={id} title={title}>
{title}
</CaseDetailsLink>
{relatedCases[index + 1] ? ',' : ''}
</span>
) : (
<></>
)
)}
</span>
);
}
RelatedCases.displayName = 'RelatedCases';
function getTextFromState(state: InsightAccordionState, caseCount = 0) {
switch (state) {
case 'loading':
return CASES_LOADING;
case 'error':
return CASES_ERROR;
case 'success':
case 'empty':
return CASES_COUNT(caseCount);
default:
return '';
}
}

View file

@ -0,0 +1,111 @@
/*
* 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 { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useAlertsByIds } from '../../../containers/alerts/use_alerts_by_ids';
import { SimpleAlertTable } from './simple_alert_table';
jest.mock('../../../containers/alerts/use_alerts_by_ids', () => ({
useAlertsByIds: jest.fn(),
}));
const mockUseAlertsByIds = useAlertsByIds as jest.Mock;
const testIds = ['wer34r34', '234234'];
const tooManyTestIds = [
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
'234',
];
const testResponse = [
{
fields: {
'kibana.alert.rule.name': ['test rule name'],
'@timestamp': ['2022-07-18T15:07:21.753Z'],
'kibana.alert.severity': ['high'],
},
},
];
describe('SimpleAlertTable', () => {
it('shows a loading indicator when the data is loading', () => {
mockUseAlertsByIds.mockReturnValue({
loading: true,
error: false,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('shows an error message when there was an error fetching the alerts', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: true,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
expect(screen.getByText(/Failed/)).toBeInTheDocument();
});
it('shows the results', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: false,
data: testResponse,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={testIds} />
</TestProviders>
);
// Renders to table headers
expect(screen.getByRole('columnheader', { name: 'Rule' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '@timestamp' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Severity' })).toBeInTheDocument();
// Renders the row
expect(screen.getByText('test rule name')).toBeInTheDocument();
expect(screen.getByText(/Jul 18/)).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
});
it('shows a note about limited results', () => {
mockUseAlertsByIds.mockReturnValue({
loading: false,
error: false,
data: testResponse,
});
render(
<TestProviders>
<SimpleAlertTable alertIds={tooManyTestIds} />
</TestProviders>
);
expect(screen.getByText(/Showing only/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,80 @@
/*
* 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, { useMemo } from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import { EuiBasicTable, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
import { PreferenceFormattedDate } from '../../formatted_date';
import { SeverityBadge } from '../../../../detections/components/rules/severity_badge';
import { useAlertsByIds } from '../../../containers/alerts/use_alerts_by_ids';
import { SIMPLE_ALERT_TABLE_ERROR, SIMPLE_ALERT_TABLE_LIMITED } from './translations';
const TABLE_FIELDS = ['@timestamp', 'kibana.alert.rule.name', 'kibana.alert.severity'];
const columns: Array<EuiBasicTableColumn<Record<string, string[]>>> = [
{
field: 'kibana.alert.rule.name',
name: 'Rule',
},
{
field: '@timestamp',
name: '@timestamp',
render: (timestamp: string) => <PreferenceFormattedDate value={new Date(timestamp)} />,
},
{
field: 'kibana.alert.severity',
name: 'Severity',
render: (severity: Severity) => <SeverityBadge value={severity} />,
},
];
/** 10 alert rows in this table has been deemed a balanced amount for the flyout */
const alertLimit = 10;
/**
* Displays a simplified alert table for the given alert ids.
* It will only fetch the latest 10 ids and in case more ids
* are passed in, it will add a note about omitted alerts.
*/
export const SimpleAlertTable = React.memo<{ alertIds: string[] }>(({ alertIds }) => {
const sampledData = useMemo(() => alertIds.slice(0, alertLimit), [alertIds]);
const { loading, error, data } = useAlertsByIds({
alertIds: sampledData,
fields: TABLE_FIELDS,
});
const mappedData = useMemo(() => {
if (!data) {
return undefined;
}
return data.map((doc) => doc.fields);
}, [data]);
if (loading) {
return <EuiLoadingContent lines={2} />;
} else if (error) {
return <>{SIMPLE_ALERT_TABLE_ERROR}</>;
} else if (mappedData) {
const showLimitedDataNote = alertIds.length > alertLimit;
return (
<>
{showLimitedDataNote && (
<div>
<em>{SIMPLE_ALERT_TABLE_LIMITED}</em>
<EuiSpacer />
</div>
)}
<EuiBasicTable compressed={true} items={mappedData} columns={columns} />
</>
);
}
return null;
});
SimpleAlertTable.displayName = 'SimpleAlertTable';

View file

@ -0,0 +1,116 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const INSIGHTS = i18n.translate('xpack.securitySolution.alertDetails.overview.insights', {
defaultMessage: 'Insights',
});
export const PROCESS_ANCESTRY = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry',
{
defaultMessage: 'Related alerts by process ancestry',
}
);
export const PROCESS_ANCESTRY_COUNT = (count: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} by process ancestry',
values: { count },
}
);
export const PROCESS_ANCESTRY_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_process_ancestry_error',
{
defaultMessage: 'Failed to fetch alerts.',
}
);
export const SESSION_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_loading',
{ defaultMessage: 'Loading related alerts by source event' }
);
export const SESSION_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_session_error',
{
defaultMessage: 'Failed to load related alerts by session',
}
);
export const SESSION_COUNT = (count?: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_session_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} related by session',
values: { count },
}
);
export const SOURCE_EVENT_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_loading',
{ defaultMessage: 'Loading related alerts by source event' }
);
export const SOURCE_EVENT_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_alerts_by_source_event_error',
{
defaultMessage: 'Failed to load related alerts by source event',
}
);
export const SOURCE_EVENT_COUNT = (count?: number) =>
i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights_related_alerts_by_source_event_count',
{
defaultMessage: '{count} {count, plural, =1 {alert} other {alerts}} related by source event',
values: { count },
}
);
export const CASES_LOADING = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_cases_loading',
{
defaultMessage: 'Loading related cases',
}
);
export const CASES_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.insights.related_cases_error',
{
defaultMessage: 'Failed to load related cases',
}
);
export const CASES_COUNT = (count: number) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.insights.related_cases_count', {
defaultMessage: '{count} {count, plural, =1 {case} other {cases}} related to this alert',
values: { count },
});
export const CASES_ERROR_TOAST = (error: string) =>
i18n.translate('xpack.securitySolution.alertDetails.overview.insights.relatedCasesFailure', {
defaultMessage: 'Unable to load related cases: "{error}"',
values: { error },
});
export const SIMPLE_ALERT_TABLE_ERROR = i18n.translate(
'xpack.securitySolution.alertDetails.overview.simpleAlertTable.error',
{
defaultMessage: 'Failed to load the alerts.',
}
);
export const SIMPLE_ALERT_TABLE_LIMITED = i18n.translate(
'xpack.securitySolution.alertDetails.overview.limitedAlerts',
{
defaultMessage: 'Showing only the latest 10 alerts. View the rest of alerts in timeline.',
}
);

View file

@ -1,101 +0,0 @@
/*
* 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, useState, useEffect } from 'react';
import { EuiFlexItem, EuiLoadingContent, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useGetUserCasesPermissions, useKibana, useToasts } from '../../lib/kibana';
import { CaseDetailsLink } from '../links';
import { APP_ID } from '../../../../common/constants';
interface Props {
eventId: string;
isReadOnly?: boolean;
}
type RelatedCaseList = Array<{ id: string; title: string }>;
export const RelatedCases: React.FC<Props> = React.memo(({ eventId, isReadOnly }) => {
const {
services: { cases },
} = useKibana();
const toasts = useToasts();
const userCasesPermissions = useGetUserCasesPermissions();
const [relatedCases, setRelatedCases] = useState<RelatedCaseList>([]);
const [areCasesLoading, setAreCasesLoading] = useState(true);
const [hasError, setHasError] = useState<boolean>(false);
const hasCasesReadPermissions = userCasesPermissions.read;
const getRelatedCases = useCallback(async () => {
let relatedCaseList: RelatedCaseList = [];
try {
if (eventId) {
relatedCaseList =
(await cases.api.getRelatedCases(eventId, {
owner: APP_ID,
})) ?? [];
}
} catch (error) {
setHasError(true);
toasts.addWarning(
i18n.translate('xpack.securitySolution.alertDetails.overview.relatedCasesFailure', {
defaultMessage: 'Unable to load related cases: "{error}"',
values: { error },
})
);
}
setRelatedCases(relatedCaseList);
setAreCasesLoading(false);
}, [eventId, cases.api, toasts]);
useEffect(() => {
getRelatedCases();
}, [eventId, getRelatedCases]);
if (hasError || !hasCasesReadPermissions || isReadOnly) return null;
return areCasesLoading ? (
<EuiLoadingContent lines={2} />
) : (
<>
<EuiSpacer size="m" />
<EuiFlexItem grow={false} style={{ flexDirection: 'row' }}>
<span>
<FormattedMessage
defaultMessage="This alert was found in"
id="xpack.securitySolution.alertDetails.overview.relatedCasesFound"
/>{' '}
<strong>
<FormattedMessage
defaultMessage="{caseCount} {caseCount, plural, =0 {cases.} =1 {case:} other {cases:}}"
id="xpack.securitySolution.alertDetails.overview.relatedCasesCount"
values={{
caseCount: relatedCases?.length ?? 0,
}}
/>
</strong>
{relatedCases?.map(({ id, title }, index) =>
id && title ? (
<span key={id}>
{' '}
<CaseDetailsLink detailName={id} title={title}>
{title}
</CaseDetailsLink>
{relatedCases[index + 1] ? ',' : ''}
</span>
) : (
<></>
)
)}
</span>
</EuiFlexItem>
</>
);
});
RelatedCases.displayName = 'RelatedCases';

View file

@ -52,10 +52,11 @@ const enrichedHostIpData: AlertSummaryRow['description'] = {
values: [...hostIpValues],
};
const mockCount = 90019001;
jest.mock('../../containers/alerts/use_alert_prevalence', () => ({
useAlertPrevalence: () => ({
loading: false,
count: 1,
count: mockCount,
error: false,
}),
}));
@ -94,7 +95,7 @@ describe('Summary View', () => {
});
// Shows alert prevalence information
expect(screen.getByTestId('alert-prevalence')).toBeInTheDocument();
expect(screen.getByText(mockCount)).toBeInTheDocument();
// Shows the Investigate in timeline button
expect(screen.getByLabelText('Investigate in timeline')).toBeInTheDocument();
});
@ -121,7 +122,7 @@ describe('Summary View', () => {
);
// Does not render the prevalence and timeline items
expect(screen.queryByTestId('alert-prevalence')).not.toBeInTheDocument();
expect(screen.queryByText(mockCount)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Investigate in timeline')).not.toBeInTheDocument();
});
});

View file

@ -73,7 +73,7 @@ export const ActionCell: React.FC<Props> = React.memo(
applyWidthAndPadding={applyWidthAndPadding}
closeTopN={closeTopN}
dataType={data.type}
dataProvider={actionCellConfig?.dataProvider}
dataProvider={actionCellConfig?.dataProviders}
enableOverflowButton={true}
field={data.field}
isAggregatable={aggregatable}
@ -86,7 +86,7 @@ export const ActionCell: React.FC<Props> = React.memo(
timelineId={timelineId ?? timelineIdFind}
toggleColumn={toggleColumn}
toggleTopN={toggleTopN}
values={actionCellConfig?.stringValues}
values={actionCellConfig?.values}
/>
);
}

View file

@ -8,77 +8,26 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import type { BrowserField } from '../../../containers/source';
import { InvestigateInTimelineButton } from './investigate_in_timeline_button';
import { TestProviders } from '../../../mock';
import type { EventFieldsData } from '../types';
import { TimelineId } from '../../../../../common/types';
import { getDataProvider } from './use_action_cell_data_provider';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
jest.mock('../../../lib/kibana');
const eventId = 'TUWyf3wBFCFU0qRJTauW';
const hostIpFieldFromBrowserField: BrowserField = {
aggregatable: true,
category: 'host',
description: 'Host ip addresses.',
example: '127.0.0.1',
fields: {},
format: '',
indexes: ['auditbeat-*', 'filebeat-*', 'logs-*', 'winlogbeat-*'],
name: 'host.ip',
readFromDocValues: false,
searchable: true,
type: 'ip',
};
const hostIpData: EventFieldsData = {
...hostIpFieldFromBrowserField,
ariaRowindex: 35,
field: 'host.ip',
fields: {},
format: '',
isObjectArray: false,
originalValue: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
values: ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'],
};
describe('InvestigateInTimelineButton', () => {
describe('When all props are provided', () => {
test('it should display the add to timeline button', () => {
const dataProviders = ['127.0.0.1', '::1', '10.1.2.3', '2001:0DB8:AC10:FE01::'].map(
(ipValue) => getDataProvider('host.ip', '', ipValue)
);
render(
<TestProviders>
<InvestigateInTimelineButton
data={hostIpData}
eventId={eventId}
fieldFromBrowserField={hostIpFieldFromBrowserField}
linkValue={undefined}
timelineId={TimelineId.test}
values={hostIpData.values}
/>
<InvestigateInTimelineButton asEmptyButton={true} dataProviders={dataProviders} />
</TestProviders>
);
expect(screen.queryByLabelText(ACTION_INVESTIGATE_IN_TIMELINE)).toBeInTheDocument();
});
});
describe('When browser field data necessary for timeline is unavailable', () => {
test('it should not render', () => {
render(
<TestProviders>
<InvestigateInTimelineButton
data={hostIpData}
eventId={eventId}
fieldFromBrowserField={undefined}
linkValue={undefined}
timelineId={TimelineId.test}
values={hostIpData.values}
/>
</TestProviders>
);
expect(screen.queryByLabelText(ACTION_INVESTIGATE_IN_TIMELINE)).not.toBeInTheDocument();
});
});
});

View file

@ -6,51 +6,38 @@
*/
import React from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { isEmpty } from 'lodash';
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import type { AlertSummaryRow } from '../helpers';
import { inputsActions } from '../../../store/inputs';
import { updateProviders } from '../../../../timelines/store/timeline/actions';
import { sourcererActions } from '../../../store/actions';
import { SourcererScopeName } from '../../../store/sourcerer/model';
import type { DataProvider } from '../../../../../common/types';
import { TimelineId, TimelineType } from '../../../../../common/types/timeline';
import { useActionCellDataProvider } from './use_action_cell_data_provider';
import { useCreateTimeline } from '../../../../timelines/components/timeline/properties/use_create_timeline';
import { ACTION_INVESTIGATE_IN_TIMELINE } from '../../../../detections/components/alerts_table/translations';
export const InvestigateInTimelineButton = React.memo<
React.PropsWithChildren<AlertSummaryRow['description']>
>(({ data, eventId, fieldFromBrowserField, linkValue, timelineId, values, children }) => {
export const InvestigateInTimelineButton: React.FunctionComponent<{
asEmptyButton: boolean;
dataProviders: DataProvider[];
}> = ({ asEmptyButton, children, dataProviders }) => {
const dispatch = useDispatch();
const actionCellConfig = useActionCellDataProvider({
contextId: timelineId,
eventId,
field: data.field,
fieldFormat: data.format,
fieldFromBrowserField,
fieldType: data.type,
isObjectArray: data.isObjectArray,
linkValue,
values,
});
const clearTimeline = useCreateTimeline({
timelineId: TimelineId.active,
timelineType: TimelineType.default,
});
const configureAndOpenTimeline = React.useCallback(() => {
if (actionCellConfig?.dataProvider) {
if (dataProviders) {
// Reset the current timeline
clearTimeline();
// Update the timeline's providers to match the current prevalence field query
dispatch(
updateProviders({
id: TimelineId.active,
providers: actionCellConfig.dataProvider,
providers: dataProviders,
})
);
// Only show detection alerts
@ -65,24 +52,22 @@ export const InvestigateInTimelineButton = React.memo<
// Unlock the time range from the global time range
dispatch(inputsActions.removeGlobalLinkTo());
}
}, [dispatch, clearTimeline, actionCellConfig]);
}, [dispatch, clearTimeline, dataProviders]);
const showButton = values != null && !isEmpty(actionCellConfig?.dataProvider);
if (showButton) {
return (
<EuiButtonEmpty
aria-label={ACTION_INVESTIGATE_IN_TIMELINE}
onClick={configureAndOpenTimeline}
flush="right"
size="xs"
>
{children}
</EuiButtonEmpty>
);
} else {
return null;
}
});
return asEmptyButton ? (
<EuiButtonEmpty
aria-label={ACTION_INVESTIGATE_IN_TIMELINE}
onClick={configureAndOpenTimeline}
flush="right"
size="xs"
>
{children}
</EuiButtonEmpty>
) : (
<EuiButton aria-label={ACTION_INVESTIGATE_IN_TIMELINE} onClick={configureAndOpenTimeline}>
{children}
</EuiButton>
);
};
InvestigateInTimelineButton.displayName = 'InvestigateInTimelineButton';

View file

@ -11,40 +11,61 @@ import { EuiLoadingSpinner } from '@elastic/eui';
import type { AlertSummaryRow } from '../helpers';
import { getEmptyTagValue } from '../../empty_value';
import { InvestigateInTimelineButton } from './investigate_in_timeline_button';
import { useActionCellDataProvider } from './use_action_cell_data_provider';
import { useAlertPrevalence } from '../../../containers/alerts/use_alert_prevalence';
const PrevalenceCell = React.memo<AlertSummaryRow['description']>(
({ data, eventId, fieldFromBrowserField, linkValue, timelineId, values }) => {
const { loading, count } = useAlertPrevalence({
field: data.field,
timelineId,
value: values,
signalIndexName: null,
});
/**
* Renders a Prevalence cell based on a regular alert prevalence query
*/
const PrevalenceCell: React.FC<AlertSummaryRow['description']> = ({
data,
eventId,
fieldFromBrowserField,
linkValue,
timelineId,
values,
}) => {
const { loading, count } = useAlertPrevalence({
field: data.field,
timelineId,
value: values,
signalIndexName: null,
});
if (loading) {
return <EuiLoadingSpinner />;
} else if (typeof count === 'number') {
return (
<InvestigateInTimelineButton
data={data}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
linkValue={linkValue}
timelineId={timelineId}
values={values}
>
<span data-test-subj="alert-prevalence">{count}</span>
</InvestigateInTimelineButton>
);
} else {
return getEmptyTagValue();
}
const cellDataProviders = useActionCellDataProvider({
contextId: timelineId,
eventId,
field: data.field,
fieldFormat: data.format,
fieldFromBrowserField,
fieldType: data.type,
isObjectArray: data.isObjectArray,
linkValue,
values,
});
if (loading) {
return <EuiLoadingSpinner />;
} else if (
typeof count === 'number' &&
cellDataProviders?.dataProviders &&
cellDataProviders?.dataProviders.length
) {
return (
<InvestigateInTimelineButton
asEmptyButton={true}
dataProviders={cellDataProviders.dataProviders}
>
<span data-test-subj="alert-prevalence">{count}</span>
</InvestigateInTimelineButton>
);
} else {
return getEmptyTagValue();
}
);
};
PrevalenceCell.displayName = 'PrevalenceCell';
export const PrevalenceCellRenderer = (data: AlertSummaryRow['description']) => {
return <PrevalenceCell {...data} />;
};
export const PrevalenceCellRenderer = (data: AlertSummaryRow['description']) => (
<PrevalenceCell {...data} />
);

View file

@ -43,6 +43,11 @@ export interface UseActionCellDataProvider {
values: string[] | null | undefined;
}
export interface ActionCellValuesAndDataProvider {
values: string[];
dataProviders: DataProvider[];
}
export const getDataProvider = (field: string, id: string, value: string): DataProvider => ({
and: [],
enabled: true,
@ -67,28 +72,22 @@ export const useActionCellDataProvider = ({
isObjectArray,
linkValue,
values,
}: UseActionCellDataProvider): {
stringValues: string[];
dataProvider: DataProvider[];
} | null => {
}: UseActionCellDataProvider): ActionCellValuesAndDataProvider | null => {
const cellData = useMemo(() => {
if (values === null || values === undefined) return null;
const arrayValues = Array.isArray(values) ? values : [values];
return arrayValues.reduce<{
stringValues: string[];
dataProvider: DataProvider[];
}>(
return arrayValues.reduce<ActionCellValuesAndDataProvider>(
(memo, value, index) => {
let id: string = '';
let valueAsString: string = isString(value) ? value : `${values}`;
const appendedUniqueId = `${contextId}-${eventId}-${field}-${index}-${value}`;
if (fieldFromBrowserField == null) {
memo.stringValues.push(valueAsString);
memo.values.push(valueAsString);
return memo;
}
if (isObjectArray || fieldType === GEO_FIELD_TYPE || [MESSAGE_FIELD_NAME].includes(field)) {
memo.stringValues.push(valueAsString);
memo.values.push(valueAsString);
return memo;
} else if (fieldType === IP_FIELD_TYPE) {
id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`;
@ -101,10 +100,10 @@ export const useActionCellDataProvider = ({
}
if (isArray(addresses)) {
valueAsString = addresses.join(',');
addresses.forEach((ip) => memo.dataProvider.push(getDataProvider(field, id, ip)));
addresses.forEach((ip) => memo.dataProviders.push(getDataProvider(field, id, ip)));
}
memo.dataProvider.push(getDataProvider(field, id, addresses));
memo.stringValues.push(valueAsString);
memo.dataProviders.push(getDataProvider(field, id, addresses));
memo.values.push(valueAsString);
return memo;
}
} else if (PORT_NAMES.some((portName) => field === portName)) {
@ -137,11 +136,11 @@ export const useActionCellDataProvider = ({
} else {
id = `event-details-value-default-draggable-${appendedUniqueId}`;
}
memo.stringValues.push(valueAsString);
memo.dataProvider.push(getDataProvider(field, id, value));
memo.values.push(valueAsString);
memo.dataProviders.push(getDataProvider(field, id, value));
return memo;
},
{ stringValues: [], dataProvider: [] }
{ values: [], dataProviders: [] }
);
}, [
contextId,

View file

@ -106,13 +106,6 @@ export const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alert
defaultMessage: 'Rule type',
});
export const SOURCE_EVENT_ID = i18n.translate(
'xpack.securitySolution.detections.alerts.sourceEventId',
{
defaultMessage: 'Source event id',
}
);
export const MULTI_FIELD_TOOLTIP = i18n.translate(
'xpack.securitySolution.eventDetails.multiFieldTooltipContent',
{
@ -138,7 +131,3 @@ export const REASON = i18n.translate('xpack.securitySolution.eventDetails.reason
export const VIEW_ALL_FIELDS = i18n.translate('xpack.securitySolution.eventDetails.viewAllFields', {
defaultMessage: 'View all fields in table',
});
export const SESSION_ID = i18n.translate('xpack.securitySolution.eventDetails.sessionId', {
defaultMessage: 'Session ID',
});

View file

@ -22,12 +22,14 @@ interface UseAlertPrevalenceOptions {
value: string | string[] | undefined | null;
timelineId: string;
signalIndexName: string | null;
includeAlertIds?: boolean;
}
interface UserAlertPrevalenceResult {
loading: boolean;
count: undefined | number;
error: boolean;
alertIds?: string[];
}
export const useAlertPrevalence = ({
@ -35,6 +37,7 @@ export const useAlertPrevalence = ({
value,
timelineId,
signalIndexName,
includeAlertIds = false,
}: UseAlertPrevalenceOptions): UserAlertPrevalenceResult => {
const timelineTime = useDeepEqualSelector((state) =>
inputsSelectors.timelineTimeRangeSelector(state)
@ -42,16 +45,18 @@ export const useAlertPrevalence = ({
const globalTime = useGlobalTime();
const { to, from } = timelineId === TimelineId.active ? timelineTime : globalTime;
const [initialQuery] = useState(() => generateAlertPrevalenceQuery(field, value, from, to));
const [initialQuery] = useState(() =>
generateAlertPrevalenceQuery(field, value, from, to, includeAlertIds)
);
const { loading, data, setQuery } = useQueryAlerts<{}, AlertPrevalenceAggregation>({
const { loading, data, setQuery } = useQueryAlerts<{ _id: string }, AlertPrevalenceAggregation>({
query: initialQuery,
indexName: signalIndexName,
});
useEffect(() => {
setQuery(generateAlertPrevalenceQuery(field, value, from, to));
}, [setQuery, field, value, from, to]);
setQuery(generateAlertPrevalenceQuery(field, value, from, to, includeAlertIds));
}, [setQuery, field, value, from, to, includeAlertIds]);
let count: undefined | number;
if (data) {
@ -68,11 +73,13 @@ export const useAlertPrevalence = ({
}
const error = !loading && count === undefined;
const alertIds = data?.hits.hits.map(({ _id }) => _id);
return {
loading,
count,
error,
alertIds,
};
};
@ -80,8 +87,14 @@ const generateAlertPrevalenceQuery = (
field: string,
value: string | string[] | undefined | null,
from: string,
to: string
to: string,
includeAlertIds: boolean
) => {
// if we don't want the alert ids included, we set size to 0 to reduce the response payload
const size = includeAlertIds ? { size: DEFAULT_MAX_TABLE_QUERY_SIZE } : { size: 0 };
// in that case, we also want to make sure we're sorting the results by timestamp
const sort = includeAlertIds ? { sort: { '@timestamp': 'desc' } } : {};
const actualValue = Array.isArray(value) && value.length === 1 ? value[0] : value;
let query;
query = {
@ -125,7 +138,9 @@ const generateAlertPrevalenceQuery = (
}
return {
size: 0,
...size,
...sort,
_source: false,
aggs: {
[ALERT_PREVALENCE_AGG]: {
terms: {

View file

@ -0,0 +1,110 @@
/*
* 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 { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { useHttp } from '../../lib/kibana';
// import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../common/constants';
// import { useGlobalTime } from '../use_global_time';
// import { TimelineId } from '../../../../common/types';
// import { useDeepEqualSelector } from '../../hooks/use_selector';
// import { inputsSelectors } from '../../store';
export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count';
interface UseAlertPrevalenceOptions {
parentEntityId: string | string[] | undefined | null;
timelineId: string;
signalIndexName: string | null;
}
interface UserAlertPrevalenceFromProcessTreeResult {
loading: boolean;
alertIds: undefined | string[];
count?: number;
error: boolean;
}
interface ProcessTreeAlertPrevalenceResponse {
alertIds: string[];
}
export function useAlertPrevalenceFromProcessTreeActual(
processEntityId: string
): UserAlertPrevalenceFromProcessTreeResult {
const http = useHttp();
const query = useQuery<ProcessTreeAlertPrevalenceResponse>(
['getAlertPrevalenceFromProcessTree', processEntityId],
() => {
return http.get<ProcessTreeAlertPrevalenceResponse>('/TBD', {
query: { processEntityId },
});
}
);
if (query.isLoading) {
return {
loading: true,
error: false,
alertIds: undefined,
};
} else if (query.data) {
return {
loading: false,
error: false,
alertIds: query.data.alertIds,
};
} else {
return {
loading: false,
error: true,
alertIds: undefined,
};
}
}
export const useAlertPrevalenceFromProcessTree = ({
parentEntityId,
timelineId,
signalIndexName,
}: UseAlertPrevalenceOptions): UserAlertPrevalenceFromProcessTreeResult => {
// const timelineTime = useDeepEqualSelector((state) =>
// inputsSelectors.timelineTimeRangeSelector(state)
// );
// const globalTime = useGlobalTime();
// const { to, from } = timelineId === TimelineId.active ? timelineTime : globalTime;
const [{ loading, alertIds }, setResult] = useState<{ loading: boolean; alertIds?: string[] }>({
loading: true,
alertIds: undefined,
});
useEffect(() => {
const t = setTimeout(() => {
setResult({
loading: false,
alertIds: [
'489ef2e50e7bb6366c5eaa1b17873e56fda738134685ca54b997a2546834f08c',
'4b8e7111166034f94f62a009fa22ad42bfbb8edc86cda03055d14a9f2dd21f48',
'0347030aa3593566a7fcd77769c798efaf02f84a3196fd586b4700c0c9ae5872',
],
});
}, Math.random() * 1500 + 500);
return () => {
clearTimeout(t);
};
}, []);
return {
loading,
alertIds,
count: alertIds ? alertIds.length : undefined,
error: false,
};
};

View file

@ -0,0 +1,99 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { useAlertsByIds } from './use_alerts_by_ids';
jest.mock('../../../detections/containers/detection_engine/alerts/use_query', () => ({
useQueryAlerts: jest.fn(),
}));
const mockUseQueryAlerts = useQueryAlerts as jest.Mock;
const alertIds = ['1', '2', '3'];
const testResult = {
hits: {
hits: [{ result: 1 }, { result: 2 }],
},
};
describe('useAlertsByIds', () => {
beforeEach(() => {
mockUseQueryAlerts.mockReset();
});
it('passes down the loading state', () => {
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: true, error: false });
});
it('calculates the error state', () => {
mockUseQueryAlerts.mockReturnValue({
loading: false,
data: undefined,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: false, error: true, data: undefined });
});
it('returns the results', () => {
mockUseQueryAlerts.mockReturnValue({
loading: false,
data: testResult,
setQuery: jest.fn(),
});
const { result } = renderHook(() => useAlertsByIds({ alertIds }));
expect(result.current).toEqual({ loading: false, error: false, data: testResult.hits.hits });
});
it('constructs the correct query', () => {
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
renderHook(() => useAlertsByIds({ alertIds }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
query: expect.objectContaining({
fields: ['*'],
_source: false,
query: {
ids: {
values: alertIds,
},
},
}),
});
});
it('requests the specified fields', () => {
const testFields = ['test.*'];
mockUseQueryAlerts.mockReturnValue({
loading: true,
setQuery: jest.fn(),
});
renderHook(() => useAlertsByIds({ alertIds, fields: testFields }));
expect(mockUseQueryAlerts).toHaveBeenCalledWith({
query: expect.objectContaining({ fields: testFields }),
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { useEffect, useState } from 'react';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
interface UseAlertByIdsOptions {
alertIds: string[];
fields?: string[];
}
interface Hit {
fields: Record<string, string[]>;
}
interface UserAlertByIdsResult {
loading: boolean;
error: boolean;
data?: Hit[];
}
/**
* Fetches the alert documents associated to the ids that are passed.
* By default it fetches all fields but they can be limited by passing
* the `fields` parameter.
*/
export const useAlertsByIds = ({
alertIds,
fields = ['*'],
}: UseAlertByIdsOptions): UserAlertByIdsResult => {
const [initialQuery] = useState(() => generateAlertByIdsQuery(alertIds, fields));
const { loading, data, setQuery } = useQueryAlerts<Hit, unknown>({
query: initialQuery,
});
useEffect(() => {
setQuery(generateAlertByIdsQuery(alertIds, fields));
}, [setQuery, alertIds, fields]);
const error = !loading && data === undefined;
return {
loading,
error,
data: data?.hits.hits,
};
};
const generateAlertByIdsQuery = (alertIds: string[], fields: string[]) => {
return {
fields,
_source: false,
query: {
ids: {
values: alertIds,
},
},
};
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { MappingRuntimeFields, Sort } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { IRuleDataClient } from '@kbn/rule-registry-plugin/server';
import type { SecuritySolutionPluginRouter } from '../../../../types';
@ -34,14 +34,17 @@ export const querySignalsRoute = (
},
async (context, request, response) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { query, aggs, _source, track_total_hits, size, runtime_mappings } = request.body;
const { query, aggs, _source, fields, track_total_hits, size, runtime_mappings, sort } =
request.body;
const siemResponse = buildSiemResponse(response);
if (
query == null &&
aggs == null &&
_source == null &&
fields == null &&
track_total_hits == null &&
size == null
size == null &&
sort == null
) {
return siemResponse.error({
statusCode: 400,
@ -57,9 +60,11 @@ export const querySignalsRoute = (
// Note: I use a spread operator to please TypeScript with aggs: { ...aggs }
aggs: { ...aggs },
_source,
fields,
track_total_hits,
size,
runtime_mappings: runtime_mappings as MappingRuntimeFields,
sort: sort as Sort,
},
ignore_unavailable: true,
});

View file

@ -24062,9 +24062,6 @@
"xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "Données de risque de lhôte",
"xpack.securitySolution.alertDetails.overview.hostsRiskScoreLink": "Score de risque de lhôte",
"xpack.securitySolution.alertDetails.overview.investigationGuide": "Guide d'investigation",
"xpack.securitySolution.alertDetails.overview.relatedCasesCount": "{caseCount} {caseCount, plural, =0 {cas.} =1 {cas :} other {cas :}}",
"xpack.securitySolution.alertDetails.overview.relatedCasesFailure": "Impossible de charger les cas connexes : \"{error}\".",
"xpack.securitySolution.alertDetails.overview.relatedCasesFound": "Alerte détectée dans",
"xpack.securitySolution.alertDetails.refresh": "Actualiser",
"xpack.securitySolution.alertDetails.summary.readLess": "Lire moins",
"xpack.securitySolution.alertDetails.summary.readMore": "En savoir plus",
@ -25663,7 +25660,6 @@
"xpack.securitySolution.detectionResponse.viewRecentCases": "Afficher les cas récents",
"xpack.securitySolution.detections.alerts.agentStatus": "Statut de l'agent",
"xpack.securitySolution.detections.alerts.ruleType": "Type de règle",
"xpack.securitySolution.detections.alerts.sourceEventId": "ID événement source",
"xpack.securitySolution.detections.hostIsolation.impactedCases": "Cette action sera ajoutée aux {cases}.",
"xpack.securitySolution.documentationLinks.ariaLabelEnding": "cliquez pour ouvrir la documentation dans un nouvel onglet",
"xpack.securitySolution.documentationLinks.detectionsRequirements.text": "Prérequis et exigences des détections",
@ -26249,7 +26245,6 @@
"xpack.securitySolution.eventDetails.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs dans un même champ",
"xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "Le champ {field} est un objet, et il est composé de champs imbriqués qui peuvent être ajoutés en tant que colonne",
"xpack.securitySolution.eventDetails.reason": "Raison",
"xpack.securitySolution.eventDetails.sessionId": "ID session",
"xpack.securitySolution.eventDetails.table": "Tableau",
"xpack.securitySolution.eventDetails.table.actions": "Actions",
"xpack.securitySolution.eventDetails.value": "Valeur",

View file

@ -24142,9 +24142,6 @@
"xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "ホストリスクデータ",
"xpack.securitySolution.alertDetails.overview.hostsRiskScoreLink": "ホストリスクスコア",
"xpack.securitySolution.alertDetails.overview.investigationGuide": "調査ガイド",
"xpack.securitySolution.alertDetails.overview.relatedCasesCount": "{caseCount} {caseCount, plural, other {個のケース:}}",
"xpack.securitySolution.alertDetails.overview.relatedCasesFailure": "関連するケースを読み込めません:\"{error}\"",
"xpack.securitySolution.alertDetails.overview.relatedCasesFound": "このアラートは次の場所で見つかりました",
"xpack.securitySolution.alertDetails.refresh": "更新",
"xpack.securitySolution.alertDetails.summary.readLess": "表示を減らす",
"xpack.securitySolution.alertDetails.summary.readMore": "続きを読む",
@ -25744,7 +25741,6 @@
"xpack.securitySolution.detectionResponse.viewRecentCases": "最近のケースを表示",
"xpack.securitySolution.detections.alerts.agentStatus": "エージェントステータス",
"xpack.securitySolution.detections.alerts.ruleType": "ルールタイプ",
"xpack.securitySolution.detections.alerts.sourceEventId": "ソースイベントID",
"xpack.securitySolution.detections.hostIsolation.impactedCases": "このアクションは{cases}に追加されます。",
"xpack.securitySolution.documentationLinks.ariaLabelEnding": "クリックすると、新しいタブでドキュメントを開きます",
"xpack.securitySolution.documentationLinks.detectionsRequirements.text": "検出の前提条件と要件",
@ -26329,7 +26325,6 @@
"xpack.securitySolution.eventDetails.multiFieldTooltipContent": "複数フィールドにはフィールドごとに複数の値を入力できます",
"xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "{field}フィールドはオブジェクトであり、列として追加できるネストされたフィールドに分解されます",
"xpack.securitySolution.eventDetails.reason": "理由",
"xpack.securitySolution.eventDetails.sessionId": "セッションID",
"xpack.securitySolution.eventDetails.table": "表",
"xpack.securitySolution.eventDetails.table.actions": "アクション",
"xpack.securitySolution.eventDetails.value": "値",

View file

@ -24168,9 +24168,6 @@
"xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "主机风险数据",
"xpack.securitySolution.alertDetails.overview.hostsRiskScoreLink": "主机风险分数",
"xpack.securitySolution.alertDetails.overview.investigationGuide": "调查指南",
"xpack.securitySolution.alertDetails.overview.relatedCasesCount": "{caseCount} 个{caseCount, plural, other {案例:}}",
"xpack.securitySolution.alertDetails.overview.relatedCasesFailure": "无法加载相关案例:“{error}”",
"xpack.securitySolution.alertDetails.overview.relatedCasesFound": "发现此告警位于",
"xpack.securitySolution.alertDetails.refresh": "刷新",
"xpack.securitySolution.alertDetails.summary.readLess": "阅读更少内容",
"xpack.securitySolution.alertDetails.summary.readMore": "阅读更多内容",
@ -25770,7 +25767,6 @@
"xpack.securitySolution.detectionResponse.viewRecentCases": "查看最近案例",
"xpack.securitySolution.detections.alerts.agentStatus": "代理状态",
"xpack.securitySolution.detections.alerts.ruleType": "规则类型",
"xpack.securitySolution.detections.alerts.sourceEventId": "源事件 ID",
"xpack.securitySolution.detections.hostIsolation.impactedCases": "此操作将添加到 {cases}。",
"xpack.securitySolution.documentationLinks.ariaLabelEnding": "单击以在新选项卡中打开文档",
"xpack.securitySolution.documentationLinks.detectionsRequirements.text": "检测先决条件和要求",
@ -26356,7 +26352,6 @@
"xpack.securitySolution.eventDetails.multiFieldTooltipContent": "多字段的每个字段可以有多个值",
"xpack.securitySolution.eventDetails.nestedColumnCheckboxAriaLabel": "{field} 字段是对象,并分解为可以添加为列的嵌套字段",
"xpack.securitySolution.eventDetails.reason": "原因",
"xpack.securitySolution.eventDetails.sessionId": "会话 ID",
"xpack.securitySolution.eventDetails.table": "表",
"xpack.securitySolution.eventDetails.table.actions": "操作",
"xpack.securitySolution.eventDetails.value": "值",

View file

@ -83,7 +83,12 @@
"args" : [
"-zsh"
],
"entity_id" : "q6pltOhTWlQx3BCD"
"entity_id" : "q6pltOhTWlQx3BCD",
"entry_leader": {
"entity_id": "q6pltOhTWlQx3BCD",
"name": "fake entry",
"pid": 2342342
}
},
"message" : "Process zsh (PID: 27884) by user test STARTED",
"user" : {