[Security Solution] Make timerange an optional request param, fire unbounded request when 0 results (#140831)

* Make timerange an optional request param, fire unbounded request when 0 results

* WIP working hook, request running twice sometimes

* Add cypress test, update reducer/selector tests, fix types

* Remove unneeded ternary
This commit is contained in:
Kevin Qualters 2022-09-20 10:29:45 -04:00 committed by GitHub
parent 5623e0ea38
commit 01b604eeb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 294 additions and 132 deletions

View file

@ -24,10 +24,12 @@ export const validateTree = {
descendants: schema.number({ defaultValue: 1000, min: 0, max: 10000 }),
// if the ancestry array isn't specified allowing 200 might be too high
ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }),
timeRange: schema.object({
from: schema.string(),
to: schema.string(),
}),
timeRange: schema.maybe(
schema.object({
from: schema.string(),
to: schema.string(),
})
),
schema: schema.object({
// the ancestry field is optional
ancestry: schema.maybe(schema.string({ minLength: 1 })),

View file

@ -0,0 +1,45 @@
/*
* 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 { ANALYZER_NODE } from '../../screens/alerts';
import { openAnalyzerForFirstAlertInTimeline } from '../../tasks/alerts';
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
import { getNewRule } from '../../objects/rule';
import { cleanKibana } from '../../tasks/common';
import { setStartDate } from '../../tasks/date_picker';
import { TOASTER } from '../../screens/alerts_detection_rules';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import { login, visit } from '../../tasks/login';
import { ALERTS_URL } from '../../urls/navigation';
describe('Analyze events view for alerts', () => {
before(() => {
cleanKibana();
login();
createCustomRuleEnabled(getNewRule());
});
beforeEach(() => {
visit(ALERTS_URL);
waitForAlertsToPopulate();
});
it('should render analyzer when button is clicked', () => {
openAnalyzerForFirstAlertInTimeline();
cy.get(ANALYZER_NODE).first().should('be.visible');
});
it(`should render an analyzer view and display
a toast indicating the date range of found events when a time range has 0 events in it`, () => {
const dateContainingZeroEvents = 'Jul 27, 2022 @ 00:00:00.000';
setStartDate(dateContainingZeroEvents);
waitForAlertsToPopulate();
openAnalyzerForFirstAlertInTimeline();
cy.get(TOASTER).should('be.visible');
cy.get(ANALYZER_NODE).first().should('be.visible');
});
});

View file

@ -81,6 +81,10 @@ export const SELECT_TABLE = '[data-test-subj="table"]';
export const SEND_ALERT_TO_TIMELINE_BTN = '[data-test-subj="send-alert-to-timeline-button"]';
export const OPEN_ANALYZER_BTN = '[data-test-subj="view-in-analyzer"]';
export const ANALYZER_NODE = '[data-test-subj="resolver:node"';
export const SEVERITY = '[data-test-subj^=formatted-field][data-test-subj$=severity]';
export const SOURCE_IP = '[data-test-subj^=formatted-field][data-test-subj$=source\\.ip]';

View file

@ -25,6 +25,7 @@ import {
TAKE_ACTION_POPOVER_BTN,
TIMELINE_CONTEXT_MENU_BTN,
CLOSE_FLYOUT,
OPEN_ANALYZER_BTN,
} from '../screens/alerts';
import { REFRESH_BUTTON } from '../screens/security_header';
import {
@ -158,6 +159,10 @@ export const investigateFirstAlertInTimeline = () => {
cy.get(SEND_ALERT_TO_TIMELINE_BTN).first().click({ force: true });
};
export const openAnalyzerForFirstAlertInTimeline = () => {
cy.get(OPEN_ANALYZER_BTN).first().click({ force: true });
};
export const addAlertPropertyToTimeline = (propertySelector: string, rowIndex: number) => {
cy.get(propertySelector).eq(rowIndex).trigger('mouseover');
cy.get(ALERT_TABLE_CELL_ACTIONS_ADD_TO_TIMELINE).first().click({ force: true });

View file

@ -11,7 +11,7 @@ import type {
SafeResolverEvent,
ResolverSchema,
} from '../../../../common/endpoint/types';
import type { TreeFetcherParameters, PanelViewAndParameters } from '../../types';
import type { TreeFetcherParameters, PanelViewAndParameters, TimeFilters } from '../../types';
interface ServerReturnedResolverData {
readonly type: 'serverReturnedResolverData';
@ -32,6 +32,12 @@ interface ServerReturnedResolverData {
* The database parameters that was used to fetch the resolver tree
*/
parameters: TreeFetcherParameters;
/**
* If the user supplied date range results in 0 process events,
* an unbounded request is made, and the time range of the result set displayed to the user through this value.
*/
detectedBounds?: TimeFilters;
};
}

View file

@ -10,7 +10,7 @@ import { createStore } from 'redux';
import { RelatedEventCategory } from '../../../../common/endpoint/generate_data';
import { dataReducer } from './reducer';
import * as selectors from './selectors';
import type { DataState, GeneratedTreeMetadata } from '../../types';
import type { DataState, GeneratedTreeMetadata, TimeFilters } from '../../types';
import type { DataAction } from './action';
import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree';
import { endpointSourceSchema, winlogSourceSchema } from '../../mocks/tree_schema';
@ -24,11 +24,19 @@ type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: strin
*/
describe('Resolver Data Middleware', () => {
let store: Store<DataState, DataAction>;
let dispatchTree: (tree: NewResolverTree, sourceAndSchema: SourceAndSchemaFunction) => void;
let dispatchTree: (
tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters
) => void;
beforeEach(() => {
store = createStore(dataReducer, undefined);
dispatchTree = (tree: NewResolverTree, sourceAndSchema: SourceAndSchemaFunction) => {
dispatchTree = (
tree: NewResolverTree,
sourceAndSchema: SourceAndSchemaFunction,
detectedBounds?: TimeFilters
) => {
const { schema, dataSource } = sourceAndSchema();
const action: DataAction = {
type: 'serverReturnedResolverData',
@ -41,6 +49,7 @@ describe('Resolver Data Middleware', () => {
indices: [],
filters: {},
},
detectedBounds,
},
};
store.dispatch(action);
@ -76,6 +85,25 @@ describe('Resolver Data Middleware', () => {
expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy();
});
});
describe('when a tree with detected bounds is loaded', () => {
it('should set the detected bounds when in the payload', () => {
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema, {
from: 'Sep 19, 2022 @ 20:49:13.452',
to: 'Sep 19, 2022 @ 20:49:13.452',
});
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
});
it('should clear the previous detected bounds when a new response without detected bounds is recevied', () => {
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema, {
from: 'Sep 19, 2022 @ 20:49:13.452',
to: 'Sep 19, 2022 @ 20:49:13.452',
});
expect(selectors.detectedBounds(store.getState())).toBeTruthy();
dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema);
expect(selectors.detectedBounds(store.getState())).toBeFalsy();
});
});
});
describe('when the generated tree has dimensions larger than the limits sent to the server', () => {

View file

@ -20,6 +20,7 @@ const initialState: DataState = {
},
resolverComponentInstanceID: undefined,
indices: [],
detectedBounds: undefined,
};
/* eslint-disable complexity */
export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => {
@ -101,6 +102,7 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS
// This cannot model multiple in-flight requests
pendingRequestParameters: undefined,
},
detectedBounds: action.payload.detectedBounds,
};
return nextState;
} else if (action.type === 'serverFailedToReturnResolverData') {

View file

@ -6,7 +6,7 @@
*/
import * as selectors from './selectors';
import type { DataState, TimeRange } from '../../types';
import type { DataState } from '../../types';
import type { ResolverAction } from '../actions';
import { dataReducer } from './reducer';
import { createStore } from 'redux';
@ -425,7 +425,7 @@ describe('data state', () => {
expect(selectors.timeRangeFilters(state())?.to).toBe(new Date(maxDate).toISOString());
});
describe('when resolver receives time range filters', () => {
const timeRangeFilters: TimeRange = {
const timeRangeFilters = {
to: 'to',
from: 'from',
};

View file

@ -46,6 +46,10 @@ export function isTreeLoading(state: DataState): boolean {
return state.tree?.pendingRequestParameters !== undefined;
}
export function detectedBounds(state: DataState) {
return state.detectedBounds;
}
/**
* If a request was made and it threw an error or returned a failure response code.
*/

View file

@ -14,6 +14,7 @@ import type {
} from '../../../../common/endpoint/types';
import type { ResolverState, DataAccessLayer } from '../../types';
import * as selectors from '../selectors';
import { firstNonNullValue } from '../../../../common/endpoint/models/ecs_safety_helpers';
import type { ResolverAction } from '../actions';
import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/resolver_tree';
@ -83,15 +84,56 @@ export function ResolverTreeFetcher(
nodes: result,
};
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
result: resolverTree,
dataSource,
if (resolverTree.nodes.length === 0) {
const unboundedTree = await dataAccessLayer.resolverTree({
dataId: entityIDToFetch,
schema: dataSourceSchema,
parameters: databaseParameters,
},
});
indices: databaseParameters.indices,
ancestors: ancestorsRequestAmount(dataSourceSchema),
descendants: descendantsRequestAmount(),
});
if (unboundedTree.length > 0) {
const timestamps = unboundedTree.map((event) =>
firstNonNullValue(event.data['@timestamp'])
);
const oldestTimestamp = timestamps[0];
const newestTimestamp = timestamps.slice(-1);
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
result: { ...resolverTree, nodes: unboundedTree },
dataSource,
schema: dataSourceSchema,
parameters: databaseParameters,
detectedBounds: {
from: String(oldestTimestamp),
to: String(newestTimestamp),
},
},
});
// 0 results with unbounded query, fail as before
} else {
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
result: resolverTree,
dataSource,
schema: dataSourceSchema,
parameters: databaseParameters,
},
});
}
} else {
api.dispatch({
type: 'serverReturnedResolverData',
payload: {
result: resolverTree,
dataSource,
schema: dataSourceSchema,
parameters: databaseParameters,
},
});
}
} catch (error) {
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
if (error instanceof DOMException && error.name === 'AbortError') {

View file

@ -30,6 +30,8 @@ export const projectionMatrix = composeSelectors(
export const translation = composeSelectors(cameraStateSelector, cameraSelectors.translation);
export const detectedBounds = composeSelectors(dataStateSelector, dataSelectors.detectedBounds);
/**
* A matrix that when applied to a Vector2 converts it from screen coordinates to world coordinates.
* See https://en.wikipedia.org/wiki/Orthographic_projection

View file

@ -307,6 +307,8 @@ export interface DataState {
data: SafeResolverEvent | null;
};
readonly detectedBounds?: TimeFilters;
readonly tree?: {
/**
* The parameters passed from the resolver properties
@ -670,8 +672,8 @@ export interface IsometricTaxiLayout {
* Defines the type for bounding a search by a time box.
*/
export interface TimeRange {
from: string;
to: string;
from: string | number;
to: string | number;
}
/**
@ -762,7 +764,7 @@ export interface DataAccessLayer {
}: {
dataId: string;
schema: ResolverSchema;
timeRange: TimeRange;
timeRange?: TimeRange;
indices: string[];
ancestors: number;
descendants: number;

View file

@ -27,6 +27,7 @@ import { PanelRouter } from './panels';
import { useColors } from './use_colors';
import { useSyncSelectedNode } from './use_sync_selected_node';
import { ResolverNoProcessEvents } from './resolver_no_process_events';
import { useAutotuneTimerange } from './use_autotune_timerange';
/**
* The highest level connected Resolver component. Needs a `Provider` in its ancestry to work.
@ -58,7 +59,7 @@ export const ResolverWithoutProviders = React.memo(
shouldUpdate,
filters,
});
useAutotuneTimerange();
/**
* This will keep the selectedNode in the view in sync with the nodeID specified in the url
*/

View file

@ -0,0 +1,43 @@
/*
* 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, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { useSelector } from 'react-redux';
import * as selectors from '../store/selectors';
import { useAppToasts } from '../../common/hooks/use_app_toasts';
import { useFormattedDate } from './panels/use_formatted_date';
import type { ResolverState } from '../types';
export function useAutotuneTimerange() {
const { addSuccess } = useAppToasts();
const { from: detectedFrom, to: detectedTo } = useSelector((state: ResolverState) => {
const detectedBounds = selectors.detectedBounds(state);
return {
from: detectedBounds?.from ? detectedBounds.from : undefined,
to: detectedBounds?.to ? detectedBounds.to : undefined,
};
});
const detectedFormattedFrom = useFormattedDate(detectedFrom);
const detectedFormattedTo = useFormattedDate(detectedTo);
const successMessage = useMemo(() => {
return i18n.translate('xpack.securitySolution.resolver.unboundedRequest.toast', {
defaultMessage: `No process events were found with your selected time range, however they were
found using a start date of {from} and an end date of {to}. Select a different time range in
the date picker to use a different range.`,
values: {
from: detectedFormattedFrom,
to: detectedFormattedTo,
},
});
}, [detectedFormattedFrom, detectedFormattedTo]);
useEffect(() => {
if (detectedFrom || detectedTo) {
addSuccess(successMessage);
}
}, [addSuccess, successMessage, detectedFrom, detectedTo]);
}

View file

@ -13,7 +13,6 @@ import {
validateEntities,
validateTree,
} from '../../../common/endpoint/schema/resolver';
import { handleTree } from './resolver/tree/handler';
import { handleEntities } from './resolver/entity/handler';
import { handleEvents } from './resolver/events';

View file

@ -0,0 +1,54 @@
/*
* 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 { JsonValue } from '@kbn/utility-types';
import type { ResolverSchema } from '../../../../../../common/endpoint/types';
import type { TimeRange } from '../utils';
import { resolverFields } from '../utils';
export interface ResolverQueryParams {
readonly schema: ResolverSchema;
readonly indexPatterns: string | string[];
readonly timeRange: TimeRange | undefined;
readonly isInternalRequest: boolean;
readonly resolverFields?: JsonValue[];
getRangeFilter?: () => Array<{
range: { '@timestamp': { gte: string; lte: string; format: string } };
}>;
}
export class BaseResolverQuery implements ResolverQueryParams {
readonly schema: ResolverSchema;
readonly indexPatterns: string | string[];
readonly timeRange: TimeRange | undefined;
readonly isInternalRequest: boolean;
readonly resolverFields?: JsonValue[];
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: ResolverQueryParams) {
this.resolverFields = resolverFields(schema);
this.schema = schema;
this.indexPatterns = indexPatterns;
this.timeRange = timeRange;
this.isInternalRequest = isInternalRequest;
}
getRangeFilter() {
return this.timeRange
? [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
]
: [];
}
}

View file

@ -8,32 +8,20 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { IScopedClusterClient } from '@kbn/core/server';
import type { JsonObject, JsonValue } from '@kbn/utility-types';
import type { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types';
import type { NodeID, TimeRange } from '../utils';
import { resolverFields, validIDs } from '../utils';
interface DescendantsParams {
schema: ResolverSchema;
indexPatterns: string | string[];
timeRange: TimeRange;
isInternalRequest: boolean;
}
import type { FieldsObject } from '../../../../../../common/endpoint/types';
import type { NodeID } from '../utils';
import { validIDs } from '../utils';
import type { ResolverQueryParams } from './base';
import { BaseResolverQuery } from './base';
/**
* Builds a query for retrieving descendants of a node.
*/
export class DescendantsQuery {
private readonly schema: ResolverSchema;
private readonly indexPatterns: string | string[];
private readonly timeRange: TimeRange;
private readonly isInternalRequest: boolean;
private readonly resolverFields: JsonValue[];
export class DescendantsQuery extends BaseResolverQuery {
declare readonly resolverFields: JsonValue[];
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: DescendantsParams) {
this.resolverFields = resolverFields(schema);
this.schema = schema;
this.indexPatterns = indexPatterns;
this.timeRange = timeRange;
this.isInternalRequest = isInternalRequest;
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: ResolverQueryParams) {
super({ schema, indexPatterns, timeRange, isInternalRequest });
}
private query(nodes: NodeID[], size: number): JsonObject {
@ -48,15 +36,7 @@ export class DescendantsQuery {
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
...this.getRangeFilter(),
{
terms: { [this.schema.parent]: nodes },
},
@ -135,15 +115,7 @@ export class DescendantsQuery {
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
...this.getRangeFilter(),
{
terms: {
[ancestryField]: nodes,

View file

@ -7,32 +7,19 @@
import type { IScopedClusterClient } from '@kbn/core/server';
import type { JsonObject, JsonValue } from '@kbn/utility-types';
import type { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types';
import type { NodeID, TimeRange } from '../utils';
import { validIDs, resolverFields } from '../utils';
interface LifecycleParams {
schema: ResolverSchema;
indexPatterns: string | string[];
timeRange: TimeRange;
isInternalRequest: boolean;
}
import type { FieldsObject } from '../../../../../../common/endpoint/types';
import type { NodeID } from '../utils';
import { validIDs } from '../utils';
import type { ResolverQueryParams } from './base';
import { BaseResolverQuery } from './base';
/**
* Builds a query for retrieving descendants of a node.
*/
export class LifecycleQuery {
private readonly schema: ResolverSchema;
private readonly indexPatterns: string | string[];
private readonly timeRange: TimeRange;
private readonly isInternalRequest: boolean;
private readonly resolverFields: JsonValue[];
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: LifecycleParams) {
this.resolverFields = resolverFields(schema);
this.schema = schema;
this.indexPatterns = indexPatterns;
this.timeRange = timeRange;
this.isInternalRequest = isInternalRequest;
export class LifecycleQuery extends BaseResolverQuery {
declare readonly resolverFields: JsonValue[];
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: ResolverQueryParams) {
super({ schema, indexPatterns, timeRange, isInternalRequest });
}
private query(nodes: NodeID[]): JsonObject {
@ -47,15 +34,7 @@ export class LifecycleQuery {
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
...this.getRangeFilter(),
{
terms: { [this.schema.id]: nodes },
},

View file

@ -8,8 +8,10 @@
import type { IScopedClusterClient } from '@kbn/core/server';
import type { AlertsClient } from '@kbn/rule-registry-plugin/server';
import type { JsonObject } from '@kbn/utility-types';
import type { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types';
import type { NodeID, TimeRange } from '../utils';
import type { EventStats } from '../../../../../../common/endpoint/types';
import type { NodeID } from '../utils';
import type { ResolverQueryParams } from './base';
import { BaseResolverQuery } from './base';
interface AggBucket {
key: string;
@ -26,27 +28,12 @@ interface CategoriesAgg extends AggBucket {
};
}
interface StatsParams {
schema: ResolverSchema;
indexPatterns: string | string[];
timeRange: TimeRange;
isInternalRequest: boolean;
}
/**
* Builds a query for retrieving descendants of a node.
*/
export class StatsQuery {
private readonly schema: ResolverSchema;
private readonly indexPatterns: string | string[];
private readonly timeRange: TimeRange;
private readonly isInternalRequest: boolean;
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: StatsParams) {
this.schema = schema;
this.indexPatterns = indexPatterns;
this.timeRange = timeRange;
this.isInternalRequest = isInternalRequest;
export class StatsQuery extends BaseResolverQuery {
constructor({ schema, indexPatterns, timeRange, isInternalRequest }: ResolverQueryParams) {
super({ schema, indexPatterns, timeRange, isInternalRequest });
}
private query(nodes: NodeID[]): JsonObject {
@ -55,15 +42,7 @@ export class StatsQuery {
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
...this.getRangeFilter(),
{
terms: { [this.schema.id]: nodes },
},
@ -105,15 +84,7 @@ export class StatsQuery {
query: {
bool: {
filter: [
{
range: {
'@timestamp': {
gte: this.timeRange.from,
lte: this.timeRange.to,
format: 'strict_date_optional_time',
},
},
},
...this.getRangeFilter(),
{
terms: { [this.schema.id]: nodes },
},

View file

@ -30,7 +30,7 @@ export interface TreeOptions {
descendantLevels: number;
descendants: number;
ancestors: number;
timeRange: {
timeRange?: {
from: string;
to: string;
};

View file

@ -36,6 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'),
'--csp.strict=false',
'--csp.warnLegacyBrowsers=false',
'--usageCollection.uiCounters.enabled=false',
// define custom kibana server args here
`--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`,
// retrieve rules from the filesystem but not from fleet for Cypress tests