From 4b7ac57dad13973f72782a53be159dcad5773d25 Mon Sep 17 00:00:00 2001 From: Konrad Szwarc Date: Thu, 1 Feb 2024 22:35:40 +0100 Subject: [PATCH] [EDR Workflows][API] Gate Agent Tamper Protection setting on Agent Policy Settings (#174400) This PR is part of an effort to limit EDR Workflow features to the Endpoint Complete tier on serverless and focuses on server skde part of gating Agent Tamper Protection. Related PRs: https://github.com/elastic/kibana/pull/174278 https://github.com/elastic/kibana/pull/175129 **We decided to stick with the existing Fleet privileges for this component, and no extra changes are needed RBAC wise (confirmed with @roxana-gheorghe).** **Plugin/Policy Watcher Changes**: To monitor agent policies for a downgrade in tier (from complete to essentials) and disable agent protections if enabled, the following steps have been taken: 1. A new app feature, `endpoint_agent_tamper_protection`, has been introduced and linked to the `endpoint:complete` tier. 2. An additional method, `bumpRevision`, has been exposed in the fleet's agent policy service. This method utilizes the service's internal update function and includes a `disable_protection` flag, allowing it to be used without further modifications. 3. The security solution side calls this method upon successful fleet plugin setup. If the `endpoint_agent_tamper_protection` app feature is not enabled, it retrieves all agent policies with `is_protected: true` and updates these policies with `is_protected: false`. **API Changes**: To respond to attempts to activate agent protection via the API by users on the Essentials tier, the following steps have been taken: 1. External callback functionality has been added to the agentPolicy service, following the implementation in packagePolicy. 2. Update and create agent policy callbacks have been registered in the security solution. These callbacks check for the enabled status of the `endpoint_agent_tamper_protection` app feature. If disabled, the callback throws an error. 3. External callback execution has been added to the update and create methods in agent policy route handlers. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../server/plugin.test.ts | 2 +- x-pack/plugins/fleet/server/mocks/index.ts | 1 + x-pack/plugins/fleet/server/plugin.ts | 51 +++--- .../server/routes/agent_policy/handlers.ts | 12 ++ .../fleet/server/services/agent_policy.ts | 51 +++++- .../fleet/server/services/app_context.ts | 14 +- x-pack/plugins/fleet/server/services/index.ts | 1 + .../server/services/preconfiguration.test.ts | 1 + .../plugins/fleet/server/types/extensions.ts | 23 ++- .../api/agent_policy_settings_complete.cy.ts | 66 +++++++ .../agent_policy_settings_essentials.cy.ts | 76 ++++++++ .../public/management/cypress/tasks/fleet.ts | 27 ++- .../endpoint/endpoint_app_context_services.ts | 11 ++ .../turn_off_agent_policy_features.test.ts | 167 ++++++++++++++++++ .../turn_off_agent_policy_features.ts | 92 ++++++++++ .../fleet_integration.test.ts | 121 ++++++++++++- .../fleet_integration/fleet_integration.ts | 54 ++++++ .../security_solution/server/plugin.ts | 8 + 18 files changed, 742 insertions(+), 36 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_complete.cy.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_essentials.cy.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.test.ts b/x-pack/plugins/cloud_security_posture/server/plugin.test.ts index 4a3b85b41a159..10edb5859ddee 100644 --- a/x-pack/plugins/cloud_security_posture/server/plugin.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.test.ts @@ -32,10 +32,10 @@ import { UpdatePackagePolicy, } from '@kbn/fleet-plugin/common'; import { - ExternalCallback, FleetStartContract, PostPackagePolicyPostDeleteCallback, PostPackagePolicyPostCreateCallback, + ExternalCallback, } from '@kbn/fleet-plugin/server'; import { CLOUD_SECURITY_POSTURE_PACKAGE_NAME } from '../common/constants'; import Chance from 'chance'; diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index ec8ada164623d..fb6dd7d075cea 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -157,6 +157,7 @@ export const createMockAgentPolicyService = (): jest.Mocked { diff --git a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts index ba348e9e639e2..68237bb4e0ac9 100644 --- a/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent_policy/handlers.ts @@ -195,6 +195,12 @@ export const createAgentPolicyHandler: FleetRequestHandler< body, }); } catch (error) { + if (error.statusCode) { + return response.customError({ + statusCode: error.statusCode, + body: { message: error.message }, + }); + } return defaultFleetErrorHandler({ error, response }); } }; @@ -229,6 +235,12 @@ export const updateAgentPolicyHandler: FleetRequestHandler< body, }); } catch (error) { + if (error.statusCode) { + return response.customError({ + statusCode: error.statusCode, + body: { message: error.message }, + }); + } return defaultFleetErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index cb64b0e543d2f..6795d81f28946 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -44,6 +44,9 @@ import type { FullAgentPolicy, ListWithKuery, NewPackagePolicy, + PostAgentPolicyCreateCallback, + PostAgentPolicyUpdateCallback, + ExternalCallback, } from '../types'; import { getAllowedOutputTypeForPolicy, @@ -234,6 +237,43 @@ class AgentPolicyService { return policyHasSyntheticsIntegration(agentPolicy); } + public async runExternalCallbacks( + externalCallbackType: ExternalCallback[0], + agentPolicy: NewAgentPolicy | Partial + ): Promise> { + const logger = appContextService.getLogger(); + logger.debug(`Running external callbacks for ${externalCallbackType}`); + try { + const externalCallbacks = appContextService.getExternalCallbacks(externalCallbackType); + let newAgentPolicy = agentPolicy; + + if (externalCallbacks && externalCallbacks.size > 0) { + let updatedNewAgentPolicy = newAgentPolicy; + for (const callback of externalCallbacks) { + let result; + if (externalCallbackType === 'agentPolicyCreate') { + result = await (callback as PostAgentPolicyCreateCallback)( + newAgentPolicy as NewAgentPolicy + ); + updatedNewAgentPolicy = result; + } + if (externalCallbackType === 'agentPolicyUpdate') { + result = await (callback as PostAgentPolicyUpdateCallback)( + newAgentPolicy as Partial + ); + updatedNewAgentPolicy = result; + } + } + newAgentPolicy = updatedNewAgentPolicy; + } + return newAgentPolicy; + } catch (error) { + logger.error(`Error running external callbacks for ${externalCallbackType}`); + logger.error(error); + throw error; + } + } + public async create( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -254,7 +294,7 @@ class AgentPolicyService { id: options.id, savedObjectType: AGENT_POLICY_SAVED_OBJECT_TYPE, }); - + await this.runExternalCallbacks('agentPolicyCreate', agentPolicy); this.checkTamperProtectionLicense(agentPolicy); const logger = appContextService.getLogger(); @@ -519,7 +559,14 @@ class AgentPolicyService { if (!existingAgentPolicy) { throw new AgentPolicyNotFoundError('Agent policy not found'); } - + try { + await this.runExternalCallbacks('agentPolicyUpdate', agentPolicy); + } catch (error) { + logger.error(`Error running external callbacks for agentPolicyUpdate`); + if (error.apiPassThrough) { + throw error; + } + } this.checkTamperProtectionLicense(agentPolicy); await this.checkForValidUninstallToken(agentPolicy, id); diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 9e40bcd64c8c8..65a0fb6c08f35 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -42,6 +42,8 @@ import type { PostPackagePolicyPostDeleteCallback, PostPackagePolicyPostCreateCallback, PutPackagePolicyUpdateCallback, + PostAgentPolicyCreateCallback, + PostAgentPolicyUpdateCallback, } from '../types'; import type { FleetAppContext } from '../plugin'; import type { TelemetryEventsSender } from '../telemetry/sender'; @@ -245,7 +247,11 @@ class AppContextService { type: T ): | Set< - T extends 'packagePolicyCreate' + T extends 'agentPolicyCreate' + ? PostAgentPolicyCreateCallback + : T extends 'agentPolicyUpdate' + ? PostAgentPolicyUpdateCallback + : T extends 'packagePolicyCreate' ? PostPackagePolicyCreateCallback : T extends 'packagePolicyDelete' ? PostPackagePolicyDeleteCallback @@ -258,7 +264,11 @@ class AppContextService { | undefined { if (this.externalCallbacks) { return this.externalCallbacks.get(type) as Set< - T extends 'packagePolicyCreate' + T extends 'agentPolicyCreate' + ? PostAgentPolicyCreateCallback + : T extends 'agentPolicyUpdate' + ? PostAgentPolicyUpdateCallback + : T extends 'packagePolicyCreate' ? PostPackagePolicyCreateCallback : T extends 'packagePolicyDelete' ? PostPackagePolicyDeleteCallback diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 323d091cae1d2..8625644eaf685 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -33,6 +33,7 @@ export interface AgentPolicyServiceInterface { list: typeof agentPolicyService['list']; getFullAgentPolicy: typeof agentPolicyService['getFullAgentPolicy']; getByIds: typeof agentPolicyService['getByIDs']; + bumpRevision: typeof agentPolicyService['bumpRevision']; } // Agent services diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index bf70ef7652d9a..fa81ae68a0445 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -283,6 +283,7 @@ jest.mock('./app_context', () => ({ getUninstallTokenService: () => ({ generateTokenForPolicyId: jest.fn(), }), + getExternalCallbacks: jest.fn(), }, })); diff --git a/x-pack/plugins/fleet/server/types/extensions.ts b/x-pack/plugins/fleet/server/types/extensions.ts index ca5a0d84c958e..594e16f619556 100644 --- a/x-pack/plugins/fleet/server/types/extensions.ts +++ b/x-pack/plugins/fleet/server/types/extensions.ts @@ -16,6 +16,8 @@ import type { UpdatePackagePolicy, PackagePolicy, DeletePackagePoliciesResponse, + NewAgentPolicy, + AgentPolicy, } from '../../common/types'; export type PostPackagePolicyDeleteCallback = ( @@ -58,6 +60,14 @@ export type PutPackagePolicyUpdateCallback = ( request?: KibanaRequest ) => Promise; +export type PostAgentPolicyCreateCallback = ( + agentPolicy: NewAgentPolicy +) => Promise; + +export type PostAgentPolicyUpdateCallback = ( + agentPolicy: Partial +) => Promise>; + export type ExternalCallbackCreate = ['packagePolicyCreate', PostPackagePolicyCreateCallback]; export type ExternalCallbackPostCreate = [ 'packagePolicyPostCreate', @@ -71,6 +81,15 @@ export type ExternalCallbackPostDelete = [ ]; export type ExternalCallbackUpdate = ['packagePolicyUpdate', PutPackagePolicyUpdateCallback]; +export type ExternalCallbackAgentPolicyCreate = [ + 'agentPolicyCreate', + PostAgentPolicyCreateCallback +]; +export type ExternalCallbackAgentPolicyUpdate = [ + 'agentPolicyUpdate', + PostAgentPolicyUpdateCallback +]; + /** * Callbacks supported by the Fleet plugin */ @@ -79,6 +98,8 @@ export type ExternalCallback = | ExternalCallbackPostCreate | ExternalCallbackDelete | ExternalCallbackPostDelete - | ExternalCallbackUpdate; + | ExternalCallbackUpdate + | ExternalCallbackAgentPolicyCreate + | ExternalCallbackAgentPolicyUpdate; export type ExternalCallbacksStorage = Map>; diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_complete.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_complete.cy.ts new file mode 100644 index 0000000000000..4b2b4dbf369d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_complete.cy.ts @@ -0,0 +1,66 @@ +/* + * 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 { IndexedFleetEndpointPolicyResponse } from '../../../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { + createAgentPolicyTask, + createAgentPolicyWithAgentTamperProtectionsEnabled, + enableAgentTamperProtectionFeatureFlagInPolicy, + getEndpointIntegrationVersion, +} from '../../../../tasks/fleet'; +import { login } from '../../../../tasks/login'; + +describe( + 'Agent policy settings API operations on Complete', + { + tags: ['@serverless'], + env: { + ftrConfig: { + productTypes: [ + { product_line: 'security', product_tier: 'complete' }, + { product_line: 'endpoint', product_tier: 'complete' }, + ], + }, + }, + }, + () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + // let policy: PolicyData; + + beforeEach(() => { + getEndpointIntegrationVersion().then((version) => + createAgentPolicyTask(version).then((data) => { + indexedPolicy = data; + }) + ); + login(); + }); + + afterEach(() => { + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + }); + + describe('Agent tamper protections', () => { + it('allow enabling the feature', () => { + enableAgentTamperProtectionFeatureFlagInPolicy(indexedPolicy.agentPolicies[0].id).then( + (response) => { + expect(response.status).to.equal(200); + expect(response.body.item.is_protected).to.equal(true); + } + ); + }); + it('throw error when trying to create agent policy', () => { + createAgentPolicyWithAgentTamperProtectionsEnabled().then((response) => { + expect(response.status).to.equal(200); + expect(response.body.item.is_protected).to.equal(false); // We don't allow creating a policy with the feature enabled + }); + }); + }); + } +); diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_essentials.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_essentials.cy.ts new file mode 100644 index 0000000000000..9b24f0e2279ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/serverless/feature_access/api/agent_policy_settings_essentials.cy.ts @@ -0,0 +1,76 @@ +/* + * 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 { UpdateAgentPolicyResponse } from '@kbn/fleet-plugin/common/types'; +import type { IndexedFleetEndpointPolicyResponse } from '../../../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { + createAgentPolicyTask, + createAgentPolicyWithAgentTamperProtectionsEnabled, + enableAgentTamperProtectionFeatureFlagInPolicy, + getEndpointIntegrationVersion, +} from '../../../../tasks/fleet'; +import { login } from '../../../../tasks/login'; + +describe( + 'Agent policy settings API operations on Essentials', + { + tags: ['@serverless'], + env: { + ftrConfig: { + productTypes: [ + { product_line: 'security', product_tier: 'essentials' }, + { product_line: 'endpoint', product_tier: 'essentials' }, + ], + }, + }, + }, + () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + + beforeEach(() => { + getEndpointIntegrationVersion().then((version) => + createAgentPolicyTask(version).then((data) => { + indexedPolicy = data; + }) + ); + login(); + }); + + afterEach(() => { + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + }); + + describe('Agent tamper protections', () => { + it('throw error when trying to update agent policy settings', () => { + enableAgentTamperProtectionFeatureFlagInPolicy(indexedPolicy.agentPolicies[0].id, { + failOnStatusCode: false, + }).then((res) => { + const response = res as Cypress.Response; + expect(response.status).to.equal(403); + expect(response.body.message).to.equal( + 'Agent Tamper Protection is not allowed in current environment' + ); + }); + }); + it('throw error when trying to create agent policy', () => { + createAgentPolicyWithAgentTamperProtectionsEnabled({ failOnStatusCode: false }).then( + (res) => { + const response = res as Cypress.Response< + UpdateAgentPolicyResponse & { message: string } + >; + expect(response.status).to.equal(403); + expect(response.body.message).to.equal( + 'Agent Tamper Protection is not allowed in current environment' + ); + } + ); + }); + }); + } +); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/fleet.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/fleet.ts index aa2568268f020..e122eebedfc33 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/fleet.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/fleet.ts @@ -11,6 +11,7 @@ import type { GetInfoResponse, GetPackagePoliciesResponse, GetOneAgentPolicyResponse, + CreateAgentPolicyResponse, } from '@kbn/fleet-plugin/common'; import { agentRouteService, @@ -99,7 +100,30 @@ export const createAgentPolicyTask = ( ); }; -export const enableAgentTamperProtectionFeatureFlagInPolicy = (agentPolicyId: string) => { +export const createAgentPolicyWithAgentTamperProtectionsEnabled = ( + overwrite?: Record +) => { + return request({ + method: 'POST', + url: agentPolicyRouteService.getCreatePath(), + body: { + name: `With agent tamper protection enabled ${Math.random().toString(36).substring(2, 7)}`, + agent_features: [{ name: 'tamper_protection', enabled: true }], + is_protected: true, + description: 'test', + namespace: 'default', + monitoring_enabled: ['logs', 'metrics'], + inactivity_timeout: 1209600, + }, + headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, + ...(overwrite ?? {}), + }); +}; + +export const enableAgentTamperProtectionFeatureFlagInPolicy = ( + agentPolicyId: string, + overwrite?: Record +) => { return request({ method: 'PUT', url: agentPolicyRouteService.getUpdatePath(agentPolicyId), @@ -113,6 +137,7 @@ export const enableAgentTamperProtectionFeatureFlagInPolicy = (agentPolicyId: st inactivity_timeout: 1209600, }, headers: { 'Elastic-Api-Version': API_VERSIONS.public.v1 }, + ...(overwrite ?? {}), }); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 4a1c1332916db..fa4cd259652db 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -24,6 +24,8 @@ import type { PluginStartContract as AlertsPluginStartContract } from '@kbn/aler import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { FleetActionsClientInterface } from '@kbn/fleet-plugin/server/services/actions/types'; import { + getAgentPolicyCreateCallback, + getAgentPolicyUpdateCallback, getPackagePolicyCreateCallback, getPackagePolicyDeleteCallback, getPackagePolicyPostCreateCallback, @@ -119,6 +121,15 @@ export class EndpointAppContextService { savedObjectsClient, } = dependencies; + registerIngestCallback( + 'agentPolicyCreate', + getAgentPolicyCreateCallback(logger, appFeaturesService) + ); + registerIngestCallback( + 'agentPolicyUpdate', + getAgentPolicyUpdateCallback(logger, appFeaturesService) + ); + registerIngestCallback( 'packagePolicyCreate', getPackagePolicyCreateCallback( diff --git a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts new file mode 100644 index 0000000000000..b54221f300e27 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { createMockEndpointAppContextServiceStartContract } from '../mocks'; +import type { Logger } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { EndpointInternalFleetServicesInterface } from '../services/fleet'; + +import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-features/keys'; +import type { AppFeaturesService } from '../../lib/app_features_service/app_features_service'; +import { createAppFeaturesServiceMock } from '../../lib/app_features_service/mocks'; +import { turnOffAgentPolicyFeatures } from './turn_off_agent_policy_features'; +import { FleetAgentPolicyGenerator } from '../../../common/endpoint/data_generators/fleet_agent_policy_generator'; +import type { AgentPolicy, GetAgentPoliciesResponseItem } from '@kbn/fleet-plugin/common'; + +describe('Turn Off Agent Policy Features Migration', () => { + let esClient: ElasticsearchClient; + let fleetServices: EndpointInternalFleetServicesInterface; + let appFeatureService: AppFeaturesService; + let logger: Logger; + + const callTurnOffAgentPolicyFeatures = () => + turnOffAgentPolicyFeatures(esClient, fleetServices, appFeatureService, logger); + + beforeEach(() => { + const endpointContextStartContract = createMockEndpointAppContextServiceStartContract(); + + ({ esClient, logger } = endpointContextStartContract); + + appFeatureService = endpointContextStartContract.appFeaturesService; + fleetServices = endpointContextStartContract.endpointFleetServicesFactory.asInternalUser(); + }); + + describe('and `agentTamperProtection` is enabled', () => { + it('should do nothing', async () => { + await callTurnOffAgentPolicyFeatures(); + + expect(fleetServices.agentPolicy.list as jest.Mock).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenLastCalledWith( + 'App feature [endpoint_agent_tamper_protection] is enabled. Nothing to do!' + ); + }); + }); + + describe('and `agentTamperProtection` is disabled', () => { + let policyGenerator: FleetAgentPolicyGenerator; + let page1Items: GetAgentPoliciesResponseItem[] = []; + let page2Items: GetAgentPoliciesResponseItem[] = []; + let page3Items: GetAgentPoliciesResponseItem[] = []; + let bulkUpdateResponse: AgentPolicy[]; + + const generatePolicyMock = (): GetAgentPoliciesResponseItem => { + return policyGenerator.generate({ is_protected: true }); + }; + + beforeEach(() => { + policyGenerator = new FleetAgentPolicyGenerator('seed'); + const agentPolicyListSrv = fleetServices.agentPolicy.list as jest.Mock; + + appFeatureService = createAppFeaturesServiceMock( + ALL_APP_FEATURE_KEYS.filter((key) => key !== 'endpoint_agent_tamper_protection') + ); + + page1Items = [generatePolicyMock(), generatePolicyMock()]; + page2Items = [generatePolicyMock(), generatePolicyMock()]; + page3Items = [generatePolicyMock()]; + + agentPolicyListSrv + .mockImplementationOnce(async () => { + return { + total: 2500, + page: 1, + perPage: 1000, + items: page1Items, + }; + }) + .mockImplementationOnce(async () => { + return { + total: 2500, + page: 2, + perPage: 1000, + items: page2Items, + }; + }) + .mockImplementationOnce(async () => { + return { + total: 2500, + page: 3, + perPage: 1000, + items: page3Items, + }; + }); + + bulkUpdateResponse = [ + page1Items[0], + page1Items[1], + page2Items[0], + page2Items[1], + page3Items[0], + ]; + + (fleetServices.agentPolicy.bumpRevision as jest.Mock).mockImplementation(async () => { + return bulkUpdateResponse; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should update only policies that have protections turn on', async () => { + await callTurnOffAgentPolicyFeatures(); + + expect(fleetServices.agentPolicy.list as jest.Mock).toHaveBeenCalledTimes(3); + + const updates = Array.from({ length: 5 }, (_, i) => ({ + soClient: fleetServices.internalSoClient, + esClient, + id: bulkUpdateResponse![i].id, + })); + + expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenCalledTimes(5); + updates.forEach((args, i) => { + expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenNthCalledWith( + i + 1, + args.soClient, + args.esClient, + args.id, + { removeProtection: true, user: { username: 'elastic' } } + ); + }); + + expect(logger.info).toHaveBeenCalledWith( + 'App feature [endpoint_agent_tamper_protection] is disabled. Checking fleet agent policies for compliance' + ); + + expect(logger.info).toHaveBeenCalledWith( + `Found 5 policies that need updates:\n${bulkUpdateResponse! + .map( + (policy) => + `Policy [${policy.id}][${policy.name}] updated to disable agent tamper protection.` + ) + .join('\n')}` + ); + expect(logger.info).toHaveBeenCalledWith('Done. All updates applied successfully'); + }); + + it('should log failures', async () => { + (fleetServices.agentPolicy.bumpRevision as jest.Mock).mockImplementationOnce(async () => { + throw new Error('oh noo'); + }); + await callTurnOffAgentPolicyFeatures(); + + expect(logger.error).toHaveBeenCalledWith( + `Done - 1 out of 5 were successful. Errors encountered:\nPolicy [${ + bulkUpdateResponse![0].id + }] failed to update due to error: Error: oh noo` + ); + + expect(fleetServices.agentPolicy.bumpRevision as jest.Mock).toHaveBeenCalledTimes(5); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts new file mode 100644 index 0000000000000..0d1b01d68b765 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/migrations/turn_off_agent_policy_features.ts @@ -0,0 +1,92 @@ +/* + * 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 { Logger, ElasticsearchClient } from '@kbn/core/server'; +import type { AgentPolicy } from '@kbn/fleet-plugin/common'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { AppFeatureSecurityKey } from '@kbn/security-solution-features/keys'; +import pMap from 'p-map'; +import type { EndpointInternalFleetServicesInterface } from '../services/fleet'; +import type { AppFeaturesService } from '../../lib/app_features_service/app_features_service'; + +export const turnOffAgentPolicyFeatures = async ( + esClient: ElasticsearchClient, + fleetServices: EndpointInternalFleetServicesInterface, + appFeaturesService: AppFeaturesService, + logger: Logger +): Promise => { + const log = logger.get('endpoint', 'agentPolicyFeatures'); + + if (appFeaturesService.isEnabled(AppFeatureSecurityKey.endpointAgentTamperProtection)) { + log.info( + `App feature [${AppFeatureSecurityKey.endpointAgentTamperProtection}] is enabled. Nothing to do!` + ); + + return; + } + + log.info( + `App feature [${AppFeatureSecurityKey.endpointAgentTamperProtection}] is disabled. Checking fleet agent policies for compliance` + ); + + const { agentPolicy: agentPolicyService, internalSoClient } = fleetServices; + const updates: AgentPolicy[] = []; + const messages: string[] = []; + const perPage = 1000; + let hasMoreData = true; + let total = 0; + let page = 1; + + do { + const currentPage = page++; + const { items, total: totalPolicies } = await agentPolicyService.list(internalSoClient, { + page: currentPage, + kuery: 'ingest-agent-policies.is_protected: true', + perPage, + }); + + total = totalPolicies; + hasMoreData = currentPage * perPage < total; + + for (const item of items) { + messages.push( + `Policy [${item.id}][${item.name}] updated to disable agent tamper protection.` + ); + + updates.push({ ...item, is_protected: false }); + } + } while (hasMoreData); + + if (updates.length > 0) { + logger.info(`Found ${updates.length} policies that need updates:\n${messages.join('\n')}`); + const policyUpdateErrors: Array<{ id: string; error: Error }> = []; + await pMap(updates, async (update) => { + try { + return await agentPolicyService.bumpRevision(internalSoClient, esClient, update.id, { + user: { username: 'elastic' } as AuthenticatedUser, + removeProtection: true, + }); + } catch (error) { + policyUpdateErrors.push({ error, id: update.id }); + } + }); + + if (policyUpdateErrors.length > 0) { + logger.error( + `Done - ${policyUpdateErrors.length} out of ${ + updates.length + } were successful. Errors encountered:\n${policyUpdateErrors + .map((e) => `Policy [${e.id}] failed to update due to error: ${e.error}`) + .join('\n')}` + ); + } else { + logger.info(`Done. All updates applied successfully`); + } + } else { + logger.info(`Done. Checked ${total} policies and no updates needed`); + } +}; diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index b2c98e5fef9e7..f3700f9f0a70f 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -24,13 +24,15 @@ import { } from '../../common/endpoint/models/policy_config'; import { buildManifestManagerMock } from '../endpoint/services/artifacts/manifest_manager/manifest_manager.mock'; import { + getAgentPolicyCreateCallback, + getAgentPolicyUpdateCallback, getPackagePolicyCreateCallback, getPackagePolicyDeleteCallback, getPackagePolicyPostCreateCallback, getPackagePolicyUpdateCallback, } from './fleet_integration'; -import type { KibanaRequest } from '@kbn/core/server'; -import { ALL_APP_FEATURE_KEYS } from '@kbn/security-solution-features/keys'; +import type { KibanaRequest, Logger } from '@kbn/core/server'; +import { ALL_APP_FEATURE_KEYS, AppFeatureSecurityKey } from '@kbn/security-solution-features/keys'; import { requestContextMock } from '../lib/detection_engine/routes/__mocks__'; import { requestContextFactoryMock } from '../request_context_factory.mock'; import type { EndpointAppContextServiceStartContract } from '../endpoint/endpoint_app_context_services'; @@ -50,7 +52,10 @@ import { getMockArtifacts, toArtifactRecords } from '../endpoint/lib/artifacts/m import { Manifest } from '../endpoint/lib/artifacts'; import type { NewPackagePolicy, PackagePolicy } from '@kbn/fleet-plugin/common/types/models'; import type { ManifestSchema } from '../../common/endpoint/schema/manifest'; -import type { PostDeletePackagePoliciesResponse } from '@kbn/fleet-plugin/common'; +import type { + GetAgentPoliciesResponseItem, + PostDeletePackagePoliciesResponse, +} from '@kbn/fleet-plugin/common'; import { createMockPolicyData } from '../endpoint/services/feature_usage/mocks'; import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../common/endpoint/service/artifacts/constants'; import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; @@ -58,6 +63,7 @@ import { disableProtections } from '../../common/endpoint/models/policy_config_h import type { AppFeaturesService } from '../lib/app_features_service/app_features_service'; import { createAppFeaturesServiceMock } from '../lib/app_features_service/mocks'; import * as moment from 'moment'; +import type { PostAgentPolicyCreateCallback } from '@kbn/fleet-plugin/server/types'; jest.mock('uuid', () => ({ v4: (): string => 'NEW_UUID', @@ -382,6 +388,115 @@ describe('ingest_integration tests ', () => { }); }); + describe('agent policy update callback', () => { + it('AppFeature disabled - returns an error if higher tier features are turned on in the policy', async () => { + const logger = loggingSystemMock.create().get('ingest_integration.test'); + + appFeaturesService = createAppFeaturesServiceMock( + ALL_APP_FEATURE_KEYS.filter( + (key) => key !== AppFeatureSecurityKey.endpointAgentTamperProtection + ) + ); + const callback = getAgentPolicyUpdateCallback(logger, appFeaturesService); + + const policyConfig = generator.generateAgentPolicy(); + policyConfig.is_protected = true; + + await expect(() => callback(policyConfig)).rejects.toThrow( + 'Agent Tamper Protection is not allowed in current environment' + ); + }); + it('AppFeature disabled - returns agent policy if higher tier features are turned off in the policy', async () => { + const logger = loggingSystemMock.create().get('ingest_integration.test'); + + appFeaturesService = createAppFeaturesServiceMock( + ALL_APP_FEATURE_KEYS.filter( + (key) => key !== AppFeatureSecurityKey.endpointAgentTamperProtection + ) + ); + const callback = getAgentPolicyUpdateCallback(logger, appFeaturesService); + + const policyConfig = generator.generateAgentPolicy(); + + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + it('AppFeature enabled - returns agent policy if higher tier features are turned on in the policy', async () => { + const logger = loggingSystemMock.create().get('ingest_integration.test'); + + const callback = getAgentPolicyUpdateCallback(logger, appFeaturesService); + + const policyConfig = generator.generateAgentPolicy(); + policyConfig.is_protected = true; + + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + it('AppFeature enabled - returns agent policy if higher tier features are turned off in the policy', async () => { + const logger = loggingSystemMock.create().get('ingest_integration.test'); + + const callback = getAgentPolicyUpdateCallback(logger, appFeaturesService); + const policyConfig = generator.generateAgentPolicy(); + + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + }); + + describe('agent policy create callback', () => { + let logger: Logger; + let callback: PostAgentPolicyCreateCallback; + let policyConfig: GetAgentPoliciesResponseItem; + + beforeEach(() => { + logger = loggingSystemMock.create().get('ingest_integration.test'); + callback = getAgentPolicyCreateCallback(logger, appFeaturesService); + policyConfig = generator.generateAgentPolicy(); + }); + + it('AppFeature disabled - returns an error if higher tier features are turned on in the policy', async () => { + appFeaturesService = createAppFeaturesServiceMock( + ALL_APP_FEATURE_KEYS.filter( + (key) => key !== AppFeatureSecurityKey.endpointAgentTamperProtection + ) + ); + callback = getAgentPolicyCreateCallback(logger, appFeaturesService); + policyConfig.is_protected = true; + + await expect(() => callback(policyConfig)).rejects.toThrow( + 'Agent Tamper Protection is not allowed in current environment' + ); + }); + + it('AppFeature disabled - returns agent policy if higher tier features are turned off in the policy', async () => { + appFeaturesService = createAppFeaturesServiceMock( + ALL_APP_FEATURE_KEYS.filter( + (key) => key !== AppFeatureSecurityKey.endpointAgentTamperProtection + ) + ); + callback = getAgentPolicyCreateCallback(logger, appFeaturesService); + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + + it('AppFeature enabled - returns agent policy if higher tier features are turned on in the policy', async () => { + policyConfig.is_protected = true; + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + + it('AppFeature enabled - returns agent policy if higher tier features are turned off in the policy', async () => { + const updatedPolicyConfig = await callback(policyConfig); + + expect(updatedPolicyConfig).toEqual(policyConfig); + }); + }); + describe('package policy update callback (when the license is below platinum)', () => { const soClient = savedObjectsClientMock.create(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts index b2e2ab65742e7..f01f2da532f8b 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts @@ -16,6 +16,8 @@ import type { } from '@kbn/fleet-plugin/server'; import type { + AgentPolicy, + NewAgentPolicy, NewPackagePolicy, PackagePolicy, UpdatePackagePolicy, @@ -23,6 +25,10 @@ import type { import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { InfoResponse } from '@elastic/elasticsearch/lib/api/types'; import { AppFeatureSecurityKey } from '@kbn/security-solution-features/keys'; +import type { + PostAgentPolicyCreateCallback, + PostAgentPolicyUpdateCallback, +} from '@kbn/fleet-plugin/server/types'; import { validatePolicyAgainstAppFeatures } from './handlers/validate_policy_against_app_features'; import { validateEndpointPackagePolicy } from './handlers/validate_endpoint_package_policy'; import { @@ -291,6 +297,54 @@ export const getPackagePolicyPostCreateCallback = ( }; }; +const throwAgentTamperProtectionUnavailableError = ( + logger: Logger, + policyName?: string, + policyId?: string +): void => { + const agentTamperProtectionUnavailableError: Error & { + statusCode?: number; + apiPassThrough?: boolean; + } = new Error('Agent Tamper Protection is not allowed in current environment'); + // Agent Policy Service will check for apiPassThrough and rethrow. Route handler will check for statusCode and overwrite. + agentTamperProtectionUnavailableError.statusCode = 403; + agentTamperProtectionUnavailableError.apiPassThrough = true; + logger.error( + `Policy [${policyName}:${policyId}] error: Agent Tamper Protection requires Complete Endpoint Security tier` + ); + throw agentTamperProtectionUnavailableError; +}; + +export const getAgentPolicyCreateCallback = ( + logger: Logger, + appFeatures: AppFeaturesService +): PostAgentPolicyCreateCallback => { + return async (agentPolicy: NewAgentPolicy): Promise => { + if ( + agentPolicy.is_protected && + !appFeatures.isEnabled(AppFeatureSecurityKey.endpointAgentTamperProtection) + ) { + throwAgentTamperProtectionUnavailableError(logger, agentPolicy.name, agentPolicy.id); + } + return agentPolicy; + }; +}; + +export const getAgentPolicyUpdateCallback = ( + logger: Logger, + appFeatures: AppFeaturesService +): PostAgentPolicyUpdateCallback => { + return async (agentPolicy: Partial): Promise> => { + if ( + agentPolicy.is_protected && + !appFeatures.isEnabled(AppFeatureSecurityKey.endpointAgentTamperProtection) + ) { + throwAgentTamperProtectionUnavailableError(logger, agentPolicy.name, agentPolicy.id); + } + return agentPolicy; + }; +}; + export const getPackagePolicyDeleteCallback = ( exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: SavedObjectsClientContract | undefined diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index ffe4e7a6e342b..aaa104533e080 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -115,6 +115,7 @@ import { } from '../common/entity_analytics/risk_engine'; import { isEndpointPackageV2 } from '../common/endpoint/utils/package_v2'; import { getAssistantTools } from './assistant/tools'; +import { turnOffAgentPolicyFeatures } from './endpoint/migrations/turn_off_agent_policy_features'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -555,6 +556,13 @@ export class Plugin implements ISecuritySolutionPlugin { appFeaturesService, logger ); + + turnOffAgentPolicyFeatures( + core.elasticsearch.client.asInternalUser, + endpointFleetServicesFactory.asInternalUser(), + appFeaturesService, + logger + ); }); // License related start