From 8f11146f2977e36c66617cbd2914e4669718b3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 2 Apr 2024 15:59:41 +0200 Subject: [PATCH 1/4] [Part of #176153] Rendering FTR core plugin (#179757) --- test/plugin_functional/test_suites/core_plugins/rendering.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 29552079a1083..ca06f8a6a731d 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -18,7 +18,7 @@ declare global { * We use this global variable to track page history changes to ensure that * navigation is done without causing a full page reload. */ - __RENDERING_SESSION__: string[]; + __RENDERING_SESSION__?: string[]; } } @@ -39,11 +39,10 @@ export default function ({ getService }: PluginFunctionalProviderContext) { await appsMenu.clickLink(title); return browser.execute(() => { if (!('__RENDERING_SESSION__' in window)) { - // @ts-expect-error upgrade typescript v4.9.5 window.__RENDERING_SESSION__ = []; } - window.__RENDERING_SESSION__.push(window.location.pathname); + window.__RENDERING_SESSION__!.push(window.location.pathname); }); }; From 507f09d0b69a99f03f0c0cb19f10f1ea8584cd47 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 2 Apr 2024 08:10:53 -0600 Subject: [PATCH 2/4] [Dashboard] [Controls] Fix bug with drilldowns when source dashboard has no controls (#179485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/elastic/kibana/issues/179391 ## Summary We were previously only calling `setSavedState` for the control group on dashboard navigation **when the control group was defined in the loaded dashboard** - however, if either the source or destination dashboard had zero controls, this caused problems on navigation: - If the source dashboard had at least one control and the destination dashboard had zero, the destination dashboard's control group `lastSavedInput` would **not** get set in `navigateToDashboard` since it is undefined - i.e. the `lastSavedInput` on the destination dashboard's control group would **still be equal to** the `lastSavedInput` of the source dashboard's control group after navigation. Therefore, hitting "reset" would replace the destination dashboard's empty control group with the source's control group. - If the source dashboard had zero controls and the destination dashboard had at least one, the first step in navigation would work as expected - the `lastSavedInput` of the destination dashboard would be set appropriately. However, upon hitting the browser back button and triggering `navigateToDashboard` a second time, the source dashboard's control group's `lastSavedInput` would **not** get set properly (since it is undefined) and would therefore still be equal to the `lastSavedInput` of the destination dashboard. Therefore, hitting "reset" would replace the source's empty control group with the destination dashboard's controls. This fixes the above scenarios by calling `setSavedState` on the control group **even if** the last saved control group state is undefined. ### Race Condition Fix In my testing for this, I discovered **another** bug caused by a race condition where, on dashboard navigation, the subscription to the control group's `initialize$` subject was firing with the **wrong** input, so the `lastSavedFilters` would be calculated incorrectly. This caused the dashboard to get stuck in an unsaved changes state, like so: https://github.com/elastic/kibana/assets/8698078/14c553a9-21b3-40d0-96ac-0282e9e66911 I fixed this by removing this subscription (it was messy anyway 🙈) and replaced this logic with individual calls to `calculateFiltersFromSelections` - this should be easier to follow, and we ensure that we are **always** doing this calculation with the expected input. Since we aren't `awaiting` these calculations, it also shouldn't slow down the control group's initialization (which was the original reason for using a subscription). ### Checklist - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) - [x] Flaky test runner - https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5583
![image](https://github.com/elastic/kibana/assets/8698078/cd8ce150-6104-4395-b70c-7309185cc04d) ### 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) --- .../embeddable/control_group_container.tsx | 43 ++++++------------- .../embeddable/dashboard_container.tsx | 6 +-- 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 4d1ecc6458ce0..26ddd1c3e0769 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -12,7 +12,7 @@ import React, { createContext, useContext } from 'react'; import ReactDOM from 'react-dom'; import { batch, Provider, TypedUseSelectorHook, useSelector } from 'react-redux'; import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged, filter, first, skip } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, skip } from 'rxjs/operators'; import { OverlayRef } from '@kbn/core/public'; import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public'; @@ -173,6 +173,13 @@ export class ControlGroupContainer extends Container< this.setupSubscriptions(); const { filters, timeslice } = this.recalculateFilters(); this.publishFilters({ filters, timeslice }); + + this.calculateFiltersFromSelections(initialComponentState?.lastSavedInput?.panels ?? {}).then( + (filterOutput) => { + this.dispatch.setLastSavedFilters(filterOutput); + } + ); + this.initialized$.next(true); }); @@ -206,29 +213,6 @@ export class ControlGroupContainer extends Container< }; private setupSubscriptions = () => { - /** - * on initialization, in order for comparison to be performed, calculate the last saved filters based on the - * selections from the last saved input and save them to component state. This is done as a subscription so that - * it can be done async without actually slowing down the loading of the controls. - */ - this.subscriptions.add( - this.initialized$ - .pipe( - filter((isInitialized) => isInitialized), - first() - ) - .subscribe(async () => { - const { - componentState: { lastSavedInput }, - explicitInput: { panels }, - } = this.getState(); - const filterOutput = await this.calculateFiltersFromSelections( - lastSavedInput?.panels ?? panels - ); - this.dispatch.setLastSavedFilters(filterOutput); - }) - ); - /** * refresh control order cache and make all panels refreshInputFromParent whenever panel orders change */ @@ -289,11 +273,12 @@ export class ControlGroupContainer extends Container< ); }; - public setSavedState(lastSavedInput: PersistableControlGroupInput): void { - batch(() => { - this.dispatch.setLastSavedInput(lastSavedInput); - const { filters, timeslice } = this.getState().output; - this.dispatch.setLastSavedFilters({ filters, timeslice }); + public setSavedState(lastSavedInput: PersistableControlGroupInput | undefined): void { + this.calculateFiltersFromSelections(lastSavedInput?.panels ?? {}).then((filterOutput) => { + batch(() => { + this.dispatch.setLastSavedInput(lastSavedInput); + this.dispatch.setLastSavedFilters(filterOutput); + }); }); } diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 90b4fb3094274..1a8e10f3999e0 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -600,10 +600,8 @@ export class DashboardContainer omit(loadDashboardReturn?.dashboardInput, 'controlGroupInput') ); this.dispatch.setManaged(loadDashboardReturn?.managed); - if (this.controlGroup && loadDashboardReturn?.dashboardInput.controlGroupInput) { - this.controlGroup.dispatch.setLastSavedInput( - loadDashboardReturn?.dashboardInput.controlGroupInput - ); + if (this.controlGroup) { + this.controlGroup.setSavedState(loadDashboardReturn.dashboardInput?.controlGroupInput); } this.dispatch.setAnimatePanelTransforms(false); // prevents panels from animating on navigate. this.dispatch.setLastSavedId(newSavedObjectId); From 961df454f1ca5878da6c9eb8e2307b6c67bdd17c Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Tue, 2 Apr 2024 07:15:14 -0700 Subject: [PATCH 3/4] Onboard APM Anomaly rule type with FAAD (#179196) towards: https://github.com/elastic/kibana/issues/169867 This PR onboards APM Anomaly rule type with FAAD. I am having trouble getting this rule to create an alert. If there is any easy way to verify pls let me know! --- .../register_anomaly_rule_type.test.ts | 56 +- .../anomaly/register_anomaly_rule_type.ts | 572 +++++++++--------- 2 files changed, 335 insertions(+), 293 deletions(-) diff --git a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts index ec77562c1b07e..8867f7cd2db4c 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.test.ts @@ -39,7 +39,7 @@ describe('Transaction duration anomaly alert', () => { services.scopedClusterClient.asCurrentUser.search ).not.toHaveBeenCalled(); - expect(services.alertFactory.create).not.toHaveBeenCalled(); + expect(services.alertsClient.report).not.toHaveBeenCalled(); }); it('ml jobs are not available', async () => { @@ -69,7 +69,7 @@ describe('Transaction duration anomaly alert', () => { services.scopedClusterClient.asCurrentUser.search ).not.toHaveBeenCalled(); - expect(services.alertFactory.create).not.toHaveBeenCalled(); + expect(services.alertsClient.report).not.toHaveBeenCalled(); }); it('anomaly is less than threshold', async () => { @@ -135,7 +135,7 @@ describe('Transaction duration anomaly alert', () => { expect( services.scopedClusterClient.asCurrentUser.search ).not.toHaveBeenCalled(); - expect(services.alertFactory.create).not.toHaveBeenCalled(); + expect(services.alertsClient.report).not.toHaveBeenCalled(); }); }); @@ -154,8 +154,9 @@ describe('Transaction duration anomaly alert', () => { ] as unknown as ApmMlJob[]) ); - const { services, dependencies, executor, scheduleActions } = - createRuleTypeMocks(); + const { services, dependencies, executor } = createRuleTypeMocks(); + + services.alertsClient.report.mockReturnValue({ uuid: 'test-uuid' }); const ml = { mlSystemProvider: () => ({ @@ -221,23 +222,38 @@ describe('Transaction duration anomaly alert', () => { await executor({ params }); - expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + expect(services.alertsClient.report).toHaveBeenCalledTimes(1); - expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.anomaly_foo_development_type-foo' - ); + expect(services.alertsClient.report).toHaveBeenCalledWith({ + actionGroup: 'threshold_met', + id: 'apm.anomaly_foo_development_type-foo', + }); - expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { - serviceName: 'foo', - transactionType: 'type-foo', - environment: 'development', - threshold: 'minor', - triggerValue: 'critical', - reason: - 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.', - viewInAppUrl: - 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development', - alertDetailsUrl: 'mockedAlertsLocator > getLocation', + expect(services.alertsClient.setAlertData).toHaveBeenCalledWith({ + context: { + alertDetailsUrl: 'mockedAlertsLocator > getLocation', + environment: 'development', + reason: + 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.', + serviceName: 'foo', + threshold: 'minor', + transactionType: 'type-foo', + triggerValue: 'critical', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=development', + }, + id: 'apm.anomaly_foo_development_type-foo', + payload: { + 'kibana.alert.evaluation.threshold': 25, + 'kibana.alert.evaluation.value': 80, + 'kibana.alert.reason': + 'critical latency anomaly with a score of 80, was detected in the last 5 mins for foo.', + 'kibana.alert.severity': 'critical', + 'processor.event': 'transaction', + 'service.environment': 'development', + 'service.name': 'foo', + 'transaction.type': 'type-foo', + }, }); }); }); diff --git a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts index 3165a72fbe134..e8dd969cdb30b 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/alerts/rule_types/anomaly/register_anomaly_rule_type.ts @@ -6,7 +6,16 @@ */ import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server'; +import { + GetViewInAppRelativeUrlFnOpts, + ActionGroupIdsOf, + AlertInstanceContext as AlertContext, + AlertInstanceState as AlertState, + RuleTypeState, + RuleExecutorOptions, + AlertsClientError, + IRuleTypeAlerts, +} from '@kbn/alerting-plugin/server'; import { KibanaRequest, DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import datemath from '@kbn/datemath'; import type { ESSearchResponse } from '@kbn/es-types'; @@ -23,7 +32,7 @@ import { ALERT_SEVERITY, ApmRuleType, } from '@kbn/rule-data-utils'; -import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; +import { ObservabilityApmAlert } from '@kbn/alerts-as-data-utils'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { asyncForEach } from '@kbn/std'; import { compact } from 'lodash'; @@ -43,6 +52,7 @@ import { APM_SERVER_FEATURE_ID, formatAnomalyReason, RULE_TYPES_CONFIG, + THRESHOLD_MET_GROUP, } from '../../../../../common/rules/apm_rule_types'; import { asMutableArray } from '../../../../../common/utils/as_mutable_array'; import { getAlertUrlTransaction } from '../../../../../common/utils/formatters'; @@ -53,7 +63,10 @@ import { RegisterRuleDependencies, } from '../../register_apm_rule_types'; import { getServiceGroupFieldsForAnomaly } from './get_service_group_fields_for_anomaly'; -import { anomalyParamsSchema } from '../../../../../common/rules/schema'; +import { + anomalyParamsSchema, + ApmRuleParamsType, +} from '../../../../../common/rules/schema'; import { getAnomalyDetectorIndex, getAnomalyDetectorType, @@ -61,6 +74,13 @@ import { const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.Anomaly]; +type AnomalyRuleTypeParams = ApmRuleParamsType[ApmRuleType.Anomaly]; +type AnomalyActionGroups = ActionGroupIdsOf; +type AnomalyRuleTypeState = RuleTypeState; +type AnomalyAlertState = AlertState; +type AnomalyAlertContext = AlertContext; +type AnomalyAlert = ObservabilityApmAlert; + export function registerAnomalyRuleType({ alerting, alertsLocator, @@ -70,296 +90,302 @@ export function registerAnomalyRuleType({ ml, ruleDataClient, }: RegisterRuleDependencies) { - const createLifecycleRuleType = createLifecycleRuleTypeFactory({ - logger, - ruleDataClient, - }); + if (!alerting) { + throw new Error( + 'Cannot register anomaly rule type. The alerting plugin needs to be enabled.' + ); + } - alerting.registerType( - createLifecycleRuleType({ - id: ApmRuleType.Anomaly, - name: ruleTypeConfig.name, - actionGroups: ruleTypeConfig.actionGroups, - defaultActionGroupId: ruleTypeConfig.defaultActionGroupId, - validate: { params: anomalyParamsSchema }, - schemas: { - params: { - type: 'config-schema', - schema: anomalyParamsSchema, - }, - }, - actionVariables: { - context: [ - apmActionVariables.alertDetailsUrl, - apmActionVariables.environment, - apmActionVariables.reason, - apmActionVariables.serviceName, - apmActionVariables.threshold, - apmActionVariables.transactionType, - apmActionVariables.triggerValue, - apmActionVariables.viewInAppUrl, - ], + alerting.registerType({ + id: ApmRuleType.Anomaly, + name: ruleTypeConfig.name, + actionGroups: ruleTypeConfig.actionGroups, + defaultActionGroupId: ruleTypeConfig.defaultActionGroupId, + validate: { params: anomalyParamsSchema }, + schemas: { + params: { + type: 'config-schema', + schema: anomalyParamsSchema, }, - category: DEFAULT_APP_CATEGORIES.observability.id, - producer: APM_SERVER_FEATURE_ID, - minimumLicenseRequired: 'basic', - isExportable: true, - executor: async ({ - params, - services, - spaceId, - startedAt, - getTimeRange, - }) => { - if (!ml) { - return { state: {} }; - } + }, + actionVariables: { + context: [ + apmActionVariables.alertDetailsUrl, + apmActionVariables.environment, + apmActionVariables.reason, + apmActionVariables.serviceName, + apmActionVariables.threshold, + apmActionVariables.transactionType, + apmActionVariables.triggerValue, + apmActionVariables.viewInAppUrl, + ], + }, + category: DEFAULT_APP_CATEGORIES.observability.id, + producer: APM_SERVER_FEATURE_ID, + minimumLicenseRequired: 'basic', + isExportable: true, + executor: async ( + options: RuleExecutorOptions< + AnomalyRuleTypeParams, + AnomalyRuleTypeState, + AnomalyAlertState, + AnomalyAlertContext, + AnomalyActionGroups, + AnomalyAlert + > + ) => { + if (!ml) { + return { state: {} }; + } - const { - getAlertUuid, - getAlertStartedDate, - savedObjectsClient, - scopedClusterClient, - } = services; + const { params, services, spaceId, startedAt, getTimeRange } = options; + const { alertsClient, savedObjectsClient, scopedClusterClient } = + services; + if (!alertsClient) { + throw new AlertsClientError(); + } - const apmIndices = await getApmIndices(savedObjectsClient); + const apmIndices = await getApmIndices(savedObjectsClient); - const ruleParams = params; - const request = {} as KibanaRequest; - const { mlAnomalySearch } = ml.mlSystemProvider( - request, - savedObjectsClient - ); - const anomalyDetectors = ml.anomalyDetectorsProvider( - request, - savedObjectsClient - ); + const ruleParams = params; + const request = {} as KibanaRequest; + const { mlAnomalySearch } = ml.mlSystemProvider( + request, + savedObjectsClient + ); + const anomalyDetectors = ml.anomalyDetectorsProvider( + request, + savedObjectsClient + ); - const mlJobs = await getMLJobs( - anomalyDetectors, - ruleParams.environment - ); + const mlJobs = await getMLJobs(anomalyDetectors, ruleParams.environment); + + const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( + (option) => option.type === ruleParams.anomalySeverityType + ); - const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( - (option) => option.type === ruleParams.anomalySeverityType + if (!selectedOption) { + throw new Error( + `Anomaly alert severity type ${ruleParams.anomalySeverityType} is not supported.` ); + } - if (!selectedOption) { - throw new Error( - `Anomaly alert severity type ${ruleParams.anomalySeverityType} is not supported.` - ); - } - - const threshold = selectedOption.threshold; - - if (mlJobs.length === 0) { - return { state: {} }; - } - - // Lookback window must be at least 30m to support rules created before this change where default was 15m - const minimumWindow = '30m'; - const requestedWindow = `${ruleParams.windowSize}${ruleParams.windowUnit}`; - - const window = - datemath.parse(`now-${minimumWindow}`)!.valueOf() < - datemath.parse(`now-${requestedWindow}`)!.valueOf() - ? minimumWindow - : requestedWindow; - - const { dateStart } = getTimeRange(window); - - const jobIds = mlJobs.map((job) => job.jobId); - const anomalySearchParams = { - body: { - track_total_hits: false, - size: 0, - query: { - bool: { - filter: [ - { term: { result_type: 'record' } }, - { terms: { job_id: jobIds } }, - { term: { is_interim: false } }, - { - range: { - timestamp: { - gte: dateStart, - }, + const threshold = selectedOption.threshold; + + if (mlJobs.length === 0) { + return { state: {} }; + } + + // Lookback window must be at least 30m to support rules created before this change where default was 15m + const minimumWindow = '30m'; + const requestedWindow = `${ruleParams.windowSize}${ruleParams.windowUnit}`; + + const window = + datemath.parse(`now-${minimumWindow}`)!.valueOf() < + datemath.parse(`now-${requestedWindow}`)!.valueOf() + ? minimumWindow + : requestedWindow; + + const { dateStart } = getTimeRange(window); + + const jobIds = mlJobs.map((job) => job.jobId); + const anomalySearchParams = { + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + { term: { result_type: 'record' } }, + { terms: { job_id: jobIds } }, + { term: { is_interim: false } }, + { + range: { + timestamp: { + gte: dateStart, }, }, - ...termQuery( - 'partition_field_value', - ruleParams.serviceName, - { queryEmptyString: false } - ), - ...termQuery('by_field_value', ruleParams.transactionType, { - queryEmptyString: false, - }), - ...termsQuery( - 'detector_index', - ...(ruleParams.anomalyDetectorTypes?.map((type) => - getAnomalyDetectorIndex(type) - ) ?? []) - ), - ] as QueryDslQueryContainer[], - }, - }, - aggs: { - anomaly_groups: { - multi_terms: { - terms: [ - { field: 'partition_field_value' }, - { field: 'by_field_value' }, - { field: 'job_id' }, - { field: 'detector_index' }, - ], - size: 1000, - order: { 'latest_score.record_score': 'desc' as const }, }, - aggs: { - latest_score: { - top_metrics: { - metrics: asMutableArray([ - { field: 'record_score' }, - { field: 'partition_field_value' }, - { field: 'by_field_value' }, - { field: 'job_id' }, - { field: 'timestamp' }, - { field: 'bucket_span' }, - { field: 'detector_index' }, - ] as const), - sort: { - timestamp: 'desc' as const, - }, + ...termQuery('partition_field_value', ruleParams.serviceName, { + queryEmptyString: false, + }), + ...termQuery('by_field_value', ruleParams.transactionType, { + queryEmptyString: false, + }), + ...termsQuery( + 'detector_index', + ...(ruleParams.anomalyDetectorTypes?.map((type) => + getAnomalyDetectorIndex(type) + ) ?? []) + ), + ] as QueryDslQueryContainer[], + }, + }, + aggs: { + anomaly_groups: { + multi_terms: { + terms: [ + { field: 'partition_field_value' }, + { field: 'by_field_value' }, + { field: 'job_id' }, + { field: 'detector_index' }, + ], + size: 1000, + order: { 'latest_score.record_score': 'desc' as const }, + }, + aggs: { + latest_score: { + top_metrics: { + metrics: asMutableArray([ + { field: 'record_score' }, + { field: 'partition_field_value' }, + { field: 'by_field_value' }, + { field: 'job_id' }, + { field: 'timestamp' }, + { field: 'bucket_span' }, + { field: 'detector_index' }, + ] as const), + sort: { + timestamp: 'desc' as const, }, }, }, }, }, }, + }, + }; + + const response: ESSearchResponse = + (await mlAnomalySearch(anomalySearchParams, [])) as any; + + const anomalies = + response.aggregations?.anomaly_groups.buckets + .map((bucket) => { + const latest = bucket.latest_score.top[0].metrics; + + const job = mlJobs.find((j) => j.jobId === latest.job_id); + + if (!job) { + logger.warn( + `Could not find matching job for job id ${latest.job_id}` + ); + return undefined; + } + + return { + serviceName: latest.partition_field_value as string, + transactionType: latest.by_field_value as string, + environment: job.environment, + score: latest.record_score as number, + detectorType: getAnomalyDetectorType( + latest.detector_index as number + ), + timestamp: Date.parse(latest.timestamp as string), + bucketSpan: latest.bucket_span as number, + bucketKey: bucket.key, + }; + }) + .filter((anomaly) => + anomaly ? anomaly.score >= threshold : false + ) ?? []; + + await asyncForEach(compact(anomalies), async (anomaly) => { + const { + serviceName, + environment, + transactionType, + score, + detectorType, + timestamp, + bucketSpan, + bucketKey, + } = anomaly; + + const eventSourceFields = await getServiceGroupFieldsForAnomaly({ + apmIndices, + scopedClusterClient, + savedObjectsClient, + serviceName, + environment, + transactionType, + timestamp, + bucketSpan, + }); + + const severityLevel = getSeverity(score); + const reasonMessage = formatAnomalyReason({ + anomalyScore: score, + serviceName, + severityLevel, + windowSize: params.windowSize, + windowUnit: params.windowUnit, + detectorType, + }); + + const alertId = bucketKey.join('_'); + + const { uuid, start } = alertsClient.report({ + id: alertId, + actionGroup: ruleTypeConfig.defaultActionGroupId, + }); + const indexedStartedAt = start ?? startedAt.toISOString(); + + const relativeViewInAppUrl = getAlertUrlTransaction( + serviceName, + getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], + transactionType + ); + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); + const alertDetailsUrl = await getAlertUrl( + uuid, + spaceId, + indexedStartedAt, + alertsLocator, + basePath.publicBaseUrl + ); + + const payload = { + [SERVICE_NAME]: serviceName, + ...getEnvironmentEsField(environment), + [TRANSACTION_TYPE]: transactionType, + [PROCESSOR_EVENT]: ProcessorEvent.transaction, + [ALERT_SEVERITY]: severityLevel, + [ALERT_EVALUATION_VALUE]: score, + [ALERT_EVALUATION_THRESHOLD]: threshold, + [ALERT_REASON]: reasonMessage, + ...eventSourceFields, }; - const response: ESSearchResponse = - (await mlAnomalySearch(anomalySearchParams, [])) as any; - - const anomalies = - response.aggregations?.anomaly_groups.buckets - .map((bucket) => { - const latest = bucket.latest_score.top[0].metrics; - - const job = mlJobs.find((j) => j.jobId === latest.job_id); - - if (!job) { - logger.warn( - `Could not find matching job for job id ${latest.job_id}` - ); - return undefined; - } - - return { - serviceName: latest.partition_field_value as string, - transactionType: latest.by_field_value as string, - environment: job.environment, - score: latest.record_score as number, - detectorType: getAnomalyDetectorType( - latest.detector_index as number - ), - timestamp: Date.parse(latest.timestamp as string), - bucketSpan: latest.bucket_span as number, - bucketKey: bucket.key, - }; - }) - .filter((anomaly) => - anomaly ? anomaly.score >= threshold : false - ) ?? []; - - await asyncForEach(compact(anomalies), async (anomaly) => { - const { - serviceName, - environment, - transactionType, - score, - detectorType, - timestamp, - bucketSpan, - bucketKey, - } = anomaly; - - const eventSourceFields = await getServiceGroupFieldsForAnomaly({ - apmIndices, - scopedClusterClient, - savedObjectsClient, - serviceName, - environment, - transactionType, - timestamp, - bucketSpan, - }); - - const severityLevel = getSeverity(score); - const reasonMessage = formatAnomalyReason({ - anomalyScore: score, - serviceName, - severityLevel, - windowSize: params.windowSize, - windowUnit: params.windowUnit, - detectorType, - }); - - const alertId = bucketKey.join('_'); - - const alert = services.alertWithLifecycle({ - id: alertId, - fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), - [TRANSACTION_TYPE]: transactionType, - [PROCESSOR_EVENT]: ProcessorEvent.transaction, - [ALERT_SEVERITY]: severityLevel, - [ALERT_EVALUATION_VALUE]: score, - [ALERT_EVALUATION_THRESHOLD]: threshold, - [ALERT_REASON]: reasonMessage, - ...eventSourceFields, - }, - }); - - const relativeViewInAppUrl = getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType - ); - const viewInAppUrl = addSpaceIdToPath( - basePath.publicBaseUrl, - spaceId, - relativeViewInAppUrl - ); - const indexedStartedAt = - getAlertStartedDate(alertId) ?? startedAt.toISOString(); - const alertUuid = getAlertUuid(alertId); - const alertDetailsUrl = await getAlertUrl( - alertUuid, - spaceId, - indexedStartedAt, - alertsLocator, - basePath.publicBaseUrl - ); - - alert.scheduleActions(ruleTypeConfig.defaultActionGroupId, { - alertDetailsUrl, - environment: getEnvironmentLabel(environment), - reason: reasonMessage, - serviceName, - threshold: selectedOption?.label, - transactionType, - triggerValue: severityLevel, - viewInAppUrl, - }); + const context = { + alertDetailsUrl, + environment: getEnvironmentLabel(environment), + reason: reasonMessage, + serviceName, + threshold: selectedOption?.label, + transactionType, + triggerValue: severityLevel, + viewInAppUrl, + }; + + alertsClient.setAlertData({ + id: alertId, + payload, + context, }); + }); - return { state: {} }; - }, - alerts: ApmRuleTypeAlertDefinition, - getViewInAppRelativeUrl: ({ rule }: GetViewInAppRelativeUrlFnOpts<{}>) => - observabilityPaths.ruleDetails(rule.id), - }) - ); + return { state: {} }; + }, + alerts: { + ...ApmRuleTypeAlertDefinition, + shouldWrite: true, + } as IRuleTypeAlerts, + getViewInAppRelativeUrl: ({ rule }: GetViewInAppRelativeUrlFnOpts<{}>) => + observabilityPaths.ruleDetails(rule.id), + }); } From 6ecbe53736919419b87635cfc603e7cbaeb1a3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20=C3=81brah=C3=A1m?= Date: Tue, 2 Apr 2024 17:00:33 +0200 Subject: [PATCH 4/4] [EDR Workflows] Add possibility to disable malware filescan on write (#179176) ## Summary - adds new field to Package Policy: `on_write_scan` for `malware` protections for every OS - adds new switch to Malware card to enable/disable this feature, but this is hidden behind FF called `malwareOnWriteScanOptionAvailable` - adds hint in order to indicate to the user, that disabling on-write scan is only effective on Agent versions 8.13 and older - adds new migration to backfill this property with default `true` values, as it has been always enabled on previous agents. note: after migration, policies are not re-deployed, so Endpoint must assume that on-write scan is enabled when property is missing image image image image image image ### Checklist Delete any items that are not applicable to this PR. - [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) - [ ] [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] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../check_registered_types.test.ts | 2 +- .../fleet/server/saved_objects/index.ts | 9 ++ .../migrations/security_solution/index.ts | 1 + .../security_solution/to_v8_14_0.test.ts | 137 ++++++++++++++++ .../security_solution/to_v8_14_0.ts | 37 +++++ x-pack/plugins/fleet/tsconfig.json | 1 + .../common/endpoint/models/policy_config.ts | 3 + .../models/policy_config_helpers.test.ts | 6 +- .../endpoint/models/policy_config_helpers.ts | 6 +- .../common/endpoint/types/index.ts | 10 +- .../common/experimental_features.ts | 5 + .../policy/store/policy_details/index.test.ts | 10 +- .../cards/malware_protections_card.test.tsx | 121 +++++++++----- .../cards/malware_protections_card.tsx | 152 ++++++++++++++---- .../policy/view/policy_settings_form/mocks.ts | 21 ++- .../policy_settings_layout.test.tsx | 16 +- 16 files changed, 429 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts create mode 100644 x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index fbc0d5b2ef269..097a85aea54df 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -109,7 +109,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-agent-policies": "7633e578f60c074f8267bc50ec4763845e431437", "ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d", "ingest-outputs": "daafff49255ab700e07491376fe89f04fc998b91", - "ingest-package-policies": "f4c2767e852b700a8b82678925b86bac08958b43", + "ingest-package-policies": "8a99e165aab00c6c365540427a3abeb7bea03f31", "ingest_manager_settings": "91445219e7115ff0c45d1dabd5d614a80b421797", "inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83", "kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad", diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 0e44db492b2c1..80665f381e871 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -73,6 +73,7 @@ import { } from './migrations/to_v8_6_0'; import { migratePackagePolicyToV8100, + migratePackagePolicyToV8140, migratePackagePolicyToV870, } from './migrations/security_solution'; import { migratePackagePolicyToV880 } from './migrations/to_v8_8_0'; @@ -473,6 +474,14 @@ export const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ }, ], }, + '6': { + changes: [ + { + type: 'data_backfill', + backfillFn: migratePackagePolicyToV8140, + }, + ], + }, }, migrations: { '7.10.0': migratePackagePolicyToV7100, diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts index 8248c181405e5..cd37ed56b5b58 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/index.ts @@ -19,3 +19,4 @@ export { migratePackagePolicyToV860 } from './to_v8_6_0'; export { migratePackagePolicyToV870 } from './to_v8_7_0'; export { migratePackagePolicyToV880 } from './to_v8_8_0'; export { migratePackagePolicyToV8100 } from './to_v8_10_0'; +export { migratePackagePolicyToV8140 } from './to_v8_14_0'; diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts new file mode 100644 index 0000000000000..e724c039179dc --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { + createModelVersionTestMigrator, + type ModelVersionTestMigrator, +} from '@kbn/core-test-helpers-model-versions'; + +import { cloneDeep } from 'lodash'; + +import type { SavedObject } from '@kbn/core-saved-objects-server'; + +import type { PackagePolicy } from '../../../../common'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../common'; +import { getSavedObjectTypes } from '../..'; + +const policyDoc: SavedObject = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + malware: { + mode: 'detect', + blocklist: true, + }, + }, + mac: { + malware: { + mode: 'detect', + blocklist: true, + }, + }, + linux: { + malware: { + mode: 'detect', + blocklist: true, + }, + }, + }, + }, + }, + }, + ], + }, + type: PACKAGE_POLICY_SAVED_OBJECT_TYPE, + references: [], +}; + +describe('8.14.0 Endpoint Package Policy migration', () => { + let migrator: ModelVersionTestMigrator; + + beforeEach(() => { + migrator = createModelVersionTestMigrator({ + type: getSavedObjectTypes()[PACKAGE_POLICY_SAVED_OBJECT_TYPE], + }); + }); + + it('should backfill `on_write_scan` field to malware protections on Kibana update', () => { + const originalPolicyConfigSO = cloneDeep(policyDoc); + + const migratedPolicyConfigSO = migrator.migrate({ + document: originalPolicyConfigSO, + fromVersion: 5, + toVersion: 6, + }); + + const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value; + expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(true); + expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true); + expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(true); + }); + + it('should not backfill `on_write_scan` field if already present due to user edit before migration is performed on serverless', () => { + const originalPolicyConfigSO = cloneDeep(policyDoc); + const originalPolicyConfig = originalPolicyConfigSO.attributes.inputs[0].config?.policy.value; + originalPolicyConfig.windows.malware.on_write_scan = false; + originalPolicyConfig.mac.malware.on_write_scan = true; + originalPolicyConfig.linux.malware.on_write_scan = false; + + const migratedPolicyConfigSO = migrator.migrate({ + document: originalPolicyConfigSO, + fromVersion: 5, + toVersion: 6, + }); + + const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value; + expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(false); + expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true); + expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(false); + }); + + // no reason for removing `on_write_scan` for a lower version Kibana - the field will just sit silently in the package config + it('should not strip `on_write_scan` in regards of forward compatibility', () => { + const originalPolicyConfigSO = cloneDeep(policyDoc); + const originalPolicyConfig = originalPolicyConfigSO.attributes.inputs[0].config?.policy.value; + originalPolicyConfig.windows.malware.on_write_scan = false; + originalPolicyConfig.mac.malware.on_write_scan = true; + originalPolicyConfig.linux.malware.on_write_scan = false; + + const migratedPolicyConfigSO = migrator.migrate({ + document: originalPolicyConfigSO, + fromVersion: 6, + toVersion: 5, + }); + + const migratedPolicyConfig = migratedPolicyConfigSO.attributes.inputs[0].config?.policy.value; + expect(migratedPolicyConfig.windows.malware.on_write_scan).toBe(false); + expect(migratedPolicyConfig.mac.malware.on_write_scan).toBe(true); + expect(migratedPolicyConfig.linux.malware.on_write_scan).toBe(false); + }); +}); diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts new file mode 100644 index 0000000000000..cc97dafe72180 --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v8_14_0.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; + +import type { SavedObjectModelDataBackfillFn } from '@kbn/core-saved-objects-server'; + +import type { PackagePolicy } from '../../../../common'; + +const ON_WRITE_SCAN_DEFAULT_VALUE = true; + +export const migratePackagePolicyToV8140: SavedObjectModelDataBackfillFn< + PackagePolicy, + PackagePolicy +> = (packagePolicyDoc) => { + if (packagePolicyDoc.attributes.package?.name !== 'endpoint') { + return { attributes: packagePolicyDoc.attributes }; + } + + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc = packagePolicyDoc; + + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + + if (input && input.config) { + const policy = input.config.policy.value; + + policy.windows.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE; + policy.mac.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE; + policy.linux.malware.on_write_scan ??= ON_WRITE_SCAN_DEFAULT_VALUE; + } + + return { attributes: updatedPackagePolicyDoc.attributes }; +}; diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 5b16316d9baaa..b99fb0cce0985 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -104,5 +104,6 @@ "@kbn/config", "@kbn/core-http-server-mocks", "@kbn/code-editor", + "@kbn/core-test-helpers-model-versions", ] } diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index 91585c9b16fa1..779a309e03d32 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -43,6 +43,7 @@ export const policyFactory = ( malware: { mode: ProtectionModes.prevent, blocklist: true, + on_write_scan: true, }, ransomware: { mode: ProtectionModes.prevent, @@ -96,6 +97,7 @@ export const policyFactory = ( malware: { mode: ProtectionModes.prevent, blocklist: true, + on_write_scan: true, }, behavior_protection: { mode: ProtectionModes.prevent, @@ -138,6 +140,7 @@ export const policyFactory = ( malware: { mode: ProtectionModes.prevent, blocklist: true, + on_write_scan: true, }, behavior_protection: { mode: ProtectionModes.prevent, diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts index 8e1c4e087c827..662c47f9c8999 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts @@ -212,7 +212,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({ registry: true, security: true, }, - malware: { mode: ProtectionModes.off, blocklist: false }, + malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false }, ransomware: { mode: ProtectionModes.off, supported: true }, memory_protection: { mode: ProtectionModes.off, supported: true }, behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false }, @@ -228,7 +228,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({ }, mac: { events: { process: true, file: true, network: true }, - malware: { mode: ProtectionModes.off, blocklist: false }, + malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false }, behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false }, memory_protection: { mode: ProtectionModes.off, supported: true }, popup: { @@ -249,7 +249,7 @@ export const eventsOnlyPolicy = (): PolicyConfig => ({ session_data: false, tty_io: false, }, - malware: { mode: ProtectionModes.off, blocklist: false }, + malware: { mode: ProtectionModes.off, blocklist: false, on_write_scan: false }, behavior_protection: { mode: ProtectionModes.off, supported: true, reputation_service: false }, memory_protection: { mode: ProtectionModes.off, supported: true }, popup: { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts index 472fcfdfd825a..c6ffc43928bd6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts @@ -103,7 +103,10 @@ const disableCommonProtections = (policy: PolicyConfig) => { }, policy); }; -const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: PolicyOperatingSystem) => ({ +const getDisabledCommonProtectionsForOS = ( + policy: PolicyConfig, + os: PolicyOperatingSystem +): Partial => ({ behavior_protection: { ...policy[os].behavior_protection, mode: ProtectionModes.off, @@ -115,6 +118,7 @@ const getDisabledCommonProtectionsForOS = (policy: PolicyConfig, os: PolicyOpera malware: { ...policy[os].malware, blocklist: false, + on_write_scan: false, mode: ProtectionModes.off, }, }); diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index f525c03ce17f7..68867e92d7294 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -973,7 +973,7 @@ export interface PolicyConfig { registry: boolean; security: boolean; }; - malware: ProtectionFields & BlocklistFields; + malware: ProtectionFields & BlocklistFields & OnWriteScanFields; memory_protection: ProtectionFields & SupportedFields; behavior_protection: BehaviorProtectionFields & SupportedFields; ransomware: ProtectionFields & SupportedFields; @@ -1014,7 +1014,7 @@ export interface PolicyConfig { process: boolean; network: boolean; }; - malware: ProtectionFields & BlocklistFields; + malware: ProtectionFields & BlocklistFields & OnWriteScanFields; behavior_protection: BehaviorProtectionFields & SupportedFields; memory_protection: ProtectionFields & SupportedFields; popup: { @@ -1044,7 +1044,7 @@ export interface PolicyConfig { session_data: boolean; tty_io: boolean; }; - malware: ProtectionFields & BlocklistFields; + malware: ProtectionFields & BlocklistFields & OnWriteScanFields; behavior_protection: BehaviorProtectionFields & SupportedFields; memory_protection: ProtectionFields & SupportedFields; popup: { @@ -1120,6 +1120,10 @@ export interface BlocklistFields { blocklist: boolean; } +export interface OnWriteScanFields { + on_write_scan?: boolean; +} + /** Policy protection mode options */ export enum ProtectionModes { detect = 'detect', diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index a6eba0197b490..72ea5b723758c 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -228,6 +228,11 @@ export const allowedExperimentalValues = Object.freeze({ * Expires: on Apr 23, 2024 */ perFieldPrebuiltRulesDiffingEnabled: true, + + /** + * Makes Elastic Defend integration's Malware On-Write Scan option available to edit. + */ + malwareOnWriteScanOptionAvailable: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index 1c7a8e0b18a61..d93b2aa6a1e39 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -12,7 +12,7 @@ import type { PolicyDetailsAction } from '.'; import { policyDetailsReducer, policyDetailsMiddlewareFactory } from '.'; import { policyConfig } from './selectors'; import { policyFactory } from '../../../../../../common/endpoint/models/policy_config'; -import type { PolicyData } from '../../../../../../common/endpoint/types'; +import type { PolicyConfig, PolicyData } from '../../../../../../common/endpoint/types'; import type { MiddlewareActionSpyHelper } from '../../../../../common/store/test_utils'; import { createSpyMiddleware } from '../../../../../common/store/test_utils'; import type { AppContextTestRender } from '../../../../../common/mock/endpoint'; @@ -289,7 +289,7 @@ describe('policy details: ', () => { registry: true, security: true, }, - malware: { mode: 'prevent', blocklist: true }, + malware: { mode: 'prevent', blocklist: true, on_write_scan: true }, memory_protection: { mode: 'off', supported: false }, behavior_protection: { mode: 'off', @@ -327,7 +327,7 @@ describe('policy details: ', () => { }, mac: { events: { process: true, file: true, network: true }, - malware: { mode: 'prevent', blocklist: true }, + malware: { mode: 'prevent', blocklist: true, on_write_scan: true }, behavior_protection: { mode: 'off', supported: false, @@ -363,7 +363,7 @@ describe('policy details: ', () => { tty_io: false, }, logging: { file: 'info' }, - malware: { mode: 'prevent', blocklist: true }, + malware: { mode: 'prevent', blocklist: true, on_write_scan: true }, behavior_protection: { mode: 'off', supported: false, @@ -388,7 +388,7 @@ describe('policy details: ', () => { capture_env_vars: 'LD_PRELOAD,LD_LIBRARY_PATH', }, }, - }, + } as PolicyConfig, }, }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx index f300622231c05..516b5dc835644 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx @@ -17,6 +17,7 @@ import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endp import React from 'react'; import type { MalwareProtectionsProps } from './malware_protections_card'; import { MalwareProtectionsCard } from './malware_protections_card'; +import type { PolicyConfig } from '../../../../../../../../common/endpoint/types'; import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; import { cloneDeep, set } from 'lodash'; import userEvent from '@testing-library/user-event'; @@ -27,11 +28,12 @@ describe('Policy Malware Protections Card', () => { const testSubj = getPolicySettingsFormTestSubjects('test').malware; let formProps: MalwareProtectionsProps; - let render: () => ReturnType; + let render: (policyConfig?: PolicyConfig) => ReturnType; let renderResult: ReturnType; beforeEach(() => { const mockedContext = createAppRootMockRenderer(); + mockedContext.setExperimentalFlag({ malwareOnWriteScanOptionAvailable: true }); formProps = { policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] @@ -41,7 +43,10 @@ describe('Policy Malware Protections Card', () => { 'data-test-subj': testSubj.card, }; - render = () => (renderResult = mockedContext.render()); + render = (policyConfig = formProps.policy) => + (renderResult = mockedContext.render( + + )); }); it('should render the card with expected components', () => { @@ -60,48 +65,72 @@ describe('Policy Malware Protections Card', () => { ); }); - it('should set Blocklist to disabled if malware is turned off', () => { - const expectedUpdatedPolicy = cloneDeep(formProps.policy); - setMalwareMode(expectedUpdatedPolicy, true); - render(); - userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); - - expect(formProps.onChange).toHaveBeenCalledWith({ - isValid: true, - updatedPolicy: expectedUpdatedPolicy, - }); - }); - - it('should allow blocklist to be disabled', () => { - const expectedUpdatedPolicy = cloneDeep(formProps.policy); - set(expectedUpdatedPolicy, 'windows.malware.blocklist', false); - set(expectedUpdatedPolicy, 'mac.malware.blocklist', false); - set(expectedUpdatedPolicy, 'linux.malware.blocklist', false); - render(); - userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch)); - - expect(formProps.onChange).toHaveBeenCalledWith({ - isValid: true, - updatedPolicy: expectedUpdatedPolicy, - }); - }); - - it('should allow blocklist to be enabled', () => { - set(formProps.policy, 'windows.malware.blocklist', false); - set(formProps.policy, 'mac.malware.blocklist', false); - set(formProps.policy, 'linux.malware.blocklist', false); - const expectedUpdatedPolicy = cloneDeep(formProps.policy); - set(expectedUpdatedPolicy, 'windows.malware.blocklist', true); - set(expectedUpdatedPolicy, 'mac.malware.blocklist', true); - set(expectedUpdatedPolicy, 'linux.malware.blocklist', true); - render(); - userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch)); - - expect(formProps.onChange).toHaveBeenCalledWith({ - isValid: true, - updatedPolicy: expectedUpdatedPolicy, - }); - }); + describe.each` + name | config | default + ${'blocklist'} | ${'blocklist'} | ${true} + ${'onWriteScan'} | ${'on_write_scan'} | ${true} + `( + '$name subfeature', + (feature: { name: 'blocklist' | 'onWriteScan'; config: string; deafult: boolean }) => { + it(`should set ${feature.name} to disabled if malware is turned off`, () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy, true); + render(); + userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it(`should set ${feature.name} to enabled if malware is turned on`, () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy); + const initialPolicy = cloneDeep(formProps.policy); + setMalwareMode(initialPolicy, true); + render(initialPolicy); + + userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it(`should allow ${feature.name} to be disabled`, () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, `windows.malware.${feature.config}`, false); + set(expectedUpdatedPolicy, `mac.malware.${feature.config}`, false); + set(expectedUpdatedPolicy, `linux.malware.${feature.config}`, false); + render(); + userEvent.click(renderResult.getByTestId(testSubj[`${feature.name}EnableDisableSwitch`])); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it(`should allow ${feature.name} to be enabled`, () => { + set(formProps.policy, `windows.malware.${feature.config}`, false); + set(formProps.policy, `mac.malware.${feature.config}`, false); + set(formProps.policy, `linux.malware.${feature.config}`, false); + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, `windows.malware.${feature.config}`, true); + set(expectedUpdatedPolicy, `mac.malware.${feature.config}`, true); + set(expectedUpdatedPolicy, `linux.malware.${feature.config}`, true); + render(); + userEvent.click(renderResult.getByTestId(testSubj[`${feature.name}EnableDisableSwitch`])); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + } + ); describe('and displayed in View mode', () => { beforeEach(() => { @@ -124,6 +153,8 @@ describe('Policy Malware Protections Card', () => { 'Prevent' + 'Blocklist enabled' + 'Info' + + 'Scan files upon modification' + + 'Info' + 'User notification' + 'Agent version 7.11+' + 'Notify user' + @@ -168,6 +199,8 @@ describe('Policy Malware Protections Card', () => { 'Prevent' + 'Blocklist enabled' + 'Info' + + 'Scan files upon modification' + + 'Info' + 'User notification' + 'Agent version 7.11+' + 'Notify user' diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx index 0e5bec1d110c2..3b0f01e1b8d2d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx @@ -7,10 +7,10 @@ import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiSwitch, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import { OperatingSystem } from '@kbn/securitysolution-utils'; import { cloneDeep } from 'lodash'; +import { useIsExperimentalFeatureEnabled } from '../../../../../../../common/hooks/use_experimental_features'; import { useGetProtectionsUnavailableComponent } from '../../hooks/use_get_protections_unavailable_component'; import { NotifyUserOption } from '../notify_user_option'; import { SettingCard } from '../setting_card'; @@ -26,29 +26,83 @@ import { ProtectionSettingCardSwitch } from '../protection_setting_card_switch'; import { DetectPreventProtectionLevel } from '../detect_prevent_protection_level'; import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator'; -const BLOCKLIST_ENABLED_LABEL = i18n.translate( - 'xpack.securitySolution.endpoint.policy.protections.blocklistEnabled', - { +const BLOCKLIST_LABELS = { + enabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.blocklistEnabled', { defaultMessage: 'Blocklist enabled', + }), + disabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.blocklistDisabled', { + defaultMessage: 'Blocklist disabled', + }), + hint: i18n.translate('xpack.securitySolution.endpoint.policyDetailsConfig.blocklistTooltip', { + defaultMessage: + 'Enables or disables the blocklist associated with this policy. The blocklist is a collection hashes, paths, or signers which extends the list of processes the endpoint considers malicious. See the blocklist tab for entry details.', + }), +}; + +const ON_WRITE_SCAN_LABELS = { + enabled: i18n.translate('xpack.securitySolution.endpoint.policy.protections.onWriteScanEnabled', { + defaultMessage: 'Scan files upon modification', + }), + disabled: i18n.translate( + 'xpack.securitySolution.endpoint.policy.protections.onWriteScanDisabled', + { + defaultMessage: 'Files are not scanned upon modification', + } + ), + hint: i18n.translate('xpack.securitySolution.endpoint.policyDetailsConfig.onWriteScanTooltip', { + defaultMessage: + "Enables or disables scanning files when they're modified. Disabling this feature improves Endpoint performance.", + }), + versionCompatibilityHint: i18n.translate( + 'xpack.securitySolution.endpoint.policy.protections.onWriteVersionCompatibilityHint', + { + defaultMessage: 'Always enabled on Agent versions 8.13 and older.', + } + ), +}; + +type AdjustSubfeatureOnProtectionSwitch = NonNullable< + ProtectionSettingCardSwitchProps['additionalOnSwitchChange'] +>; + +// NOTE: it mutates `policyConfigData` passed on input +const adjustBlocklistSettingsOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({ + value, + policyConfigData, + protectionOsList, +}) => { + for (const os of protectionOsList) { + policyConfigData[os].malware.blocklist = value; } -); -const BLOCKLIST_DISABLED_LABEL = i18n.translate( - 'xpack.securitySolution.endpoint.policy.protections.blocklistDisabled', - { - defaultMessage: 'Blocklist disabled', + return policyConfigData; +}; + +const adjustOnWriteSettingsOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({ + value, + policyConfigData, + protectionOsList, +}) => { + for (const os of protectionOsList) { + policyConfigData[os].malware.on_write_scan = value; } -); -// NOTE: it mutates `policyConfigData` passed on input -const adjustBlocklistSettingsOnProtectionSwitch: ProtectionSettingCardSwitchProps['additionalOnSwitchChange'] = - ({ value, policyConfigData, protectionOsList }) => { - for (const os of protectionOsList) { - policyConfigData[os].malware.blocklist = value; - } + return policyConfigData; +}; - return policyConfigData; - }; +const adjustAllSubfeaturesOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch = ({ + policyConfigData, + ...rest +}) => { + const modifiedPolicy = adjustBlocklistSettingsOnProtectionSwitch({ + policyConfigData, + ...rest, + }); + return adjustOnWriteSettingsOnProtectionSwitch({ + policyConfigData: modifiedPolicy, + ...rest, + }); +}; const MALWARE_OS_VALUES: Immutable = [ PolicyOperatingSystem.windows, @@ -64,6 +118,9 @@ export type MalwareProtectionsProps = PolicyFormComponentCommonProps; */ export const MalwareProtectionsCard = React.memo( ({ policy, onChange, mode = 'edit', 'data-test-subj': dataTestSubj }) => { + const isMalwareOnwriteScanOptionAvailable = useIsExperimentalFeatureEnabled( + 'malwareOnWriteScanOptionAvailable' + ); const getTestId = useTestIdGenerator(dataTestSubj); const isProtectionsAllowed = !useGetProtectionsUnavailableComponent(); const protection = 'malware'; @@ -95,7 +152,7 @@ export const MalwareProtectionsCard = React.memo( protection={protection} protectionLabel={protectionLabel} osList={MALWARE_OS_VALUES} - additionalOnSwitchChange={adjustBlocklistSettingsOnProtectionSwitch} + additionalOnSwitchChange={adjustAllSubfeaturesOnProtectionSwitch} policy={policy} onChange={onChange} mode={mode} @@ -113,13 +170,31 @@ export const MalwareProtectionsCard = React.memo( /> - + {isMalwareOnwriteScanOptionAvailable && ( + <> + + + + )} + ( MalwareProtectionsCard.displayName = 'MalwareProtectionsCard'; -type EnableDisableBlocklistProps = PolicyFormComponentCommonProps; +type SubfeatureSwitchProps = PolicyFormComponentCommonProps & { + labels: { enabled: string; disabled: string; versionCompatibilityHint?: string; hint: string }; + adjustSubfeatureOnProtectionSwitch: AdjustSubfeatureOnProtectionSwitch; + checked: boolean; +}; -const EnableDisableBlocklist = memo( - ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { +const SubfeatureSwitch = memo( + ({ + policy, + onChange, + mode, + 'data-test-subj': dataTestSubj, + labels, + adjustSubfeatureOnProtectionSwitch, + checked, + }) => { const getTestId = useTestIdGenerator(dataTestSubj); - const checked = policy.windows.malware.blocklist; + const isDisabled = policy.windows.malware.mode === 'off'; const isEditMode = mode === 'edit'; - const label = checked ? BLOCKLIST_ENABLED_LABEL : BLOCKLIST_DISABLED_LABEL; + const label = checked ? labels.enabled : labels.disabled; const handleBlocklistSwitchChange = useCallback( (event) => { const value = event.target.checked; const newPayload = cloneDeep(policy); - adjustBlocklistSettingsOnProtectionSwitch({ + adjustSubfeatureOnProtectionSwitch({ value, policyConfigData: newPayload, protectionOsList: MALWARE_OS_VALUES, @@ -159,7 +246,7 @@ const EnableDisableBlocklist = memo( onChange({ isValid: true, updatedPolicy: newPayload }); }, - [onChange, policy] + [adjustSubfeatureOnProtectionSwitch, onChange, policy] ); return ( @@ -174,7 +261,7 @@ const EnableDisableBlocklist = memo( data-test-subj={getTestId('enableDisableSwitch')} /> ) : ( - <>{label} + label )} @@ -182,10 +269,9 @@ const EnableDisableBlocklist = memo( position="right" content={ <> - +

{labels.hint}

+ {labels.versionCompatibilityHint && } + {labels.versionCompatibilityHint && {labels.versionCompatibilityHint}} } /> @@ -194,4 +280,4 @@ const EnableDisableBlocklist = memo( ); } ); -EnableDisableBlocklist.displayName = 'EnableDisableBlocklist'; +SubfeatureSwitch.displayName = 'SubfeatureSwitch'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts index ee2e77307cd1b..5e1bb397f3b88 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts @@ -61,6 +61,7 @@ export const getPolicySettingsFormTestSubjects = ( rulesCallout: malwareTestSubj('rulesCallout'), blocklistContainer: malwareTestSubj('blocklist'), blocklistEnableDisableSwitch: malwareTestSubj('blocklist-enableDisableSwitch'), + onWriteScanEnableDisableSwitch: malwareTestSubj('onWriteScan-enableDisableSwitch'), }, ransomware: { card: ransomwareTestSubj(), @@ -192,13 +193,13 @@ export const exactMatchText = (text: string): RegExp => { * @param policy * @param turnOff * @param includePopup - * @param includeBlocklist + * @param includeSubfeatures */ export const setMalwareMode = ( policy: PolicyConfig, turnOff: boolean = false, includePopup: boolean = true, - includeBlocklist: boolean = true + includeSubfeatures: boolean = true ) => { const mode = turnOff ? ProtectionModes.off : ProtectionModes.prevent; const enableValue = mode !== ProtectionModes.off; @@ -207,15 +208,19 @@ export const setMalwareMode = ( set(policy, 'mac.malware.mode', mode); set(policy, 'linux.malware.mode', mode); - if (includeBlocklist) { - set(policy, 'windows.malware.blocklist', enableValue); - set(policy, 'mac.malware.blocklist', enableValue); - set(policy, 'linux.malware.blocklist', enableValue); - } - if (includePopup) { set(policy, 'windows.popup.malware.enabled', enableValue); set(policy, 'mac.popup.malware.enabled', enableValue); set(policy, 'linux.popup.malware.enabled', enableValue); } + + if (includeSubfeatures) { + set(policy, 'windows.malware.blocklist', enableValue); + set(policy, 'mac.malware.blocklist', enableValue); + set(policy, 'linux.malware.blocklist', enableValue); + + set(policy, 'windows.malware.on_write_scan', enableValue); + set(policy, 'mac.malware.on_write_scan', enableValue); + set(policy, 'linux.malware.on_write_scan', enableValue); + } }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx index b7c4c13237a1f..bfc0ec3691363 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx @@ -16,7 +16,11 @@ import { getUserPrivilegesMockDefaultValue } from '../../../../../common/compone import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks'; import { allFleetHttpMocks } from '../../../../mocks'; import userEvent from '@testing-library/user-event'; -import { expectIsViewOnly, getPolicySettingsFormTestSubjects } from '../policy_settings_form/mocks'; +import { + expectIsViewOnly, + getPolicySettingsFormTestSubjects, + setMalwareMode, +} from '../policy_settings_form/mocks'; import { cloneDeep, set } from 'lodash'; import { ProtectionModes } from '../../../../../../common/endpoint/types'; import { waitFor, cleanup } from '@testing-library/react'; @@ -89,15 +93,7 @@ describe('When rendering PolicySettingsLayout', () => { // Turn off malware userEvent.click(getByTestId(testSubj.malware.enableDisableSwitch)); - set(policySettings, 'windows.malware.mode', ProtectionModes.off); - set(policySettings, 'mac.malware.mode', ProtectionModes.off); - set(policySettings, 'linux.malware.mode', ProtectionModes.off); - set(policySettings, 'windows.malware.blocklist', false); - set(policySettings, 'mac.malware.blocklist', false); - set(policySettings, 'linux.malware.blocklist', false); - set(policySettings, 'windows.popup.malware.enabled', false); - set(policySettings, 'mac.popup.malware.enabled', false); - set(policySettings, 'linux.popup.malware.enabled', false); + setMalwareMode(policySettings, true); // Turn off Behaviour Protection userEvent.click(getByTestId(testSubj.behaviour.enableDisableSwitch));