From 69cd603a9047918c56430fe405850c415ee17f16 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Mon, 20 Jun 2022 17:15:15 +0200 Subject: [PATCH 1/3] [Discover] Replace RxJS firstValueFrom with RxJS lastValueFrom to prevent problem with partial results (#134682) --- .../public/hooks/use_es_doc_search.test.tsx | 59 +++++++++++-- .../public/hooks/use_es_doc_search.ts | 87 ++++++++++--------- .../expression/es_query_expression.test.tsx | 38 +++++++- .../expression/es_query_expression.tsx | 4 +- .../search_source_expression.test.tsx | 30 +++++-- .../search_source_expression_form.tsx | 4 +- 6 files changed, 158 insertions(+), 64 deletions(-) diff --git a/src/plugins/discover/public/hooks/use_es_doc_search.test.tsx b/src/plugins/discover/public/hooks/use_es_doc_search.test.tsx index a059ae721ecad..7a482ab086501 100644 --- a/src/plugins/discover/public/hooks/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/hooks/use_es_doc_search.test.tsx @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; import { buildSearchBody, useEsDocSearch } from './use_es_doc_search'; -import { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; import { DataView } from '@kbn/data-views-plugin/public'; import { DocProps } from '../application/doc/components/doc'; import { ElasticRequestState } from '../application/doc/types'; @@ -16,8 +16,7 @@ import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../c import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import React from 'react'; -const mockSearchResult = new Observable(); - +const mockSearchResult = new Subject(); const services = { data: { search: { @@ -171,23 +170,69 @@ describe('Test of helper / hook', () => { `); }); - test('useEsDocSearch', async () => { + test('useEsDocSearch loading', async () => { + const indexPattern = { + getComputedFields: () => [], + }; + const props = { + id: '1', + index: 'index1', + indexPattern, + } as unknown as DocProps; + + const hook = renderHook((p: DocProps) => useEsDocSearch(p), { + initialProps: props, + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(hook.result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]); + }); + + test('useEsDocSearch ignore partial results', async () => { const indexPattern = { getComputedFields: () => [], }; + + const record = { test: 1 }; + const props = { id: '1', index: 'index1', indexPattern, } as unknown as DocProps; - const { result } = renderHook((p: DocProps) => useEsDocSearch(p), { + const hook = renderHook((p: DocProps) => useEsDocSearch(p), { initialProps: props, wrapper: ({ children }) => ( {children} ), }); - expect(result.current.slice(0, 2)).toEqual([ElasticRequestState.Loading, null]); + await act(async () => { + mockSearchResult.next({ + isPartial: true, + isRunning: false, + rawResponse: { + hits: { + hits: [], + }, + }, + }); + mockSearchResult.next({ + isPartial: false, + isRunning: false, + rawResponse: { + hits: { + hits: [record], + }, + }, + }); + mockSearchResult.complete(); + await hook.waitForNextUpdate(); + }); + + expect(hook.result.current.slice(0, 2)).toEqual([ElasticRequestState.Found, record]); }); }); diff --git a/src/plugins/discover/public/hooks/use_es_doc_search.ts b/src/plugins/discover/public/hooks/use_es_doc_search.ts index 27393dca72da3..84e759962de04 100644 --- a/src/plugins/discover/public/hooks/use_es_doc_search.ts +++ b/src/plugins/discover/public/hooks/use_es_doc_search.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { firstValueFrom } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; import { DataView } from '@kbn/data-views-plugin/public'; import { DocProps } from '../application/doc/components/doc'; import { ElasticRequestState } from '../application/doc/types'; @@ -18,47 +18,6 @@ import { useDiscoverServices } from './use_discover_services'; type RequestBody = Pick; -/** - * helper function to build a query body for Elasticsearch - * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html - */ -export function buildSearchBody( - id: string, - indexPattern: DataView, - useNewFieldsApi: boolean, - requestAllFields?: boolean -): RequestBody | undefined { - const computedFields = indexPattern.getComputedFields(); - const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields; - const request: RequestBody = { - body: { - query: { - ids: { - values: [id], - }, - }, - stored_fields: computedFields.storedFields, - script_fields: computedFields.scriptFields, - version: true, - }, - }; - if (!request.body) { - return undefined; - } - if (useNewFieldsApi) { - // @ts-expect-error - request.body.fields = [{ field: '*', include_unmapped: 'true' }]; - request.body.runtime_mappings = runtimeFields ? runtimeFields : {}; - if (requestAllFields) { - request.body._source = true; - } - } else { - request.body._source = true; - } - request.body.fields = [...(request.body?.fields || []), ...(computedFields.docvalueFields || [])]; - return request; -} - /** * Custom react hook for querying a single doc in ElasticSearch */ @@ -75,7 +34,7 @@ export function useEsDocSearch({ const requestData = useCallback(async () => { try { - const { rawResponse } = await firstValueFrom( + const result = await lastValueFrom( data.search.search({ params: { index, @@ -83,6 +42,7 @@ export function useEsDocSearch({ }, }) ); + const rawResponse = result.rawResponse; const hits = rawResponse.hits; @@ -109,3 +69,44 @@ export function useEsDocSearch({ return [status, hit, requestData]; } + +/** + * helper function to build a query body for Elasticsearch + * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html + */ +export function buildSearchBody( + id: string, + indexPattern: DataView, + useNewFieldsApi: boolean, + requestAllFields?: boolean +): RequestBody | undefined { + const computedFields = indexPattern.getComputedFields(); + const runtimeFields = computedFields.runtimeFields as estypes.MappingRuntimeFields; + const request: RequestBody = { + body: { + query: { + ids: { + values: [id], + }, + }, + stored_fields: computedFields.storedFields, + script_fields: computedFields.scriptFields, + version: true, + }, + }; + if (!request.body) { + return undefined; + } + if (useNewFieldsApi) { + // @ts-expect-error + request.body.fields = [{ field: '*', include_unmapped: 'true' }]; + request.body.runtime_mappings = runtimeFields ? runtimeFields : {}; + if (requestAllFields) { + request.body._source = true; + } + } else { + request.body._source = true; + } + request.body.fields = [...(request.body?.fields || []), ...(computedFields.docvalueFields || [])]; + return request; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.test.tsx index 7e38fcf81c678..e361c4d98bb77 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import 'brace'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { act } from 'react-dom/test-utils'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -220,6 +220,42 @@ describe('EsQueryAlertTypeExpression', () => { ); }); + test('should show success message if Test Query is successful (with partial result)', async () => { + const partial = { + isRunning: true, + isPartial: true, + }; + const complete = { + isRunning: false, + isPartial: false, + rawResponse: { + hits: { + total: 1234, + }, + }, + }; + const searchResponseMock$ = new Subject(); + dataMock.search.search.mockImplementation(() => searchResponseMock$); + const wrapper = await setup(defaultEsQueryExpressionParams); + const testQueryButton = wrapper.find('EuiButton[data-test-subj="testQuery"]'); + + testQueryButton.simulate('click'); + expect(dataMock.search.search).toHaveBeenCalled(); + await act(async () => { + searchResponseMock$.next(partial); + searchResponseMock$.next(complete); + searchResponseMock$.complete(); + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy(); + expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual( + `Query matched 1234 documents in the last 15s.` + ); + }); + test('should show error message if Test Query is throws error', async () => { dataMock.search.search.mockImplementation(() => { throw new Error('What is this query'); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx index 92096ba4541c4..97bf42ca2599a 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/es_query_expression.tsx @@ -6,7 +6,7 @@ */ import React, { useState, Fragment, useEffect, useCallback } from 'react'; -import { firstValueFrom } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -141,7 +141,7 @@ export const EsQueryExpression = ({ const timeWindow = parseDuration(window); const parsedQuery = JSON.parse(esQuery); const now = Date.now(); - const { rawResponse } = await firstValueFrom( + const { rawResponse } = await lastValueFrom( data.search.search({ params: buildSortedEventsQuery({ index, diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx index 091fd606e1bf0..1ad76de08f5e7 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.test.tsx @@ -14,8 +14,8 @@ import { EsQueryAlertParams, SearchType } from '../types'; import { SearchSourceExpression } from './search_source_expression'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { act } from 'react-dom/test-utils'; -import { of } from 'rxjs'; -import { IKibanaSearchResponse, ISearchSource } from '@kbn/data-plugin/common'; +import { Subject } from 'rxjs'; +import { ISearchSource } from '@kbn/data-plugin/common'; import { IUiSettingsClient } from '@kbn/core/public'; import { findTestSubject } from '@elastic/eui/lib/test'; import { EuiLoadingSpinner } from '@elastic/eui'; @@ -40,6 +40,20 @@ const defaultSearchSourceExpressionParams: EsQueryAlertParams { - return of({ - rawResponse: { - hits: { - total: 1234, - }, - }, - }); + return mockSearchResult; }), } as unknown as ISearchSource; @@ -143,6 +151,7 @@ describe('SearchSourceAlertTypeExpression', () => { wrapper = await wrapper.update(); expect(findTestSubject(wrapper, 'thresholdExpression')).toBeTruthy(); }); + test('should show success message if Test Query is successful', async () => { let wrapper = setup(defaultSearchSourceExpressionParams); await act(async () => { @@ -156,6 +165,9 @@ describe('SearchSourceAlertTypeExpression', () => { wrapper = await wrapper.update(); await act(async () => { + mockSearchResult.next(testResultPartial); + mockSearchResult.next(testResultComplete); + mockSearchResult.complete(); await nextTick(); wrapper.update(); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx index c351a1fe04c6a..bd03babf85a0b 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression_form.tsx @@ -7,7 +7,7 @@ import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import deepEqual from 'fast-deep-equal'; -import { firstValueFrom } from 'rxjs'; +import { lastValueFrom } from 'rxjs'; import { Filter } from '@kbn/es-query'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; @@ -183,7 +183,7 @@ export const SearchSourceExpressionForm = (props: SearchSourceExpressionFormProp 'filter', timeFilter ? [timeFilter, ...ruleConfiguration.filter] : ruleConfiguration.filter ); - const { rawResponse } = await firstValueFrom(testSearchSource.fetch$()); + const { rawResponse } = await lastValueFrom(testSearchSource.fetch$()); return { nrOfDocs: totalHitsToNumber(rawResponse.hits.total), timeWindow }; }, [searchSource, timeWindowSize, timeWindowUnit, ruleConfiguration]); From 7649b006899063b1ce06b158abaf5cbc3ac0ff21 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 20 Jun 2022 08:19:38 -0700 Subject: [PATCH 2/3] [DOCS] Adds prerequisites in update rule API (#134151) --- docs/api/alerting.asciidoc | 2 +- docs/api/alerting/create_rule.asciidoc | 1 - docs/api/alerting/update_rule.asciidoc | 156 ++++++++++++++++--------- 3 files changed, 102 insertions(+), 57 deletions(-) diff --git a/docs/api/alerting.asciidoc b/docs/api/alerting.asciidoc index e0ea8cdc98509..fd5a23886cc5a 100644 --- a/docs/api/alerting.asciidoc +++ b/docs/api/alerting.asciidoc @@ -33,7 +33,7 @@ For deprecated APIs, refer to <>. include::alerting/create_rule.asciidoc[leveloffset=+1] include::alerting/delete_rule.asciidoc[leveloffset=+1] -include::alerting/update_rule.asciidoc[] +include::alerting/update_rule.asciidoc[leveloffset=+1] include::alerting/get_rules.asciidoc[] include::alerting/find_rules.asciidoc[] include::alerting/list_rule_types.asciidoc[] diff --git a/docs/api/alerting/create_rule.asciidoc b/docs/api/alerting/create_rule.asciidoc index 484866436d97d..0b219ad00ebcb 100644 --- a/docs/api/alerting/create_rule.asciidoc +++ b/docs/api/alerting/create_rule.asciidoc @@ -207,7 +207,6 @@ The API returns the following: }, "rule_type_id": ".index-threshold", "scheduled_task_id": "425b0800-6bca-11eb-9e0d-85d233e3ee35", - "snooze_schedule":[], "created_by": "elastic", "updated_by": "elastic", "created_at": "2022-06-08T17:20:31.632Z", diff --git a/docs/api/alerting/update_rule.asciidoc b/docs/api/alerting/update_rule.asciidoc index 5fb8f17d6ebb5..ecce62912939d 100644 --- a/docs/api/alerting/update_rule.asciidoc +++ b/docs/api/alerting/update_rule.asciidoc @@ -1,5 +1,5 @@ [[update-rule-api]] -=== Update rule API +== Update rule API ++++ Update rule ++++ @@ -7,13 +7,22 @@ Update the attributes for an existing rule. [[update-rule-api-request]] -==== Request +=== {api-request-title} `PUT :/api/alerting/rule/` `PUT :/s//api/alerting/rule/` -==== {api-description-title} +=== {api-prereq-title} + +You must have `all` privileges for the *Management* > *Stack Rules* feature or +for the *{ml-app}*, *{observability}*, or *Security* features, depending on the +`consumer` and `rule_type_id` of the rule you're updating. If the rule has +`actions`, you must also have `read` privileges for the *Management* > +*Actions and Connectors* feature. For more details, refer to +<>. + +=== {api-description-title} [WARNING] ==== @@ -23,83 +32,109 @@ Update the attributes for an existing rule. Thereafter, when the rule performs queries, it uses those security privileges. If you have different privileges than the user that created or most recently updated the rule, you might change its behavior. +* Though some properties are optional, when you update the rule the existing +property values are overwritten with default values. Therefore, it is +recommended to explicitly set all property values. ==== [[update-rule-api-path-params]] -==== Path parameters +=== {api-path-parms-title} `id`:: - (Required, string) The ID of the rule that you want to update. +(Required, string) The ID of the rule that you want to update. `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. +(Optional, string) An identifier for the space. If `space_id` is not provided in +the URL, the default space is used. +[role="child_attributes"] [[update-rule-api-request-body]] -==== Request body - -`name`:: - (Required, string) A name to reference and search. - -`tags`:: - (Optional, string array) A list of keywords to reference and search. +=== {api-request-body-title} -`schedule`:: - (Required, object) When to run this rule. Use one of the available schedule formats. +`actions`:: +(Optional, object array) An array of action objects. The default value is an +empty array (`[]`). + -._Schedule Formats_. +.Properties of the action objects: [%collapsible%open] ===== -A schedule uses a key: value format. {kib} currently supports the _Interval format_ , which specifies the interval in seconds, minutes, hours, or days at which to execute the rule. +`group`::: +(Required, string) Grouping actions is recommended for escalations for different +types of alerts. If you don't need this, set the value to `default`. -Example: `{ interval: "10s" }`, `{ interval: "5m" }`, `{ interval: "1h" }`, `{ interval: "1d" }`. +`id`::: +(Required, string) The identifier of the action. +`params`::: +(Required, object) The map to the `params` that the +<> will receive. The `params` are handled as +Mustache templates and passed a default set of context. ===== -`throttle`:: - (Optional, string) How often this rule should fire the same actions. This will prevent the rule from sending out the same notification over and over. For example, if a rule with a `schedule` of 1 minute stays in a triggered state for 90 minutes, setting a `throttle` of `10m` or `1h` will prevent it from sending 90 notifications during this period. +`name`:: +(Required, string) A name to reference and search. `notify_when`:: - (Required, string) The condition for throttling the notification: `onActionGroupChange`, `onActiveAlert`, or `onThrottleInterval`. +(Required, string) The condition for throttling the notification: +`onActionGroupChange`, `onActiveAlert`, or `onThrottleInterval`. `params`:: - (Required, object) The parameters to pass to the rule type executor `params` value. This will also validate against the rule type params validator, if defined. +(Required, object) The parameters to pass to the rule type executor `params` +value. This will also validate against the rule type params validator, if defined. -`actions`:: - (Optional, object array) An array of the following action objects. +`schedule`:: +(Required, object) When to run this rule. Use one of the available schedule formats. + -.Properties of the action objects: +.Schedule formats [%collapsible%open] ===== - `group`::: - (Required, string) Grouping actions is recommended for escalations for different types of alerts. If you don't need this, set the value to `default`. - - `id`::: - (Required, string) The ID of the action that saved object executes. +A schedule uses a `key: value` format. {kib} currently supports the +_interval format_, which specifies the interval in seconds, minutes, hours, or +days at which to run the rule. For example: `{ "interval": "10s" }`, +`{ "interval": "5m" }`, `{ "interval": "1h" }`, or `{ "interval": "1d" }`. - `params`::: - (Required, object) The map to the `params` that the <> will receive. `params` are handled as Mustache templates and passed a default set of context. ===== +`tags`:: +(Optional, string array) A list of keywords to reference and search. The default +value is an empty array (`[]`). + +`throttle`:: +(Optional, string) How often this rule should fire the same actions. This will +prevent the rule from sending out the same notification over and over. For +example, if a rule with a `schedule` of 1 minute stays in a triggered state for +90 minutes, setting a `throttle` of `10m` or `1h` will prevent it from sending +90 notifications during this period. The default value is `null`. [[update-rule-api-response-codes]] -==== Response code +=== {api-response-codes-title} `200`:: - Indicates a successful call. +Indicates a successful call. [[update-rule-api-example]] -==== Example +=== {api-examples-title} -Update a rule with ID `ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74` with a different name: +Update an index threshold rule with ID `ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74`: [source,sh] -------------------------------------------------- -$ curl -X PUT api/alerting/rule/ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74 - +PUT api/alerting/rule/ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74 { "notify_when": "onActionGroupChange", "params": { - "aggType": "avg", + "index":[".test-index"], + "timeField":"@timestamp", + "groupBy":"top", + "aggType":"avg", + "timeWindowSize":5, + "timeWindowUnit":"m", + "thresholdComparator":">", + "threshold":[1000], + "aggType":"avg", + "aggField":"sheet.version", + "termField":"name.keyword", + "termSize":6 }, "schedule": { "interval": "1m" @@ -107,7 +142,7 @@ $ curl -X PUT api/alerting/rule/ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74 "actions": [], "tags": [], "name": "new name", - "throttle": null, + "throttle": null } -------------------------------------------------- // KIBANA @@ -118,31 +153,42 @@ The API returns the following: -------------------------------------------------- { "id": "ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74", - "notify_when": "onActionGroupChange", - "params": { - "aggType": "avg", - }, "consumer": "alerts", - "rule_type_id": "test.rule.type", - "schedule": { - "interval": "1m" - }, - "actions": [], "tags": [], "name": "new name", "enabled": true, "throttle": null, + "schedule": { + "interval": "1m" + }, + "params": { + "index": [".updated-index"], + "timeField": "@timestamp", + "groupBy": "top", + "aggType": "avg", + "timeWindowSize": 5, + "timeWindowUnit": "m", + "thresholdComparator": ">", + "threshold": [1000], + "aggField": "sheet.version", + "termField": "name.keyword", + "termSize": 6 + }, "api_key_owner": "elastic", "created_by": "elastic", "updated_by": "elastic", + "rule_type_id": ".index-threshold", + "scheduled_task_id": "4c5eda00-e74f-11ec-b72f-5b18752ff9ea", + "created_at": "2022-06-08T17:20:31.632Z", + "updated_at": "2022-06-09T23:36:36.090Z", + "notify_when": "onActionGroupChange", "mute_all": false, "muted_alert_ids": [], - "updated_at": "2021-02-10T05:37:19.086Z", - "created_at": "2021-02-10T05:37:19.086Z", - "scheduled_task_id": "0b092d90-6b62-11eb-9e0d-85d233e3ee35", "execution_status": { - "last_execution_date": "2021-02-10T17:55:14.262Z", - "status": "ok" - } + "status": "ok", + "last_execution_date": "2022-06-09T23:36:17.332Z", + "last_duration": 577 + }, + "actions":[] } -------------------------------------------------- From e668693f36dda056011153be0c604be77c7bf6b8 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Mon, 20 Jun 2022 10:25:03 -0500 Subject: [PATCH 3/3] [Security Solution] add running-processes response action API (#134573) --- .../common/endpoint/schema/actions.test.ts | 16 ++++---- .../common/endpoint/schema/actions.ts | 4 +- .../endpoint/service/authz/authz.test.ts | 11 ++++++ .../common/endpoint/service/authz/authz.ts | 2 + .../common/endpoint/types/actions.ts | 10 +++-- .../common/endpoint/types/authz.ts | 2 + .../endpoint/routes/actions/isolation.test.ts | 12 +++--- .../endpoint/routes/actions/isolation.ts | 8 ++-- .../routes/actions/response_actions.test.ts | 39 +++++++++++++++++++ .../routes/actions/response_actions.ts | 21 ++++++++-- .../services/feature_usage/service.ts | 1 + 11 files changed, 100 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts index 60cab431a5444..d3f47421d7d7c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts @@ -9,7 +9,7 @@ import uuid from 'uuid'; import { EndpointActionListRequestSchema, - HostIsolationRequestSchema, + NoParametersRequestSchema, KillOrSuspendProcessRequestSchema, } from './actions'; @@ -147,16 +147,16 @@ describe('actions schemas', () => { }); }); - describe('HostIsolationRequestSchema', () => { + describe('NoParametersRequestSchema', () => { it('should require at least 1 Endpoint ID', () => { expect(() => { - HostIsolationRequestSchema.body.validate({}); + NoParametersRequestSchema.body.validate({}); }).toThrow(); }); it('should accept an Endpoint ID as the only required field', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], }); }).not.toThrow(); @@ -164,7 +164,7 @@ describe('actions schemas', () => { it('should accept a comment', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], comment: 'a user comment', }); @@ -173,7 +173,7 @@ describe('actions schemas', () => { it('should accept alert IDs', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], alert_ids: ['0000000-000-00'], }); @@ -182,7 +182,7 @@ describe('actions schemas', () => { it('should accept case IDs', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], case_ids: ['000000000-000-000'], }); @@ -193,7 +193,7 @@ describe('actions schemas', () => { describe('KillOrSuspendProcessRequestSchema', () => { it('should require at least 1 Endpoint ID', () => { expect(() => { - HostIsolationRequestSchema.body.validate({}); + NoParametersRequestSchema.body.validate({}); }).toThrow(); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index c4dfa7a5b434c..cf190e5c62ca2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -18,7 +18,7 @@ const BaseActionRequestSchema = { parameters: schema.maybe(schema.object({})), }; -export const HostIsolationRequestSchema = { +export const NoParametersRequestSchema = { body: schema.object({ ...BaseActionRequestSchema }), }; @@ -33,7 +33,7 @@ export const KillOrSuspendProcessRequestSchema = { }; export const ResponseActionBodySchema = schema.oneOf([ - HostIsolationRequestSchema.body, + NoParametersRequestSchema.body, KillOrSuspendProcessRequestSchema.body, ]); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index 0389ac8e216ae..acf71fa584198 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -30,6 +30,7 @@ describe('Endpoint Authz service', () => { ['canUnIsolateHost'], ['canKillProcess'], ['canSuspendProcess'], + ['canGetRunningProcesses'], ])('should set `%s` to `true`', (authProperty) => { expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe( true @@ -60,6 +61,14 @@ describe('Endpoint Authz service', () => { ).toBe(false); }); + it('should set `canGetRunningProcesses` to false if not proper license', () => { + licenseService.isPlatinumPlus.mockReturnValue(false); + + expect( + calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canGetRunningProcesses + ).toBe(false); + }); + it('should set `canUnIsolateHost` to true even if not proper license', () => { licenseService.isPlatinumPlus.mockReturnValue(false); @@ -82,6 +91,7 @@ describe('Endpoint Authz service', () => { ['canUnIsolateHost'], ['canKillProcess'], ['canSuspendProcess'], + ['canGetRunningProcesses'], ])('should set `%s` to `false`', (authProperty) => { expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe( false @@ -108,6 +118,7 @@ describe('Endpoint Authz service', () => { canCreateArtifactsByPolicy: false, canKillProcess: false, canSuspendProcess: false, + canGetRunningProcesses: false, }); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts index 5acf3e5df1975..6015d621714a1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -34,6 +34,7 @@ export const calculateEndpointAuthz = ( canUnIsolateHost: hasAllAccessToFleet, canKillProcess: hasAllAccessToFleet && isPlatinumPlusLicense, canSuspendProcess: hasAllAccessToFleet && isPlatinumPlusLicense, + canGetRunningProcesses: hasAllAccessToFleet && isPlatinumPlusLicense, }; }; @@ -46,5 +47,6 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canUnIsolateHost: true, canKillProcess: false, canSuspendProcess: false, + canGetRunningProcesses: false, }; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 2dd82d7609de4..ee07e05bac2d3 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -8,13 +8,17 @@ import { TypeOf } from '@kbn/config-schema'; import { ActionStatusRequestSchema, - HostIsolationRequestSchema, + NoParametersRequestSchema, ResponseActionBodySchema, } from '../schema/actions'; export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; -export type ResponseActions = ISOLATION_ACTIONS | 'kill-process' | 'suspend-process'; +export type ResponseActions = + | ISOLATION_ACTIONS + | 'kill-process' + | 'suspend-process' + | 'running-processes'; export const ActivityLogItemTypes = { ACTION: 'action' as const, @@ -189,7 +193,7 @@ export interface ActivityLog { data: ActivityLogEntry[]; } -export type HostIsolationRequestBody = TypeOf; +export type HostIsolationRequestBody = TypeOf; export type ResponseActionRequestBody = TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts index 3f7a50537177f..054f6ab99aa89 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts @@ -24,6 +24,8 @@ export interface EndpointAuthz { canKillProcess: boolean; /** If user has permissions to suspend process on hosts */ canSuspendProcess: boolean; + /** If user has permissions to get running processes on hosts */ + canGetRunningProcesses: boolean; } export type EndpointAuthzKeyList = Array; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts index 85d3fd1f064e2..bd3484a0e7518 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.test.ts @@ -28,7 +28,7 @@ import { createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; -import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { NoParametersRequestSchema } from '../../../../common/endpoint/schema/actions'; import { registerHostIsolationRoutes } from './isolation'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { LicenseService } from '../../../../common/license'; @@ -74,13 +74,13 @@ describe('Host Isolation', () => { describe('schema', () => { it('should require at least 1 Endpoint ID', () => { expect(() => { - HostIsolationRequestSchema.body.validate({}); + NoParametersRequestSchema.body.validate({}); }).toThrow(); }); it('should accept an Endpoint ID as the only required field', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], }); }).not.toThrow(); @@ -88,7 +88,7 @@ describe('Host Isolation', () => { it('should accept a comment', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], comment: 'a user comment', }); @@ -97,7 +97,7 @@ describe('Host Isolation', () => { it('should accept alert IDs', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], alert_ids: ['0000000-000-00'], }); @@ -106,7 +106,7 @@ describe('Host Isolation', () => { it('should accept case IDs', () => { expect(() => { - HostIsolationRequestSchema.body.validate({ + NoParametersRequestSchema.body.validate({ endpoint_ids: ['ABC-XYZ-000'], case_ids: ['000000000-000-000'], }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index c640f56efb512..bd0e3f07ce6a8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -12,7 +12,7 @@ import { TypeOf } from '@kbn/config-schema'; import { CommentType } from '@kbn/cases-plugin/common'; import { CasesByAlertId } from '@kbn/cases-plugin/common/api/cases/case'; import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common'; -import { HostIsolationRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { NoParametersRequestSchema } from '../../../../common/endpoint/schema/actions'; import { ENDPOINT_ACTIONS_DS, ENDPOINT_ACTION_RESPONSES_DS, @@ -49,7 +49,7 @@ export function registerHostIsolationRoutes( router.post( { path: ISOLATE_HOST_ROUTE, - validate: HostIsolationRequestSchema, + validate: NoParametersRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( @@ -63,7 +63,7 @@ export function registerHostIsolationRoutes( router.post( { path: UNISOLATE_HOST_ROUTE, - validate: HostIsolationRequestSchema, + validate: NoParametersRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( @@ -107,7 +107,7 @@ export const isolationRequestHandler = function ( ): RequestHandler< unknown, unknown, - TypeOf, + TypeOf, SecuritySolutionRequestHandlerContext > { return async (context, req, res) => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts index 3a91ee35269be..bbcb549f55f74 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts @@ -40,6 +40,7 @@ import { ENDPOINT_ACTIONS_INDEX, KILL_PROCESS_ROUTE, SUSPEND_PROCESS_ROUTE, + GET_RUNNING_PROCESSES_ROUTE, } from '../../../../common/endpoint/constants'; import { ActionDetails, @@ -381,6 +382,17 @@ describe('Response actions', () => { expect(actionDoc.data.command).toEqual('suspend-process'); }); + it('sends the running-processes command payload from the running processes route', async () => { + const ctx = await callRoute(GET_RUNNING_PROCESSES_ROUTE, { + body: { endpoint_ids: ['XYZ'] }, + }); + const actionDoc: EndpointAction = ( + ctx.core.elasticsearch.client.asInternalUser.index.mock + .calls[0][0] as estypes.IndexRequest + ).body!; + expect(actionDoc.data.command).toEqual('running-processes'); + }); + describe('With endpoint data streams', () => { it('handles unisolation', async () => { const ctx = await callRoute( @@ -495,6 +507,33 @@ describe('Response actions', () => { expect(responseBody.action).toBeUndefined(); }); + it('handles running-processes', async () => { + const ctx = await callRoute( + GET_RUNNING_PROCESSES_ROUTE, + { + body: { endpoint_ids: ['XYZ'] }, + }, + { endpointDsExists: true } + ); + const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index; + const actionDocs: [ + { index: string; body?: LogsEndpointAction }, + { index: string; body?: EndpointAction } + ] = [ + indexDoc.mock.calls[0][0] as estypes.IndexRequest, + indexDoc.mock.calls[1][0] as estypes.IndexRequest, + ]; + + expect(actionDocs[0].index).toEqual(ENDPOINT_ACTIONS_INDEX); + expect(actionDocs[1].index).toEqual(AGENT_ACTIONS_INDEX); + expect(actionDocs[0].body!.EndpointActions.data.command).toEqual('running-processes'); + expect(actionDocs[1].body!.data.command).toEqual('running-processes'); + + expect(mockResponse.ok).toBeCalled(); + const responseBody = mockResponse.ok.mock.calls[0][0]?.body as ResponseActionApiResponse; + expect(responseBody.action).toBeUndefined(); + }); + it('handles errors', async () => { const ErrMessage = 'Uh oh!'; await callRoute( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts index 5f7ad42127f7c..71c61d8aacce8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.ts @@ -15,7 +15,7 @@ import { AGENT_ACTIONS_INDEX } from '@kbn/fleet-plugin/common'; import { CommentType } from '@kbn/cases-plugin/common'; import { - HostIsolationRequestSchema, + NoParametersRequestSchema, KillOrSuspendProcessRequestSchema, ResponseActionBodySchema, } from '../../../../common/endpoint/schema/actions'; @@ -28,6 +28,7 @@ import { failedFleetActionErrorCode, KILL_PROCESS_ROUTE, SUSPEND_PROCESS_ROUTE, + GET_RUNNING_PROCESSES_ROUTE, } from '../../../../common/endpoint/constants'; import type { EndpointAction, @@ -58,7 +59,7 @@ export function registerResponseActionRoutes( router.post( { path: ISOLATE_HOST_ROUTE_V2, - validate: HostIsolationRequestSchema, + validate: NoParametersRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( @@ -71,7 +72,7 @@ export function registerResponseActionRoutes( router.post( { path: RELEASE_HOST_ROUTE, - validate: HostIsolationRequestSchema, + validate: NoParametersRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( @@ -112,6 +113,19 @@ export function registerResponseActionRoutes( ) ) ); + + router.post( + { + path: GET_RUNNING_PROCESSES_ROUTE, + validate: NoParametersRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { all: ['canGetRunningProcesses'] }, + logger, + responseActionRequestHandler(endpointContext, 'running-processes') + ) + ); } const commandToFeatureKeyMap = new Map([ @@ -119,6 +133,7 @@ const commandToFeatureKeyMap = new Map([ ['unisolate', 'HOST_ISOLATION'], ['kill-process', 'KILL_PROCESS'], ['suspend-process', 'SUSPEND_PROCESS'], + ['running-processes', 'RUNNING_PROCESSES'], ]); const returnActionIdCommands: ResponseActions[] = ['isolate', 'unisolate']; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts index 4171bb803fe65..53cb1b8ec2098 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts @@ -20,6 +20,7 @@ const FEATURES = { BEHAVIOR_PROTECTION: 'Behavior protection', KILL_PROCESS: 'Kill process', SUSPEND_PROCESS: 'Suspend process', + RUNNING_PROCESSES: 'Get running processes', } as const; export type FeatureKeys = keyof typeof FEATURES;