[SECURITY SOLUTION] Investigate EQL signal in timeline (#79049)

* fix template timeline for rule

* fix moving column with linkfield by giving back the browserfield

* leftover from investigate timeline with template from rule

* add visualization for eql sequences in timeline + allow eql investigate to timeline through signal.group.id

* bug fix of column in eventviewer

* review I

* review II

* fix bug - Columns dynamically added to timeline indicate no data

* fix pagination to work as attempted by elastic search

* no tweak on pagination timeline

* fix snapshot

* reset activePage to 0 when changing indexNames

* remove last page when we are not sure if it is really the last page

* update activePage when resetting it by searchParameter

* review bug on the last commit

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Xavier Mouligneau 2020-10-05 21:56:48 -04:00 committed by GitHub
parent e451a4dc16
commit cf45fef4c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 334 additions and 253 deletions

View file

@ -41,4 +41,5 @@ export interface RuleEcs {
updated_by?: string[];
version?: string[];
note?: string[];
building_block_type?: string[];
}

View file

@ -10,4 +10,7 @@ export interface SignalEcs {
rule?: RuleEcs;
original_time?: string[];
status?: string[];
group?: {
id?: string[];
};
}

View file

@ -6,7 +6,7 @@
import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common';
import { Ecs } from '../../../../ecs';
import { CursorType, Inspect, Maybe, PageInfoPaginated } from '../../../common';
import { CursorType, Inspect, Maybe, PaginationInputPaginated } from '../../../common';
import { TimelineRequestOptionsPaginated } from '../..';
export interface TimelineEdges {
@ -29,7 +29,7 @@ export interface TimelineNonEcsData {
export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse {
edges: TimelineEdges[];
totalCount: number;
pageInfo: PageInfoPaginated;
pageInfo: Pick<PaginationInputPaginated, 'activePage' | 'querySize'>;
inspect?: Maybe<Inspect>;
}

View file

@ -14,13 +14,7 @@ import {
TimelineEventsLastEventTimeRequestOptions,
TimelineEventsLastEventTimeStrategyResponse,
} from './events';
import {
DocValueFields,
PaginationInput,
PaginationInputPaginated,
TimerangeInput,
SortField,
} from '../common';
import { DocValueFields, PaginationInputPaginated, TimerangeInput, SortField } from '../common';
export * from './events';
@ -34,14 +28,9 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest {
factoryQueryType?: TimelineFactoryQueryTypes;
}
export interface TimelineRequestOptions<Field = string> extends TimelineRequestBasicOptions {
pagination: PaginationInput;
sort: SortField<Field>;
}
export interface TimelineRequestOptionsPaginated<Field = string>
extends TimelineRequestBasicOptions {
pagination: PaginationInputPaginated;
pagination: Pick<PaginationInputPaginated, 'activePage' | 'querySize'>;
sort: SortField<Field>;
}

View file

@ -58,7 +58,11 @@ const HomePageComponent: React.FC<HomePageProps> = ({ children }) => {
);
const [showTimeline] = useShowTimeline();
const { browserFields, indexPattern, indicesExist } = useSourcererScope();
const { browserFields, indexPattern, indicesExist } = useSourcererScope(
subPluginId.current === DETECTIONS_SUB_PLUGIN_ID
? SourcererScopeName.detections
: SourcererScopeName.default
);
// side effect: this will attempt to upgrade the endpoint package if it is not up to date
// this will run when a user navigates to the Security Solution app and when they navigate between
// tabs in the app. This is useful for keeping the endpoint package as up to date as possible until

View file

@ -126,8 +126,7 @@ export const DragDropContextWrapperComponent = React.memo<Props & PropsFromRedux
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dataProviders, activeTimelineDataProviders, browserFields]
[activeTimelineDataProviders, browserFields, dataProviders, dispatch, onAddedToTimeline]
);
return (
<DragDropContext onDragEnd={onDragEnd} onBeforeCapture={onBeforeCapture} sensors={sensors}>

View file

@ -9,6 +9,7 @@ import { DropResult } from 'react-beautiful-dnd';
import { Dispatch } from 'redux';
import { ActionCreator } from 'typescript-fsa';
import { alertsHeaders } from '../../../detections/components/alerts_table/default_config';
import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source';
import { dragAndDropActions } from '../../store/actions';
import { IdToDataProvider } from '../../store/drag_and_drop/model';
@ -17,6 +18,7 @@ import { timelineActions } from '../../../timelines/store/timeline';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
import { addContentToTimeline } from '../../../timelines/components/timeline/data_providers/helpers';
import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
import { TimelineId } from '../../../../common/types/timeline';
export const draggableIdPrefix = 'draggableId';
@ -197,6 +199,10 @@ export const addFieldToTimelineColumns = ({
const fieldId = getFieldIdFromDraggable(result);
const allColumns = getAllFieldsByName(browserFields);
const column = allColumns[fieldId];
const initColumnHeader =
timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage
? alertsHeaders.find((c) => c.id === fieldId) ?? {}
: {};
if (column != null) {
dispatch(
@ -211,6 +217,7 @@ export const addFieldToTimelineColumns = ({
type: column.type,
aggregatable: column.aggregatable,
width: DEFAULT_COLUMN_MIN_WIDTH,
...initColumnHeader,
},
id: timelineId,
index: result.destination != null ? result.destination.index : 0,

View file

@ -66,8 +66,6 @@ const ProviderContainerComponent = styled.div<ProviderContainerProps>`
.${STATEFUL_EVENT_CSS_CLASS_NAME}:hover &,
tr:hover & {
background-color: ${({ theme }) => theme.eui.euiColorLightShade};
&::before {
background-image: linear-gradient(
135deg,

View file

@ -5,7 +5,7 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { isEmpty, union } from 'lodash/fp';
import { isEmpty } from 'lodash/fp';
import React, { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
@ -190,14 +190,10 @@ const EventsViewerComponent: React.FC<Props> = ({
[isLoadingIndexPattern, combinedQueries, start, end]
);
const fields = useMemo(
() =>
union(
columnsHeader.map((c) => c.id),
queryFields ?? []
),
[columnsHeader, queryFields]
);
const fields = useMemo(() => [...columnsHeader.map((c) => c.id), ...(queryFields ?? [])], [
columnsHeader,
queryFields,
]);
const sortField = useMemo(
() => ({
@ -311,9 +307,7 @@ const EventsViewerComponent: React.FC<Props> = ({
itemsPerPageOptions={itemsPerPageOptions}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadPage}
serverSideEventCount={totalCountMinusDeleted}
showMorePagesIndicator={pageInfo.showMorePagesIndicator}
totalCount={pageInfo.fakeTotalCount}
totalCount={totalCountMinusDeleted}
/>
)
}

View file

@ -150,6 +150,12 @@ export const getThresholdAggregationDataProvider = (
];
};
export const isEqlRule = (ecsData: Ecs) =>
ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'eql';
export const isThresholdRule = (ecsData: Ecs) =>
ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'threshold';
export const sendAlertToTimelineAction = async ({
apolloClient,
createTimeline,
@ -158,13 +164,12 @@ export const sendAlertToTimelineAction = async ({
updateTimelineIsLoading,
searchStrategyClient,
}: SendAlertToTimelineActionProps) => {
let openAlertInBasicTimeline = true;
const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : '';
const timelineId =
ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : '';
const { to, from } = determineToAndFrom({ ecsData });
if (timelineId !== '' && apolloClient != null) {
if (!isEmpty(timelineId) && apolloClient != null) {
try {
updateTimelineIsLoading({ id: TimelineId.active, isLoading: true });
const [responseTimeline, eventDataResp] = await Promise.all([
@ -173,6 +178,7 @@ export const sendAlertToTimelineAction = async ({
fetchPolicy: 'no-cache',
variables: {
id: timelineId,
timelineType: TimelineType.template,
},
}),
searchStrategyClient.search<
@ -195,7 +201,6 @@ export const sendAlertToTimelineAction = async ({
const eventData: TimelineEventsDetailsItem[] = getOr([], 'data', eventDataResp);
if (!isEmpty(resultingTimeline)) {
const timelineTemplate: TimelineResult = omitTypenameInTimeline(resultingTimeline);
openAlertInBasicTimeline = false;
const { timeline, notes } = formatTimelineResultToModel(
timelineTemplate,
true,
@ -250,16 +255,11 @@ export const sendAlertToTimelineAction = async ({
});
}
} catch {
openAlertInBasicTimeline = true;
updateTimelineIsLoading({ id: TimelineId.active, isLoading: false });
}
}
if (
ecsData.signal?.rule?.type?.length &&
ecsData.signal?.rule?.type[0] === 'threshold' &&
openAlertInBasicTimeline
) {
if (isThresholdRule(ecsData)) {
return createTimeline({
from,
notes: null,
@ -312,26 +312,44 @@ export const sendAlertToTimelineAction = async ({
ruleNote: noteContent,
});
} else {
let dataProviders = [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`,
name: ecsData._id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: ecsData._id,
operator: ':' as const,
},
},
];
if (isEqlRule(ecsData)) {
const signalGroupId = ecsData.signal?.group?.id?.length
? ecsData.signal?.group?.id[0]
: 'unknown-signal-group-id';
dataProviders = [
{
...dataProviders[0],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`,
queryMatch: {
field: 'signal.group.id',
value: signalGroupId,
operator: ':' as const,
},
},
];
}
return createTimeline({
from,
notes: null,
timeline: {
...timelineDefaults,
dataProviders: [
{
and: [],
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`,
name: ecsData._id,
enabled: true,
excluded: false,
kqlQuery: '',
queryMatch: {
field: '_id',
value: ecsData._id,
operator: ':',
},
},
],
dataProviders,
id: TimelineId.active,
indexNames: [],
dateRange: {

View file

@ -47,6 +47,17 @@ const UtilityBarFlexGroup = styled(EuiFlexGroup)`
min-width: 175px;
`;
const BuildingBlockContainer = styled(EuiFlexItem)`
background: repeating-linear-gradient(
127deg,
rgba(245, 167, 0, 0.2),
rgba(245, 167, 0, 0.2) 1px,
rgba(245, 167, 0, 0.05) 2px,
rgba(245, 167, 0, 0.05) 10px
);
padding: ${({ theme }) => `${theme.eui.paddingSizes.xs}`};
`;
const AlertsUtilityBarComponent: React.FC<AlertsUtilityBarProps> = ({
canUserCRUD,
hasIndexWrite,
@ -133,7 +144,7 @@ const AlertsUtilityBarComponent: React.FC<AlertsUtilityBarProps> = ({
const UtilityBarAdditionalFiltersContent = (closePopover: () => void) => (
<UtilityBarFlexGroup direction="column">
<EuiFlexItem>
<BuildingBlockContainer>
<EuiCheckbox
id="showBuildingBlockAlertsCheckbox"
aria-label="showBuildingBlockAlerts"
@ -146,7 +157,7 @@ const AlertsUtilityBarComponent: React.FC<AlertsUtilityBarProps> = ({
data-test-subj="showBuildingBlockAlertsCheckbox"
label={i18n.ADDITIONAL_FILTERS_ACTIONS_SHOW_BUILDING_BLOCK}
/>
</EuiFlexItem>
</BuildingBlockContainer>
</UtilityBarFlexGroup>
);

View file

@ -165,7 +165,9 @@ export const alertsDefaultModel: SubsetTimelineModel = {
export const requiredFieldsForActions = [
'@timestamp',
'signal.status',
'signal.group.id',
'signal.original_time',
'signal.rule.building_block_type',
'signal.rule.filters',
'signal.rule.from',
'signal.rule.language',
@ -177,7 +179,6 @@ export const requiredFieldsForActions = [
'signal.rule.type',
'signal.original_event.kind',
'signal.original_event.module',
// Endpoint exception fields
'file.path',
'file.Ext.code_signature.subject_name',

View file

@ -212,6 +212,12 @@
"ofType": { "kind": "SCALAR", "name": "ID", "ofType": null }
},
"defaultValue": null
},
{
"name": "timelineType",
"description": "",
"type": { "kind": "ENUM", "name": "TimelineType", "ofType": null },
"defaultValue": null
}
],
"type": {
@ -1914,6 +1920,29 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TimelineType",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "default",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "template",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimelineResult",
@ -2953,29 +2982,6 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TimelineType",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "default",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "template",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PageInfoTimeline",

View file

@ -274,6 +274,11 @@ export enum HostPolicyResponseActionStatus {
warning = 'warning',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum DataProviderType {
default = 'default',
template = 'template',
@ -301,11 +306,6 @@ export enum TimelineStatus {
immutable = 'immutable',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum SortFieldTimeline {
title = 'title',
description = 'description',
@ -1599,6 +1599,8 @@ export interface SourceQueryArgs {
}
export interface GetOneTimelineQueryArgs {
id: string;
timelineType?: Maybe<TimelineType>;
}
export interface GetAllTimelineQueryArgs {
pageInfo: PageInfoTimeline;
@ -2166,6 +2168,7 @@ export namespace PersistTimelineNoteMutation {
export namespace GetOneTimeline {
export type Variables = {
id: string;
timelineType?: Maybe<TimelineType>;
};
export type Query = {

View file

@ -110,5 +110,6 @@ export const getInspectResponse = <T extends FactoryQueryTypes>(
prevResponse: InspectResponse
): InspectResponse => ({
dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [],
response: response != null ? [JSON.stringify(response, null, 2)] : prevResponse?.response,
response:
response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response,
});

View file

@ -35,6 +35,7 @@ import { OnUpdateColumns } from '../timeline/events';
import { TruncatableText } from '../../../common/components/truncatable_text';
import { FieldName } from './field_name';
import * as i18n from './translations';
import { getAlertColumnHeader } from './helpers';
const TypeIcon = styled(EuiIcon)`
margin-left: 5px;
@ -127,6 +128,7 @@ export const getFieldItems = ({
columnHeaderType: defaultColumnHeaderType,
id: field.name || '',
width: DEFAULT_COLUMN_MIN_WIDTH,
...getAlertColumnHeader(timelineId, field.name || ''),
})
}
/>

View file

@ -7,8 +7,10 @@
import { EuiLoadingSpinner } from '@elastic/eui';
import { filter, get, pickBy } from 'lodash/fp';
import styled from 'styled-components';
import { TimelineId } from '../../../../common/types/timeline';
import { BrowserField, BrowserFields } from '../../../common/containers/source';
import { alertsHeaders } from '../../../detections/components/alerts_table/default_config';
import {
DEFAULT_CATEGORY_NAME,
defaultHeaders,
@ -141,3 +143,8 @@ export const mergeBrowserFieldsWithDefaultCategory = (
fieldIds: defaultHeaders.map((header) => header.id),
}),
});
export const getAlertColumnHeader = (timelineId: string, fieldId: string) =>
timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage
? alertsHeaders.find((c) => c.id === fieldId) ?? {}
: {};

View file

@ -25,21 +25,21 @@ NoteContainer.displayName = 'NoteContainer';
interface NoteCardsCompProps {
children: React.ReactNode;
}
const NoteCardsCompContainer = styled(EuiPanel)`
border: none;
background-color: transparent;
box-shadow: none;
`;
NoteCardsCompContainer.displayName = 'NoteCardsCompContainer';
const NoteCardsComp = React.memo<NoteCardsCompProps>(({ children }) => (
<EuiPanel
data-test-subj="note-cards"
hasShadow={false}
paddingSize="none"
style={{ border: 'none' }}
>
<NoteCardsCompContainer data-test-subj="note-cards" hasShadow={false} paddingSize="none">
{children}
</EuiPanel>
</NoteCardsCompContainer>
));
NoteCardsComp.displayName = 'NoteCardsComp';
const NotesContainer = styled(EuiFlexGroup)`
padding: 0 5px;
margin-bottom: 5px;
`;
NotesContainer.displayName = 'NotesContainer';

View file

@ -95,9 +95,9 @@ export const EventColumnView = React.memo<Props>(
toggleShowNotes,
updateNote,
}) => {
const { eventType: timelineEventType, timelineType, status } = useShallowEqualSelector<
TimelineModel
>((state) => state.timeline.timelineById[timelineId]);
const { timelineType, status } = useShallowEqualSelector<TimelineModel>(
(state) => state.timeline.timelineById[timelineId]
);
const handlePinClicked = useCallback(
() =>
@ -151,17 +151,13 @@ export const EventColumnView = React.memo<Props>(
/>,
]
: []),
...(timelineEventType !== 'raw'
? [
<AlertContextMenu
key="alert-context-menu"
ecsRowData={ecsData}
timelineId={timelineId}
disabled={eventType !== 'signal'}
refetch={refetch}
/>,
]
: []),
<AlertContextMenu
key="alert-context-menu"
ecsRowData={ecsData}
timelineId={timelineId}
disabled={eventType !== 'signal'}
refetch={refetch}
/>,
],
[
associateNote,
@ -178,7 +174,6 @@ export const EventColumnView = React.memo<Props>(
showNotes,
status,
timelineId,
timelineEventType,
timelineType,
toggleShowNotes,
updateNote,

View file

@ -33,7 +33,7 @@ import { EventsTrGroup, EventsTrSupplement, EventsTrSupplementContainer } from '
import { ColumnRenderer } from '../renderers/column_renderer';
import { getRowRenderer } from '../renderers/get_row_renderer';
import { RowRenderer } from '../renderers/row_renderer';
import { getEventType } from '../helpers';
import { isEventBuildingBlockType, getEventType } from '../helpers';
import { NoteCards } from '../../../notes/note_cards';
import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context';
import { EventColumnView } from './event_column_view';
@ -183,6 +183,7 @@ const StatefulEventComponent: React.FC<Props> = ({
className={STATEFUL_EVENT_CSS_CLASS_NAME}
data-test-subj="event"
eventType={getEventType(event.ecs)}
isBuildingBlockType={isEventBuildingBlockType(event.ecs)}
showLeftBorder={!isEventViewer}
ref={divElement}
>

View file

@ -103,6 +103,9 @@ export const getEventIdToDataMapping = (
};
}, {});
export const isEventBuildingBlockType = (event: Ecs): boolean =>
!isEmpty(event.signal?.rule?.building_block_type);
/** Return eventType raw or signal */
export const getEventType = (event: Ecs): Omit<TimelineEventsType, 'all'> => {
if (!isEmpty(event.signal?.rule?.id)) {

View file

@ -72,15 +72,14 @@ exports[`Footer Timeline Component rendering it renders the default timeline foo
data-test-subj="paging-control-container"
grow={false}
>
<PaginationEuiFlexItem>
<PagingControl
activePage={0}
data-test-subj="paging-control"
isLoading={false}
onPageClick={[Function]}
totalPages={5}
/>
</PaginationEuiFlexItem>
<PagingControl
activePage={0}
data-test-subj="paging-control"
isLoading={false}
onPageClick={[Function]}
totalCount={15546}
totalPages={7773}
/>
</EuiFlexItem>
<EuiFlexItem
data-test-subj="last-updated-container"

View file

@ -17,7 +17,6 @@ describe('Footer Timeline Component', () => {
const updatedAt = 1546878704036;
const serverSideEventCount = 15546;
const itemsCount = 2;
const totalCount = 10;
describe('rendering', () => {
test('it renders the default timeline footer', () => {
@ -34,9 +33,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
);
@ -57,9 +54,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
);
@ -81,9 +76,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);
@ -95,6 +88,7 @@ describe('Footer Timeline Component', () => {
const wrapper = shallow(
<PagingControlComponent
activePage={0}
totalCount={30}
totalPages={3}
onPageClick={loadMore}
isLoading={true}
@ -110,6 +104,7 @@ describe('Footer Timeline Component', () => {
const wrapper = shallow(
<PagingControlComponent
activePage={0}
totalCount={30}
totalPages={3}
onPageClick={loadMore}
isLoading={false}
@ -133,9 +128,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
);
@ -157,9 +150,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);
@ -185,9 +176,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);
@ -211,9 +200,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);
@ -239,9 +226,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);
@ -265,9 +250,7 @@ describe('Footer Timeline Component', () => {
itemsPerPageOptions={[1, 5, 10, 20]}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadMore}
serverSideEventCount={serverSideEventCount}
totalCount={totalCount}
showMorePagesIndicator
totalCount={serverSideEventCount}
/>
</TestProviders>
);

View file

@ -28,7 +28,6 @@ import { OnChangeItemsPerPage, OnChangePage } from '../events';
import { LastUpdatedAt } from './last_updated';
import * as i18n from './translations';
import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context';
import { PaginationEuiFlexItem } from '../../../../common/components/paginated_table';
import { useManageTimeline } from '../../manage_timeline';
export const isCompactFooter = (width: number): boolean => width < 600;
@ -179,13 +178,23 @@ interface PagingControlProps {
activePage: number;
isLoading: boolean;
onPageClick: OnChangePage;
totalCount: number;
totalPages: number;
}
const TimelinePaginationContainer = styled.div<{ hideLastPage: boolean }>`
ul.euiPagination__list {
li.euiPagination__item:last-child {
${({ hideLastPage }) => `${hideLastPage ? 'display:none' : ''}`};
}
}
`;
export const PagingControlComponent: React.FC<PagingControlProps> = ({
activePage,
isLoading,
onPageClick,
totalCount,
totalPages,
}) => {
if (isLoading) {
@ -197,12 +206,14 @@ export const PagingControlComponent: React.FC<PagingControlProps> = ({
}
return (
<EuiPagination
data-test-subj="timeline-pagination"
pageCount={totalPages}
activePage={activePage}
onPageClick={onPageClick}
/>
<TimelinePaginationContainer hideLastPage={totalCount > 9999}>
<EuiPagination
data-test-subj="timeline-pagination"
pageCount={totalPages}
activePage={activePage}
onPageClick={onPageClick}
/>
</TimelinePaginationContainer>
);
};
@ -223,8 +234,6 @@ interface FooterProps {
itemsPerPageOptions: number[];
onChangeItemsPerPage: OnChangeItemsPerPage;
onChangePage: OnChangePage;
serverSideEventCount: number;
showMorePagesIndicator: boolean;
totalCount: number;
}
@ -241,8 +250,6 @@ export const FooterComponent = ({
itemsPerPageOptions,
onChangeItemsPerPage,
onChangePage,
serverSideEventCount,
showMorePagesIndicator,
totalCount,
}: FooterProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@ -292,11 +299,6 @@ export const FooterComponent = ({
totalCount,
]);
const PaginationWrapper = useMemo(
() => (showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem),
[showMorePagesIndicator]
);
useEffect(() => {
if (paginationLoading && !isLoading) {
setPaginationLoading(false);
@ -347,7 +349,7 @@ export const FooterComponent = ({
items={rowItems}
itemsCount={itemsCount}
onClick={onButtonClick}
serverSideEventCount={serverSideEventCount}
serverSideEventCount={totalCount}
/>
</EuiFlexGroup>
</EuiFlexItem>
@ -373,15 +375,14 @@ export const FooterComponent = ({
</b>
</EuiText>
) : (
<PaginationWrapper>
<PagingControl
data-test-subj="paging-control"
totalPages={totalPages}
activePage={activePage}
onPageClick={handleChangePageClick}
isLoading={isLoading}
/>
</PaginationWrapper>
<PagingControl
data-test-subj="paging-control"
totalCount={totalCount}
totalPages={totalPages}
activePage={activePage}
onPageClick={handleChangePageClick}
isLoading={isLoading}
/>
)}
</EuiFlexItem>

View file

@ -106,14 +106,16 @@ const SearchTimelineSuperSelectComponent: React.FC<SearchTimelineSuperSelectProp
description: t.description,
favorite: t.favorite,
label: t.title,
id: t.savedObjectId,
id: timelineType === TimelineType.template ? t.templateTimelineId : t.savedObjectId,
key: `${t.title}-${index}`,
title: t.title,
checked: t.savedObjectId === timelineId ? 'on' : undefined,
checked: [t.savedObjectId, t.templateTimelineId].includes(timelineId)
? 'on'
: undefined,
} as EuiSelectableOption)
),
],
[hideUntitled, timelineId]
[hideUntitled, timelineId, timelineType]
);
return (

View file

@ -173,14 +173,23 @@ export const EventsTbody = styled.div.attrs(({ className = '' }) => ({
export const EventsTrGroup = styled.div.attrs(({ className = '' }) => ({
className: `siemEventsTable__trGroup ${className}`,
}))<{ className?: string; eventType: Omit<TimelineEventsType, 'all'>; showLeftBorder: boolean }>`
}))<{
className?: string;
eventType: Omit<TimelineEventsType, 'all'>;
isBuildingBlockType: boolean;
showLeftBorder: boolean;
}>`
border-bottom: ${({ theme }) => theme.eui.euiBorderWidthThin} solid
${({ theme }) => theme.eui.euiColorLightShade};
${({ theme, eventType, showLeftBorder }) =>
${({ theme, eventType, isBuildingBlockType, showLeftBorder }) =>
showLeftBorder
? `border-left: 4px solid
${eventType === 'raw' ? theme.eui.euiColorLightShade : theme.eui.euiColorWarning}`
: ''};
${({ isBuildingBlockType, showLeftBorder }) =>
isBuildingBlockType
? `background: repeating-linear-gradient(127deg, rgba(245, 167, 0, 0.2), rgba(245, 167, 0, 0.2) 1px, rgba(245, 167, 0, 0.05) 2px, rgba(245, 167, 0, 0.05) 10px);`
: ''};
&:hover {
background-color: ${({ theme }) => theme.eui.euiTableHoverColor};
@ -207,7 +216,13 @@ export const EventsTrSupplement = styled.div.attrs(({ className = '' }) => ({
}))<{ className: string }>`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
line-height: ${({ theme }) => theme.eui.euiLineHeight};
padding: 0 ${({ theme }) => theme.eui.paddingSizes.xs} 0 52px;
padding: 0 ${({ theme }) => theme.eui.paddingSizes.m};
.euiAccordion + div {
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
padding: 0 ${({ theme }) => theme.eui.paddingSizes.s};
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
border-radius: ${({ theme }) => theme.eui.paddingSizes.xs};
}
`;
export const EventsTdGroupActions = styled.div.attrs(({ className = '' }) => ({

View file

@ -301,9 +301,7 @@ export const TimelineComponent: React.FC<Props> = ({
itemsPerPageOptions={itemsPerPageOptions}
onChangeItemsPerPage={onChangeItemsPerPage}
onChangePage={loadPage}
serverSideEventCount={totalCount}
showMorePagesIndicator={pageInfo.showMorePagesIndicator}
totalCount={pageInfo.fakeTotalCount}
totalCount={totalCount}
/>
</StyledEuiFlyoutFooter>
)

View file

@ -93,6 +93,7 @@ export const getAllTimeline = memoizeOne(
updated: timeline.updated,
updatedBy: timeline.updatedBy,
timelineType: timeline.timelineType ?? TimelineType.default,
templateTimelineId: timeline.templateTimelineId,
}))
);

View file

@ -15,13 +15,12 @@ import { inputsModel } from '../../common/store';
import { useKibana } from '../../common/lib/kibana';
import { createFilter } from '../../common/containers/helpers';
import { DocValueFields } from '../../common/containers/query_template';
import { generateTablePaginationOptions } from '../../common/components/paginated_table/helpers';
import { timelineActions } from '../../timelines/store/timeline';
import { detectionsTimelineIds, skipQueryForDetectionsPage } from './helpers';
import { getInspectResponse } from '../../helpers';
import {
Direction,
PageInfoPaginated,
PaginationInputPaginated,
TimelineEventsQueries,
TimelineEventsAllStrategyResponse,
TimelineEventsAllRequestOptions,
@ -37,7 +36,7 @@ export interface TimelineArgs {
id: string;
inspect: InspectResponse;
loadPage: LoadPage;
pageInfo: PageInfoPaginated;
pageInfo: Pick<PaginationInputPaginated, 'activePage' | 'querySize'>;
refetch: inputsModel.Refetch;
totalCount: number;
updatedAt: number;
@ -62,6 +61,10 @@ const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] =>
timelineEdges.map((e: TimelineEdges) => e.node);
const ID = 'timelineEventsQuery';
const initSortDefault = {
field: '@timestamp',
direction: Direction.asc,
};
export const useTimelineEvents = ({
docValueFields,
@ -72,10 +75,7 @@ export const useTimelineEvents = ({
filterQuery,
startDate,
limit,
sort = {
field: '@timestamp',
direction: Direction.asc,
},
sort = initSortDefault,
skip = false,
}: UseTimelineEventsProps): [boolean, TimelineArgs] => {
const dispatch = useDispatch();
@ -87,7 +87,7 @@ export const useTimelineEvents = ({
const [timelineRequest, setTimelineRequest] = useState<TimelineEventsAllRequestOptions | null>(
!skip
? {
fields,
fields: [],
fieldRequested: fields,
filterQuery: createFilter(filterQuery),
id: ID,
@ -96,7 +96,10 @@ export const useTimelineEvents = ({
from: startDate,
to: endDate,
},
pagination: generateTablePaginationOptions(activePage, limit),
pagination: {
activePage,
querySize: limit,
},
sort,
defaultIndex: indexNames,
docValueFields: docValueFields ?? [],
@ -130,8 +133,7 @@ export const useTimelineEvents = ({
totalCount: -1,
pageInfo: {
activePage: 0,
fakeTotalCount: 0,
showMorePagesIndicator: false,
querySize: 0,
},
events: [],
loadPage: wrappedLoadPage,
@ -205,31 +207,60 @@ export const useTimelineEvents = ({
}
setTimelineRequest((prevRequest) => {
const myRequest = {
...(prevRequest ?? {
fields,
fieldRequested: fields,
id,
factoryQueryType: TimelineEventsQueries.all,
}),
const prevSearchParameters = {
defaultIndex: prevRequest?.defaultIndex ?? [],
filterQuery: prevRequest?.filterQuery ?? '',
querySize: prevRequest?.pagination.querySize ?? 0,
sort: prevRequest?.sort ?? initSortDefault,
timerange: prevRequest?.timerange ?? {},
};
const currentSearchParameters = {
defaultIndex: indexNames,
docValueFields: docValueFields ?? [],
filterQuery: createFilter(filterQuery),
id: ID,
pagination: generateTablePaginationOptions(activePage, limit),
querySize: limit,
sort,
timerange: {
interval: '12h',
from: startDate,
to: endDate,
},
sort,
};
const newActivePage = deepEqual(prevSearchParameters, currentSearchParameters)
? activePage
: 0;
const currentRequest = {
defaultIndex: indexNames,
docValueFields: docValueFields ?? [],
factoryQueryType: TimelineEventsQueries.all,
fieldRequested: fields,
fields: [],
filterQuery: createFilter(filterQuery),
id,
pagination: {
activePage: newActivePage,
querySize: limit,
},
sort,
timerange: {
interval: '12h',
from: startDate,
to: endDate,
},
};
if (activePage !== newActivePage) {
setActivePage(newActivePage);
}
if (
!skip &&
!skipQueryForDetectionsPage(id, indexNames) &&
!deepEqual(prevRequest, myRequest)
!deepEqual(prevRequest, currentRequest)
) {
return myRequest;
return currentRequest;
}
return prevRequest;
});

View file

@ -7,8 +7,8 @@
import gql from 'graphql-tag';
export const oneTimelineQuery = gql`
query GetOneTimeline($id: ID!) {
getOneTimeline(id: $id) {
query GetOneTimeline($id: ID!, $timelineType: TimelineType) {
getOneTimeline(id: $id, timelineType: $timelineType) {
savedObjectId
columns {
aggregatable

View file

@ -44,7 +44,7 @@ export const createTimelineResolvers = (
} => ({
Query: {
async getOneTimeline(root, args, { req }) {
return libs.timeline.getTimeline(req, args.id);
return libs.timeline.getTimeline(req, args.id, args.timelineType);
},
async getAllTimeline(root, args, { req }) {
return libs.timeline.getAllTimeline(

View file

@ -317,7 +317,7 @@ export const timelineSchema = gql`
#########################
extend type Query {
getOneTimeline(id: ID!): TimelineResult!
getOneTimeline(id: ID!, timelineType: TimelineType): TimelineResult!
getAllTimeline(pageInfo: PageInfoTimeline!, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType, status: TimelineStatus): ResponseTimelines!
}

View file

@ -276,6 +276,11 @@ export enum HostPolicyResponseActionStatus {
warning = 'warning',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum DataProviderType {
default = 'default',
template = 'template',
@ -303,11 +308,6 @@ export enum TimelineStatus {
immutable = 'immutable',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum SortFieldTimeline {
title = 'title',
description = 'description',
@ -1601,6 +1601,8 @@ export interface SourceQueryArgs {
}
export interface GetOneTimelineQueryArgs {
id: string;
timelineType?: Maybe<TimelineType>;
}
export interface GetAllTimelineQueryArgs {
pageInfo: PageInfoTimeline;
@ -1838,6 +1840,8 @@ export namespace QueryResolvers {
> = Resolver<R, Parent, TContext, GetOneTimelineArgs>;
export interface GetOneTimelineArgs {
id: string;
timelineType?: Maybe<TimelineType>;
}
export type GetAllTimelineResolver<

View file

@ -58,7 +58,11 @@ export interface ResponseTemplateTimeline {
}
export interface Timeline {
getTimeline: (request: FrameworkRequest, timelineId: string) => Promise<TimelineSavedObject>;
getTimeline: (
request: FrameworkRequest,
timelineId: string,
timelineType?: TimelineTypeLiteralWithNull
) => Promise<TimelineSavedObject>;
getAllTimeline: (
request: FrameworkRequest,
@ -95,9 +99,27 @@ export interface Timeline {
export const getTimeline = async (
request: FrameworkRequest,
timelineId: string
timelineId: string,
timelineType: TimelineTypeLiteralWithNull = TimelineType.default
): Promise<TimelineSavedObject> => {
return getSavedTimeline(request, timelineId);
let timelineIdToUse = timelineId;
try {
if (timelineType === TimelineType.template) {
const options = {
type: timelineSavedObjectType,
perPage: 1,
page: 1,
filter: `siem-ui-timeline.attributes.templateTimelineId: ${timelineId}`,
};
const result = await getAllSavedTimeline(request, options);
if (result.totalCount === 1) {
timelineIdToUse = result.timeline[0].savedObjectId;
}
}
} catch {
// TO DO, we need to bring the logger here
}
return getSavedTimeline(request, timelineIdToUse);
};
export const getTimelineByTemplateTimelineId = async (

View file

@ -7,6 +7,7 @@
export const TIMELINE_EVENTS_FIELDS = [
'@timestamp',
'signal.status',
'signal.group.id',
'signal.original_time',
'signal.rule.filters',
'signal.rule.from',
@ -136,6 +137,7 @@ export const TIMELINE_EVENTS_FIELDS = [
'signal.rule.note',
'signal.rule.threshold',
'signal.rule.exceptions_list',
'signal.rule.building_block_type',
'suricata.eve.proto',
'suricata.eve.flow_id',
'suricata.eve.alert.signature',

View file

@ -11,8 +11,7 @@ import { toArray } from '../../../../helpers/to_array';
export const formatTimelineData = (
dataFields: readonly string[],
ecsFields: readonly string[],
hit: EventHit,
fieldMap: Readonly<Record<string, string>>
hit: EventHit
) =>
uniq([...ecsFields, ...dataFields]).reduce<TimelineEdges>(
(flattenedFields, fieldName) => {
@ -25,14 +24,7 @@ export const formatTimelineData = (
flattenedFields.cursor.value = hit.sort[0];
flattenedFields.cursor.tiebreaker = hit.sort[1];
}
return mergeTimelineFieldsWithHit(
fieldName,
flattenedFields,
fieldMap,
hit,
dataFields,
ecsFields
);
return mergeTimelineFieldsWithHit(fieldName, flattenedFields, hit, dataFields, ecsFields);
},
{
node: { ecs: { _id: '' }, data: [], _id: '', _index: '' },
@ -48,13 +40,12 @@ const specialFields = ['_id', '_index', '_type', '_score'];
const mergeTimelineFieldsWithHit = <T>(
fieldName: string,
flattenedFields: T,
fieldMap: Readonly<Record<string, string>>,
hit: { _source: {} },
dataFields: readonly string[],
ecsFields: readonly string[]
) => {
if (fieldMap[fieldName] != null || dataFields.includes(fieldName)) {
const esField = dataFields.includes(fieldName) ? fieldName : fieldMap[fieldName];
if (fieldName != null || dataFields.includes(fieldName)) {
const esField = fieldName;
if (has(esField, hit._source) || specialFields.includes(esField)) {
const objectWithProperty = {
node: {

View file

@ -14,10 +14,9 @@ import {
TimelineEventsAllRequestOptions,
TimelineEdges,
} from '../../../../../../common/search_strategy/timeline';
import { inspectStringifyObject, reduceFields } from '../../../../../utils/build_query';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import { SecuritySolutionTimelineFactory } from '../../types';
import { buildTimelineEventsAllQuery } from './query.events_all.dsl';
import { eventFieldsMap } from '../../../../../lib/ecs_fields';
import { TIMELINE_EVENTS_FIELDS } from './constants';
import { formatTimelineData } from './helpers';
@ -27,10 +26,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory<TimelineEventsQu
throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`);
}
const { fieldRequested, ...queryOptions } = cloneDeep(options);
queryOptions.fields = uniq([
...fieldRequested,
...reduceFields(TIMELINE_EVENTS_FIELDS, eventFieldsMap),
]);
queryOptions.fields = uniq([...fieldRequested, ...TIMELINE_EVENTS_FIELDS]);
return buildTimelineEventsAllQuery(queryOptions);
},
parse: async (
@ -38,23 +34,17 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory<TimelineEventsQu
response: IEsSearchResponse<unknown>
): Promise<TimelineEventsAllStrategyResponse> => {
const { fieldRequested, ...queryOptions } = cloneDeep(options);
queryOptions.fields = uniq([
...fieldRequested,
...reduceFields(TIMELINE_EVENTS_FIELDS, eventFieldsMap),
]);
const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination;
queryOptions.fields = uniq([...fieldRequested, ...TIMELINE_EVENTS_FIELDS]);
const { activePage, querySize } = options.pagination;
const totalCount = getOr(0, 'hits.total.value', response.rawResponse);
const hits = response.rawResponse.hits.hits;
const edges: TimelineEdges[] = hits.splice(cursorStart, querySize - cursorStart).map((hit) =>
const edges: TimelineEdges[] = hits.map((hit) =>
// @ts-expect-error
formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit, eventFieldsMap)
formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit)
);
const inspect = {
dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))],
};
const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount;
const showMorePagesIndicator = totalCount > fakeTotalCount;
return {
...response,
@ -63,8 +53,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory<TimelineEventsQu
totalCount,
pageInfo: {
activePage: activePage ?? 0,
fakeTotalCount,
showMorePagesIndicator,
querySize,
},
};
},

View file

@ -12,11 +12,11 @@ import {
TimelineEventsAllRequestOptions,
} from '../../../../../../common/search_strategy';
import { createQueryFilterClauses } from '../../../../../utils/build_query';
import { TIMELINE_EVENTS_FIELDS } from './constants';
export const buildTimelineEventsAllQuery = ({
defaultIndex,
docValueFields,
fields,
filterQuery,
pagination: { activePage, querySize },
sort,
@ -68,7 +68,7 @@ export const buildTimelineEventsAllQuery = ({
size: querySize,
track_total_hits: true,
sort: getSortField(sort),
_source: TIMELINE_EVENTS_FIELDS,
_source: fields,
},
};