[Security Solution][Analyzer] Add alerts to analyzer, display alerts by process ancestry in alert flyout (#135340)

* WIP stats appearing in tree api, events api TODO

* All panels work, types/tests TODO

* WIP handle events with only alerts or only events better

* Throw away commit just POC alert ids in tree response

* Remove console.log

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

* Fix some tests and 2/3 type errors, still WIP

* Disable tree request until entity request succeeds

* Remove console.log

* Fix remaining types

* Create shared hook for timeline selectors used by analyzer

* Remove reset scroll

* Change type definition for getRacClient

* Address pr comments

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kevin Qualters 2022-07-25 18:13:23 -04:00 committed by GitHub
parent 3515e6f22c
commit 3669e79b82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 542 additions and 235 deletions

View file

@ -191,9 +191,11 @@ export function processNameSafeVersion(event: SafeResolverEvent): string | undef
}
export function eventID(event: SafeResolverEvent): number | undefined | string {
return firstNonNullValue(
isLegacyEventSafeVersion(event) ? event.endgame.serial_event_id : event.event?.id
);
if (isLegacyEventSafeVersion(event)) {
return firstNonNullValue(event.endgame?.serial_event_id);
} else {
return firstNonNullValue(event.event?.id);
}
}
/**
@ -217,11 +219,11 @@ export function eventIDSafeVersion(event: SafeResolverEvent): number | undefined
/**
* The event.entity_id field.
*/
export function entityId(event: ResolverEvent): string {
if (isLegacyEvent(event)) {
export function entityId(event: SafeResolverEvent): string | undefined {
if (isLegacyEventSafeVersion(event)) {
return event.endgame.unique_pid ? String(event.endgame.unique_pid) : '';
}
return event.process.entity_id;
return firstNonNullValue(event.process?.entity_id);
}
/**

View file

@ -42,6 +42,7 @@ export const validateTree = {
minSize: 1,
}),
indexPatterns: schema.arrayOf(schema.string(), { minSize: 1 }),
includeHits: schema.boolean({ defaultValue: false }),
}),
};
@ -61,6 +62,8 @@ export const validateEvents = {
}),
indexPatterns: schema.arrayOf(schema.string()),
filter: schema.maybe(schema.string()),
entityType: schema.maybe(schema.string()),
eventID: schema.maybe(schema.string()),
}),
};

View file

@ -699,6 +699,13 @@ export type SafeEndpointEvent = Partial<{
kind: ECSField<string>;
sequence: ECSField<number>;
}>;
kibana: Partial<{
alert: Partial<{
rule: Partial<{
name: ECSField<string>;
}>;
}>;
}>;
host: Partial<{
id: ECSField<string>;
hostname: ECSField<string>;

View file

@ -79,6 +79,7 @@ export const RelatedAlertsByProcessAncestry = React.memo<Props>(({ data, eventId
return (
<FetchAndNotifyCachedAlertsByProcessAncestry
data={data}
eventId={eventId}
timelineId={timelineId}
onCacheLoad={setCache}
/>
@ -110,14 +111,11 @@ RelatedAlertsByProcessAncestry.displayName = 'RelatedAlertsByProcessAncestry';
*/
const FetchAndNotifyCachedAlertsByProcessAncestry: React.FC<{
data: TimelineEventsDetailsItem;
eventId: string;
timelineId?: string;
onCacheLoad: (cache: Cache) => void;
}> = ({ data, timelineId, onCacheLoad }) => {
const { loading, error, alertIds } = useAlertPrevalenceFromProcessTree({
parentEntityId: data.values,
timelineId: timelineId ?? '',
signalIndexName: null,
});
}> = ({ data, timelineId, onCacheLoad, eventId }) => {
const { loading, error, alertIds } = useAlertPrevalenceFromProcessTree(eventId, timelineId);
useEffect(() => {
if (alertIds) {

View file

@ -4,27 +4,12 @@
* 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';
import { useTimelineDataFilters } from '../../../timelines/containers/use_timeline_data_filters';
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[];
@ -36,20 +21,91 @@ interface ProcessTreeAlertPrevalenceResponse {
alertIds: string[];
}
export function useAlertPrevalenceFromProcessTreeActual(
processEntityId: string
interface EntityResponse {
id: string;
name: string;
schema: object;
}
interface TreeResponse {
statsNodes: Array<{
data: object;
id: string;
parent: string;
stats: {
total: number;
byCategory: {
alerts?: number;
};
};
}>;
alertIds: string[];
}
function useAlertDocumentAnalyzerSchema(processEntityId: string, indices: string[]) {
const http = useHttp();
const query = useQuery<EntityResponse[]>(['getAlertPrevalenceSchema', processEntityId], () => {
return http.get<EntityResponse[]>(`/api/endpoint/resolver/entity`, {
query: {
_id: processEntityId,
indices,
},
});
});
if (query.isLoading) {
return {
loading: true,
error: false,
id: null,
schema: null,
};
} else if (query.data) {
const {
data: [{ schema, id }],
} = query;
return {
loading: false,
error: false,
id,
schema,
};
} else {
return {
loading: false,
error: true,
id: null,
schema: null,
};
}
}
export function useAlertPrevalenceFromProcessTree(
processEntityId: string,
timelineId: string | undefined
): UserAlertPrevalenceFromProcessTreeResult {
const http = useHttp();
const query = useQuery<ProcessTreeAlertPrevalenceResponse>(
['getAlertPrevalenceFromProcessTree', processEntityId],
() => {
return http.get<ProcessTreeAlertPrevalenceResponse>('/TBD', {
query: { processEntityId },
});
}
);
if (query.isLoading) {
const { selectedPatterns, to, from } = useTimelineDataFilters(timelineId);
const { loading, id, schema } = useAlertDocumentAnalyzerSchema(processEntityId, selectedPatterns);
const query = useQuery<ProcessTreeAlertPrevalenceResponse>(
['getAlertPrevalenceFromProcessTree', id],
() => {
return http.post<TreeResponse>(`/api/endpoint/resolver/tree`, {
body: JSON.stringify({
schema,
ancestors: 200,
descendants: 500,
indexPatterns: selectedPatterns,
nodes: [id],
timeRange: { from, to },
includeHits: true,
}),
});
},
{ enabled: schema !== null && id !== null }
);
if (query.isLoading || loading) {
return {
loading: true,
error: false,
@ -69,42 +125,3 @@ export function useAlertPrevalenceFromProcessTreeActual(
};
}
}
export const useAlertPrevalenceFromProcessTree = ({
parentEntityId,
timelineId,
signalIndexName,
}: UseAlertPrevalenceOptions): UserAlertPrevalenceFromProcessTreeResult => {
// const timelineTime = useDeepEqualSelector((state) =>
// inputsSelectors.timelineTimeRangeSelector(state)
// );
// const globalTime = useGlobalTime();
// const { to, from } = timelineId === TimelineId.active ? timelineTime : globalTime;
const [{ loading, alertIds }, setResult] = useState<{ loading: boolean; alertIds?: string[] }>({
loading: true,
alertIds: undefined,
});
useEffect(() => {
const t = setTimeout(() => {
setResult({
loading: false,
alertIds: [
'489ef2e50e7bb6366c5eaa1b17873e56fda738134685ca54b997a2546834f08c',
'4b8e7111166034f94f62a009fa22ad42bfbb8edc86cda03055d14a9f2dd21f48',
'0347030aa3593566a7fcd77769c798efaf02f84a3196fd586b4700c0c9ae5872',
],
});
}, Math.random() * 1500 + 500);
return () => {
clearTimeout(t);
};
}, []);
return {
loading,
alertIds,
count: alertIds ? alertIds.length : undefined,
error: false,
};
};

View file

@ -13,21 +13,6 @@ import { useShallowEqualSelector } from '../../hooks/use_selector';
import { inputsSelectors } from '../../store';
import { inputsActions } from '../../store/actions';
export const resetScroll = () => {
setTimeout(() => {
window.scrollTo(0, 0);
const kibanaBody = document.querySelector('#kibana-body');
if (kibanaBody != null) {
kibanaBody.scrollTop = 0;
}
const pageContainer = document.querySelector('[data-test-subj="pageContainer"]');
if (pageContainer != null) {
pageContainer.scrollTop = 0;
}
}, 0);
};
export interface GlobalFullScreen {
globalFullScreen: boolean;
setGlobalFullScreen: (fullScreen: boolean) => void;
@ -47,10 +32,8 @@ export const useGlobalFullScreen = (): GlobalFullScreen => {
const isDataGridFullScreen = document.querySelector('.euiDataGrid--fullScreen') !== null;
if (fullScreen) {
document.body.classList.add(SCROLLING_DISABLED_CLASS_NAME, 'euiDataGrid__restrictBody');
resetScroll();
} else if (isDataGridFullScreen === false || fullScreen === false) {
document.body.classList.remove(SCROLLING_DISABLED_CLASS_NAME, 'euiDataGrid__restrictBody');
resetScroll();
}
dispatch(inputsActions.setFullScreen({ id: 'global', fullScreen }));

View file

@ -79,24 +79,41 @@ export function dataAccessLayerFactory(
timeRange: TimeRange;
indexPatterns: string[];
}): Promise<ResolverPaginatedEvents> {
return context.services.http.post('/api/endpoint/resolver/events', {
const commonFields = {
query: { afterEvent: after, limit: 25 },
body: JSON.stringify({
body: {
timeRange: {
from: timeRange.from,
to: timeRange.to,
},
indexPatterns,
filter: JSON.stringify({
bool: {
filter: [
{ term: { 'process.entity_id': entityID } },
{ term: { 'event.category': category } },
],
},
},
};
if (category === 'alerts') {
return context.services.http.post('/api/endpoint/resolver/events', {
query: commonFields.query,
body: JSON.stringify({
...commonFields,
entityType: 'alerts',
eventID: entityID,
}),
}),
});
});
} else {
return context.services.http.post('/api/endpoint/resolver/events', {
query: commonFields.query,
body: JSON.stringify({
...commonFields,
filter: JSON.stringify({
bool: {
filter: [
{ term: { 'process.entity_id': entityID } },
{ term: { 'event.category': category } },
],
},
}),
}),
});
}
},
/**
@ -176,22 +193,42 @@ export function dataAccessLayerFactory(
filter: [{ term: { 'event.id': eventID } }],
},
};
const response: ResolverPaginatedEvents = await context.services.http.post(
'/api/endpoint/resolver/events',
{
query: { limit: 1 },
body: JSON.stringify({
indexPatterns,
timeRange: {
from: timeRange.from,
to: timeRange.to,
},
filter: JSON.stringify(filter),
}),
}
);
const [oneEvent] = response.events;
return oneEvent ?? null;
if (eventCategory.includes('alerts') === false) {
const response: ResolverPaginatedEvents = await context.services.http.post(
'/api/endpoint/resolver/events',
{
query: { limit: 1 },
body: JSON.stringify({
indexPatterns,
timeRange: {
from: timeRange.from,
to: timeRange.to,
},
filter: JSON.stringify(filter),
}),
}
);
const [oneEvent] = response.events;
return oneEvent ?? null;
} else {
const response: ResolverPaginatedEvents = await context.services.http.post(
'/api/endpoint/resolver/events',
{
query: { limit: 1 },
body: JSON.stringify({
indexPatterns,
timeRange: {
from: timeRange.from,
to: timeRange.to,
},
entityType: 'alertDetail',
eventID,
}),
}
);
const [oneEvent] = response.events;
return oneEvent ?? null;
}
},
/**

View file

@ -39,6 +39,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
['@timestamp', 'Sep 23, 2020 @ 08:25:32.316'],
['process.executable', 'executable'],
['process.pid', '0'],
['process.entity_id', 'origin'],
['user.name', 'user.name'],
['user.domain', 'user.domain'],
['process.parent.pid', '0'],
@ -186,6 +187,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and
['@timestamp', 'Sep 23, 2020 @ 08:25:32.317'],
['process.executable', 'executable'],
['process.pid', '1'],
['process.entity_id', 'firstChild'],
['user.name', 'user.name'],
['user.domain', 'user.domain'],
['process.parent.pid', '0'],

View file

@ -55,6 +55,18 @@ export function DescriptiveName({ event }: { event: SafeResolverEvent }) {
);
}
if (event.kibana?.alert?.rule?.name) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.alertEventNameLabel"
defaultMessage="{ ruleName }"
values={{
ruleName: String(event.kibana?.alert?.rule?.name),
}}
/>
);
}
if (event.file?.path) {
return (
<FormattedMessage

View file

@ -160,7 +160,7 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) {
}> = [];
for (const [key, value] of Object.entries(event)) {
// ignore these keys
if (key === 'agent' || key === 'ecs' || key === 'process' || key === '@timestamp') {
if (key === 'agent' || key === 'ecs' || key === '@timestamp') {
continue;
}

View file

@ -103,6 +103,11 @@ const NodeDetailView = memo(function ({
description: eventModel.userName(processEvent),
};
const processEntityId = {
title: 'process.entity_id',
description: eventModel.entityId(processEvent),
};
const domainEntry = {
title: 'user.domain',
description: eventModel.userDomain(processEvent),
@ -132,6 +137,7 @@ const NodeDetailView = memo(function ({
createdEntry,
pathEntry,
pidEntry,
processEntityId,
userEntry,
domainEntry,
parentPidEntry,

View file

@ -32,6 +32,7 @@ import { DescriptiveName } from './descriptive_name';
import { useLinkProps } from '../use_link_props';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { useFormattedDate } from './use_formatted_date';
import { expandDottedObject } from '../../../../common/utils/expand_dotted';
/**
* Render a list of events that are related to `nodeID` and that have a category of `eventType`.
@ -104,7 +105,7 @@ const NodeEventsListItem = memo(function ({
eventCategory: string;
}) {
const timestamp = eventModel.eventTimestamp(event);
const eventID = eventModel.eventID(event);
const eventID = eventModel.eventID(expandDottedObject(event));
const winlogRecordID = eventModel.winlogRecordID(event);
const date =
useFormattedDate(timestamp) ||

View file

@ -27,14 +27,7 @@ import { timelineDefaults } from '../../store/timeline/defaults';
import { isFullScreen } from '../timeline/body/column_headers';
import { inputsActions } from '../../../common/store/actions';
import { Resolver } from '../../../resolver/view';
import {
isLoadingSelector,
startSelector,
endSelector,
} from '../../../common/components/super_date_picker/selectors';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import { sourcererSelectors } from '../../../common/store';
import { useTimelineDataFilters } from '../../containers/use_timeline_data_filters';
const SESSION_VIEW_FULL_SCREEN = 'sessionViewFullScreen';
@ -98,32 +91,6 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
(state) => (getTimeline(state, timelineId) ?? timelineDefaults).sessionViewConfig
);
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []);
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
const shouldUpdate = useDeepEqualSelector((state) => {
if (isActive) {
return getIsLoadingSelector(state.inputs.timeline);
} else {
return getIsLoadingSelector(state.inputs.global);
}
});
const from = useDeepEqualSelector((state) => {
if (isActive) {
return getStartSelector(state.inputs.timeline);
} else {
return getStartSelector(state.inputs.global);
}
});
const to = useDeepEqualSelector((state) => {
if (isActive) {
return getEndSelector(state.inputs.timeline);
} else {
return getEndSelector(state.inputs.global);
}
});
const fullScreen = useMemo(
() => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }),
[globalFullScreen, timelineId, timelineFullScreen]
@ -141,18 +108,7 @@ const GraphOverlayComponent: React.FC<GraphOverlayProps> = ({
};
}, [dispatch, timelineId]);
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
[]
);
const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector);
const { selectedPatterns: timelinePatterns } = useSourcererDataView(SourcererScopeName.timeline);
const selectedPatterns = useMemo(
() => (isInTimeline ? timelinePatterns : defaultDataView.patternList),
[defaultDataView.patternList, isInTimeline, timelinePatterns]
);
const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters(timelineId);
const sessionContainerRef = useRef<HTMLDivElement | null>(null);

View file

@ -66,7 +66,6 @@ export const useTimelineEventsDetails = ({
const [ecsData, setEcsData] = useState<EventsArgs['ecs']>(null);
const [rawEventData, setRawEventData] = useState<object | undefined>(undefined);
const timelineDetailsSearch = useCallback(
(request: TimelineEventsDetailsRequestOptions | null) => {
if (request == null || skip || isEmpty(request.eventId)) {

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { useDeepEqualSelector } from '../../common/hooks/use_selector';
import { TimelineId } from '../../../common/types/timeline';
import {
isLoadingSelector,
startSelector,
endSelector,
} from '../../common/components/super_date_picker/selectors';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../common/containers/sourcerer';
import { sourcererSelectors } from '../../common/store';
export function useTimelineDataFilters(timelineId: string | undefined) {
const getStartSelector = useMemo(() => startSelector(), []);
const getEndSelector = useMemo(() => endSelector(), []);
const getIsLoadingSelector = useMemo(() => isLoadingSelector(), []);
const isActive = useMemo(() => timelineId === TimelineId.active, [timelineId]);
const isInTimeline = timelineId === TimelineId.active;
const shouldUpdate = useDeepEqualSelector((state) => {
if (isActive) {
return getIsLoadingSelector(state.inputs.timeline);
} else {
return getIsLoadingSelector(state.inputs.global);
}
});
const from = useDeepEqualSelector((state) => {
if (isActive) {
return getStartSelector(state.inputs.timeline);
} else {
return getStartSelector(state.inputs.global);
}
});
const to = useDeepEqualSelector((state) => {
if (isActive) {
return getEndSelector(state.inputs.timeline);
} else {
return getEndSelector(state.inputs.global);
}
});
const getDefaultDataViewSelector = useMemo(
() => sourcererSelectors.defaultDataViewSelector(),
[]
);
const defaultDataView = useDeepEqualSelector(getDefaultDataViewSelector);
const { selectedPatterns: timelinePatterns } = useSourcererDataView(SourcererScopeName.timeline);
const selectedPatterns = useMemo(
() => (isInTimeline ? timelinePatterns : defaultDataView.patternList),
[defaultDataView.patternList, isInTimeline, timelinePatterns]
);
return {
selectedPatterns,
from,
to,
shouldUpdate,
};
}

View file

@ -4,8 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IRouter } from '@kbn/core/server';
import type { StartServicesAccessor } from '@kbn/core/server';
import type { SecuritySolutionPluginRouter } from '../../types';
import type { StartPlugins } from '../../plugin';
import {
validateEvents,
validateEntities,
@ -16,14 +17,18 @@ import { handleTree } from './resolver/tree/handler';
import { handleEntities } from './resolver/entity/handler';
import { handleEvents } from './resolver/events';
export function registerResolverRoutes(router: IRouter) {
export const registerResolverRoutes = async (
router: SecuritySolutionPluginRouter,
startServices: StartServicesAccessor<StartPlugins>
) => {
const [, { ruleRegistry }] = await startServices();
router.post(
{
path: '/api/endpoint/resolver/tree',
validate: validateTree,
options: { authRequired: true },
},
handleTree()
handleTree(ruleRegistry)
);
router.post(
@ -32,7 +37,7 @@ export function registerResolverRoutes(router: IRouter) {
validate: validateEvents,
options: { authRequired: true },
},
handleEvents()
handleEvents(ruleRegistry)
);
/**
@ -46,4 +51,4 @@ export function registerResolverRoutes(router: IRouter) {
},
handleEntities()
);
}
};

View file

@ -7,6 +7,7 @@
import type { TypeOf } from '@kbn/config-schema';
import type { RequestHandler } from '@kbn/core/server';
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import type { ResolverPaginatedEvents, SafeResolverEvent } from '../../../../common/endpoint/types';
import type { validateEvents } from '../../../../common/endpoint/schema/resolver';
import { EventsQuery } from './queries/events';
@ -29,7 +30,9 @@ function createEvents(
* This function handles the `/events` api and returns an array of events and a cursor if more events exist than were
* requested.
*/
export function handleEvents(): RequestHandler<
export function handleEvents(
ruleRegistry: RuleRegistryPluginStartContract
): RequestHandler<
unknown,
TypeOf<typeof validateEvents.query>,
TypeOf<typeof validateEvents.body>
@ -39,13 +42,15 @@ export function handleEvents(): RequestHandler<
query: { limit, afterEvent },
body,
} = req;
const client = (await context.core).elasticsearch.client;
const query = new EventsQuery({
const eventsClient = (await context.core).elasticsearch.client;
const alertsClient = await ruleRegistry.getRacClientWithRequest(req);
const eventsQuery = new EventsQuery({
pagination: PaginationBuilder.createBuilder(limit, afterEvent),
indexPatterns: body.indexPatterns,
timeRange: body.timeRange,
});
const results = await query.search(client, body.filter);
const results = await eventsQuery.search(eventsClient, body, alertsClient);
return res.ok({
body: createEvents(results, PaginationBuilder.buildCursorRequestLimit(limit, results)),
});

View file

@ -6,7 +6,8 @@
*/
import type { IScopedClusterClient } from '@kbn/core/server';
import type { JsonObject } from '@kbn/utility-types';
import type { AlertsClient } from '@kbn/rule-registry-plugin/server';
import type { JsonObject, JsonValue } from '@kbn/utility-types';
import { parseFilterQuery } from '../../../../utils/serialized_query';
import type { SafeResolverEvent } from '../../../../../common/endpoint/types';
import type { PaginationBuilder } from '../utils/pagination';
@ -62,6 +63,58 @@ export class EventsQuery {
};
}
private alertDetailQuery(id?: JsonValue): { query: object; index: string } {
return {
query: {
bool: {
filter: [
{
term: { 'event.id': id },
},
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
],
},
},
index:
typeof this.indexPatterns === 'string' ? this.indexPatterns : this.indexPatterns.join(','),
...this.pagination.buildQueryFields('event.id', 'desc'),
};
}
private alertsForProcessQuery(id?: JsonValue): { query: object; index: string } {
return {
query: {
bool: {
filter: [
{
term: { 'process.entity_id': id },
},
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
],
},
},
index:
typeof this.indexPatterns === 'string' ? this.indexPatterns : this.indexPatterns.join(','),
...this.pagination.buildQueryFields('event.id', 'desc'),
};
}
private buildSearch(filters: JsonObject[]) {
return {
body: this.query(filters),
@ -78,20 +131,34 @@ export class EventsQuery {
}
/**
* Searches ES for the specified events and format the response.
* Will search ES using a filter for normal events associated with a process, or an entity type and event id for alert events.
*
* @param client a client for searching ES
* @param filter an optional string representation of a raw Elasticsearch clause for filtering the results
*/
async search(
client: IScopedClusterClient,
filter: string | undefined
body: { filter?: string; eventID?: string; entityType?: string },
alertsClient: AlertsClient
): Promise<SafeResolverEvent[]> {
const parsedFilters = EventsQuery.buildFilters(filter);
const response = await client.asCurrentUser.search<SafeResolverEvent>(
this.buildSearch(parsedFilters)
);
// @ts-expect-error @elastic/elasticsearch _source is optional
return response.hits.hits.map((hit) => hit._source);
if (body.filter) {
const parsedFilters = EventsQuery.buildFilters(body.filter);
const response = await client.asCurrentUser.search<SafeResolverEvent>(
this.buildSearch(parsedFilters)
);
// @ts-expect-error @elastic/elasticsearch _source is optional
return response.hits.hits.map((hit) => hit._source);
} else {
const { eventID, entityType } = body;
if (entityType === 'alertDetail') {
const response = await alertsClient.find(this.alertDetailQuery(eventID));
// @ts-expect-error @elastic/elasticsearch _source is optional
return response.hits.hits.map((hit) => hit._source);
} else {
const response = await alertsClient.find(this.alertsForProcessQuery(eventID));
// @ts-expect-error @elastic/elasticsearch _source is optional
return response.hits.hits.map((hit) => hit._source);
}
}
}
}

View file

@ -7,13 +7,17 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TypeOf } from '@kbn/config-schema';
import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server';
import type { validateTree } from '../../../../../common/endpoint/schema/resolver';
import { Fetcher } from './utils/fetch';
export function handleTree(): RequestHandler<unknown, unknown, TypeOf<typeof validateTree.body>> {
export function handleTree(
ruleRegistry: RuleRegistryPluginStartContract
): RequestHandler<unknown, unknown, TypeOf<typeof validateTree.body>> {
return async (context, req, res) => {
const client = (await context.core).elasticsearch.client;
const fetcher = new Fetcher(client);
const alertsClient = await ruleRegistry.getRacClientWithRequest(req);
const fetcher = new Fetcher(client, alertsClient);
const body = await fetcher.tree(req.body);
return res.ok({
body,

View file

@ -6,6 +6,8 @@
*/
import type { IScopedClusterClient } from '@kbn/core/server';
import type { AlertsClient } from '@kbn/rule-registry-plugin/server';
import { ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import type { JsonObject } from '@kbn/utility-types';
import type { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types';
import type { NodeID, TimeRange } from '../utils';
@ -94,6 +96,40 @@ export class StatsQuery {
};
}
private alertStatsQuery(
nodes: NodeID[],
index: string,
includeHits: boolean
): { size: number; query: object; index: string; aggs: object; fields?: string[] } {
return {
size: includeHits ? 5000 : 0,
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
{
terms: { [this.schema.id]: nodes },
},
],
},
},
index,
aggs: {
ids: {
terms: { field: this.schema.id },
},
},
};
}
private static getEventStats(catAgg: CategoriesAgg): EventStats {
const total = catAgg.doc_count;
if (!catAgg.categories?.buckets) {
@ -121,26 +157,99 @@ export class StatsQuery {
* @param client used to make requests to Elasticsearch
* @param nodes an array of unique IDs representing nodes in a resolver graph
*/
async search(client: IScopedClusterClient, nodes: NodeID[]): Promise<Record<string, EventStats>> {
async search(
client: IScopedClusterClient,
nodes: NodeID[],
alertsClient: AlertsClient | undefined,
includeHits: boolean
): Promise<{ eventStats?: Record<string, EventStats>; alertIds?: string[] }> {
if (nodes.length <= 0) {
return {};
}
const esClient = this.isInternalRequest ? client.asInternalUser : client.asCurrentUser;
const alertIndex =
typeof this.indexPatterns === 'string' ? this.indexPatterns : this.indexPatterns.join(',');
// leaving unknown here because we don't actually need the hits part of the body
const body = await esClient.search({
body: this.query(nodes),
index: this.indexPatterns,
});
// @ts-expect-error declare aggegations type explicitly
return body.aggregations?.ids?.buckets.reduce(
(cummulative: Record<string, number>, bucket: CategoriesAgg) => ({
...cummulative,
[bucket.key]: StatsQuery.getEventStats(bucket),
const [body, alertsBody] = await Promise.all([
await esClient.search({
body: this.query(nodes),
index: this.indexPatterns,
}),
alertsClient
? await alertsClient.find(this.alertStatsQuery(nodes, alertIndex, includeHits))
: { hits: { hits: [] } },
]);
// @ts-expect-error declare aggegations type explicitly
const eventAggs: CategoriesAgg[] = body.aggregations?.ids?.buckets ?? [];
// @ts-expect-error declare aggegations type explicitly
const alertAggs: AggBucket[] = alertsBody.aggregations?.ids?.buckets ?? [];
const eventsWithAggs = new Set([
...eventAggs.map((agg) => agg.key),
...alertAggs.map((agg) => agg.key),
]);
const alertsAggsMap = new Map(alertAggs.map(({ key, doc_count: docCount }) => [key, docCount]));
const eventAggsMap = new Map<string, EventStats>(
eventAggs.map(({ key, doc_count: docCount, categories }): [string, EventStats] => [
key,
{
...StatsQuery.getEventStats({ key, doc_count: docCount, categories }),
},
])
);
const alertIdsRaw: Array<string | undefined> = alertsBody.hits.hits.map((hit) => {
return hit._source && hit._source[ALERT_RULE_UUID];
});
const alertIds = alertIdsRaw.flatMap((id) => (!id ? [] : [id]));
const eventAggStats = [...eventsWithAggs.values()];
const eventStats = eventAggStats.reduce(
(cummulative: Record<string, EventStats>, id: string) => {
const alertCount = alertsAggsMap.get(id);
const otherEvents = eventAggsMap.get(id);
if (alertCount !== undefined) {
if (otherEvents !== undefined) {
return {
...cummulative,
[id]: {
total: alertCount + otherEvents.total,
byCategory: {
alerts: alertCount,
...otherEvents.byCategory,
},
},
};
} else {
return {
...cummulative,
[id]: {
total: alertCount,
byCategory: {
alerts: alertCount,
},
},
};
}
} else {
if (otherEvents !== undefined) {
return {
...cummulative,
[id]: {
total: otherEvents.total,
byCategory: otherEvents.byCategory,
},
};
} else {
return {};
}
}
},
{}
);
if (includeHits) {
return { alertIds, eventStats };
} else {
return { eventStats };
}
}
}

View file

@ -6,6 +6,7 @@
*/
import type { IScopedClusterClient } from '@kbn/core/server';
import type { AlertsClient } from '@kbn/rule-registry-plugin/server';
import {
firstNonNullValue,
values,
@ -36,23 +37,32 @@ export interface TreeOptions {
schema: ResolverSchema;
nodes: NodeID[];
indexPatterns: string[];
includeHits?: boolean;
}
export type TreeResponse = Promise<
| ResolverNode[]
| {
alertIds: string[] | undefined;
statsNodes: ResolverNode[];
}
>;
/**
* Handles retrieving nodes of a resolver tree.
*/
export class Fetcher {
constructor(private readonly client: IScopedClusterClient) {}
private alertsClient?: AlertsClient;
constructor(private readonly client: IScopedClusterClient, alertsClient?: AlertsClient) {
this.alertsClient = alertsClient;
}
/**
* This method retrieves the ancestors and descendants of a resolver tree.
*
* @param options the options for retrieving the structure of the tree.
*/
public async tree(
options: TreeOptions,
isInternalRequest: boolean = false
): Promise<ResolverNode[]> {
public async tree(options: TreeOptions, isInternalRequest: boolean = false): TreeResponse {
const treeParts = await Promise.all([
this.retrieveAncestors(options, isInternalRequest),
this.retrieveDescendants(options, isInternalRequest),
@ -70,7 +80,7 @@ export class Fetcher {
treeNodes: FieldsObject[],
options: TreeOptions,
isInternalRequest: boolean
): Promise<ResolverNode[]> {
): TreeResponse {
const statsIDs: NodeID[] = [];
for (const node of treeNodes) {
const id = getIDField(node, options.schema);
@ -86,7 +96,12 @@ export class Fetcher {
isInternalRequest,
});
const eventStats = await query.search(this.client, statsIDs);
const { eventStats, alertIds } = await query.search(
this.client,
statsIDs,
this.alertsClient,
options.includeHits ?? false
);
const statsNodes: ResolverNode[] = [];
for (const node of treeNodes) {
const id = getIDField(node, options.schema);
@ -96,16 +111,21 @@ export class Fetcher {
// at this point id should never be undefined, it should be enforced by the Elasticsearch query
// but let's check anyway
if (id !== undefined) {
const stats = (eventStats && eventStats[id]) ?? { total: 0, byCategory: {} };
statsNodes.push({
id,
parent,
name,
data: node,
stats: eventStats[id] ?? { total: 0, byCategory: {} },
stats,
});
}
}
return statsNodes;
if (options.includeHits) {
return { alertIds, statsNodes };
} else {
return statsNodes;
}
}
private static getNextAncestorsToFind(

View file

@ -123,6 +123,9 @@ const createSecuritySolutionRequestContextMock = (
};
}),
getAppClient: jest.fn(() => clients.appClient),
getRacClient: jest.fn((req: KibanaRequest) => {
throw new Error('Not implemented');
}),
getSpaceId: jest.fn(() => 'default'),
getRuleDataService: jest.fn(() => clients.ruleDataService),
getRuleExecutionLog: jest.fn(() => clients.ruleExecutionLog),

View file

@ -39,12 +39,8 @@ import {
ruleExceptionListItemToTelemetryEvent,
} from './helpers';
import { Fetcher } from '../../endpoint/routes/resolver/tree/utils/fetch';
import type { TreeOptions } from '../../endpoint/routes/resolver/tree/utils/fetch';
import type {
ResolverNode,
SafeEndpointEvent,
ResolverSchema,
} from '../../../common/endpoint/types';
import type { TreeOptions, TreeResponse } from '../../endpoint/routes/resolver/tree/utils/fetch';
import type { SafeEndpointEvent, ResolverSchema } from '../../../common/endpoint/types';
import type {
TelemetryEvent,
EnhancedAlertEvent,
@ -153,7 +149,7 @@ export interface ITelemetryReceiver {
resolverSchema: ResolverSchema,
startOfDay: string,
endOfDay: string
): Promise<ResolverNode[]>;
): TreeResponse;
fetchTimelineEvents(
nodeIds: string[]
@ -739,7 +735,7 @@ export class TelemetryReceiver implements ITelemetryReceiver {
resolverSchema: ResolverSchema,
startOfDay: string,
endOfDay: string
): Promise<ResolverNode[]> {
): TreeResponse {
if (this.processTreeFetcher === undefined || this.processTreeFetcher === null) {
throw Error(
'resolver tree builder is unavailable: cannot build encoded endpoint event graph'

View file

@ -104,9 +104,11 @@ export function createTelemetryTimelineTaskConfig() {
);
const nodeIds = [] as string[];
for (const node of tree) {
const nodeId = node?.id.toString();
nodeIds.push(nodeId);
if (Array.isArray(tree)) {
for (const node of tree) {
const nodeId = node?.id.toString();
nodeIds.push(nodeId);
}
}
sender.getTelemetryUsageCluster()?.incrementCounter({
@ -138,16 +140,18 @@ export function createTelemetryTimelineTaskConfig() {
// Create telemetry record
const telemetryTimeline: TimelineTelemetryEvent[] = [];
for (const node of tree) {
const id = node.id.toString();
const event = eventsStore.get(id);
if (Array.isArray(tree)) {
for (const node of tree) {
const id = node.id.toString();
const event = eventsStore.get(id);
const timelineTelemetryEvent: TimelineTelemetryEvent = {
...node,
event,
};
const timelineTelemetryEvent: TimelineTelemetryEvent = {
...node,
event,
};
telemetryTimeline.push(timelineTelemetryEvent);
telemetryTimeline.push(timelineTelemetryEvent);
}
}
if (telemetryTimeline.length >= 1) {

View file

@ -54,7 +54,6 @@ import {
DEFAULT_ALERTS_INDEX,
} from '../common/constants';
import { registerEndpointRoutes } from './endpoint/routes/metadata';
import { registerResolverRoutes } from './endpoint/routes/resolver';
import { registerPolicyRoutes } from './endpoint/routes/policy';
import { registerActionRoutes } from './endpoint/routes/actions';
import { EndpointArtifactClient, ManifestManager } from './endpoint/services';
@ -275,7 +274,6 @@ export class Plugin implements ISecuritySolutionPlugin {
);
registerEndpointRoutes(router, endpointContext);
registerLimitedConcurrencyRoutes(core);
registerResolverRoutes(router);
registerPolicyRoutes(router, endpointContext);
registerActionRoutes(router, endpointContext);

View file

@ -109,6 +109,8 @@ export class RequestContextFactory implements IRequestContextFactory {
getRuleDataService: () => ruleRegistry.ruleDataService,
getRacClient: startPlugins.ruleRegistry.getRacClientWithRequest,
getRuleExecutionLog: memoize(() =>
ruleExecutionLogService.createClientForRoutes({
savedObjectsClient: coreContext.savedObjects.client,

View file

@ -73,6 +73,7 @@ import { readPrebuiltDevToolContentRoute } from '../lib/prebuilt_dev_tool_conten
import { createPrebuiltSavedObjectsRoute } from '../lib/prebuilt_saved_objects/routes/create_prebuilt_saved_objects';
import { readAlertsIndexExistsRoute } from '../lib/detection_engine/routes/index/read_alerts_index_exists_route';
import { getInstalledIntegrationsRoute } from '../lib/detection_engine/routes/fleet/get_installed_integrations/get_installed_integrations_route';
import { registerResolverRoutes } from '../endpoint/routes/resolver';
export const initRoutes = (
router: SecuritySolutionPluginRouter,
@ -119,6 +120,7 @@ export const initRoutes = (
patchRulesBulkRoute(router, ml, logger);
deleteRulesBulkRoute(router, logger);
performBulkActionRoute(router, ml, logger);
registerResolverRoutes(router, getStartServices);
registerRuleMonitoringRoutes(router);

View file

@ -16,7 +16,7 @@ import type { AlertingApiRequestHandlerContext } from '@kbn/alerting-plugin/serv
import type { FleetRequestHandlerContext } from '@kbn/fleet-plugin/server';
import type { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server';
import type { ListsApiRequestHandlerContext, ExceptionListClient } from '@kbn/lists-plugin/server';
import type { IRuleDataService } from '@kbn/rule-registry-plugin/server';
import type { IRuleDataService, AlertsClient } from '@kbn/rule-registry-plugin/server';
import { AppClient } from './client';
import type { ConfigType } from './config';
@ -39,6 +39,7 @@ export interface SecuritySolutionApiRequestHandlerContext {
getSpaceId: () => string;
getRuleDataService: () => IRuleDataService;
getRuleExecutionLog: () => IRuleExecutionLogForRoutes;
getRacClient: (req: KibanaRequest) => Promise<AlertsClient>;
getExceptionListClient: () => ExceptionListClient | null;
getInternalFleetServices: () => EndpointInternalFleetServicesInterface;
getScopedFleetServices: (req: KibanaRequest) => EndpointScopedFleetServicesInterface;