From ad8cec13b6ebb8270dd91fc4012ed8ff2b531353 Mon Sep 17 00:00:00 2001 From: Jorge Sanz Date: Thu, 10 Oct 2024 21:02:23 +0200 Subject: [PATCH 01/18] [Docs][Maps] Update EMS Server instructions (#195419) ## Summary Small improvements to the Elastic Maps Service documentation: * fixes the reference to the Docker image to pull * adds details about using `cosign` to verify the image pulled * updates the screenshot to a more recent UI. --- docs/maps/connect-to-ems.asciidoc | 45 ++++++++++++------ .../elastic-maps-server-instructions.png | Bin 48671 -> 107478 bytes 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/docs/maps/connect-to-ems.asciidoc b/docs/maps/connect-to-ems.asciidoc index e41d544d64e4d..1ccdedb1da2a9 100644 --- a/docs/maps/connect-to-ems.asciidoc +++ b/docs/maps/connect-to-ems.asciidoc @@ -1,6 +1,6 @@ :ems: Elastic Maps Service :ems-docker-repo: docker.elastic.co/elastic-maps-service/elastic-maps-server -:ems-docker-image: {ems-docker-repo}:{version}-amd64 +:ems-docker-image: {ems-docker-repo}:{version} :ems-headers-url: https://deployment-host [[maps-connect-to-ems]] @@ -81,34 +81,53 @@ If you cannot connect to {ems} from the {kib} server or browser clients, and you {hosted-ems} is a self-managed version of {ems} offered as a Docker image that provides both the EMS basemaps and EMS boundaries. The image is bundled with basemaps up to zoom level 8. After connecting it to your {es} cluster for license validation, you have the option to download and configure a more detailed basemaps database. -You can use +docker pull+ to download the {hosted-ems} image from the Elastic Docker registry. - +. Pull the {hosted-ems} Docker image. ++ ifeval::["{release-state}"=="unreleased"] -Version {version} of {hosted-ems} has not yet been released, so no Docker image is currently available for this version. +WARNING: Version {version} of {hosted-ems} has not yet been released. +No Docker image is currently available for this version. endif::[] - -ifeval::["{release-state}"!="unreleased"] - ++ ["source","bash",subs="attributes"] ---------------------------------- docker pull {ems-docker-image} ---------------------------------- -Start {hosted-ems} and expose the default port `8080`: +. Optional: Install +https://docs.sigstore.dev/system_config/installation/[Cosign] for your +environment. Then use Cosign to verify the {es} image's signature. ++ +[source,sh,subs="attributes"] +---- +wget https://artifacts.elastic.co/cosign.pub +cosign verify --key cosign.pub {ems-docker-image} +---- ++ +The `cosign` command prints the check results and the signature payload in JSON format: ++ +[source,sh,subs="attributes"] +-------------------------------------------- +Verification for {ems-docker-image} -- +The following checks were performed on each of these signatures: + - The cosign claims were validated + - Existence of the claims in the transparency log was verified offline + - The signatures were verified against the specified public key +-------------------------------------------- + +. Start {hosted-ems} and expose the default port `8080`: ++ ["source","bash",subs="attributes"] ---------------------------------- docker run --rm --init --publish 8080:8080 \ {ems-docker-image} ---------------------------------- - ++ Once {hosted-ems} is running, follow instructions from the webpage at `localhost:8080` to define a configuration file and optionally download a more detailed basemaps database. - ++ [role="screenshot"] image::images/elastic-maps-server-instructions.png[Set-up instructions] -endif::[] - [float] [[elastic-maps-server-configuration]] ==== Configuration @@ -193,7 +212,6 @@ One way to configure {hosted-ems} is to provide `elastic-maps-server.yml` via bi ["source","yaml",subs="attributes"] -------------------------------------------- -version: '2' services: ems-server: image: {ems-docker-image} @@ -212,7 +230,6 @@ These variables can be set with +docker-compose+ like this: ["source","yaml",subs="attributes"] ---------------------------------------------------------- -version: '2' services: ems-server: image: {ems-docker-image} diff --git a/docs/maps/images/elastic-maps-server-instructions.png b/docs/maps/images/elastic-maps-server-instructions.png index 5c0b47ce8f49f34278e6f5515b940585ee74cb52..524ae2192b5e57a91d538fefea302e6f5b1421a8 100644 GIT binary patch literal 107478 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

WRf>n&7Y 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�c@_+`AqvL}#)jNh5|2F0;pEc_zHjN)n!-xwcG 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�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 literal 48671 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

!XY%h{+U)}( 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 Date: Thu, 10 Oct 2024 14:59:20 -0500 Subject: [PATCH 02/18] Skip only Risk Engine initializing test with a FIPS issue (#195651) ## Summary More investigation is needed to ensure that the `remove legacy risk score transform` test is passing in promotion pipelines. However, that particular feature that the test asserts (Legacy Entity Risk Scoring) was never available in Serverless. Therefore, we're enabling the broader tests, and just skipping the one containing the FIPS issue. --- .../trial_license_complete_tier/init_and_status_apis.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts index bd3493b82d348..19a9bb85326fa 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/init_and_status_apis.ts @@ -26,8 +26,7 @@ export default ({ getService }: FtrProviderContext) => { const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); const log = getService('log'); - // Failing: See https://github.com/elastic/kibana/issues/191637 - describe.skip('@ess @serverless @serverlessQA init_and_status_apis', () => { + describe('@ess @serverless @serverlessQA init_and_status_apis', () => { before(async () => { await riskEngineRoutes.cleanUp(); }); @@ -298,8 +297,8 @@ export default ({ getService }: FtrProviderContext) => { firstResponse?.saved_objects?.[0]?.id ); }); - - describe('remove legacy risk score transform', function () { + // Failing: See https://github.com/elastic/kibana/issues/191637 + describe.skip('remove legacy risk score transform', function () { this.tags('skipFIPS'); it('should remove legacy risk score transform if it exists', async () => { await installLegacyRiskScore({ supertest }); From 3ec190823fa39520dc50f5c4631eb81cd223ed3e Mon Sep 17 00:00:00 2001 From: Sandra G Date: Thu, 10 Oct 2024 15:59:48 -0400 Subject: [PATCH 03/18] [Data Usage] process autoops mock data (#195640) - validates autoOps response data using mock data and new type - processes autoOps data to return an object of {x,y} values from our API instead of array of [timestamp, value]. updates UI accordingly --- .../common/rest_types/usage_metrics.test.ts | 79 +++++++-------- .../common/rest_types/usage_metrics.ts | 99 ++++++++++--------- .../public/app/components/chart_panel.tsx | 13 +-- .../public/app/components/charts.tsx | 4 +- .../data_usage/public/app/data_usage.tsx | 29 +++--- x-pack/plugins/data_usage/public/app/types.ts | 24 ----- .../public/hooks/use_get_usage_metrics.ts | 18 ++-- .../server/routes/internal/usage_metrics.ts | 6 +- .../routes/internal/usage_metrics_handler.ts | 59 +++++------ 9 files changed, 153 insertions(+), 178 deletions(-) delete mode 100644 x-pack/plugins/data_usage/public/app/types.ts diff --git a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts index f6c08e2caddc0..473e64c6b03d9 100644 --- a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts +++ b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.test.ts @@ -10,48 +10,29 @@ import { UsageMetricsRequestSchema } from './usage_metrics'; describe('usage_metrics schemas', () => { it('should accept valid request query', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], - }) - ).not.toThrow(); - }); - - it('should accept a single `metricTypes` in request query', () => { - expect(() => - UsageMetricsRequestSchema.query.validate({ - from: new Date().toISOString(), - to: new Date().toISOString(), - metricTypes: 'ingest_rate', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], }) ).not.toThrow(); }); it('should accept multiple `metricTypes` in request query', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['ingest_rate', 'storage_retained', 'index_rate'], - }) - ).not.toThrow(); - }); - - it('should accept a single string as `dataStreams` in request query', () => { - expect(() => - UsageMetricsRequestSchema.query.validate({ - from: new Date().toISOString(), - to: new Date().toISOString(), - metricTypes: 'storage_retained', - dataStreams: 'data_stream_1', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], }) ).not.toThrow(); }); it('should accept `dataStream` list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], @@ -62,74 +43,76 @@ describe('usage_metrics schemas', () => { it('should error if `dataStream` list is empty', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: [], }) - ).toThrowError('expected value of type [string] but got [Array]'); + ).toThrowError('[dataStreams]: array size is [0], but cannot be smaller than [1]'); }); - it('should error if `dataStream` is given an empty string', () => { + it('should error if `dataStream` is given type not array', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: ' ', }) - ).toThrow('[dataStreams] must have at least one value'); + ).toThrow('[dataStreams]: could not parse array value from json input'); }); it('should error if `dataStream` is given an empty item in the list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), metricTypes: ['storage_retained'], dataStreams: ['ds_1', ' '], }) - ).toThrow('[dataStreams] list can not contain empty values'); + ).toThrow('[dataStreams]: [dataStreams] list cannot contain empty values'); }); it('should error if `metricTypes` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ' ', }) ).toThrow(); }); - it('should error if `metricTypes` is empty item', () => { + it('should error if `metricTypes` contains an empty item', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), - metricTypes: [' ', 'storage_retained'], + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], + metricTypes: [' ', 'storage_retained'], // First item is invalid }) - ).toThrow('[metricTypes] list can not contain empty values'); + ).toThrowError(/list cannot contain empty values/); }); - it('should error if `metricTypes` is not a valid value', () => { + it('should error if `metricTypes` is not a valid type', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: 'foo', }) - ).toThrow( - '[metricTypes] must be one of storage_retained, ingest_rate, search_vcu, ingest_vcu, ml_vcu, index_latency, index_rate, search_latency, search_rate' - ); + ).toThrow('[metricTypes]: could not parse array value from json input'); }); it('should error if `metricTypes` is not a valid list', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow( @@ -139,9 +122,10 @@ describe('usage_metrics schemas', () => { it('should error if `from` is not a valid input', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: 1010, to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[from]: expected value of type [string] but got [number]'); @@ -149,9 +133,10 @@ describe('usage_metrics schemas', () => { it('should error if `to` is not a valid input', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: 1010, + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[to]: expected value of type [string] but got [number]'); @@ -159,9 +144,10 @@ describe('usage_metrics schemas', () => { it('should error if `from` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: ' ', to: new Date().toISOString(), + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[from]: Date ISO string must not be empty'); @@ -169,9 +155,10 @@ describe('usage_metrics schemas', () => { it('should error if `to` is empty string', () => { expect(() => - UsageMetricsRequestSchema.query.validate({ + UsageMetricsRequestSchema.validate({ from: new Date().toISOString(), to: ' ', + dataStreams: ['data_stream_1', 'data_stream_2', 'data_stream_3'], metricTypes: ['storage_retained', 'foo'], }) ).toThrow('[to]: Date ISO string must not be empty'); diff --git a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts index f2bbdb616fc79..3dceeadc198b0 100644 --- a/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts +++ b/x-pack/plugins/data_usage/common/rest_types/usage_metrics.ts @@ -37,51 +37,31 @@ const metricTypesSchema = schema.oneOf( // @ts-expect-error TS2769: No overload matches this call METRIC_TYPE_VALUES.map((metricType) => schema.literal(metricType)) // Create a oneOf schema for the keys ); -export const UsageMetricsRequestSchema = { - query: schema.object({ - from: DateSchema, - to: DateSchema, - metricTypes: schema.oneOf([ - schema.arrayOf(schema.string(), { - minSize: 1, - validate: (values) => { - if (values.map((v) => v.trim()).some((v) => !v.length)) { - return '[metricTypes] list can not contain empty values'; - } else if (values.map((v) => v.trim()).some((v) => !isValidMetricType(v))) { - return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; - } - }, - }), - schema.string({ - validate: (v) => { - if (!v.trim().length) { - return '[metricTypes] must have at least one value'; - } else if (!isValidMetricType(v)) { - return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; - } - }, - }), - ]), - dataStreams: schema.maybe( - schema.oneOf([ - schema.arrayOf(schema.string(), { - minSize: 1, - validate: (values) => { - if (values.map((v) => v.trim()).some((v) => !v.length)) { - return '[dataStreams] list can not contain empty values'; - } - }, - }), - schema.string({ - validate: (v) => - v.trim().length ? undefined : '[dataStreams] must have at least one value', - }), - ]) - ), +export const UsageMetricsRequestSchema = schema.object({ + from: DateSchema, + to: DateSchema, + metricTypes: schema.arrayOf(schema.string(), { + minSize: 1, + validate: (values) => { + const trimmedValues = values.map((v) => v.trim()); + if (trimmedValues.some((v) => !v.length)) { + return '[metricTypes] list cannot contain empty values'; + } else if (trimmedValues.some((v) => !isValidMetricType(v))) { + return `[metricTypes] must be one of ${METRIC_TYPE_VALUES.join(', ')}`; + } + }, }), -}; + dataStreams: schema.arrayOf(schema.string(), { + minSize: 1, + validate: (values) => { + if (values.map((v) => v.trim()).some((v) => !v.length)) { + return '[dataStreams] list cannot contain empty values'; + } + }, + }), +}); -export type UsageMetricsRequestSchemaQueryParams = TypeOf; +export type UsageMetricsRequestSchemaQueryParams = TypeOf; export const UsageMetricsResponseSchema = { body: () => @@ -92,11 +72,40 @@ export const UsageMetricsResponseSchema = { schema.object({ name: schema.string(), data: schema.arrayOf( - schema.arrayOf(schema.number(), { minSize: 2, maxSize: 2 }) // Each data point is an array of 2 numbers + schema.object({ + x: schema.number(), + y: schema.number(), + }) ), }) ) ), }), }; -export type UsageMetricsResponseSchemaBody = TypeOf; +export type UsageMetricsResponseSchemaBody = Omit< + TypeOf, + 'metrics' +> & { + metrics: Partial>; +}; +export type MetricSeries = TypeOf< + typeof UsageMetricsResponseSchema.body +>['metrics'][MetricTypes][number]; + +export const UsageMetricsAutoOpsResponseSchema = { + body: () => + schema.object({ + metrics: schema.recordOf( + metricTypesSchema, + schema.arrayOf( + schema.object({ + name: schema.string(), + data: schema.arrayOf(schema.arrayOf(schema.number(), { minSize: 2, maxSize: 2 })), + }) + ) + ), + }), +}; +export type UsageMetricsAutoOpsResponseSchemaBody = TypeOf< + typeof UsageMetricsAutoOpsResponseSchema.body +>; diff --git a/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx b/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx index c7937ae149de9..1ba3f0fe3f454 100644 --- a/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx +++ b/x-pack/plugins/data_usage/public/app/components/chart_panel.tsx @@ -19,8 +19,7 @@ import { } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import { LegendAction } from './legend_action'; -import { MetricTypes } from '../../../common/rest_types'; -import { MetricSeries } from '../types'; +import { MetricTypes, MetricSeries } from '../../../common/rest_types'; // TODO: Remove this when we have a title for each metric type type ChartKey = Extract; @@ -50,7 +49,7 @@ export const ChartPanel: React.FC = ({ }) => { const theme = useEuiTheme(); - const chartTimestamps = series.flatMap((stream) => stream.data.map((d) => d[0])); + const chartTimestamps = series.flatMap((stream) => stream.data.map((d) => d.x)); const [minTimestamp, maxTimestamp] = [Math.min(...chartTimestamps), Math.max(...chartTimestamps)]; @@ -72,6 +71,7 @@ export const ChartPanel: React.FC = ({ }, [idx, popoverOpen, togglePopover] ); + return ( @@ -94,9 +94,9 @@ export const ChartPanel: React.FC = ({ data={stream.data} xScaleType={ScaleType.Time} yScaleType={ScaleType.Linear} - xAccessor={0} // x is the first element in the tuple - yAccessors={[1]} // y is the second element in the tuple - stackAccessors={[0]} + xAccessor="x" + yAccessors={['y']} + stackAccessors={['x']} /> ))} @@ -118,6 +118,7 @@ export const ChartPanel: React.FC = ({ ); }; + const formatBytes = (bytes: number) => { return numeral(bytes).format('0.0 b'); }; diff --git a/x-pack/plugins/data_usage/public/app/components/charts.tsx b/x-pack/plugins/data_usage/public/app/components/charts.tsx index 6549f7e03830a..8d04324fb2246 100644 --- a/x-pack/plugins/data_usage/public/app/components/charts.tsx +++ b/x-pack/plugins/data_usage/public/app/components/charts.tsx @@ -6,11 +6,11 @@ */ import React, { useCallback, useState } from 'react'; import { EuiFlexGroup } from '@elastic/eui'; -import { MetricsResponse } from '../types'; import { MetricTypes } from '../../../common/rest_types'; import { ChartPanel } from './chart_panel'; +import { UsageMetricsResponseSchemaBody } from '../../../common/rest_types'; interface ChartsProps { - data: MetricsResponse; + data: UsageMetricsResponseSchemaBody; } export const Charts: React.FC = ({ data }) => { diff --git a/x-pack/plugins/data_usage/public/app/data_usage.tsx b/x-pack/plugins/data_usage/public/app/data_usage.tsx index c32f86d68b5bf..bea9f2b511a77 100644 --- a/x-pack/plugins/data_usage/public/app/data_usage.tsx +++ b/x-pack/plugins/data_usage/public/app/data_usage.tsx @@ -26,7 +26,6 @@ import { PLUGIN_NAME } from '../../common'; import { useGetDataUsageMetrics } from '../hooks/use_get_usage_metrics'; import { DEFAULT_DATE_RANGE_OPTIONS, useDateRangePicker } from './hooks/use_date_picker'; import { useDataUsageMetricsUrlParams } from './hooks/use_charts_url_params'; -import { MetricsResponse } from './types'; export const DataUsage = () => { const { @@ -42,37 +41,37 @@ export const DataUsage = () => { setUrlDateRangeFilter, } = useDataUsageMetricsUrlParams(); - const [queryParams, setQueryParams] = useState({ + const [metricsFilters, setMetricsFilters] = useState({ metricTypes: ['storage_retained', 'ingest_rate'], - dataStreams: [], + // TODO: Replace with data streams from /data_streams api + dataStreams: [ + '.alerts-ml.anomaly-detection-health.alerts-default', + '.alerts-stack.alerts-default', + ], from: DEFAULT_DATE_RANGE_OPTIONS.startDate, to: DEFAULT_DATE_RANGE_OPTIONS.endDate, }); useEffect(() => { if (!metricTypesFromUrl) { - setUrlMetricTypesFilter( - typeof queryParams.metricTypes !== 'string' - ? queryParams.metricTypes.join(',') - : queryParams.metricTypes - ); + setUrlMetricTypesFilter(metricsFilters.metricTypes.join(',')); } if (!startDateFromUrl || !endDateFromUrl) { - setUrlDateRangeFilter({ startDate: queryParams.from, endDate: queryParams.to }); + setUrlDateRangeFilter({ startDate: metricsFilters.from, endDate: metricsFilters.to }); } }, [ endDateFromUrl, metricTypesFromUrl, - queryParams.from, - queryParams.metricTypes, - queryParams.to, + metricsFilters.from, + metricsFilters.metricTypes, + metricsFilters.to, setUrlDateRangeFilter, setUrlMetricTypesFilter, startDateFromUrl, ]); useEffect(() => { - setQueryParams((prevState) => ({ + setMetricsFilters((prevState) => ({ ...prevState, metricTypes: metricTypesFromUrl?.length ? metricTypesFromUrl : prevState.metricTypes, dataStreams: dataStreamsFromUrl?.length ? dataStreamsFromUrl : prevState.dataStreams, @@ -89,7 +88,7 @@ export const DataUsage = () => { refetch: refetchDataUsageMetrics, } = useGetDataUsageMetrics( { - ...queryParams, + ...metricsFilters, from: dateRangePickerState.startDate, to: dateRangePickerState.endDate, }, @@ -140,7 +139,7 @@ export const DataUsage = () => { - {isFetched && data ? : } + {isFetched && data ? : } ); diff --git a/x-pack/plugins/data_usage/public/app/types.ts b/x-pack/plugins/data_usage/public/app/types.ts deleted file mode 100644 index 13f53bc2ea6dd..0000000000000 --- a/x-pack/plugins/data_usage/public/app/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { MetricTypes } from '../../common/rest_types'; - -export type DataPoint = [number, number]; // [timestamp, value] - -export interface MetricSeries { - name: string; // Name of the data stream - data: DataPoint[]; // Array of data points in tuple format [timestamp, value] -} -// Use MetricTypes dynamically as keys for the Metrics interface -export type Metrics = Partial>; - -export interface MetricsResponse { - metrics: Metrics; -} -export interface MetricsResponse { - metrics: Metrics; -} diff --git a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts index 6b9860e997c12..3d648eb183f07 100644 --- a/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts +++ b/x-pack/plugins/data_usage/public/hooks/use_get_usage_metrics.ts @@ -21,24 +21,24 @@ interface ErrorType { } export const useGetDataUsageMetrics = ( - query: UsageMetricsRequestSchemaQueryParams, + body: UsageMetricsRequestSchemaQueryParams, options: UseQueryOptions> = {} ): UseQueryResult> => { const http = useKibanaContextForPlugin().services.http; return useQuery>({ - queryKey: ['get-data-usage-metrics', query], + queryKey: ['get-data-usage-metrics', body], ...options, keepPreviousData: true, queryFn: async () => { - return http.get(DATA_USAGE_METRICS_API_ROUTE, { + return http.post(DATA_USAGE_METRICS_API_ROUTE, { version: '1', - query: { - from: query.from, - to: query.to, - metricTypes: query.metricTypes, - dataStreams: query.dataStreams, - }, + body: JSON.stringify({ + from: body.from, + to: body.to, + metricTypes: body.metricTypes, + dataStreams: body.dataStreams, + }), }); }, }); diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts index 5bf3008ef668a..0013102f697fb 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics.ts @@ -17,7 +17,7 @@ export const registerUsageMetricsRoute = ( ) => { if (dataUsageContext.serverConfig.enabled) { router.versioned - .get({ + .post({ access: 'internal', path: DATA_USAGE_METRICS_API_ROUTE, }) @@ -25,7 +25,9 @@ export const registerUsageMetricsRoute = ( { version: '1', validate: { - request: UsageMetricsRequestSchema, + request: { + body: UsageMetricsRequestSchema, + }, response: { 200: UsageMetricsResponseSchema, }, diff --git a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts index 6f992c9fb2a38..09e9f88721c63 100644 --- a/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts +++ b/x-pack/plugins/data_usage/server/routes/internal/usage_metrics_handler.ts @@ -9,8 +9,10 @@ import { RequestHandler } from '@kbn/core/server'; import { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; import { MetricTypes, + UsageMetricsAutoOpsResponseSchema, + UsageMetricsAutoOpsResponseSchemaBody, UsageMetricsRequestSchemaQueryParams, - UsageMetricsResponseSchema, + UsageMetricsResponseSchemaBody, } from '../../../common/rest_types'; import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types'; @@ -34,45 +36,26 @@ export const getUsageMetricsHandler = ( const core = await context.core; const esClient = core.elasticsearch.client.asCurrentUser; - // @ts-ignore - const { from, to, metricTypes, dataStreams: dsNames, size } = request.query; + const { from, to, metricTypes, dataStreams: requestDsNames } = request.query; logger.debug(`Retrieving usage metrics`); const { data_streams: dataStreamsResponse }: IndicesGetDataStreamResponse = await esClient.indices.getDataStream({ - name: '*', + name: requestDsNames, expand_wildcards: 'all', }); - const hasDataStreams = dataStreamsResponse.length > 0; - let userDsNames: string[] = []; - - if (dsNames?.length) { - userDsNames = typeof dsNames === 'string' ? [dsNames] : dsNames; - } else if (!userDsNames.length && hasDataStreams) { - userDsNames = dataStreamsResponse.map((ds) => ds.name); - } - - // If no data streams are found, return an empty response - if (!userDsNames.length) { - return response.ok({ - body: { - metrics: {}, - }, - }); - } - const metrics = await fetchMetricsFromAutoOps({ from, to, metricTypes: formatStringParams(metricTypes) as MetricTypes[], - dataStreams: formatStringParams(userDsNames), + dataStreams: formatStringParams(dataStreamsResponse.map((ds) => ds.name)), }); + const processedMetrics = transformMetricsData(metrics); + return response.ok({ - body: { - metrics, - }, + body: processedMetrics, }); } catch (error) { logger.error(`Error retrieving usage metrics: ${error.message}`); @@ -94,7 +77,7 @@ const fetchMetricsFromAutoOps = async ({ }) => { // TODO: fetch data from autoOps using userDsNames /* - const response = await axios.post('https://api.auto-ops.{region}.{csp}.cloud.elastic.co/monitoring/serverless/v1/projects/{project_id}/metrics', { + const response = await axios.post({AUTOOPS_URL}, { from: Date.parse(from), to: Date.parse(to), metric_types: metricTypes, @@ -231,7 +214,25 @@ const fetchMetricsFromAutoOps = async ({ }, }; // Make sure data is what we expect - const validatedData = UsageMetricsResponseSchema.body().validate(mockData); + const validatedData = UsageMetricsAutoOpsResponseSchema.body().validate(mockData); - return validatedData.metrics; + return validatedData; }; +function transformMetricsData( + data: UsageMetricsAutoOpsResponseSchemaBody +): UsageMetricsResponseSchemaBody { + return { + metrics: Object.fromEntries( + Object.entries(data.metrics).map(([metricType, series]) => [ + metricType, + series.map((metricSeries) => ({ + name: metricSeries.name, + data: (metricSeries.data as Array<[number, number]>).map(([timestamp, value]) => ({ + x: timestamp, + y: value, + })), + })), + ]) + ), + }; +} From eefabb0f534234d6c2c0e3468bbdc65a16009e93 Mon Sep 17 00:00:00 2001 From: Victor Martinez Date: Thu, 10 Oct 2024 22:00:38 +0200 Subject: [PATCH 04/18] ci(bump automation): bump ubi9 for ironbank (#191660) --- .github/updatecli/values.d/ironbank.yml | 2 + .github/updatecli/values.d/scm.yml | 11 ++++++ .../updatecli/values.d/updatecli-compose.yml | 3 ++ .github/workflows/updatecli-compose.yml | 38 +++++++++++++++++++ src/dev/precommit_hook/casing_check_config.js | 3 ++ updatecli-compose.yaml | 14 +++++++ 6 files changed, 71 insertions(+) create mode 100644 .github/updatecli/values.d/ironbank.yml create mode 100644 .github/updatecli/values.d/scm.yml create mode 100644 .github/updatecli/values.d/updatecli-compose.yml create mode 100644 .github/workflows/updatecli-compose.yml create mode 100644 updatecli-compose.yaml diff --git a/.github/updatecli/values.d/ironbank.yml b/.github/updatecli/values.d/ironbank.yml new file mode 100644 index 0000000000000..fd1134eda376a --- /dev/null +++ b/.github/updatecli/values.d/ironbank.yml @@ -0,0 +1,2 @@ +config: + - path: src/dev/build/tasks/os_packages/docker_generator/templates/ironbank \ No newline at end of file diff --git a/.github/updatecli/values.d/scm.yml b/.github/updatecli/values.d/scm.yml new file mode 100644 index 0000000000000..34d902fb389d5 --- /dev/null +++ b/.github/updatecli/values.d/scm.yml @@ -0,0 +1,11 @@ +scm: + enabled: true + owner: elastic + repository: kibana + branch: main + commitusingapi: true + # begin updatecli-compose policy values + user: kibanamachine + email: 42973632+kibanamachine@users.noreply.github.com + # end updatecli-compose policy values + diff --git a/.github/updatecli/values.d/updatecli-compose.yml b/.github/updatecli/values.d/updatecli-compose.yml new file mode 100644 index 0000000000000..02df609f2a30c --- /dev/null +++ b/.github/updatecli/values.d/updatecli-compose.yml @@ -0,0 +1,3 @@ +spec: + files: + - "updatecli-compose.yaml" \ No newline at end of file diff --git a/.github/workflows/updatecli-compose.yml b/.github/workflows/updatecli-compose.yml new file mode 100644 index 0000000000000..cbab42d3a63b1 --- /dev/null +++ b/.github/workflows/updatecli-compose.yml @@ -0,0 +1,38 @@ +--- +name: updatecli-compose + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * *' + +permissions: + contents: read + +jobs: + compose: + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446 # v3.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 + with: + command: --experimental compose diff + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 + with: + command: --experimental compose apply + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 2eaeb64f8be5f..3572781c4b262 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -87,6 +87,9 @@ export const IGNORE_FILE_GLOBS = [ // Support for including http-client.env.json configurations '**/http-client.env.json', + + // updatecli configuration for driving the UBI/Ironbank image updates + 'updatecli-compose.yaml', ]; /** diff --git a/updatecli-compose.yaml b/updatecli-compose.yaml new file mode 100644 index 0000000000000..8ad9bd6df8afb --- /dev/null +++ b/updatecli-compose.yaml @@ -0,0 +1,14 @@ +# Config file for `updatecli compose ...`. +# https://www.updatecli.io/docs/core/compose/ +policies: + - name: Handle ironbank bumps + policy: ghcr.io/elastic/oblt-updatecli-policies/ironbank/templates:0.3.0@sha256:b0c841d8fb294e6b58359462afbc83070dca375ac5dd0c5216c8926872a98bb1 + values: + - .github/updatecli/values.d/scm.yml + - .github/updatecli/values.d/ironbank.yml + + - name: Update Updatecli policies + policy: ghcr.io/updatecli/policies/autodiscovery/updatecli:0.4.0@sha256:254367f5b1454fd6032b88b314450cd3b6d5e8d5b6c953eb242a6464105eb869 + values: + - .github/updatecli/values.d/scm.yml + - .github/updatecli/values.d/updatecli-compose.yml \ No newline at end of file From 84d6899a4f2f97e0d015e733cc20064b43636154 Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 10 Oct 2024 21:20:50 +0100 Subject: [PATCH 05/18] [Security Solution][Detection Engine] removes feature flag for logged requests for preview (#195569) ## Summary - removes feature flag for logged requests for preview --- .../common/experimental_features.ts | 5 ----- .../components/rule_preview/index.test.tsx | 21 ------------------- .../components/rule_preview/index.tsx | 6 +----- .../config/ess/config.base.ts | 1 - .../configs/serverless.config.ts | 1 - .../execution_logic/eql.ts | 3 +-- .../execution_logic/esql.ts | 3 +-- .../test/security_solution_cypress/config.ts | 1 - .../detection_engine/rule_edit/preview.cy.ts | 5 ----- .../serverless_config.ts | 1 - 10 files changed, 3 insertions(+), 44 deletions(-) diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 1ae20af759611..1e5ffee50afc7 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -138,11 +138,6 @@ export const allowedExperimentalValues = Object.freeze({ */ esqlRulesDisabled: false, - /** - * enables logging requests during rule preview - */ - loggingRequestsEnabled: false, - /** * Enables Protection Updates tab in the Endpoint Policy Details page */ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx index 4ebb460177476..25d5b90d5408a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.test.tsx @@ -23,7 +23,6 @@ import { stepDefineDefaultValue, } from '../../../../detections/pages/detection_engine/rules/utils'; import { usePreviewInvocationCount } from './use_preview_invocation_count'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; jest.mock('../../../../common/lib/kibana'); jest.mock('./use_preview_route'); @@ -40,7 +39,6 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), })); -const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; // rule types that do not support logged requests const doNotSupportLoggedRequests: Type[] = [ 'threshold', @@ -114,8 +112,6 @@ describe('PreviewQuery', () => { }); (usePreviewInvocationCount as jest.Mock).mockReturnValue({ invocationCount: 500 }); - - useIsExperimentalFeatureEnabledMock.mockReturnValue(true); }); afterEach(() => { @@ -172,23 +168,6 @@ describe('PreviewQuery', () => { }); }); - supportLoggedRequests.forEach((ruleType) => { - test(`does not render "Show Elasticsearch requests" for ${ruleType} rule type when feature is disabled`, () => { - useIsExperimentalFeatureEnabledMock.mockReturnValue(false); - - render( - - - - ); - - expect(screen.queryByTestId('show-elasticsearch-requests')).toBeNull(); - }); - }); - doNotSupportLoggedRequests.forEach((ruleType) => { test(`does not render "Show Elasticsearch requests" for ${ruleType} rule type`, () => { render( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx index 2a86600d94e7a..f941cad91d3a4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/components/rule_preview/index.tsx @@ -40,7 +40,6 @@ import type { TimeframePreviewOptions, } from '../../../../detections/pages/detection_engine/rules/types'; import { usePreviewInvocationCount } from './use_preview_invocation_count'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; export const REASONABLE_INVOCATION_COUNT = 200; @@ -90,8 +89,6 @@ const RulePreviewComponent: React.FC = ({ const { indexPattern, ruleType } = defineRuleData; const { spaces } = useKibana().services; - const isLoggingRequestsFeatureEnabled = useIsExperimentalFeatureEnabled('loggingRequestsEnabled'); - const [spaceId, setSpaceId] = useState(''); useEffect(() => { if (spaces) { @@ -282,8 +279,7 @@ const RulePreviewComponent: React.FC = ({ - {isLoggingRequestsFeatureEnabled && - RULE_TYPES_SUPPORTING_LOGGED_REQUESTS.includes(ruleType) ? ( + {RULE_TYPES_SUPPORTING_LOGGED_REQUESTS.includes(ruleType) ? ( diff --git a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts index 3ab6d5059fd07..a0d2ee79a7b46 100644 --- a/x-pack/test/security_solution_api_integration/config/ess/config.base.ts +++ b/x-pack/test/security_solution_api_integration/config/ess/config.base.ts @@ -82,7 +82,6 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'previewTelemetryUrlEnabled', - 'loggingRequestsEnabled', 'riskScoringPersistence', 'riskScoringRoutesEnabled', ])}`, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts index ce949d5cc23fc..137ee1f67b9b3 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/configs/serverless.config.ts @@ -17,6 +17,5 @@ export default createTestConfig({ 'testing_ignored.constant', '/testing_regex*/', ])}`, // See tests within the file "ignore_fields.ts" which use these values in "alertIgnoreFields" - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['loggingRequestsEnabled'])}`, ], }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts index aff2ccc6bccb3..9077873274fa5 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/eql.ts @@ -1190,8 +1190,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // skipped on MKI since feature flags are not supported there - describe('@skipInServerlessMKI preview logged requests', () => { + describe('preview logged requests', () => { it('should not return requests property when not enabled', async () => { const { logs } = await previewRule({ supertest, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts index 166a62b9b08ad..ee976de14186d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/esql.ts @@ -1409,8 +1409,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // skipped on MKI since feature flags are not supported there - describe('@skipInServerlessMKI preview logged requests', () => { + describe('preview logged requests', () => { let rule: EsqlRuleCreateProps; let id: string; beforeEach(async () => { diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 05bc2e381527a..f02968945087d 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -44,7 +44,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // See https://github.com/elastic/kibana/pull/125396 for details '--xpack.alerting.rules.minimumScheduleInterval.value=1s', '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['loggingRequestsEnabled'])}`, // mock cloud to enable the guided onboarding tour in e2e tests '--xpack.cloud.id=test', `--home.disableWelcomeScreen=true`, diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts index c2e41c9d4680c..268968c76ecc0 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_edit/preview.cy.ts @@ -33,11 +33,6 @@ describe( 'Detection rules, preview', { tags: ['@ess', '@serverless'], - env: { - kbnServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['loggingRequestsEnabled'])}`, - ], - }, }, () => { beforeEach(() => { diff --git a/x-pack/test/security_solution_cypress/serverless_config.ts b/x-pack/test/security_solution_cypress/serverless_config.ts index 71a63b697187f..f3f04dda79dbb 100644 --- a/x-pack/test/security_solution_cypress/serverless_config.ts +++ b/x-pack/test/security_solution_cypress/serverless_config.ts @@ -34,7 +34,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { { product_line: 'endpoint', product_tier: 'complete' }, { product_line: 'cloud', product_tier: 'complete' }, ])}`, - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['loggingRequestsEnabled'])}`, '--csp.strict=false', '--csp.warnLegacyBrowsers=false', ], From 4df2d9f068445d3606a0cea58be6c32e00721d3f Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Thu, 10 Oct 2024 22:22:23 +0200 Subject: [PATCH 06/18] [ES|QL] Add pretty-printing support for list literals (#195383) ## Summary Closes https://github.com/elastic/kibana/issues/194840 This PR add pretty-printing support for list literal expressions. For example, this query: ``` ROW ["..............................................", "..............................................", ".............................................."] ``` will be formatted as so: ``` ROW [ "..............................................", "..............................................", ".............................................."] ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Stratoula Kalafateli --- .../__tests__/wrapping_pretty_printer.test.ts | 79 +++++++++++++++++++ .../pretty_print/wrapping_pretty_printer.ts | 23 +++--- packages/kbn-esql-ast/src/types.ts | 1 + packages/kbn-esql-ast/src/visitor/contexts.ts | 12 ++- packages/kbn-esql-ast/src/visitor/utils.ts | 31 +++++++- 5 files changed, 132 insertions(+), 14 deletions(-) diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts index 21330d0fea3b1..2dfe239ce5b88 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts @@ -593,6 +593,85 @@ ROW (asdf + asdf)::string, 1.2::string, "1234"::integer, (12321342134 + 23412341 - "aaaaaaaaaaa")::boolean`); }); }); + + describe('list literals', () => { + describe('numeric', () => { + test('wraps long list literals one line', () => { + const query = + 'ROW [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`); + }); + + test('wraps long list literals to multiple lines one line', () => { + const query = `ROW [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, 1234567890, + 1234567890, 1234567890, 1234567890]`); + }); + + test('breaks very long values one-per-line', () => { + const query = `ROW fn1(fn2(fn3(fn4(fn5(fn6(fn7(fn8([1234567890, 1234567890, 1234567890, 1234567890, 1234567890]))))))))`; + const text = reprint(query, { wrap: 40 }).text; + + expect('\n' + text).toBe(` +ROW + FN1( + FN2( + FN3( + FN4( + FN5( + FN6( + FN7( + FN8( + [ + 1234567890, + 1234567890, + 1234567890, + 1234567890, + 1234567890]))))))))`); + }); + }); + + describe('string', () => { + test('wraps long list literals one line', () => { + const query = + 'ROW ["some text", "another text", "one more text literal", "and another one", "and one more", "and one more", "and one more", "and one more", "and one more"]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + ["some text", "another text", "one more text literal", "and another one", + "and one more", "and one more", "and one more", "and one more", + "and one more"]`); + }); + + test('can break very long strings per line', () => { + const query = + 'ROW ["..............................................", "..............................................", ".............................................."]'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +ROW + [ + "..............................................", + "..............................................", + ".............................................."]`); + }); + }); + }); }); test.todo('Idempotence on multiple times pretty printing'); diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts index fde7f60a1dba5..91f65a389f0c3 100644 --- a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -15,9 +15,10 @@ import { CommandVisitorContext, ExpressionVisitorContext, FunctionCallExpressionVisitorContext, + ListLiteralExpressionVisitorContext, Visitor, } from '../visitor'; -import { singleItems } from '../visitor/utils'; +import { children, singleItems } from '../visitor/utils'; import { BasicPrettyPrinter, BasicPrettyPrinterOptions } from './basic_pretty_printer'; import { getPrettyPrintStats } from './helpers'; import { LeafPrinter } from './leaf_printer'; @@ -235,7 +236,11 @@ export class WrappingPrettyPrinter { } private printArguments( - ctx: CommandVisitorContext | CommandOptionVisitorContext | FunctionCallExpressionVisitorContext, + ctx: + | CommandVisitorContext + | CommandOptionVisitorContext + | FunctionCallExpressionVisitorContext + | ListLiteralExpressionVisitorContext, inp: Input ) { let txt = ''; @@ -247,7 +252,7 @@ export class WrappingPrettyPrinter { let remainingCurrentLine = inp.remaining; let oneArgumentPerLine = false; - for (const child of singleItems(ctx.node.args)) { + for (const child of children(ctx.node)) { if (getPrettyPrintStats(child).hasLineBreakingDecorations) { oneArgumentPerLine = true; break; @@ -489,13 +494,11 @@ export class WrappingPrettyPrinter { }) .on('visitListLiteralExpression', (ctx, inp: Input): Output => { - let elements = ''; - - for (const out of ctx.visitElements(inp)) { - elements += (elements ? ', ' : '') + out.txt; - } - - const formatted = `[${elements}]${inp.suffix ?? ''}`; + const args = this.printArguments(ctx, { + indent: inp.indent, + remaining: inp.remaining - 1, + }); + const formatted = `[${args.txt}]${inp.suffix ?? ''}`; const { txt, indented } = this.decorateWithComments(inp.indent, ctx.node, formatted); return { txt, indented }; diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 0ca48b2326f7d..1bac6e0cff5b3 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -40,6 +40,7 @@ export type ESQLAstField = ESQLFunction | ESQLColumn; export type ESQLAstItem = ESQLSingleAstItem | ESQLAstItem[]; export type ESQLAstNodeWithArgs = ESQLCommand | ESQLCommandOption | ESQLFunction; +export type ESQLAstNodeWithChildren = ESQLAstNodeWithArgs | ESQLList; /** * *Proper* are nodes which are objects with `type` property, once we get rid diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index 0f637962b7ddd..4b4f04fdca4bb 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -12,11 +12,12 @@ // and makes it harder to understand the code structure. import { type GlobalVisitorContext, SharedData } from './global_visitor_context'; -import { firstItem, singleItems } from './utils'; +import { children, firstItem, singleItems } from './utils'; import type { ESQLAstCommand, ESQLAstItem, ESQLAstNodeWithArgs, + ESQLAstNodeWithChildren, ESQLAstRenameExpression, ESQLColumn, ESQLCommandOption, @@ -47,6 +48,11 @@ import { Builder } from '../builder'; const isNodeWithArgs = (x: unknown): x is ESQLAstNodeWithArgs => !!x && typeof x === 'object' && Array.isArray((x as any).args); +const isNodeWithChildren = (x: unknown): x is ESQLAstNodeWithChildren => + !!x && + typeof x === 'object' && + (Array.isArray((x as any).args) || Array.isArray((x as any).values)); + export class VisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData, @@ -99,13 +105,13 @@ export class VisitorContext< public arguments(): ESQLAstExpressionNode[] { const node = this.node; - if (!isNodeWithArgs(node)) { + if (!isNodeWithChildren(node)) { return []; } const args: ESQLAstExpressionNode[] = []; - for (const arg of singleItems(node.args)) { + for (const arg of children(node)) { args.push(arg); } diff --git a/packages/kbn-esql-ast/src/visitor/utils.ts b/packages/kbn-esql-ast/src/visitor/utils.ts index 2e54a89c2bf52..0dc95b73cf9d7 100644 --- a/packages/kbn-esql-ast/src/visitor/utils.ts +++ b/packages/kbn-esql-ast/src/visitor/utils.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ESQLAstItem, ESQLSingleAstItem } from '../types'; +import { ESQLAstItem, ESQLProperNode, ESQLSingleAstItem } from '../types'; /** * Normalizes AST "item" list to only contain *single* items. @@ -48,3 +48,32 @@ export const lastItem = (items: ESQLAstItem[]): ESQLSingleAstItem | undefined => if (Array.isArray(last)) return lastItem(last as ESQLAstItem[]); return last as ESQLSingleAstItem; }; + +export function* children(node: ESQLProperNode): Iterable { + switch (node.type) { + case 'function': + case 'command': + case 'option': { + for (const arg of singleItems(node.args)) { + yield arg; + } + break; + } + case 'list': { + for (const item of singleItems(node.values)) { + yield item; + } + break; + } + case 'inlineCast': { + if (Array.isArray(node.value)) { + for (const item of singleItems(node.value)) { + yield item; + } + } else { + yield node.value; + } + break; + } + } +} From 3974845d24c16d6d9da91d00ad3d2a226ac457bf Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Thu, 10 Oct 2024 22:29:36 +0200 Subject: [PATCH 07/18] [Security Solution] - skipping CSP Cypress test failing on MKI (#195794) ## Summary This PR is skipping a CSP test that is failing on MKI (see failing [build](https://buildkite.com/elastic/kibana-serverless-security-solution-quality-gate-investigations/builds/1390#01927579-caed-41bc-9440-3cf29629a263)) The CSP tests are currently under the `expandable_flyout` folder own by the @elastic/security-threat-hunting-investigations team. This is temporary until the CSP has the time to create their own folder and all the associated scripts for CI to run. --- .../expandable_flyout/vulnerabilities_contextual_flyout.cy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts index 591d458af56c1..fb83df1c79141 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/alerts/expandable_flyout/vulnerabilities_contextual_flyout.cy.ts @@ -138,7 +138,8 @@ const deleteDataStream = () => { }); }; -describe('Alert Host details expandable flyout', { tags: ['@ess', '@serverless'] }, () => { +// skipping because failure on MKI environment (https://buildkite.com/elastic/kibana-serverless-security-solution-quality-gate-investigations/builds/1390#01927579-caed-41bc-9440-3cf29629a263) +describe.skip('Alert Host details expandable flyout', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { deleteAlertsAndRules(); login(); From cd217c072fc786cb76ee47d885501688507c2dde Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:46:51 -0500 Subject: [PATCH 08/18] [Security Solution] Add alert and cloud insights to document flyout (#195509) ## Summary This PR adds alert count, misconfiguration and vulnerabilities insights to alert/event flyout. If data is not available, the insights are hidden. [Mocks](https://www.figma.com/design/ubvhBGHee58diJNvSiy0GZ/%5B8.%2B%5D-%5BAlerts%5D-Expandable-Event-Flyout?node-id=8017-179782&node-type=canvas&t=0YjHfPi9zOUFUScc-0) ![image](https://github.com/user-attachments/assets/ba706ab8-448a-4286-8229-c4c398136638) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../src/distribution_bar.stories.tsx | 8 ++ .../src/distribution_bar.test.tsx | 62 ++++++++++++ .../distribution_bar/src/distribution_bar.tsx | 9 +- .../misconfiguration_preview.tsx | 2 +- .../left/components/host_details.test.tsx | 52 ++++++++++ .../left/components/host_details.tsx | 30 ++++++ .../left/components/test_ids.ts | 8 ++ .../left/components/user_details.test.tsx | 38 +++++++ .../left/components/user_details.tsx | 22 +++++ .../components/host_entity_overview.test.tsx | 65 ++++++++++++ .../right/components/host_entity_overview.tsx | 24 ++++- .../right/components/test_ids.ts | 10 ++ .../components/user_entity_overview.test.tsx | 52 ++++++++++ .../right/components/user_entity_overview.tsx | 18 +++- .../components/alert_count_insight.test.tsx | 64 ++++++++++++ .../shared/components/alert_count_insight.tsx | 99 +++++++++++++++++++ .../insight_distribution_bar.test.tsx | 41 ++++++++ .../components/insight_distribution_bar.tsx | 88 +++++++++++++++++ .../misconfiguration_insight.test.tsx | 43 ++++++++ .../components/misconfiguration_insight.tsx | 80 +++++++++++++++ .../shared/components/test_ids.ts | 3 + .../vulnerabilities_insight.test.tsx | 44 +++++++++ .../components/vulnerabilities_insight.tsx | 91 +++++++++++++++++ 23 files changed, 946 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx index 90b6887636c8a..c1b292c3f08cc 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx @@ -70,6 +70,14 @@ export const DistributionBar = () => { , + + +

{'Hide last tooltip'}

+ + + + + ,

{'Empty state'}

diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx index d4bdf4c20f133..e83b66e5e01e7 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx @@ -79,5 +79,67 @@ describe('DistributionBar', () => { }); }); + it('should render last tooltip by default', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part, index) => { + if (index < parts.length - 1) { + expect(part).toHaveStyle({ opacity: 0 }); + } else { + expect(part).toHaveStyle({ opacity: 1 }); + } + }); + }); + + it('should not render last tooltip when hideLastTooltip is true', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part) => { + expect(part).toHaveStyle({ opacity: 0 }); + }); + }); + // todo: test tooltip visibility logic }); diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx index 28d8ca4a8a148..5b06292813ccd 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx @@ -13,6 +13,8 @@ import { css } from '@emotion/react'; export interface DistributionBarProps { /** distribution data points */ stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** hide the label above the bar at first render */ + hideLastTooltip?: boolean; /** data-test-subj used for querying the component in tests */ ['data-test-subj']?: string; } @@ -136,18 +138,21 @@ export const DistributionBar: React.FC = React.memo(functi props ) { const styles = useStyles(); - const { stats, 'data-test-subj': dataTestSubj } = props; + const { stats, 'data-test-subj': dataTestSubj, hideLastTooltip } = props; const parts = stats.map((stat) => { const partStyle = [ styles.part.base, styles.part.tick, styles.part.hover, - styles.part.lastTooltip, css` background-color: ${stat.color}; flex: ${stat.count}; `, ]; + if (!hideLastTooltip) { + partStyle.push(styles.part.lastTooltip); + } + const prettyNumber = numeral(stat.count).format('0,0a'); return ( diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx index a13a77a3562ff..a372ca4755fd8 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx @@ -35,7 +35,7 @@ const FIRST_RECORD_PAGINATION = { querySize: 1, }; -const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { +export const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; return [ { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index 46288434f48bb..23f6969c36778 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import type { Anomalies } from '../../../../common/components/ml/types'; import { DocumentDetailsContext } from '../../shared/context'; import { TestProviders } from '../../../../common/mock'; @@ -24,6 +26,9 @@ import { HOST_DETAILS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID, + HOST_DETAILS_MISCONFIGURATIONS_TEST_ID, + HOST_DETAILS_VULNERABILITIES_TEST_ID, + HOST_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; @@ -35,8 +40,11 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -104,6 +112,10 @@ const mockUseHostsRelatedUsers = useHostRelatedUsers as jest.Mock; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + const timestamp = '2022-07-25T08:20:18.966Z'; const defaultProps = { @@ -158,6 +170,9 @@ describe('', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseHostsRelatedUsers.mockReturnValue(mockRelatedUsersResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render host details correctly', () => { @@ -296,4 +311,41 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostDetails(mockContextValue); + expect(queryByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx index 33b8bb22fce53..122caa657b039 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx @@ -18,6 +18,8 @@ import { EuiToolTip, EuiIcon, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,9 @@ import { HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID, HOST_DETAILS_RELATED_USERS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID, + HOST_DETAILS_ALERT_COUNT_TEST_ID, + HOST_DETAILS_MISCONFIGURATIONS_TEST_ID, + HOST_DETAILS_VULNERABILITIES_TEST_ID, } from './test_ids'; import { USER_NAME_FIELD_NAME, @@ -63,6 +68,9 @@ import { PreviewLink } from '../../../shared/components/preview_link'; import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_DETAILS_ID = 'entities-hosts-details'; const RELATED_USERS_ID = 'entities-hosts-related-users'; @@ -337,6 +345,28 @@ export const HostDetails: React.FC = ({ hostName, timestamp, s )} + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 0779f3c135b2d..8669b504f6861 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -43,6 +43,9 @@ export const PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID = export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const; export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const; export const USER_DETAILS_LINK_TEST_ID = `${USER_DETAILS_TEST_ID}TitleLink` as const; +export const USER_DETAILS_ALERT_COUNT_TEST_ID = `${USER_DETAILS_TEST_ID}AlertCount` as const; +export const USER_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${USER_DETAILS_TEST_ID}Misconfigurations` as const; export const USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID = `${USER_DETAILS_TEST_ID}RelatedHostsTable` as const; export const USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID = @@ -53,6 +56,11 @@ export const USER_DETAILS_INFO_TEST_ID = 'user-overview' as const; export const HOST_DETAILS_TEST_ID = `${PREFIX}HostsDetails` as const; export const HOST_DETAILS_LINK_TEST_ID = `${HOST_DETAILS_TEST_ID}TitleLink` as const; +export const HOST_DETAILS_ALERT_COUNT_TEST_ID = `${HOST_DETAILS_TEST_ID}AlertCount` as const; +export const HOST_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${HOST_DETAILS_TEST_ID}Misconfigurations` as const; +export const HOST_DETAILS_VULNERABILITIES_TEST_ID = + `${HOST_DETAILS_TEST_ID}Vulnerabilities` as const; export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID = `${HOST_DETAILS_TEST_ID}RelatedUsersTable` as const; export const HOST_DETAILS_RELATED_USERS_LINK_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx index c1ed881e80a95..a2c53afb8c3f3 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import type { Anomalies } from '../../../../common/components/ml/types'; import { TestProviders } from '../../../../common/mock'; import { DocumentDetailsContext } from '../../shared/context'; @@ -24,6 +25,8 @@ import { USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + USER_DETAILS_MISCONFIGURATIONS_TEST_ID, + USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; @@ -35,8 +38,10 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -101,6 +106,10 @@ const mockUseUsersRelatedHosts = useUserRelatedHosts as jest.Mock; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + const timestamp = '2022-07-25T08:20:18.966Z'; const defaultProps = { @@ -155,6 +164,8 @@ describe('', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render user details correctly', () => { @@ -278,4 +289,31 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserDetails(mockContextValue); + expect(queryByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx index 13d3e825053ba..c90d11f4b8bc2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx @@ -18,6 +18,8 @@ import { EuiFlexItem, EuiToolTip, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,8 @@ import { USER_DETAILS_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + USER_DETAILS_MISCONFIGURATIONS_TEST_ID, + USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { HOST_NAME_FIELD_NAME, @@ -63,6 +67,8 @@ import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { PreviewLink } from '../../../shared/components/preview_link'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_DETAILS_ID = 'entities-users-details'; const RELATED_HOSTS_ID = 'entities-users-related-hosts'; @@ -340,6 +346,22 @@ export const UserDetails: React.FC = ({ userName, timestamp, s )} + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx index b710df84e1a13..6ad90adb28997 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx @@ -6,6 +6,8 @@ */ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import { TestProviders } from '../../../../common/mock'; import { HostEntityOverview, HOST_PREVIEW_BANNER } from './host_entity_overview'; import { useHostDetails } from '../../../../explore/hosts/containers/hosts/details'; @@ -16,6 +18,9 @@ import { ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { DocumentDetailsContext } from '../../shared/context'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -29,6 +34,7 @@ import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const hostName = 'host'; const osFamily = 'Windows'; @@ -46,6 +52,17 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); const mockedTelemetry = createTelemetryServiceMock(); jest.mock('../../../../common/lib/kibana', () => { @@ -99,6 +116,9 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -150,6 +170,7 @@ describe('', () => { ); expect(getByTestId(ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID)).toBeInTheDocument(); }); + describe('license is not valid', () => { it('should render os family and last seen', () => { mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); @@ -210,4 +231,48 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostEntityContent(); + expect( + queryByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx index ca6a68eb23be8..90405286b004c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx @@ -52,11 +52,17 @@ import { ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, } from './test_ids'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_ICON = 'storage'; @@ -196,12 +202,12 @@ export const HostEntityOverview: React.FC = ({ hostName return ( - + @@ -270,6 +276,20 @@ export const HostEntityOverview: React.FC = ({ hostName )} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts index 40670ddc7110a..e0d8bc6db0f5c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts @@ -121,6 +121,10 @@ export const ENTITIES_USER_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}Misconfigurations` as const; export const ENTITIES_HOST_OVERVIEW_TEST_ID = `${INSIGHTS_ENTITIES_TEST_ID}HostOverview` as const; export const ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID = @@ -132,6 +136,12 @@ export const ENTITIES_HOST_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Misconfigurations` as const; +export const ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Vulnerabilities` as const; /* Threat intelligence */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx index 000da8946ff61..95c399ca4362e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { UserEntityOverview, USER_PREVIEW_BANNER } from './user_entity_overview'; import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen'; import { @@ -15,6 +16,8 @@ import { ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -28,6 +31,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const userName = 'user'; const domain = 'n54bg2lfc7'; @@ -45,6 +49,18 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -85,6 +101,8 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -211,4 +229,38 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseUserDetails.mockReturnValue([false, { userDetails: userData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserEntityOverview(); + expect( + queryByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx index 624b9e816c9e5..0019228d656cd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx @@ -53,10 +53,14 @@ import { ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_ICON = 'user'; @@ -196,12 +200,12 @@ export const UserEntityOverview: React.FC = ({ userName return ( - + @@ -270,6 +274,16 @@ export const UserEntityOverview: React.FC = ({ userName )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx new file mode 100644 index 0000000000000..f0d16a418f2b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertCountInsight } from './alert_count_insight'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderAlertCountInsight = () => { + return render( + + + + ); +}; + +describe('AlertCountInsight', () => { + it('renders', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [ + { key: 'high', value: 78, label: 'High' }, + { key: 'low', value: 46, label: 'Low' }, + { key: 'medium', value: 32, label: 'Medium' }, + { key: 'critical', value: 21, label: 'Critical' }, + ], + }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders loading spinner if data is being fetched', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: true, items: [] }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(`${testId}-loading-spinner`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + const { container } = renderAlertCountInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx new file mode 100644 index 0000000000000..566b77b5739a9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import { EuiLoadingSpinner, EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { severityAggregations } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { + getIsAlertsBySeverityData, + getSeverityColor, +} from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; + +const ENTITY_ALERT_COUNT_ID = 'entity-alert-count'; + +interface AlertCountInsightProps { + /** + * The name of the entity to filter the alerts by. + */ + name: string; + /** + * The field name to filter the alerts by. + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group. + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component. + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical alerts for a given entity + */ +export const AlertCountInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const uniqueQueryId = useMemo(() => `${ENTITY_ALERT_COUNT_ID}-${uuid()}`, []); + const entityFilter = useMemo(() => ({ field: fieldName, value: name }), [fieldName, name]); + + const { items, isLoading } = useSummaryChartData({ + aggregations: severityAggregations, + entityFilter, + uniqueQueryId, + signalIndexName: null, + }); + + const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]); + + const alertStats = useMemo(() => { + return data.map((item) => ({ + key: item.key, + count: item.value, + color: getSeverityColor(item.key), + })); + }, [data]); + + const count = useMemo( + () => data.filter((item) => item.key === 'critical')[0]?.value ?? 0, + [data] + ); + + if (!isLoading && items.length === 0) return null; + + return ( + + {isLoading ? ( + + ) : ( + + } + stats={alertStats} + count={count} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + )} + + ); +}; + +AlertCountInsight.displayName = 'AlertCountInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx new file mode 100644 index 0000000000000..a775da8a7f73a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { TestProviders } from '../../../../common/mock'; + +const title = 'test title'; +const count = 10; +const testId = 'test-id'; +const stats = [ + { + key: 'passed', + count: 90, + color: 'green', + }, + { + key: 'failed', + count: 10, + color: 'red', + }, +]; + +describe('', () => { + it('should render', () => { + const { getByTestId, getByText } = render( + + + + ); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByTestId(`${testId}-badge`)).toHaveTextContent(`${count}`); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx new file mode 100644 index 0000000000000..006ec8c5dad4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { css } from '@emotion/css'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiBadge, + useEuiTheme, + useEuiFontSize, + type EuiFlexGroupProps, +} from '@elastic/eui'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { FormattedCount } from '../../../../common/components/formatted_number'; + +export interface InsightDistributionBarProps { + /** + * Title of the insight + */ + title: string | React.ReactNode; + /** + * Distribution stats to display + */ + stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** + * Count to be displayed in the badge + */ + count: number; + /** + * Flex direction of the component + */ + direction?: EuiFlexGroupProps['direction']; + /** + * Optional test id + */ + ['data-test-subj']?: string; +} + +// Displays a distribution bar with a count badge +export const InsightDistributionBar: React.FC = ({ + title, + stats, + count, + direction = 'row', + 'data-test-subj': dataTestSubj, +}) => { + const { euiTheme } = useEuiTheme(); + const xsFontSize = useEuiFontSize('xs').fontSize; + + return ( + + + + {title} + + + + + + + + + + + + + + + + ); +}; + +InsightDistributionBar.displayName = 'InsightDistributionBar'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx new file mode 100644 index 0000000000000..296a61f444a17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { MisconfigurationsInsight } from './misconfiguration_insight'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderMisconfigurationsInsight = () => { + return render( + + + + ); +}; + +describe('MisconfigurationsInsight', () => { + it('renders', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + const { getByTestId } = renderMisconfigurationsInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + const { container } = renderMisconfigurationsInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx new file mode 100644 index 0000000000000..552a242c84893 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { getFindingsStats } from '../../../../cloud_security_posture/components/misconfiguration/misconfiguration_preview'; + +interface MisconfigurationsInsightProps { + /** + * Entity name to retrieve misconfigurations for + */ + name: string; + /** + * Indicator whether the entity is host or user + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of failed misconfigurations for a given entity + */ +export const MisconfigurationsInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useMisconfigurationPreview({ + query: buildEntityFlyoutPreviewQuery(fieldName, name), + sort: [], + enabled: true, + pageSize: 1, + }); + + const passedFindings = data?.count.passed || 0; + const failedFindings = data?.count.failed || 0; + const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0; + + const misconfigurationsStats = useMemo( + () => getFindingsStats(passedFindings, failedFindings), + [passedFindings, failedFindings] + ); + + if (!hasMisconfigurationFindings) return null; + + return ( + + + } + stats={misconfigurationsStats} + count={failedFindings} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +MisconfigurationsInsight.displayName = 'MisconfigurationsInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts index 8561df63d7199..7c2ce2ff5870b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts @@ -12,3 +12,6 @@ export const FLYOUT_PREVIEW_LINK_TEST_ID = `${PREFIX}PreviewLink` as const; export const SESSION_VIEW_UPSELL_TEST_ID = `${PREFIX}SessionViewUpsell` as const; export const SESSION_VIEW_NO_DATA_TEST_ID = `${PREFIX}SessionViewNoData` as const; + +export const MISCONFIGURATIONS_TEST_ID = `${PREFIX}Misconfigurations` as const; +export const VULNERABILITIES_TEST_ID = `${PREFIX}Vulnerabilities` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx new file mode 100644 index 0000000000000..77c6737266b89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { TestProviders } from '../../../../common/mock'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { VulnerabilitiesInsight } from './vulnerabilities_insight'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +const hostName = 'test host'; +const testId = 'test'; + +const renderVulnerabilitiesInsight = () => { + return render( + + + + ); +}; + +describe('VulnerabilitiesInsight', () => { + it('renders', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderVulnerabilitiesInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null when data is not available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + + const { container } = renderVulnerabilitiesInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx new file mode 100644 index 0000000000000..4c581b6db57d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { getVulnerabilityStats, hasVulnerabilitiesData } from '@kbn/cloud-security-posture'; +import { InsightDistributionBar } from './insight_distribution_bar'; + +interface VulnerabilitiesInsightProps { + /** + * Host name to retrieve vulnerabilities for + */ + hostName: string; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical vulnerabilities for a given host + */ +export const VulnerabilitiesInsight: React.FC = ({ + hostName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useVulnerabilitiesPreview({ + query: buildEntityFlyoutPreviewQuery('host.name', hostName), + sort: [], + enabled: true, + pageSize: 1, + }); + + const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {}; + const hasVulnerabilitiesFindings = useMemo( + () => + hasVulnerabilitiesData({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + const vulnerabilitiesStats = useMemo( + () => + getVulnerabilityStats({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + if (!hasVulnerabilitiesFindings) return null; + + return ( + + + } + stats={vulnerabilitiesStats} + count={CRITICAL} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +VulnerabilitiesInsight.displayName = 'VulnerabilitiesInsight'; From 6ff2d87b5c8ed48ccfaa66f9cc8d712ae161a076 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 10 Oct 2024 15:59:10 -0600 Subject: [PATCH 09/18] [Security GenAI] Fix `VertexChatAI` tool calling (#195689) --- .../chat_vertex/chat_vertex.test.ts | 33 ++++++++++++++++++- .../language_models/chat_vertex/connection.ts | 17 ++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts index 37506922ff69b..07fe252bd5074 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/chat_vertex.test.ts @@ -12,6 +12,7 @@ import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/act import { BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; import { ActionsClientChatVertexAI } from './chat_vertex'; import { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; +import { GeminiContent } from '@langchain/google-common'; const connectorId = 'mock-connector-id'; @@ -54,8 +55,10 @@ const mockStreamExecute = jest.fn().mockImplementation(() => { }; }); +const systemInstruction = 'Answer the following questions truthfully and as best you can.'; + const callMessages = [ - new SystemMessage('Answer the following questions truthfully and as best you can.'), + new SystemMessage(systemInstruction), new HumanMessage('Question: Do you know my name?\n\n'), ] as unknown as BaseMessage[]; @@ -196,4 +199,32 @@ describe('ActionsClientChatVertexAI', () => { expect(handleLLMNewToken).toHaveBeenCalledWith('token3'); }); }); + + describe('message formatting', () => { + it('Properly sorts out the system role', async () => { + const actionsClientChatVertexAI = new ActionsClientChatVertexAI(defaultArgs); + + await actionsClientChatVertexAI._generate(callMessages, callOptions, callRunManager); + const params = actionsClient.execute.mock.calls[0][0].params.subActionParams as unknown as { + messages: GeminiContent[]; + systemInstruction: string; + }; + expect(params.messages.length).toEqual(1); + expect(params.messages[0].parts.length).toEqual(1); + expect(params.systemInstruction).toEqual(systemInstruction); + }); + it('Handles 2 messages in a row from the same role', async () => { + const actionsClientChatVertexAI = new ActionsClientChatVertexAI(defaultArgs); + + await actionsClientChatVertexAI._generate( + [...callMessages, new HumanMessage('Oh boy, another')], + callOptions, + callRunManager + ); + const { messages } = actionsClient.execute.mock.calls[0][0].params + .subActionParams as unknown as { messages: GeminiContent[] }; + expect(messages.length).toEqual(1); + expect(messages[0].parts.length).toEqual(2); + }); + }); }); diff --git a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts index 0340d71b438db..dd3c1e1abdda0 100644 --- a/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts +++ b/x-pack/packages/kbn-langchain/server/language_models/chat_vertex/connection.ts @@ -7,6 +7,7 @@ import { ChatConnection, + GeminiContent, GoogleAbstractedClient, GoogleAIBaseLLMInput, GoogleLLMResponse, @@ -39,6 +40,22 @@ export class ActionsClientChatConnection extends ChatConnection { this.caller = caller; this.#model = fields.model; this.temperature = fields.temperature ?? 0; + const nativeFormatData = this.formatData.bind(this); + this.formatData = async (data, options) => { + const result = await nativeFormatData(data, options); + if (result?.contents != null && result?.contents.length) { + // ensure there are not 2 messages in a row from the same role, + // if there are combine them + result.contents = result.contents.reduce((acc: GeminiContent[], currentEntry) => { + if (currentEntry.role === acc[acc.length - 1]?.role) { + acc[acc.length - 1].parts = acc[acc.length - 1].parts.concat(currentEntry.parts); + return acc; + } + return [...acc, currentEntry]; + }, []); + } + return result; + }; } async _request( From a397bb72d52e865d0f44c6983bf01c85875251e8 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:59:54 -0500 Subject: [PATCH 10/18] [Security Solution] Update footer link in rule preview to go to rule details page (#195806) ## Summary Currently, the rule preview footer will open the rule flyout. Although this behavior is consistent with other previews (host, user, alert etc.), the rule flyout does not provide additional information for users. This PR updates the footer go to rule details page instead. https://github.com/user-attachments/assets/6de03775-b1a4-41b9-b233-7817d6cca8ec ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Jan Monschke --- .../rule_details/preview/footer.test.tsx | 31 +++++++++---------- .../flyout/rule_details/preview/footer.tsx | 25 +++++---------- .../flyout/rule_details/right/index.test.tsx | 7 +++-- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx index f1e276011ca26..0f2a7dc74662f 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.test.tsx @@ -9,20 +9,21 @@ import { render } from '@testing-library/react'; import React from 'react'; import { RULE_PREVIEW_FOOTER_TEST_ID, RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID } from './test_ids'; import { PreviewFooter } from './footer'; -import { mockFlyoutApi } from '../../document_details/shared/mocks/mock_flyout_context'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { RulePanelKey } from '../right'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; +import { TestProviders } from '../../../common/mock'; -jest.mock('@kbn/expandable-flyout'); +jest.mock('../../document_details/shared/hooks/use_rule_details_link'); -const renderRulePreviewFooter = () => render(); +const renderRulePreviewFooter = () => + render( + + + + ); describe('', () => { - beforeAll(() => { - jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); - }); - it('should render rule details link correctly when ruleId is available', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue('rule_details_link'); const { getByTestId } = renderRulePreviewFooter(); expect(getByTestId(RULE_PREVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); @@ -32,13 +33,9 @@ describe('', () => { ); }); - it('should open rule flyout when clicked', () => { - const { getByTestId } = renderRulePreviewFooter(); - - getByTestId(RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID).click(); - - expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ - right: { id: RulePanelKey, params: { ruleId: 'ruleid' } }, - }); + it('should not render the footer if rule link is not available', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue(null); + const { container } = renderRulePreviewFooter(); + expect(container).toBeEmptyDOMElement(); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx index 1774c37d9e535..42c8c1a6d85b9 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/preview/footer.tsx @@ -5,38 +5,27 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlyoutFooter } from '@kbn/security-solution-common'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { RULE_PREVIEW_FOOTER_TEST_ID, RULE_PREVIEW_OPEN_RULE_FLYOUT_TEST_ID } from './test_ids'; -import { RulePanelKey } from '../right'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; /** * Footer in rule preview panel */ export const PreviewFooter = memo(({ ruleId }: { ruleId: string }) => { - const { openFlyout } = useExpandableFlyoutApi(); + const href = useRuleDetailsLink({ ruleId }); - const openRuleFlyout = useCallback(() => { - openFlyout({ - right: { - id: RulePanelKey, - params: { - ruleId, - }, - }, - }); - }, [openFlyout, ruleId]); - - return ( + return href ? ( {i18n.translate('xpack.securitySolution.flyout.preview.rule.viewDetailsLabel', { @@ -46,7 +35,7 @@ export const PreviewFooter = memo(({ ruleId }: { ruleId: string }) => { - ); + ) : null; }); PreviewFooter.displayName = 'PreviewFooter'; diff --git a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx index 1ce755575450c..146da2be34346 100644 --- a/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/rule_details/right/index.test.tsx @@ -10,7 +10,7 @@ import { render } from '@testing-library/react'; import { ThemeProvider } from 'styled-components'; import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock'; import { TestProviders } from '../../../common/mock'; -// import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; +import { useRuleDetailsLink } from '../../document_details/shared/hooks/use_rule_details_link'; import { RulePanel } from '.'; import { getStepsData } from '../../../detections/pages/detection_engine/rules/helpers'; import { useRuleDetails } from '../hooks/use_rule_details'; @@ -23,6 +23,8 @@ import type { RuleResponse } from '../../../../common/api/detection_engine'; import { BODY_TEST_ID, LOADING_TEST_ID } from './test_ids'; import { RULE_PREVIEW_FOOTER_TEST_ID } from '../preview/test_ids'; +jest.mock('../../document_details/shared/hooks/use_rule_details_link'); + const mockUseRuleDetails = useRuleDetails as jest.Mock; jest.mock('../hooks/use_rule_details'); @@ -89,6 +91,7 @@ describe('', () => { }); it('should render preview footer when isPreviewMode is true', () => { + (useRuleDetailsLink as jest.Mock).mockReturnValue('rule_details_link'); mockUseRuleDetails.mockReturnValue({ rule, loading: false, @@ -97,8 +100,6 @@ describe('', () => { mockGetStepsData.mockReturnValue({}); const { getByTestId } = renderRulePanel(true); - // await act(async () => { expect(getByTestId(RULE_PREVIEW_FOOTER_TEST_ID)).toBeInTheDocument(); - // }); }); }); From db54cb1054cfb83f0efef6a2b087cc914c6694a0 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Thu, 10 Oct 2024 17:23:30 -0500 Subject: [PATCH 11/18] [Lens][Datatable] Fix non-numeric default cell text alignment (#193886) Fixes #191258 where the default alignment of the cell was different between the dimension editor and the table vis. - Assigns default alignment of `'right'` for all number values excluding `ranges`, `multi_terms`, `filters` and `filtered_metric`. Otherwise assigns `'left'`. - The default alignment is never save until the user changes it themselves. --- .../common/expressions/datatable/utils.ts | 31 ++++++++-- .../shared_components/coloring/utils.test.ts | 2 +- .../shared_components/coloring/utils.ts | 21 ++++--- .../datatable/components/cell_value.test.tsx | 6 +- .../datatable/components/cell_value.tsx | 2 +- .../datatable/components/columns.test.tsx | 2 +- .../datatable/components/columns.tsx | 4 +- .../components/dimension_editor.test.tsx | 60 +++++++++---------- .../datatable/components/dimension_editor.tsx | 27 +++++---- .../datatable/components/table_basic.test.tsx | 46 ++++++++++++-- .../datatable/components/table_basic.tsx | 57 ++++++++---------- .../datatable/components/types.ts | 8 +-- .../visualizations/datatable/expression.tsx | 8 ++- .../public/visualizations/datatable/index.ts | 6 +- .../public/visualizations/datatable/utils.ts | 14 +++++ .../datatable/visualization.tsx | 6 +- .../public/visualizations/heatmap/utils.ts | 5 +- 17 files changed, 196 insertions(+), 109 deletions(-) create mode 100644 x-pack/plugins/lens/public/visualizations/datatable/utils.ts diff --git a/x-pack/plugins/lens/common/expressions/datatable/utils.ts b/x-pack/plugins/lens/common/expressions/datatable/utils.ts index 71c3d92126b33..bc617d931f500 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/utils.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/utils.ts @@ -5,14 +5,37 @@ * 2.0. */ -import type { Datatable } from '@kbn/expressions-plugin/common'; +import { type Datatable, type DatatableColumnMeta } from '@kbn/expressions-plugin/common'; import { getOriginalId } from './transpose_helpers'; +/** + * Returns true for numerical fields + * + * Excludes the following types: + * - `range` - Stringified range + * - `multi_terms` - Multiple values + * - `filters` - Arbitrary label + * - `filtered_metric` - Array of values + */ +export function isNumericField(meta?: DatatableColumnMeta): boolean { + return ( + meta?.type === 'number' && + meta.params?.id !== 'range' && + meta.params?.id !== 'multi_terms' && + meta.sourceParams?.type !== 'filters' && + meta.sourceParams?.type !== 'filtered_metric' + ); +} + +/** + * Returns true for numerical fields, excluding ranges + */ export function isNumericFieldForDatatable(table: Datatable | undefined, accessor: string) { - return getFieldTypeFromDatatable(table, accessor) === 'number'; + const meta = getFieldMetaFromDatatable(table, accessor); + return isNumericField(meta); } -export function getFieldTypeFromDatatable(table: Datatable | undefined, accessor: string) { +export function getFieldMetaFromDatatable(table: Datatable | undefined, accessor: string) { return table?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor) - ?.meta.type; + ?.meta; } diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts index 5a126565c251f..cc6044fc0f624 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.test.ts @@ -110,7 +110,7 @@ describe('findMinMaxByColumnId', () => { { a: 'shoes', b: 53 }, ], }) - ).toEqual({ b: { min: 2, max: 53 } }); + ).toEqual(new Map([['b', { min: 2, max: 53 }]])); }); }); diff --git a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts index 211628a096189..c58fec1ddb03e 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/utils.ts +++ b/x-pack/plugins/lens/public/shared_components/coloring/utils.ts @@ -95,12 +95,12 @@ export const findMinMaxByColumnId = ( table: Datatable | undefined, getOriginalId: (id: string) => string = (id: string) => id ) => { - const minMax: Record = {}; + const minMaxMap = new Map(); if (table != null) { for (const columnId of columnIds) { const originalId = getOriginalId(columnId); - minMax[originalId] = minMax[originalId] || { + const minMax = minMaxMap.get(originalId) ?? { max: Number.NEGATIVE_INFINITY, min: Number.POSITIVE_INFINITY, }; @@ -108,19 +108,22 @@ export const findMinMaxByColumnId = ( const rowValue = row[columnId]; const numericValue = getNumericValue(rowValue); if (numericValue != null) { - if (minMax[originalId].min > numericValue) { - minMax[originalId].min = numericValue; + if (minMax.min > numericValue) { + minMax.min = numericValue; } - if (minMax[originalId].max < numericValue) { - minMax[originalId].max = numericValue; + if (minMax.max < numericValue) { + minMax.max = numericValue; } } }); + // what happens when there's no data in the table? Fallback to a percent range - if (minMax[originalId].max === Number.NEGATIVE_INFINITY) { - minMax[originalId] = getFallbackDataBounds(); + if (minMax.max === Number.NEGATIVE_INFINITY) { + minMaxMap.set(originalId, getFallbackDataBounds()); + } else { + minMaxMap.set(originalId, minMax); } } } - return minMax; + return minMaxMap; }; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx index 76b8fc7b61740..e9f3caba9ec05 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.test.tsx @@ -54,9 +54,7 @@ describe('datatable cell renderer', () => { @@ -217,7 +215,7 @@ describe('datatable cell renderer', () => { { wrapper: DataContextProviderWrapper({ table, - minMaxByColumnId: { a: { min: 12, max: 155 } }, + minMaxByColumnId: new Map([['a', { min: 12, max: 155 }]]), ...context, }), } diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx index 0761c7904e75f..97e7e755ac36e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/cell_value.tsx @@ -53,7 +53,7 @@ export const createGridCell = ( } = columnConfig.columns[colIndex] ?? {}; const filterOnClick = oneClickFilter && handleFilterClick; const content = formatters[columnId]?.convert(rawRowValue, filterOnClick ? 'text' : 'html'); - const currentAlignment = alignments && alignments[columnId]; + const currentAlignment = alignments?.get(columnId); useEffect(() => { let colorSet = false; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx index 3612317f7a565..76437743c5723 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx @@ -72,7 +72,7 @@ const callCreateGridColumns = ( params.formatFactory ?? (((x: unknown) => ({ convert: () => x })) as unknown as FormatFactory), params.onColumnResize ?? jest.fn(), params.onColumnHide ?? jest.fn(), - params.alignments ?? {}, + params.alignments ?? new Map(), params.headerRowHeight ?? RowHeightMode.auto, params.headerRowLines ?? 1, params.columnCellValueActions ?? [], diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx index 6cd8c32db4b6d..8d2fcc9fac0c0 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx @@ -51,7 +51,7 @@ export const createGridColumns = ( formatFactory: FormatFactory, onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void, onColumnHide: ((eventData: { columnId: string }) => void) | undefined, - alignments: Record, + alignments: Map, headerRowHeight: RowHeightMode, headerRowLines: number, columnCellValueActions: LensCellValueAction[][] | undefined, @@ -261,7 +261,7 @@ export const createGridColumns = ( }); } } - const currentAlignment = alignments && alignments[field]; + const currentAlignment = alignments && alignments.get(field); const hasMultipleRows = [RowHeightMode.auto, RowHeightMode.custom, undefined].includes( headerRowHeight ); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx index 09c7d95b309e7..738f7edab2a6e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.test.tsx @@ -6,25 +6,20 @@ */ import React from 'react'; -import { DEFAULT_COLOR_MAPPING_CONFIG, type PaletteRegistry } from '@kbn/coloring'; +import { DEFAULT_COLOR_MAPPING_CONFIG } from '@kbn/coloring'; import { act, render, screen } from '@testing-library/react'; import userEvent, { type UserEvent } from '@testing-library/user-event'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { LayerTypes } from '@kbn/expression-xy-plugin/public'; import { EuiButtonGroupTestHarness } from '@kbn/test-eui-helpers'; -import { - FramePublicAPI, - OperationDescriptor, - VisualizationDimensionEditorProps, - DatasourcePublicAPI, - DataType, -} from '../../../types'; +import { FramePublicAPI, DatasourcePublicAPI, OperationDescriptor } from '../../../types'; import { DatatableVisualizationState } from '../visualization'; import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks'; -import { TableDimensionEditor } from './dimension_editor'; +import { TableDimensionEditor, TableDimensionEditorProps } from './dimension_editor'; import { ColumnState } from '../../../../common/expressions'; import { capitalize } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; +import { DatatableColumnType } from '@kbn/expressions-plugin/common'; describe('data table dimension editor', () => { let user: UserEvent; @@ -35,10 +30,8 @@ describe('data table dimension editor', () => { alignment: EuiButtonGroupTestHarness; }; let mockOperationForFirstColumn: (overrides?: Partial) => void; - let props: VisualizationDimensionEditorProps & { - paletteService: PaletteRegistry; - isDarkMode: boolean; - }; + + let props: TableDimensionEditorProps; function testState(): DatatableVisualizationState { return { @@ -80,6 +73,7 @@ describe('data table dimension editor', () => { name: 'foo', meta: { type: 'string', + params: {}, }, }, ], @@ -114,13 +108,7 @@ describe('data table dimension editor', () => { mockOperationForFirstColumn(); }); - const renderTableDimensionEditor = ( - overrideProps?: Partial< - VisualizationDimensionEditorProps & { - paletteService: PaletteRegistry; - } - > - ) => { + const renderTableDimensionEditor = (overrideProps?: Partial) => { return render(, { wrapper: ({ children }) => ( @@ -137,11 +125,18 @@ describe('data table dimension editor', () => { }); it('should render default alignment for number', () => { - mockOperationForFirstColumn({ dataType: 'number' }); + frame.activeData!.first.columns[0].meta.type = 'number'; renderTableDimensionEditor(); expect(btnGroups.alignment.selected).toHaveTextContent('Right'); }); + it('should render default alignment for ranges', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + frame.activeData!.first.columns[0].meta.params = { id: 'range' }; + renderTableDimensionEditor(); + expect(btnGroups.alignment.selected).toHaveTextContent('Left'); + }); + it('should render specific alignment', () => { state.columns[0].alignment = 'center'; renderTableDimensionEditor(); @@ -181,10 +176,11 @@ describe('data table dimension editor', () => { expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument(); }); - it.each(['date'])( + it.each(['date'])( 'should not show the dynamic coloring option for "%s" columns', - (dataType) => { - mockOperationForFirstColumn({ dataType }); + (type) => { + frame.activeData!.first.columns[0].meta.type = type; + renderTableDimensionEditor(); expect(screen.queryByTestId('lnsDatatable_dynamicColoring_groups')).not.toBeInTheDocument(); expect(screen.queryByTestId('lns_dynamicColoring_edit')).not.toBeInTheDocument(); @@ -231,15 +227,16 @@ describe('data table dimension editor', () => { }); }); - it.each<{ flyout: 'terms' | 'values'; isBucketed: boolean; dataType: DataType }>([ - { flyout: 'terms', isBucketed: true, dataType: 'number' }, - { flyout: 'terms', isBucketed: false, dataType: 'string' }, - { flyout: 'values', isBucketed: false, dataType: 'number' }, + it.each<{ flyout: 'terms' | 'values'; isBucketed: boolean; type: DatatableColumnType }>([ + { flyout: 'terms', isBucketed: true, type: 'number' }, + { flyout: 'terms', isBucketed: false, type: 'string' }, + { flyout: 'values', isBucketed: false, type: 'number' }, ])( - 'should show color by $flyout flyout when bucketing is $isBucketed with $dataType column', - async ({ flyout, isBucketed, dataType }) => { + 'should show color by $flyout flyout when bucketing is $isBucketed with $type column', + async ({ flyout, isBucketed, type }) => { state.columns[0].colorMode = 'cell'; - mockOperationForFirstColumn({ isBucketed, dataType }); + frame.activeData!.first.columns[0].meta.type = type; + mockOperationForFirstColumn({ isBucketed }); renderTableDimensionEditor(); await user.click(screen.getByLabelText('Edit colors')); @@ -251,6 +248,7 @@ describe('data table dimension editor', () => { it('should show the dynamic coloring option for a bucketed operation', () => { state.columns[0].colorMode = 'cell'; + frame.activeData!.first.columns[0].meta.type = 'string'; mockOperationForFirstColumn({ isBucketed: true }); renderTableDimensionEditor(); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx index c1e097276cf3d..99fe3cc1c164e 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/dimension_editor.tsx @@ -8,7 +8,7 @@ import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui'; -import { PaletteRegistry } from '@kbn/coloring'; +import { PaletteRegistry, getFallbackDataBounds } from '@kbn/coloring'; import { getColorCategories } from '@kbn/chart-expressions-common'; import { useDebouncedValue } from '@kbn/visualization-utils'; import type { VisualizationDimensionEditorProps } from '../../../types'; @@ -26,6 +26,11 @@ import './dimension_editor.scss'; import { CollapseSetting } from '../../../shared_components/collapse_setting'; import { ColorMappingByValues } from '../../../shared_components/coloring/color_mapping_by_values'; import { ColorMappingByTerms } from '../../../shared_components/coloring/color_mapping_by_terms'; +import { getColumnAlignment } from '../utils'; +import { + getFieldMetaFromDatatable, + isNumericField, +} from '../../../../common/expressions/datatable/utils'; const idPrefix = htmlIdGenerator()(); @@ -45,12 +50,13 @@ function updateColumn( }); } -export function TableDimensionEditor( - props: VisualizationDimensionEditorProps & { +export type TableDimensionEditorProps = + VisualizationDimensionEditorProps & { paletteService: PaletteRegistry; isDarkMode: boolean; - } -) { + }; + +export function TableDimensionEditor(props: TableDimensionEditorProps) { const { frame, accessor, isInlineEditing, isDarkMode } = props; const column = props.state.columns.find(({ columnId }) => accessor === columnId); const { inputValue: localState, handleInputChange: setLocalState } = @@ -74,12 +80,13 @@ export function TableDimensionEditor( const currentData = frame.activeData?.[localState.layerId]; const datasource = frame.datasourceLayers?.[localState.layerId]; - const { dataType, isBucketed } = datasource?.getOperationForColumnId(accessor) ?? {}; - const showColorByTerms = shouldColorByTerms(dataType, isBucketed); - const currentAlignment = column?.alignment || (dataType === 'number' ? 'right' : 'left'); + const { isBucketed } = datasource?.getOperationForColumnId(accessor) ?? {}; + const meta = getFieldMetaFromDatatable(currentData, accessor); + const showColorByTerms = shouldColorByTerms(meta?.type, isBucketed); + const currentAlignment = getColumnAlignment(column, isNumericField(meta)); const currentColorMode = column?.colorMode || 'none'; const hasDynamicColoring = currentColorMode !== 'none'; - const showDynamicColoringFeature = dataType !== 'date'; + const showDynamicColoringFeature = meta?.type !== 'date'; const visibleColumnsCount = localState.columns.filter((c) => !c.hidden).length; const hasTransposedColumn = localState.columns.some(({ isTransposed }) => isTransposed); @@ -88,7 +95,7 @@ export function TableDimensionEditor( [] : [accessor]; const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId); - const currentMinMax = minMaxByColumnId[accessor]; + const currentMinMax = minMaxByColumnId.get(accessor) ?? getFallbackDataBounds(); const activePalette = column?.palette ?? { type: 'palette', diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx index 21361f874e83e..2358b9ec5b563 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.test.tsx @@ -11,7 +11,6 @@ import userEvent from '@testing-library/user-event'; import { I18nProvider } from '@kbn/i18n-react'; import faker from 'faker'; import { act } from 'react-dom/test-utils'; -import { IAggType } from '@kbn/data-plugin/public'; import { IFieldFormat } from '@kbn/field-formats-plugin/common'; import { coreMock } from '@kbn/core/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; @@ -73,6 +72,17 @@ function sampleArgs() { sourceParams: { indexPatternId, type: 'count' }, }, }, + { + id: 'd', + name: 'd', + meta: { + type: 'number', + source: 'esaggs', + field: 'd', + params: { id: 'range' }, + sourceParams: { indexPatternId, type: 'range' }, + }, + }, ], rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; @@ -119,7 +129,9 @@ describe('DatatableComponent', () => { args, formatFactory: () => ({ convert: (x) => x } as IFieldFormat), dispatchEvent: onDispatchEvent, - getType: jest.fn(() => ({ type: 'buckets' } as IAggType)), + getType: jest.fn().mockReturnValue({ + type: 'buckets', + }), paletteService: chartPluginMock.createPaletteRegistry(), theme: setUpMockTheme, renderMode: 'edit' as const, @@ -357,14 +369,39 @@ describe('DatatableComponent', () => { ]); }); - test('it adds alignment data to context', () => { + test('it adds explicit alignment to context', () => { renderDatatableComponent({ args: { ...args, columns: [ { columnId: 'a', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'b', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'c', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'd', alignment: 'center', type: 'lens_datatable_column', colorMode: 'none' }, + ], + }, + }); + const alignmentsClassNames = screen + .getAllByTestId('lnsTableCellContent') + .map((cell) => cell.className); + + expect(alignmentsClassNames).toEqual([ + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + 'lnsTableCell--center', // set via args + ]); + }); + + test('it adds default alignment data to context', () => { + renderDatatableComponent({ + args: { + ...args, + columns: [ + { columnId: 'a', type: 'lens_datatable_column', colorMode: 'none' }, { columnId: 'b', type: 'lens_datatable_column', colorMode: 'none' }, { columnId: 'c', type: 'lens_datatable_column', colorMode: 'none' }, + { columnId: 'd', type: 'lens_datatable_column', colorMode: 'none' }, ], sortingColumnId: 'b', sortingDirection: 'desc', @@ -375,9 +412,10 @@ describe('DatatableComponent', () => { .map((cell) => cell.className); expect(alignmentsClassNames).toEqual([ - 'lnsTableCell--center', // set via args + 'lnsTableCell--left', // default for string 'lnsTableCell--left', // default for date 'lnsTableCell--right', // default for number + 'lnsTableCell--left', // default for range ]); }); diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx index 83249f86ffa79..55e198b943e81 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx @@ -6,7 +6,7 @@ */ import './table_basic.scss'; -import { ColorMappingInputData, PaletteOutput } from '@kbn/coloring'; +import { ColorMappingInputData, PaletteOutput, getFallbackDataBounds } from '@kbn/coloring'; import React, { useLayoutEffect, useCallback, @@ -58,8 +58,12 @@ import { } from './table_actions'; import { getFinalSummaryConfiguration } from '../../../../common/expressions/datatable/summary'; import { DEFAULT_HEADER_ROW_HEIGHT, DEFAULT_HEADER_ROW_HEIGHT_LINES } from './constants'; -import { getFieldTypeFromDatatable } from '../../../../common/expressions/datatable/utils'; +import { + getFieldMetaFromDatatable, + isNumericField, +} from '../../../../common/expressions/datatable/utils'; import { CellColorFn, getCellColorFn } from '../../../shared_components/coloring/get_cell_color_fn'; +import { getColumnAlignment } from '../utils'; export const DataContext = React.createContext({}); @@ -229,10 +233,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig.columns .filter((_col, index) => { const col = firstTableRef.current.columns[index]; - return ( - col?.meta?.sourceParams?.type && - getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); + return getType(col?.meta)?.type === 'buckets'; }) .map((col) => col.columnId), [firstTableRef, columnConfig, getType] @@ -240,7 +241,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const isEmpty = firstLocalTable.rows.length === 0 || - (bucketedColumns.length && + (bucketedColumns.length > 0 && props.data.rows.every((row) => bucketedColumns.every((col) => row[col] == null))); const visibleColumns = useMemo( @@ -266,34 +267,26 @@ export const DatatableComponent = (props: DatatableRenderProps) => { [onEditAction, setColumnConfig, columnConfig, isInteractive] ); - const isNumericMap: Record = useMemo( + const isNumericMap: Map = useMemo( () => - firstLocalTable.columns.reduce>( - (map, column) => ({ - ...map, - [column.id]: column.meta.type === 'number', - }), - {} - ), - [firstLocalTable] + firstLocalTable.columns.reduce((acc, column) => { + acc.set(column.id, isNumericField(column.meta)); + return acc; + }, new Map()), + [firstLocalTable.columns] ); - const alignments: Record = useMemo(() => { - const alignmentMap: Record = {}; - columnConfig.columns.forEach((column) => { - if (column.alignment) { - alignmentMap[column.columnId] = column.alignment; - } else { - alignmentMap[column.columnId] = isNumericMap[column.columnId] ? 'right' : 'left'; - } - }); - return alignmentMap; - }, [columnConfig, isNumericMap]); + const alignments: Map = useMemo(() => { + return columnConfig.columns.reduce((acc, column) => { + acc.set(column.columnId, getColumnAlignment(column, isNumericMap.get(column.columnId))); + return acc; + }, new Map()); + }, [columnConfig.columns, isNumericMap]); - const minMaxByColumnId: Record = useMemo(() => { + const minMaxByColumnId: Map = useMemo(() => { return findMinMaxByColumnId( columnConfig.columns - .filter(({ columnId }) => isNumericMap[columnId]) + .filter(({ columnId }) => isNumericMap.get(columnId)) .map(({ columnId }) => columnId), props.data, getOriginalId @@ -402,7 +395,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { return cellColorFnMap.get(originalId)!; } - const dataType = getFieldTypeFromDatatable(firstLocalTable, originalId); + const dataType = getFieldMetaFromDatatable(firstLocalTable, originalId)?.type; const isBucketed = bucketedColumns.some((id) => id === columnId); const colorByTerms = shouldColorByTerms(dataType, isBucketed); @@ -419,7 +412,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { : { type: 'ranges', bins: 0, - ...minMaxByColumnId[originalId], + ...(minMaxByColumnId.get(originalId) ?? getFallbackDataBounds()), }; const colorFn = getCellColorFn( props.paletteService, @@ -491,7 +484,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ]) ); return ({ columnId }: { columnId: string }) => { - const currentAlignment = alignments && alignments[columnId]; + const currentAlignment = alignments.get(columnId); const alignmentClassName = `lnsTableCell--${currentAlignment}`; const columnName = columns.find(({ id }) => id === columnId)?.displayAsText?.replace(/ /g, '-') || columnId; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts index b884a2c716be9..00d916bf956ae 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts @@ -8,7 +8,7 @@ import { CoreSetup } from '@kbn/core/public'; import type { PaletteRegistry } from '@kbn/coloring'; import type { IAggType } from '@kbn/data-plugin/public'; -import type { Datatable, RenderMode } from '@kbn/expressions-plugin/common'; +import type { Datatable, DatatableColumnMeta, RenderMode } from '@kbn/expressions-plugin/common'; import type { ILensInterpreterRenderHandlers, LensCellValueAction, @@ -49,7 +49,7 @@ export type LensPagesizeAction = LensEditEvent export type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; dispatchEvent: ILensInterpreterRenderHandlers['event']; - getType: (name: string) => IAggType | undefined; + getType: (meta?: DatatableColumnMeta) => IAggType | undefined; renderMode: RenderMode; paletteService: PaletteRegistry; theme: CoreSetup['theme']; @@ -72,8 +72,8 @@ export type DatatableRenderProps = DatatableProps & { export interface DataContextType { table?: Datatable; rowHasRowClickTriggerActions?: boolean[]; - alignments?: Record; - minMaxByColumnId?: Record; + alignments?: Map; + minMaxByColumnId?: Map; handleFilterClick?: ( field: string, value: unknown, diff --git a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx index 652abec75695e..a5927dd9183bf 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx @@ -13,6 +13,7 @@ import type { IAggType } from '@kbn/data-plugin/public'; import { CoreSetup, IUiSettingsClient } from '@kbn/core/public'; import type { Datatable, + DatatableColumnMeta, ExpressionRenderDefinition, IInterpreterRenderHandlers, } from '@kbn/expressions-plugin/common'; @@ -102,6 +103,11 @@ export const getDatatableRenderer = (dependencies: { handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); const resolvedGetType = await dependencies.getType; + const getType = (meta?: DatatableColumnMeta): IAggType | undefined => { + if (meta?.sourceParams?.type === undefined) return; + return resolvedGetType(String(meta.sourceParams.type)); + }; + const { hasCompatibleActions, isInteractive, getCompatibleCellValueActions } = handlers; const renderComplete = () => { @@ -161,7 +167,7 @@ export const getDatatableRenderer = (dependencies: { dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} paletteService={dependencies.paletteService} - getType={resolvedGetType} + getType={getType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} columnCellValueActions={columnCellValueActions} columnFilterable={columnsFilterable} diff --git a/x-pack/plugins/lens/public/visualizations/datatable/index.ts b/x-pack/plugins/lens/public/visualizations/datatable/index.ts index f68f167ea5f02..93e5e38e03c3c 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/index.ts +++ b/x-pack/plugins/lens/public/visualizations/datatable/index.ts @@ -32,6 +32,7 @@ export class DatatableVisualization { '../../async_services' ); const palettes = await charts.palettes.getPalettes(); + expressions.registerRenderer(() => getDatatableRenderer({ formatFactory, @@ -44,7 +45,10 @@ export class DatatableVisualization { }) ); - return getDatatableVisualization({ paletteService: palettes, kibanaTheme: core.theme }); + return getDatatableVisualization({ + paletteService: palettes, + kibanaTheme: core.theme, + }); }); } } diff --git a/x-pack/plugins/lens/public/visualizations/datatable/utils.ts b/x-pack/plugins/lens/public/visualizations/datatable/utils.ts new file mode 100644 index 0000000000000..ab4d8f05f8d44 --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/datatable/utils.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export function getColumnAlignment( + { alignment }: C, + isNumeric = false +): 'left' | 'right' | 'center' { + if (alignment) return alignment; + return isNumeric ? 'right' : 'left'; +} diff --git a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx index 0187776985a30..d2d23b2033f90 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/datatable/visualization.tsx @@ -147,8 +147,8 @@ export const getDatatableVisualization = ({ .map(({ id }) => id) || [] : [accessor]; const minMaxByColumnId = findMinMaxByColumnId(columnsToCheck, currentData, getOriginalId); - - if (palette && !showColorByTerms && !palette?.canDynamicColoring) { + const dataBounds = minMaxByColumnId.get(accessor); + if (palette && !showColorByTerms && !palette?.canDynamicColoring && dataBounds) { const newPalette: PaletteOutput = { type: 'palette', name: showColorByTerms ? 'default' : defaultPaletteParams.name, @@ -158,7 +158,7 @@ export const getDatatableVisualization = ({ palette: { ...newPalette, params: { - stops: applyPaletteParams(paletteService, newPalette, minMaxByColumnId[accessor]), + stops: applyPaletteParams(paletteService, newPalette, dataBounds), }, }, }; diff --git a/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts b/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts index 5e09ce2987bae..fe942dd40427c 100644 --- a/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts +++ b/x-pack/plugins/lens/public/visualizations/heatmap/utils.ts @@ -26,7 +26,10 @@ export function getSafePaletteParams( accessor, }; const minMaxByColumnId = findMinMaxByColumnId([accessor], currentData); - const currentMinMax = minMaxByColumnId[accessor]; + const currentMinMax = minMaxByColumnId.get(accessor) ?? { + max: Number.NEGATIVE_INFINITY, + min: Number.POSITIVE_INFINITY, + }; // need to tell the helper that the colorStops are required to display return { From 872d9da30e74f64dd33e25c6fed2d55e6aa4af47 Mon Sep 17 00:00:00 2001 From: Amir Ben Nun <34831306+amirbenun@users.noreply.github.com> Date: Fri, 11 Oct 2024 01:44:25 +0300 Subject: [PATCH 12/18] Agentless api fix delete path (#195762) ## Summary Agentless API delete path should have the `ess` or `serverless` mark based on the environment. This PR build the request URL based on that. --- .../services/agents/agentless_agent.test.ts | 76 +++++++++++++++++++ .../server/services/agents/agentless_agent.ts | 11 ++- .../fleet/server/services/utils/agentless.ts | 7 -- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts index e55b883e80029..e7db96812749b 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts @@ -370,6 +370,82 @@ describe('Agentless Agent service', () => { ); }); + it('should delete agentless agent for ESS', async () => { + const returnValue = { + id: 'mocked', + }; + + (axios as jest.MockedFunction).mockResolvedValueOnce(returnValue); + jest.spyOn(appContextService, 'getConfig').mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'http://api.agentless.com', + tls: { + certificate: '/path/to/cert', + key: '/path/to/key', + ca: '/path/to/ca', + }, + }, + }, + } as any); + jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: true } as any); + + const deleteAgentlessAgentReturnValue = await agentlessAgentService.deleteAgentlessAgent( + 'mocked-agentless-agent-policy-id' + ); + + expect(axios).toHaveBeenCalledTimes(1); + expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.anything(), + httpsAgent: expect.anything(), + method: 'DELETE', + url: 'http://api.agentless.com/api/v1/ess/deployments/mocked-agentless-agent-policy-id', + }) + ); + }); + + it('should delete agentless agent for serverless', async () => { + const returnValue = { + id: 'mocked', + }; + + (axios as jest.MockedFunction).mockResolvedValueOnce(returnValue); + jest.spyOn(appContextService, 'getConfig').mockReturnValue({ + agentless: { + enabled: true, + api: { + url: 'http://api.agentless.com', + tls: { + certificate: '/path/to/cert', + key: '/path/to/key', + ca: '/path/to/ca', + }, + }, + }, + } as any); + jest + .spyOn(appContextService, 'getCloud') + .mockReturnValue({ isCloudEnabled: true, isServerlessEnabled: true } as any); + + const deleteAgentlessAgentReturnValue = await agentlessAgentService.deleteAgentlessAgent( + 'mocked-agentless-agent-policy-id' + ); + + expect(axios).toHaveBeenCalledTimes(1); + expect(deleteAgentlessAgentReturnValue).toEqual(returnValue); + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.anything(), + httpsAgent: expect.anything(), + method: 'DELETE', + url: 'http://api.agentless.com/api/v1/serverless/deployments/mocked-agentless-agent-policy-id', + }) + ); + }); + it('should redact sensitive information from debug logs', async () => { const returnValue = { id: 'mocked', diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts index 3bf21c3bec0d1..617f3db7849f4 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts @@ -25,11 +25,7 @@ import { appContextService } from '../app_context'; import { listEnrollmentApiKeys } from '../api_keys'; import { listFleetServerHosts } from '../fleet_server_host'; import type { AgentlessConfig } from '../utils/agentless'; -import { - prependAgentlessApiBasePathToEndpoint, - isAgentlessApiEnabled, - getDeletionEndpointPath, -} from '../utils/agentless'; +import { prependAgentlessApiBasePathToEndpoint, isAgentlessApiEnabled } from '../utils/agentless'; class AgentlessAgentService { public async createAgentlessAgent( @@ -188,7 +184,10 @@ class AgentlessAgentService { const agentlessConfig = appContextService.getConfig()?.agentless; const tlsConfig = this.createTlsConfig(agentlessConfig); const requestConfig = { - url: getDeletionEndpointPath(agentlessConfig, `/deployments/${agentlessPolicyId}`), + url: prependAgentlessApiBasePathToEndpoint( + agentlessConfig, + `/deployments/${agentlessPolicyId}` + ), method: 'DELETE', headers: { 'Content-type': 'application/json', diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.ts b/x-pack/plugins/fleet/server/services/utils/agentless.ts index c85e9cc991a6c..4c27d583d9a79 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.ts @@ -50,10 +50,3 @@ export const prependAgentlessApiBasePathToEndpoint = ( : AGENTLESS_ESS_API_BASE_PATH; return `${agentlessConfig.api.url}${endpointPrefix}${endpoint}`; }; - -export const getDeletionEndpointPath = ( - agentlessConfig: FleetConfigType['agentless'], - endpoint: AgentlessApiEndpoints -) => { - return `${agentlessConfig.api.url}${AGENTLESS_ESS_API_BASE_PATH}${endpoint}`; -}; From 00042177a8e976d379b5e40db3664db1e333999d Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:48:47 -0400 Subject: [PATCH 13/18] [Security Solution] Prevent non-customizable fields from updating for Prebuilt rule types (#195318) ## Summary Addresses https://github.com/elastic/kibana/issues/180273 Adds validation in the `detectionRulesClient` to prevent the updating of non-customizable fields in Prebuilt rule types (i.e. external `rule_source`). Returns a `400` error if `author` or `license` fields are updated via `PUT` and `PATCH` endpoints for external rules. Also updates related test utils to reflect this new logic ### Checklist Delete any items that are not applicable to this PR. - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Elastic Machine --- .../rule_assets/prebuilt_rule_asset.mock.ts | 2 + .../detection_rules_client.patch_rule.test.ts | 21 ++++++++++ ...detection_rules_client.update_rule.test.ts | 21 ++++++++++ .../methods/patch_rule.ts | 3 ++ .../methods/update_rule.ts | 3 ++ .../rule_management/utils/validate.ts | 30 +++++++++++++++ .../patch_rules.ts | 22 +++++++++++ .../patch_rules_bulk.ts | 38 +++++++++++++++++++ .../update_rules.ts | 30 +++++++++++++++ .../update_rules_bulk.ts | 27 +++++++++++++ .../usage_collector/detection_rules.ts | 20 ++++++---- .../detection_rules_legacy_action.ts | 13 ++++--- .../get_custom_query_rule_params.ts | 1 + 13 files changed, 218 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts index c73203c2871ab..8f9c1a6a32357 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/model/rule_assets/prebuilt_rule_asset.mock.ts @@ -18,6 +18,7 @@ export const getPrebuiltRuleMock = (rewrites?: Partial): Preb language: 'kuery', rule_id: 'rule-1', version: 1, + author: [], ...rewrites, } as PrebuiltRuleAsset); @@ -51,6 +52,7 @@ export const getPrebuiltThreatMatchRuleMock = (): PrebuiltRuleAsset => ({ language: 'kuery', rule_id: 'rule-1', version: 1, + author: [], threat_query: '*:*', threat_index: ['list-index'], threat_mapping: [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts index e460581c02a1c..448df6b581a3b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.patch_rule.test.ts @@ -277,6 +277,27 @@ describe('DetectionRulesClient.patchRule', () => { expect(rulesClient.create).not.toHaveBeenCalled(); }); + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Mock the existing rule + const existingRule = { + ...getRulesSchemaMock(), + rule_source: { type: 'external', is_customized: true }, + }; + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const rulePatch = getCreateRulesSchemaMock('query-rule-id'); + rulePatch.license = 'new license'; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + + await expect(detectionRulesClient.patchRule({ rulePatch })).rejects.toThrow( + 'Cannot update "license" field for prebuilt rules' + ); + }); + describe('actions', () => { it("updates the rule's actions if provided", async () => { // Mock the existing rule diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts index a660e5c5e8747..cbd0fb1fe3680 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/detection_rules_client.update_rule.test.ts @@ -498,5 +498,26 @@ describe('DetectionRulesClient.updateRule', () => { }) ); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Mock the existing rule + const existingRule = { + ...getRulesSchemaMock(), + rule_source: { type: 'external', is_customized: true }, + }; + + (getRuleByRuleId as jest.Mock).mockResolvedValueOnce(existingRule); + + // Mock the rule update + const ruleUpdate = { ...getCreateRulesSchemaMock(), author: ['new user'] }; + + // Mock the rule returned after update; not used for this test directly but + // needed so that the patchRule method does not throw + rulesClient.update.mockResolvedValue(getRuleMock(getQueryRuleParams())); + + await expect(detectionRulesClient.updateRule({ ruleUpdate })).rejects.toThrow( + 'Cannot update "author" field for prebuilt rules' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts index 1218991bf388e..113576e8d02e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/patch_rule.ts @@ -16,6 +16,7 @@ import type { MlAuthz } from '../../../../../machine_learning/authz'; import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client'; import { applyRulePatch } from '../mergers/apply_rule_patch'; import { getIdError } from '../../../utils/utils'; +import { validateNonCustomizablePatchFields } from '../../../utils/validate'; import { convertAlertingRuleToRuleResponse } from '../converters/convert_alerting_rule_to_rule_response'; import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; @@ -51,6 +52,8 @@ export const patchRule = async ({ await validateMlAuth(mlAuthz, rulePatch.type ?? existingRule.type); + validateNonCustomizablePatchFields(rulePatch, existingRule); + const patchedRule = await applyRulePatch({ prebuiltRuleAssetClient, existingRule, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts index cd84788026870..8fd7f7a89dcb7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/methods/update_rule.ts @@ -11,6 +11,7 @@ import type { RuleResponse } from '../../../../../../../common/api/detection_eng import type { MlAuthz } from '../../../../../machine_learning/authz'; import { applyRuleUpdate } from '../mergers/apply_rule_update'; import { getIdError } from '../../../utils/utils'; +import { validateNonCustomizableUpdateFields } from '../../../utils/validate'; import { convertRuleResponseToAlertingRule } from '../converters/convert_rule_response_to_alerting_rule'; import { ClientError, toggleRuleEnabledOnUpdate, validateMlAuth } from '../utils'; @@ -50,6 +51,8 @@ export const updateRule = async ({ throw new ClientError(error.message, error.statusCode); } + validateNonCustomizableUpdateFields(ruleUpdate, existingRule); + const ruleWithUpdates = await applyRuleUpdate({ prebuiltRuleAssetClient, existingRule, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts index 3d07f935deb7b..5ff9d2d97f2b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/utils/validate.ts @@ -15,6 +15,7 @@ import { RuleResponse, type RuleResponseAction, type RuleUpdateProps, + type RulePatchProps, } from '../../../../../common/api/detection_engine'; import { RESPONSE_ACTION_API_COMMAND_TO_CONSOLE_COMMAND_MAP, @@ -25,6 +26,7 @@ import { CustomHttpRequestError } from '../../../../utils/custom_http_request_er import { hasValidRuleType, type RuleAlertType, type RuleParams } from '../../rule_schema'; import { type BulkError, createBulkErrorObject } from '../../routes/utils'; import { internalRuleToAPIResponse } from '../logic/detection_rules_client/converters/internal_rule_to_api_response'; +import { ClientError } from '../logic/detection_rules_client/utils'; export const transformValidateBulkError = ( ruleId: string, @@ -117,3 +119,31 @@ function rulePayloadContainsResponseActions(rule: RuleCreateProps | RuleUpdatePr function ruleObjectContainsResponseActions(rule?: RuleAlertType) { return rule != null && 'params' in rule && 'responseActions' in rule?.params; } + +export const validateNonCustomizableUpdateFields = ( + ruleUpdate: RuleUpdateProps, + existingRule: RuleResponse +) => { + // We don't allow non-customizable fields to be changed for prebuilt rules + if (existingRule.rule_source && existingRule.rule_source.type === 'external') { + if (!isEqual(ruleUpdate.author, existingRule.author)) { + throw new ClientError(`Cannot update "author" field for prebuilt rules`, 400); + } else if (ruleUpdate.license !== existingRule.license) { + throw new ClientError(`Cannot update "license" field for prebuilt rules`, 400); + } + } +}; + +export const validateNonCustomizablePatchFields = ( + rulePatch: RulePatchProps, + existingRule: RuleResponse +) => { + // We don't allow non-customizable fields to be changed for prebuilt rules + if (existingRule.rule_source && existingRule.rule_source.type === 'external') { + if (rulePatch.author && !isEqual(rulePatch.author, existingRule.author)) { + throw new ClientError(`Cannot update "author" field for prebuilt rules`, 400); + } else if (rulePatch.license != null && rulePatch.license !== existingRule.license) { + throw new ClientError(`Cannot update "license" field for prebuilt rules`, 400); + } + } +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts index a567eb78a776d..41f207c90f319 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules.ts @@ -16,6 +16,9 @@ import { removeServerGeneratedPropertiesIncludingRuleId, getSimpleRuleOutputWithoutRuleId, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -238,6 +241,25 @@ export default ({ getService }: FtrProviderContext) => { }); }); + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .patchRule({ + body: { + rule_id: 'rule-1', + author: ['new user'], + }, + }) + .expect(400); + + expect(body.message).toEqual('Cannot update "author" field for prebuilt rules'); + }); + describe('max signals', () => { afterEach(async () => { await deleteAllRules(supertest, log); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts index 086909fc4945b..7929b912768ff 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_patch/basic_license_essentials_tier/patch_rules_bulk.ts @@ -16,6 +16,9 @@ import { getSimpleRuleOutputWithoutRuleId, removeServerGeneratedPropertiesIncludingRuleId, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -347,6 +350,41 @@ export default ({ getService }: FtrProviderContext) => { }, ]); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + createRuleAssetSavedObject({ rule_id: 'rule-2', license: 'basic' }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .bulkPatchRules({ + body: [ + { rule_id: 'rule-1', author: ['new user'] }, + { rule_id: 'rule-2', license: 'new license' }, + ], + }) + .expect(200); + + expect([body[0], body[1]]).toEqual([ + { + error: { + message: 'Cannot update "author" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-1', + }, + { + error: { + message: 'Cannot update "license" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-2', + }, + ]); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts index 60e7bfe3ff88f..c84236a14eb37 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules.ts @@ -18,6 +18,9 @@ import { getSimpleMlRuleUpdate, getSimpleRule, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -309,6 +312,33 @@ export default ({ getService }: FtrProviderContext) => { expect(updatedRuleResponse).toMatchObject(expectedRule); }); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', license: 'elastic' }), + ]); + await installPrebuiltRules(es, supertest); + + const { body: existingRule } = await securitySolutionApi + .readRule({ + query: { rule_id: 'rule-1' }, + }) + .expect(200); + + const { body } = await securitySolutionApi + .updateRule({ + body: getCustomQueryRuleParams({ + ...existingRule, + rule_id: 'rule-1', + id: undefined, + license: 'new license', + }), + }) + .expect(400); + + expect(body.message).toEqual('Cannot update "license" field for prebuilt rules'); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts index f9faee0481bf6..cdca9e3ca6e1a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/rules_management/rule_update/basic_license_essentials_tier/update_rules_bulk.ts @@ -17,6 +17,9 @@ import { getSimpleRuleUpdate, getSimpleRule, updateUsername, + createHistoricalPrebuiltRuleAssetSavedObjects, + installPrebuiltRules, + createRuleAssetSavedObject, } from '../../../utils'; import { createAlertsIndex, @@ -370,6 +373,30 @@ export default ({ getService }: FtrProviderContext) => { }, ]); }); + + it('throws an error if rule has external rule source and non-customizable fields are changed', async () => { + // Install base prebuilt detection rule + await createHistoricalPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ rule_id: 'rule-1', author: ['elastic'] }), + ]); + await installPrebuiltRules(es, supertest); + + const { body } = await securitySolutionApi + .bulkUpdateRules({ + body: [getCustomQueryRuleParams({ rule_id: 'rule-1', author: ['new user'] })], + }) + .expect(200); + + expect([body[0]]).toEqual([ + { + error: { + message: 'Cannot update "author" field for prebuilt rules', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); }); }; diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts index b3b58ac7880f8..c43d08a805ca8 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules.ts @@ -31,6 +31,7 @@ import { getRuleSavedObjectWithLegacyInvestigationFields, getRuleSavedObjectWithLegacyInvestigationFieldsEmptyArray, createRuleThroughAlertingEndpoint, + getCustomQueryRuleParams, } from '../../../utils'; import { createRule, @@ -1140,7 +1141,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, false, newRuleToUpdate); await updateRule(supertest, ruleToUpdate); @@ -1161,7 +1162,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -1197,7 +1198,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); const ruleToUpdate = getRuleWithWebHookAction(hookAction.id, true, newRuleToUpdate); await updateRule(supertest, ruleToUpdate); @@ -1218,7 +1219,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, @@ -1254,7 +1255,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, false); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -1275,7 +1276,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -1311,7 +1312,10 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, true); + const newRuleToUpdate = getCustomQueryRuleParams({ + rule_id: immutableRule.rule_id, + enabled: true, + }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -1332,7 +1336,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts index e3754d9a09b60..f85f317e2da07 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/telemetry/trial_license_complete_tier/usage_collector/detection_rules_legacy_action.ts @@ -21,13 +21,13 @@ import { fetchRule, getRuleWithWebHookAction, getSimpleMlRule, - getSimpleRule, getSimpleThreatMatch, getStats, getThresholdRuleForAlertTesting, installMockPrebuiltRules, updateRule, deleteAllEventLogExecutionEvents, + getCustomQueryRuleParams, } from '../../../utils'; import { createRule, @@ -408,7 +408,7 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, false); + const newRuleToUpdate = getCustomQueryRuleParams({ rule_id: immutableRule.rule_id }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -429,7 +429,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: false, elastic_rule: true, @@ -465,7 +465,10 @@ export default ({ getService }: FtrProviderContext) => { await installMockPrebuiltRules(supertest, es); const immutableRule = await fetchRule(supertest, { ruleId: ELASTIC_SECURITY_RULE_ID }); const hookAction = await createWebHookRuleAction(supertest); - const newRuleToUpdate = getSimpleRule(immutableRule.rule_id, true); + const newRuleToUpdate = getCustomQueryRuleParams({ + rule_id: immutableRule.rule_id, + enabled: true, + }); await updateRule(supertest, newRuleToUpdate); await createLegacyRuleAction(supertest, immutableRule.id, hookAction.id); @@ -486,7 +489,7 @@ export default ({ getService }: FtrProviderContext) => { ...omittedFields } = foundRule; expect(omittedFields).to.eql({ - rule_name: 'Simple Rule Query', + rule_name: 'Custom query rule', rule_type: 'query', enabled: true, elastic_rule: true, diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts index b561d3e8dc023..a5c5fe00ed700 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_rule_params/get_custom_query_rule_params.ts @@ -29,6 +29,7 @@ export function getCustomQueryRuleParams( index: ['logs-*'], interval: '100m', from: 'now-6m', + author: [], enabled: false, ...rewrites, }; From 2b995fa86eb44a2bd54c44a74eb47a2a26ec0ed2 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 10 Oct 2024 16:49:22 -0600 Subject: [PATCH 14/18] [Security Assistant] Fix ESQL tool availability (#195827) --- .../graphs/default_assistant_graph/index.ts | 1 - .../routes/attack_discovery/helpers.test.ts | 2 - .../server/routes/attack_discovery/helpers.ts | 1 - .../server/routes/evaluate/post_evaluate.ts | 1 - .../plugins/elastic_assistant/server/types.ts | 1 - .../alert_counts/alert_counts_tool.test.ts | 2 - .../attack_discovery_tool.test.ts | 1 - .../tools/esql/nl_to_esql_tool.test.ts | 65 ++----------------- .../assistant/tools/esql/nl_to_esql_tool.ts | 5 +- .../knowledge_base_retrieval_tool.ts | 4 +- .../knowledge_base_write_tool.ts | 4 +- .../open_and_acknowledged_alerts_tool.test.ts | 2 - .../tools/security_labs/security_labs_tool.ts | 4 +- 13 files changed, 13 insertions(+), 80 deletions(-) diff --git a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts index ada5b8a421441..4f043c681f8df 100644 --- a/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts +++ b/x-pack/plugins/elastic_assistant/server/lib/langchain/graphs/default_assistant_graph/index.ts @@ -103,7 +103,6 @@ export const callAssistantGraph: AgentExecutor = async ({ isEnabledKnowledgeBase, kbDataClient: dataClients?.kbDataClient, logger, - modelExists: isEnabledKnowledgeBase, onNewReplacements, replacements, request, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts index 15877e6727715..d5eaf7d159618 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.test.ts @@ -196,7 +196,6 @@ describe('helpers', () => { langChainTimeout, llm, logger: mockLogger, - modelExists: false, onNewReplacements, replacements: latestReplacements, request: mockRequest, @@ -231,7 +230,6 @@ describe('helpers', () => { langChainTimeout, llm, logger: mockLogger, - modelExists: false, onNewReplacements, replacements: latestReplacements, request: mockRequest, diff --git a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts index 2a1450a9f7b9b..f016d6ac29118 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/attack_discovery/helpers.ts @@ -157,7 +157,6 @@ const formatAssistantToolParams = ({ langChainTimeout, llm, logger, - modelExists: false, // not required for attack discovery onNewReplacements, replacements: latestReplacements, request, diff --git a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts index de154a1ddd96d..29a7527964677 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/evaluate/post_evaluate.ts @@ -236,7 +236,6 @@ export const postEvaluateRoute = ( llm, isOssModel, logger, - modelExists: isEnabledKnowledgeBase, request: skeletonRequest, alertsIndexPattern, // onNewReplacements, diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index 3117295810877..45bd5a4149b58 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -244,7 +244,6 @@ export interface AssistantToolParams { llm?: ActionsClientLlm | AssistantToolLlm; isOssModel?: boolean; logger: Logger; - modelExists: boolean; onNewReplacements?: (newReplacements: Replacements) => void; replacements?: Replacements; request: KibanaRequest< diff --git a/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts index 752f8e472a755..814a00853927f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/alert_counts/alert_counts_tool.test.ts @@ -29,13 +29,11 @@ describe('AlertCountsTool', () => { } as unknown as KibanaRequest; const isEnabledKnowledgeBase = true; const chain = {} as unknown as RetrievalQAChain; - const modelExists = true; const logger = loggerMock.create(); const rest = { isEnabledKnowledgeBase, chain, logger, - modelExists, }; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts index 5d8fb0b51739a..4d06751f57d7d 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/attack_discovery/attack_discovery_tool.test.ts @@ -75,7 +75,6 @@ describe('AttackDiscoveryTool', () => { isEnabledKnowledgeBase: false, llm, logger, - modelExists: false, onNewReplacements: jest.fn(), size, }; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts index f078bccb24a36..10b1fa21daefe 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.test.ts @@ -40,65 +40,18 @@ describe('NaturalLanguageESQLTool', () => { request, inference, connectorId, + isEnabledKnowledgeBase: true, }; describe('isSupported', () => { - it('returns false if isEnabledKnowledgeBase is false', () => { - const params = { - isEnabledKnowledgeBase: false, - modelExists: true, - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(false); - }); - - it('returns false if modelExists is false (the ELSER model is not installed)', () => { - const params = { - isEnabledKnowledgeBase: true, - modelExists: false, // <-- ELSER model is not installed - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(false); - }); - - it('returns true if isEnabledKnowledgeBase and modelExists are true', () => { - const params = { - isEnabledKnowledgeBase: true, - modelExists: true, - ...rest, - }; - - expect(NL_TO_ESQL_TOOL.isSupported(params)).toBe(true); + it('returns true if connectorId and inference have values', () => { + expect(NL_TO_ESQL_TOOL.isSupported(rest)).toBe(true); }); }); describe('getTool', () => { - it('returns null if isEnabledKnowledgeBase is false', () => { - const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: false, - modelExists: true, - ...rest, - }); - - expect(tool).toBeNull(); - }); - - it('returns null if modelExists is false (the ELSER model is not installed)', () => { - const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: false, // <-- ELSER model is not installed - ...rest, - }); - - expect(tool).toBeNull(); - }); - it('returns null if inference plugin is not provided', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, inference: undefined, }); @@ -108,8 +61,6 @@ describe('NaturalLanguageESQLTool', () => { it('returns null if connectorId is not provided', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, connectorId: undefined, }); @@ -117,10 +68,8 @@ describe('NaturalLanguageESQLTool', () => { expect(tool).toBeNull(); }); - it('should return a Tool instance if isEnabledKnowledgeBase and modelExists are true', () => { + it('should return a Tool instance when given required properties', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, }); @@ -129,8 +78,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return a tool with the expected tags', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, ...rest, }) as DynamicTool; @@ -139,8 +86,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return tool with the expected description for OSS model', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, isOssModel: true, ...rest, }) as DynamicTool; @@ -150,8 +95,6 @@ describe('NaturalLanguageESQLTool', () => { it('should return tool with the expected description for non-OSS model', () => { const tool = NL_TO_ESQL_TOOL.getTool({ - isEnabledKnowledgeBase: true, - modelExists: true, isOssModel: false, ...rest, }) as DynamicTool; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts index 96b865efeaed4..1205fb03b0458 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/esql/nl_to_esql_tool.ts @@ -13,6 +13,7 @@ import { naturalLanguageToEsql } from '@kbn/inference-plugin/server'; import { APP_UI_ID } from '../../../../common'; import { getPromptSuffixForOssModel } from './common'; +// select only some properties of AssistantToolParams export type ESQLToolParams = AssistantToolParams; const TOOL_NAME = 'NaturalLanguageESQLTool'; @@ -32,8 +33,8 @@ export const NL_TO_ESQL_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: ESQLToolParams): params is ESQLToolParams => { - const { inference, connectorId, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && inference != null && connectorId != null; + const { inference, connectorId } = params; + return inference != null && connectorId != null; }, getTool(params: ESQLToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts index 7739de18857aa..cea2bdadf5970 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_retrieval_tool.ts @@ -25,8 +25,8 @@ export const KNOWLEDGE_BASE_RETRIEVAL_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is KnowledgeBaseRetrievalToolParams => { - const { kbDataClient, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { kbDataClient, isEnabledKnowledgeBase } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts index 9b46c625e115b..4069eeeef5b97 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/knowledge_base/knowledge_base_write_tool.ts @@ -28,8 +28,8 @@ export const KNOWLEDGE_BASE_WRITE_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is KnowledgeBaseWriteToolParams => { - const { isEnabledKnowledgeBase, kbDataClient, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { isEnabledKnowledgeBase, kbDataClient } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; diff --git a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts index 2b134dfd86335..09bae1639f1b1 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/open_and_acknowledged_alerts/open_and_acknowledged_alerts_tool.test.ts @@ -32,14 +32,12 @@ describe('OpenAndAcknowledgedAlertsTool', () => { } as unknown as KibanaRequest; const isEnabledKnowledgeBase = true; const chain = {} as unknown as RetrievalQAChain; - const modelExists = true; const logger = loggerMock.create(); const rest = { isEnabledKnowledgeBase, esClient, chain, logger, - modelExists, }; const anonymizationFields = [ diff --git a/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts b/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts index 70e955dda8470..48e1619c2f00f 100644 --- a/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts +++ b/x-pack/plugins/security_solution/server/assistant/tools/security_labs/security_labs_tool.ts @@ -22,8 +22,8 @@ export const SECURITY_LABS_KNOWLEDGE_BASE_TOOL: AssistantTool = { ...toolDetails, sourceRegister: APP_UI_ID, isSupported: (params: AssistantToolParams): params is AssistantToolParams => { - const { kbDataClient, isEnabledKnowledgeBase, modelExists } = params; - return isEnabledKnowledgeBase && modelExists && kbDataClient != null; + const { kbDataClient, isEnabledKnowledgeBase } = params; + return isEnabledKnowledgeBase && kbDataClient != null; }, getTool(params: AssistantToolParams) { if (!this.isSupported(params)) return null; From 56b478fef283a0d77e19e2ccf65455c30f51ff2e Mon Sep 17 00:00:00 2001 From: Brad White Date: Thu, 10 Oct 2024 17:48:45 -0600 Subject: [PATCH 15/18] [CI] Skip ci for devcontainer changes (#195814) ## Summary For now it is not necessary to run any of CI for `.devcontainer` changes. --- .buildkite/pull_requests.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 1f45c01042888..614d45969cdd7 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -30,7 +30,8 @@ "^\\.backportrc\\.json$", "^nav-kibana-dev\\.docnav\\.json$", "^src/dev/prs/kibana_qa_pr_list\\.json$", - "^\\.buildkite/pull_requests\\.json$" + "^\\.buildkite/pull_requests\\.json$", + "^\\.devcontainer/" ], "always_require_ci_on_changed": [ "^docs/developer/plugin-list.asciidoc$", From edd8f08e1b1e213bbe67c9f5ce9de5326ca94877 Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Fri, 11 Oct 2024 03:51:44 +0200 Subject: [PATCH 16/18] [SecuritySolution][Threat Intelligence] - re-enable Cypress test skipped because of removal of bsearch (#195826) --- .../e2e/investigations/threat_intelligence/indicators.cy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts index f485ead495949..b0e5764469459 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/threat_intelligence/indicators.cy.ts @@ -67,8 +67,7 @@ const URL = '/app/security/threat_intelligence/indicators'; const URL_WITH_CONTRADICTORY_FILTERS = '/app/security/threat_intelligence/indicators?indicators=(filterQuery:(language:kuery,query:%27%27),filters:!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:%27%27,key:threat.indicator.type,negate:!f,params:(query:file),type:phrase),query:(match_phrase:(threat.indicator.type:file))),(%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,index:%27%27,key:threat.indicator.type,negate:!f,params:(query:url),type:phrase),query:(match_phrase:(threat.indicator.type:url)))),timeRange:(from:now/d,to:now/d))'; -// Failing: See https://github.com/elastic/kibana/issues/195804 -describe.skip('Single indicator', { tags: ['@ess'] }, () => { +describe('Single indicator', { tags: ['@ess'] }, () => { before(() => cy.task('esArchiverLoad', { archiveName: 'ti_indicators_data_single' })); after(() => cy.task('esArchiverUnload', { archiveName: 'ti_indicators_data_single' })); @@ -299,7 +298,7 @@ describe('Multiple indicators', { tags: ['@ess'] }, () => { cy.log('should reload the data when refresh button is pressed'); - cy.intercept(/bsearch/).as('search'); + cy.intercept('POST', '/internal/search/threatIntelligenceSearchStrategy').as('search'); cy.get(REFRESH_BUTTON).should('exist').click(); cy.wait('@search'); }); From b30c19f87b7e6ce9e320a99d2e63d2714f8150b9 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Fri, 11 Oct 2024 12:56:51 +0900 Subject: [PATCH 17/18] [Security Solution] enable cert check for usage-api calls (#194133) ## Summary Enables cert validation for usage-api requests if configs are provided. Also updated to use the usage-api url provided by configs. Maintains existing functionality if no configs are provided which is to be removed in a separate PR once configs are fully propagated. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../server/common/services/index.ts | 8 - .../services/usage_reporting_service.test.ts | 179 ++++++++++++++++++ .../services/usage_reporting_service.ts | 74 ++++++-- .../server/config.ts | 26 +-- .../server/constants.ts | 1 + .../server/plugin.ts | 6 + .../task_manager/usage_reporting_task.test.ts | 47 +++-- .../task_manager/usage_reporting_task.ts | 13 +- .../server/types.ts | 2 + .../tsconfig.json | 2 + .../test_suites/security/config.ts | 2 +- 11 files changed, 300 insertions(+), 60 deletions(-) delete mode 100644 x-pack/plugins/security_solution_serverless/server/common/services/index.ts create mode 100644 x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/index.ts b/x-pack/plugins/security_solution_serverless/server/common/services/index.ts deleted file mode 100644 index a76f6359f7e5b..0000000000000 --- a/x-pack/plugins/security_solution_serverless/server/common/services/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { usageReportingService } from './usage_reporting_service'; diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts new file mode 100644 index 0000000000000..e43df68cc200b --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.test.ts @@ -0,0 +1,179 @@ +/* + * 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 fetch from 'node-fetch'; +import https from 'https'; +import { merge } from 'lodash'; + +import { KBN_CERT_PATH, KBN_KEY_PATH, CA_CERT_PATH } from '@kbn/dev-utils'; + +import type { UsageApiConfigSchema } from '../../config'; +import type { UsageRecord } from '../../types'; + +import { UsageReportingService } from './usage_reporting_service'; +import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants'; + +jest.mock('node-fetch'); +const { Response } = jest.requireActual('node-fetch'); + +describe('UsageReportingService', () => { + let usageApiConfig: UsageApiConfigSchema; + let service: UsageReportingService; + + function generateUsageApiConfig(overrides?: Partial): UsageApiConfigSchema { + const DEFAULT_USAGE_API_CONFIG = { enabled: false }; + usageApiConfig = merge(DEFAULT_USAGE_API_CONFIG, overrides); + + return usageApiConfig; + } + + function setupService( + usageApi: UsageApiConfigSchema = generateUsageApiConfig() + ): UsageReportingService { + service = new UsageReportingService(usageApi); + return service; + } + + function generateUsageRecord(overrides?: Partial): UsageRecord { + const date = new Date().toISOString(); + const DEFAULT_USAGE_RECORD = { + id: `usage-record-id-${date}`, + usage_timestamp: date, + creation_timestamp: date, + usage: {}, + source: {}, + } as UsageRecord; + return merge(DEFAULT_USAGE_RECORD, overrides); + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('usageApi configs not provided', () => { + beforeEach(() => { + setupService(); + }); + + it('should still work if usageApi.url is not provided', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.any(https.Agent), + }); + expect(response).toBe(mockResponse); + }); + + it('should use an agent with rejectUnauthorized false if config.enabled is false', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(USAGE_SERVICE_USAGE_URL, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.objectContaining({ + options: expect.objectContaining({ rejectUnauthorized: false }), + }), + }); + expect(response).toBe(mockResponse); + }); + + it('should not set agent if the URL is not https', async () => { + const url = 'http://usage-api.example'; + setupService(generateUsageApiConfig({ url })); + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValue(mockResponse); + + const response = await service.reportUsage(records); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(`${url}${USAGE_REPORTING_ENDPOINT}`, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + }); + expect(response).toBe(mockResponse); + }); + }); + + describe('usageApi configs provided', () => { + const DEFAULT_CONFIG = { + enabled: true, + url: 'https://usage-api.example', + tls: { + certificate: KBN_CERT_PATH, + key: KBN_KEY_PATH, + ca: CA_CERT_PATH, + }, + }; + + beforeEach(() => { + setupService(generateUsageApiConfig(DEFAULT_CONFIG)); + }); + + it('should use usageApi.url if provided', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`; + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.any(https.Agent), + }); + expect(response).toBe(mockResponse); + }); + + it('should use an agent with TLS configuration if config.enabled is true', async () => { + const usageRecord = generateUsageRecord(); + const records: UsageRecord[] = [usageRecord]; + const mockResponse = new Response(null, { status: 200 }); + (fetch as jest.MockedFunction).mockResolvedValueOnce(mockResponse); + + const response = await service.reportUsage(records); + const url = `${DEFAULT_CONFIG.url}${USAGE_REPORTING_ENDPOINT}`; + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(url, { + method: 'post', + body: JSON.stringify(records), + headers: { 'Content-Type': 'application/json' }, + agent: expect.objectContaining({ + options: expect.objectContaining({ + cert: expect.any(String), + key: expect.any(String), + ca: expect.arrayContaining([expect.any(String)]), + }), + }), + }); + expect(response).toBe(mockResponse); + }); + }); +}); diff --git a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts index 0e47b982e692e..ee402872ef33a 100644 --- a/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts +++ b/x-pack/plugins/security_solution_serverless/server/common/services/usage_reporting_service.ts @@ -5,29 +5,77 @@ * 2.0. */ -import type { Response } from 'node-fetch'; +import type { RequestInit, Response } from 'node-fetch'; + import fetch from 'node-fetch'; import https from 'https'; -import { USAGE_SERVICE_USAGE_URL } from '../../constants'; +import { SslConfig, sslSchema } from '@kbn/server-http-tools'; + import type { UsageRecord } from '../../types'; +import type { UsageApiConfigSchema, TlsConfigSchema } from '../../config'; + +import { USAGE_REPORTING_ENDPOINT, USAGE_SERVICE_USAGE_URL } from '../../constants'; -// TODO remove once we have the CA available -const agent = new https.Agent({ rejectUnauthorized: false }); export class UsageReportingService { - public async reportUsage( - records: UsageRecord[], - url = USAGE_SERVICE_USAGE_URL - ): Promise { - const isHttps = url.includes('https'); + private agent: https.Agent | undefined; - return fetch(url, { + constructor(private readonly config: UsageApiConfigSchema) {} + + public async reportUsage(records: UsageRecord[]): Promise { + const reqArgs: RequestInit = { method: 'post', body: JSON.stringify(records), headers: { 'Content-Type': 'application/json' }, - agent: isHttps ? agent : undefined, // Conditionally add agent if URL is HTTPS for supporting integration tests. + }; + if (this.usageApiUrl.includes('https')) { + reqArgs.agent = this.httpAgent; + } + return fetch(this.usageApiUrl, reqArgs); + } + + private get tlsConfigs(): NonNullable { + if (!this.config.tls) { + throw new Error('UsageReportingService: usageApi.tls configs not provided'); + } + + return this.config.tls; + } + + private get usageApiUrl(): string { + if (!this.config.url) { + return USAGE_SERVICE_USAGE_URL; + } + + return `${this.config.url}${USAGE_REPORTING_ENDPOINT}`; + } + + private get httpAgent(): https.Agent { + if (this.agent) { + return this.agent; + } + + if (!this.config.enabled) { + this.agent = new https.Agent({ rejectUnauthorized: false }); + return this.agent; + } + + const tlsConfig = new SslConfig( + sslSchema.validate({ + enabled: true, + certificate: this.tlsConfigs.certificate, + key: this.tlsConfigs.key, + certificateAuthorities: this.tlsConfigs.ca, + }) + ); + + this.agent = new https.Agent({ + rejectUnauthorized: tlsConfig.rejectUnauthorized, + cert: tlsConfig.certificate, + key: tlsConfig.key, + ca: tlsConfig.certificateAuthorities, }); + + return this.agent; } } - -export const usageReportingService = new UsageReportingService(); diff --git a/x-pack/plugins/security_solution_serverless/server/config.ts b/x-pack/plugins/security_solution_serverless/server/config.ts index 96e743a59b425..d4bafd9b9ddb9 100644 --- a/x-pack/plugins/security_solution_serverless/server/config.ts +++ b/x-pack/plugins/security_solution_serverless/server/config.ts @@ -16,19 +16,19 @@ import type { ExperimentalFeatures } from '../common/experimental_features'; import { productTypes } from '../common/config'; import { parseExperimentalConfigValue } from '../common/experimental_features'; -const usageApiConfig = schema.maybe( - schema.object({ - enabled: schema.maybe(schema.boolean()), - url: schema.string(), - tls: schema.maybe( - schema.object({ - certificate: schema.string(), - key: schema.string(), - ca: schema.string(), - }) - ), - }) -); +const tlsConfig = schema.object({ + certificate: schema.string(), + key: schema.string(), + ca: schema.string(), +}); +export type TlsConfigSchema = TypeOf; + +const usageApiConfig = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + url: schema.maybe(schema.string()), + tls: schema.maybe(tlsConfig), +}); +export type UsageApiConfigSchema = TypeOf; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), diff --git a/x-pack/plugins/security_solution_serverless/server/constants.ts b/x-pack/plugins/security_solution_serverless/server/constants.ts index f4fcad6b760c6..411a7209682de 100644 --- a/x-pack/plugins/security_solution_serverless/server/constants.ts +++ b/x-pack/plugins/security_solution_serverless/server/constants.ts @@ -9,4 +9,5 @@ const namespace = 'elastic-system'; const USAGE_SERVICE_BASE_API_URL = `https://usage-api.${namespace}/api`; const USAGE_SERVICE_BASE_API_URL_V1 = `${USAGE_SERVICE_BASE_API_URL}/v1`; export const USAGE_SERVICE_USAGE_URL = `${USAGE_SERVICE_BASE_API_URL_V1}/usage`; +export const USAGE_REPORTING_ENDPOINT = '/api/v1/usage'; export const METERING_SERVICE_BATCH_SIZE = 1000; diff --git a/x-pack/plugins/security_solution_serverless/server/plugin.ts b/x-pack/plugins/security_solution_serverless/server/plugin.ts index 7161c5b684505..c249e48ca13a0 100644 --- a/x-pack/plugins/security_solution_serverless/server/plugin.ts +++ b/x-pack/plugins/security_solution_serverless/server/plugin.ts @@ -34,6 +34,7 @@ import { } from './endpoint/services'; import { NLPCleanupTask } from './task_manager/nlp_cleanup_task/nlp_cleanup_task'; import { telemetryEvents } from './telemetry/event_based_telemetry'; +import { UsageReportingService } from './common/services/usage_reporting_service'; export class SecuritySolutionServerlessPlugin implements @@ -49,11 +50,14 @@ export class SecuritySolutionServerlessPlugin private endpointUsageReportingTask: SecurityUsageReportingTask | undefined; private nlpCleanupTask: NLPCleanupTask | undefined; private readonly logger: Logger; + private readonly usageReportingService: UsageReportingService; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); this.logger = this.initializerContext.logger.get(); + this.usageReportingService = new UsageReportingService(this.config.usageApi); + const productTypesStr = JSON.stringify(this.config.productTypes, null, 2); this.logger.info(`Security Solution running with product types:\n${productTypesStr}`); } @@ -83,6 +87,7 @@ export class SecuritySolutionServerlessPlugin taskTitle: cloudSecurityMetringTaskProperties.taskTitle, version: cloudSecurityMetringTaskProperties.version, meteringCallback: cloudSecurityMetringTaskProperties.meteringCallback, + usageReportingService: this.usageReportingService, }); this.endpointUsageReportingTask = new SecurityUsageReportingTask({ @@ -95,6 +100,7 @@ export class SecuritySolutionServerlessPlugin meteringCallback: endpointMeteringService.getUsageRecords, taskManager: pluginsSetup.taskManager, cloudSetup: pluginsSetup.cloud, + usageReportingService: this.usageReportingService, }); this.nlpCleanupTask = new NLPCleanupTask({ diff --git a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts index 66307e8f8a693..01c38ed6eed31 100644 --- a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts +++ b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.test.ts @@ -7,28 +7,26 @@ import { assign } from 'lodash'; +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; import type { TaskManagerSetupContract, ConcreteTaskInstance, } from '@kbn/task-manager-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; + import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; import { coreMock } from '@kbn/core/server/mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ProductLine, ProductTier } from '../../common/product'; - -import { usageReportingService } from '../common/services'; import type { ServerlessSecurityConfig } from '../config'; import type { SecurityUsageReportingTaskSetupContract, UsageRecord } from '../types'; +import { ProductLine, ProductTier } from '../../common/product'; import { SecurityUsageReportingTask } from './usage_reporting_task'; import { endpointMeteringService } from '../endpoint/services'; -import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; -import { USAGE_SERVICE_USAGE_URL } from '../constants'; describe('SecurityUsageReportingTask', () => { const TITLE = 'test-task-title'; @@ -45,7 +43,7 @@ describe('SecurityUsageReportingTask', () => { let mockEsClient: jest.Mocked; let mockCore: CoreSetup; let mockTaskManagerSetup: jest.Mocked; - let reportUsageSpy: jest.SpyInstance; + let reportUsageMock: jest.Mock; let meteringCallbackMock: jest.Mock; let taskArgs: SecurityUsageReportingTaskSetupContract; let usageRecord: UsageRecord; @@ -118,11 +116,24 @@ describe('SecurityUsageReportingTask', () => { taskTitle: TITLE, version: VERSION, meteringCallback: meteringCallbackMock, + usageReportingService: { + reportUsage: reportUsageMock, + }, }, overrides ); } + const USAGE_API_CONFIG = { + enabled: true, + url: 'https://usage-api-url', + tls: { + certificate: '', + key: '', + ca: '', + }, + }; + async function runTask(taskInstance = buildMockTaskInstance(), callNum: number = 0) { const mockTaskManagerStart = tmStartMock(); await mockTask.start({ taskManager: mockTaskManagerStart, interval: '5m' }); @@ -138,7 +149,7 @@ describe('SecurityUsageReportingTask', () => { .asInternalUser as jest.Mocked; mockTaskManagerSetup = tmSetupMock(); usageRecord = buildUsageRecord(); - reportUsageSpy = jest.spyOn(usageReportingService, 'reportUsage'); + reportUsageMock = jest.fn(); } describe('meteringCallback integration', () => { @@ -150,7 +161,7 @@ describe('SecurityUsageReportingTask', () => { productTypes: [ { product_line: ProductLine.endpoint, product_tier: ProductTier.complete }, ], - usageApi: { url: USAGE_SERVICE_USAGE_URL }, + usageApi: USAGE_API_CONFIG, } as ServerlessSecurityConfig, }); mockTask = new SecurityUsageReportingTask(taskArgs); @@ -199,9 +210,9 @@ describe('SecurityUsageReportingTask', () => { await runTasksUntilNoRunAt(); - expect(reportUsageSpy).toHaveBeenCalledTimes(3); + expect(reportUsageMock).toHaveBeenCalledTimes(3); batches.forEach((batch, i) => { - expect(reportUsageSpy).toHaveBeenNthCalledWith( + expect(reportUsageMock).toHaveBeenNthCalledWith( i + 1, expect.arrayContaining( batch.map(({ _source }) => @@ -209,8 +220,7 @@ describe('SecurityUsageReportingTask', () => { id: `endpoint-${_source.agent.id}-2021-09-01T00:00:00.000Z`, }) ) - ), - USAGE_SERVICE_USAGE_URL + ) ); }); }); @@ -227,7 +237,7 @@ describe('SecurityUsageReportingTask', () => { }); taskArgs = buildTaskArgs({ config: { - usageApi: { url: USAGE_SERVICE_USAGE_URL }, + usageApi: USAGE_API_CONFIG, } as ServerlessSecurityConfig, }); mockTask = new SecurityUsageReportingTask(taskArgs); @@ -273,7 +283,7 @@ describe('SecurityUsageReportingTask', () => { it('should report metering records', async () => { await runTask(); - expect(reportUsageSpy).toHaveBeenCalledWith( + expect(reportUsageMock).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ creation_timestamp: usageRecord.creation_timestamp, @@ -286,8 +296,7 @@ describe('SecurityUsageReportingTask', () => { usage: { period_seconds: 3600, quantity: 1, type: USAGE_TYPE }, usage_timestamp: usageRecord.usage_timestamp, }), - ]), - USAGE_SERVICE_USAGE_URL + ]) ); }); @@ -296,12 +305,12 @@ describe('SecurityUsageReportingTask', () => { expect(result).toEqual(getDeleteTaskRunResult()); - expect(reportUsageSpy).not.toHaveBeenCalled(); + expect(reportUsageMock).not.toHaveBeenCalled(); expect(meteringCallbackMock).not.toHaveBeenCalled(); }); describe('lastSuccessfulReport', () => { it('should set lastSuccessfulReport correctly if report success', async () => { - reportUsageSpy.mockResolvedValueOnce({ status: 201 }); + reportUsageMock.mockResolvedValueOnce({ status: 201 }); const taskInstance = buildMockTaskInstance(); const task = await runTask(taskInstance); const newLastSuccessfulReport = task?.state.lastSuccessfulReport; @@ -320,7 +329,7 @@ describe('SecurityUsageReportingTask', () => { describe('and response is NOT 201', () => { beforeEach(() => { - reportUsageSpy.mockResolvedValueOnce({ status: 500 }); + reportUsageMock.mockResolvedValueOnce({ status: 500 }); }); it('should set lastSuccessfulReport correctly', async () => { diff --git a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts index 83ef25a849f2d..6eb682a84d474 100644 --- a/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts +++ b/x-pack/plugins/security_solution_serverless/server/task_manager/usage_reporting_task.ts @@ -8,10 +8,10 @@ import type { Response } from 'node-fetch'; import type { CoreSetup, Logger } from '@kbn/core/server'; import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; -import { usageReportingService } from '../common/services'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; + import type { MeteringCallback, SecurityUsageReportingTaskStartContract, @@ -19,6 +19,7 @@ import type { UsageRecord, } from '../types'; import type { ServerlessSecurityConfig } from '../config'; +import type { UsageReportingService } from '../common/services/usage_reporting_service'; import { stateSchemaByVersion, emptyState } from './task_state'; @@ -34,6 +35,7 @@ export class SecurityUsageReportingTask { private readonly version: string; private readonly logger: Logger; private readonly config: ServerlessSecurityConfig; + private readonly usageReportingService: UsageReportingService; constructor(setupContract: SecurityUsageReportingTaskSetupContract) { const { @@ -46,6 +48,7 @@ export class SecurityUsageReportingTask { taskTitle, version, meteringCallback, + usageReportingService, } = setupContract; this.cloudSetup = cloudSetup; @@ -53,6 +56,7 @@ export class SecurityUsageReportingTask { this.version = version; this.logger = logFactory.get(this.taskId); this.config = config; + this.usageReportingService = usageReportingService; try { taskManager.registerTaskDefinitions({ @@ -163,10 +167,7 @@ export class SecurityUsageReportingTask { try { this.logger.debug(`Sending ${usageRecords.length} usage records to the API`); - usageReportResponse = await usageReportingService.reportUsage( - usageRecords, - this.config.usageApi?.url - ); + usageReportResponse = await this.usageReportingService.reportUsage(usageRecords); if (!usageReportResponse.ok) { const errorResponse = await usageReportResponse.json(); diff --git a/x-pack/plugins/security_solution_serverless/server/types.ts b/x-pack/plugins/security_solution_serverless/server/types.ts index 4f3a7bf3c3db0..a838c410793c3 100644 --- a/x-pack/plugins/security_solution_serverless/server/types.ts +++ b/x-pack/plugins/security_solution_serverless/server/types.ts @@ -25,6 +25,7 @@ import type { IntegrationAssistantPluginSetup } from '@kbn/integration-assistant import type { ProductTier } from '../common/product'; import type { ServerlessSecurityConfig } from './config'; +import type { UsageReportingService } from './common/services/usage_reporting_service'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SecuritySolutionServerlessPluginSetup {} @@ -86,6 +87,7 @@ export interface SecurityUsageReportingTaskSetupContract { taskTitle: string; version: string; meteringCallback: MeteringCallback; + usageReportingService: UsageReportingService; } export interface SecurityUsageReportingTaskStartContract { diff --git a/x-pack/plugins/security_solution_serverless/tsconfig.json b/x-pack/plugins/security_solution_serverless/tsconfig.json index 55a4882655dc7..cb0518fc4dcd5 100644 --- a/x-pack/plugins/security_solution_serverless/tsconfig.json +++ b/x-pack/plugins/security_solution_serverless/tsconfig.json @@ -19,6 +19,7 @@ "@kbn/security-plugin", "@kbn/security-solution-ess", "@kbn/security-solution-plugin", + "@kbn/server-http-tools", "@kbn/serverless", "@kbn/security-solution-navigation", "@kbn/security-solution-upselling", @@ -46,5 +47,6 @@ "@kbn/logging", "@kbn/integration-assistant-plugin", "@kbn/cloud-security-posture-common", + "@kbn/dev-utils" ] } diff --git a/x-pack/test_serverless/api_integration/test_suites/security/config.ts b/x-pack/test_serverless/api_integration/test_suites/security/config.ts index d40cde3c25837..0b24438b81591 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/config.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/config.ts @@ -24,6 +24,6 @@ export default createTestConfig({ // useful for testing (also enabled in MKI QA) '--coreApp.allowDynamicConfigOverrides=true', `--xpack.securitySolutionServerless.cloudSecurityUsageReportingTaskInterval=5s`, - `--xpack.securitySolutionServerless.usageApi.url=http://localhost:8081/api/v1/usage`, + `--xpack.securitySolutionServerless.usageApi.url=http://localhost:8081`, ], }); From 3493be490b10b1510101ce7723ef8ee44e618853 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Fri, 11 Oct 2024 06:31:52 +0100 Subject: [PATCH 18/18] skip flaky suite (#194731) --- .../reporting_functional/reporting_and_security/management.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/reporting_functional/reporting_and_security/management.ts b/x-pack/test/reporting_functional/reporting_and_security/management.ts index 570c1bbdda4c7..b1a6c107b9bb7 100644 --- a/x-pack/test/reporting_functional/reporting_and_security/management.ts +++ b/x-pack/test/reporting_functional/reporting_and_security/management.ts @@ -57,6 +57,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { }); // FLAKY: https://github.com/elastic/kibana/issues/195144 + // FLAKY: https://github.com/elastic/kibana/issues/194731 describe.skip('Download report', () => { // use archived reports to allow reporting_user to view report jobs they've created before('log in as reporting user', async () => {