mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
a59ba34397
commit
7a36cf3ab5
41 changed files with 2193 additions and 333 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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']`;
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 '';
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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.',
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 }),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -24062,9 +24062,6 @@
|
|||
"xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "Données de risque de l’hôte",
|
||||
"xpack.securitySolution.alertDetails.overview.hostsRiskScoreLink": "Score de risque de l’hô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",
|
||||
|
|
|
@ -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": "値",
|
||||
|
|
|
@ -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": "值",
|
||||
|
|
|
@ -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" : {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue