From 5dd5ec218228b6ed8ae304e5d42d0eb7465ae14d Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Mon, 14 Aug 2023 01:45:32 -0400 Subject: [PATCH] [Fleet][Agent Policy][Agent Tamper Protection] UI / API guard agent tamper protection only available if security defend integration present (#162196) ## Summary UI - [x] When there is no elastic defend integration present, the agent tamper protection (`is_protected`) switch and instruction link are disabled and there is an info tooltip explaining why the switch is disabled API - [x] Requires the elastic defend integration to be present, in order to set `is_protected` to true. Will allow the user to create the agent policy and not throw an error, but will keep `is_protected` as false and log a warning in the kibana server. In the next release, the response will be modified to send back a 201 with the relevant messaging. - [x] Sets `is_protected` to false when a user deletes the elastic defend package policy ## Screenshots ### No Elastic Defend integration installed image --- .../common/services/agent_policies_helpers.ts | 13 +++- .../services/agent_policy_config.test.ts | 8 +-- .../generate_new_agent_policy.test.ts | 3 +- x-pack/plugins/fleet/common/services/index.ts | 1 + .../index.test.tsx | 61 ++++++++++++++++--- .../agent_policy_advanced_fields/index.tsx | 34 +++++++++-- .../fleet/server/services/agent_policy.ts | 45 ++++++++++++-- .../fleet/server/services/package_policy.ts | 9 +++ .../apis/agent_policy/agent_policy.ts | 7 +-- 9 files changed, 150 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts b/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts index 52ce24634886e2..1ebe49ef4ed395 100644 --- a/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts +++ b/x-pack/plugins/fleet/common/services/agent_policies_helpers.ts @@ -5,8 +5,13 @@ * 2.0. */ -import type { AgentPolicy } from '../types'; -import { FLEET_SERVER_PACKAGE, FLEET_APM_PACKAGE, FLEET_SYNTHETICS_PACKAGE } from '../constants'; +import type { NewAgentPolicy, AgentPolicy } from '../types'; +import { + FLEET_SERVER_PACKAGE, + FLEET_APM_PACKAGE, + FLEET_SYNTHETICS_PACKAGE, + FLEET_ENDPOINT_PACKAGE, +} from '../constants'; export function policyHasFleetServer(agentPolicy: AgentPolicy) { if (!agentPolicy.package_policies) { @@ -26,6 +31,10 @@ export function policyHasSyntheticsIntegration(agentPolicy: AgentPolicy) { return policyHasIntegration(agentPolicy, FLEET_SYNTHETICS_PACKAGE); } +export function policyHasEndpointSecurity(agentPolicy: Partial) { + return policyHasIntegration(agentPolicy as AgentPolicy, FLEET_ENDPOINT_PACKAGE); +} + function policyHasIntegration(agentPolicy: AgentPolicy, packageName: string) { if (!agentPolicy.package_policies) { return false; diff --git a/x-pack/plugins/fleet/common/services/agent_policy_config.test.ts b/x-pack/plugins/fleet/common/services/agent_policy_config.test.ts index 70ee2ae631a3cc..e9bd5aa23b5d9f 100644 --- a/x-pack/plugins/fleet/common/services/agent_policy_config.test.ts +++ b/x-pack/plugins/fleet/common/services/agent_policy_config.test.ts @@ -5,15 +5,15 @@ * 2.0. */ +import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock'; import { pick } from 'lodash'; -import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock'; +import { createAgentPolicyMock } from '../mocks'; import { isAgentPolicyValidForLicense, unsetAgentPolicyAccordingToLicenseLevel, } from './agent_policy_config'; -import { generateNewAgentPolicyWithDefaults } from './generate_new_agent_policy'; describe('agent policy config and licenses', () => { const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); @@ -34,13 +34,13 @@ describe('agent policy config and licenses', () => { }); describe('unsetAgentPolicyAccordingToLicenseLevel', () => { it('resets all paid features to default if license is gold', () => { - const defaults = pick(generateNewAgentPolicyWithDefaults(), 'is_protected'); + const defaults = pick(createAgentPolicyMock(), 'is_protected'); const partialPolicy = { is_protected: true }; const retPolicy = unsetAgentPolicyAccordingToLicenseLevel(partialPolicy, Gold); expect(retPolicy).toEqual(defaults); }); it('does not change paid features if license is platinum', () => { - const expected = pick(generateNewAgentPolicyWithDefaults(), 'is_protected'); + const expected = pick(createAgentPolicyMock(), 'is_protected'); const partialPolicy = { is_protected: false }; const expected2 = { is_protected: true }; const partialPolicy2 = { is_protected: true }; diff --git a/x-pack/plugins/fleet/common/services/generate_new_agent_policy.test.ts b/x-pack/plugins/fleet/common/services/generate_new_agent_policy.test.ts index 97e63be4bd7013..bc4a6b55f75ee8 100644 --- a/x-pack/plugins/fleet/common/services/generate_new_agent_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/generate_new_agent_policy.test.ts @@ -27,7 +27,6 @@ describe('generateNewAgentPolicyWithDefaults', () => { description: 'test description', namespace: 'test-namespace', monitoring_enabled: ['logs'], - is_protected: true, }); expect(newAgentPolicy).toEqual({ @@ -36,7 +35,7 @@ describe('generateNewAgentPolicyWithDefaults', () => { namespace: 'test-namespace', monitoring_enabled: ['logs'], inactivity_timeout: 1209600, - is_protected: true, + is_protected: false, }); }); }); diff --git a/x-pack/plugins/fleet/common/services/index.ts b/x-pack/plugins/fleet/common/services/index.ts index abf06ac54b07cf..04f74404ba382e 100644 --- a/x-pack/plugins/fleet/common/services/index.ts +++ b/x-pack/plugins/fleet/common/services/index.ts @@ -66,6 +66,7 @@ export { agentStatusesToSummary } from './agent_statuses_to_summary'; export { policyHasFleetServer, policyHasAPMIntegration, + policyHasEndpointSecurity, policyHasSyntheticsIntegration, } from './agent_policies_helpers'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx index 019395c0cb5f60..fe21159ab347b3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.test.tsx @@ -17,11 +17,13 @@ import { allowedExperimentalValues } from '../../../../../../../common/experimen import { ExperimentalFeaturesService } from '../../../../../../services/experimental_features'; -import type { NewAgentPolicy, AgentPolicy } from '../../../../../../../common/types'; +import { createAgentPolicyMock, createPackagePolicyMock } from '../../../../../../../common/mocks'; +import type { AgentPolicy, NewAgentPolicy } from '../../../../../../../common/types'; import { useLicense } from '../../../../../../hooks/use_license'; import type { LicenseService } from '../../../../../../../common/services'; +import { generateNewAgentPolicyWithDefaults } from '../../../../../../../common/services'; import type { ValidationResults } from '../agent_policy_validation'; @@ -34,12 +36,7 @@ const mockedUseLicence = useLicense as jest.MockedFunction; describe('Agent policy advanced options content', () => { let testRender: TestRenderer; let renderResult: RenderResult; - - const mockAgentPolicy: Partial = { - name: 'some-agent-policy', - is_managed: false, - }; - + let mockAgentPolicy: Partial; const mockUpdateAgentPolicy = jest.fn(); const mockValidation = jest.fn() as unknown as ValidationResults; const usePlatinumLicense = () => @@ -48,16 +45,34 @@ describe('Agent policy advanced options content', () => { isPlatinum: () => true, } as unknown as LicenseService); - const render = ({ isProtected = false, policyId = 'agent-policy-1' } = {}) => { + const render = ({ + isProtected = false, + policyId = 'agent-policy-1', + newAgentPolicy = false, + packagePolicy = [createPackagePolicyMock()], + } = {}) => { // remove when feature flag is removed ExperimentalFeaturesService.init({ ...allowedExperimentalValues, agentTamperProtectionEnabled: true, }); + if (newAgentPolicy) { + mockAgentPolicy = generateNewAgentPolicyWithDefaults(); + } else { + mockAgentPolicy = { + ...createAgentPolicyMock(), + package_policies: packagePolicy, + id: policyId, + }; + } + renderResult = testRender.render( @@ -118,5 +133,33 @@ describe('Agent policy advanced options content', () => { }); expect(mockUpdateAgentPolicy).toHaveBeenCalledWith({ is_protected: true }); }); + describe('when the defend integration is not installed', () => { + beforeEach(() => { + usePlatinumLicense(); + render({ + packagePolicy: [ + { + ...createPackagePolicyMock(), + package: { name: 'not-endpoint', title: 'Not Endpoint', version: '0.1.0' }, + }, + ], + isProtected: true, + }); + }); + it('should disable the switch and uninstall command link', () => { + expect(renderResult.getByTestId('tamperProtectionSwitch')).toBeDisabled(); + expect(renderResult.getByTestId('uninstallCommandLink')).toBeDisabled(); + }); + it('should show an icon tip explaining why the switch is disabled', () => { + expect(renderResult.getByTestId('tamperMissingIntegrationTooltip')).toBeTruthy(); + }); + }); + describe('when the user is creating a new agent policy', () => { + it('should be disabled, since it has no package policies and therefore elastic defend integration is not installed', async () => { + usePlatinumLicense(); + render({ newAgentPolicy: true }); + expect(renderResult.getByTestId('tamperProtectionSwitch')).toBeDisabled(); + }); + }); }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 8811a7d97ed610..49288da22c9350 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { EuiDescribedFormGroup, EuiFormRow, @@ -46,6 +46,8 @@ import type { ValidationResults } from '../agent_policy_validation'; import { ExperimentalFeaturesService, policyHasFleetServer } from '../../../../services'; +import { policyHasEndpointSecurity as hasElasticDefend } from '../../../../../../../common/services'; + import { useOutputOptions, useDownloadSourcesOptions, @@ -106,6 +108,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get(); const licenseService = useLicense(); const [isUninstallCommandFlyoutOpen, setIsUninstallCommandFlyoutOpen] = useState(false); + const policyHasElasticDefend = useMemo(() => hasElasticDefend(agentPolicy), [agentPolicy]); return ( <> @@ -317,13 +320,34 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = } > + {' '} + {!policyHasElasticDefend && ( + + + + )} + + } checked={agentPolicy.is_protected ?? false} onChange={(e) => { updateAgentPolicy({ is_protected: e.target.checked }); }} + disabled={!policyHasElasticDefend} data-test-subj="tamperProtectionSwitch" /> {agentPolicy.id && ( @@ -333,7 +357,7 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = onClick={() => { setIsUninstallCommandFlyoutOpen(true); }} - disabled={agentPolicy.is_protected === false} + disabled={!agentPolicy.is_protected || !policyHasElasticDefend} data-test-subj="uninstallCommandLink" > {i18n.translate('xpack.fleet.agentPolicyForm.tamperingUninstallLink', { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 6958ea80c00d60..44635eee45200a 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -22,6 +22,8 @@ import type { BulkResponseItem } from '@elastic/elasticsearch/lib/api/typesWithB import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; +import { policyHasEndpointSecurity } from '../../common/services'; + import { populateAssignedAgentsCount } from '../routes/agent_policy/handlers'; import type { HTTPAuthorizationHeader } from '../../common/http_authorization_header'; @@ -113,7 +115,10 @@ class AgentPolicyService { id: string, agentPolicy: Partial, user?: AuthenticatedUser, - options: { bumpRevision: boolean } = { bumpRevision: true } + options: { bumpRevision: boolean; removeProtection: boolean } = { + bumpRevision: true, + removeProtection: false, + } ): Promise { auditLoggingService.writeCustomSoAuditLog({ action: 'update', @@ -136,6 +141,12 @@ class AgentPolicyService { ); } + const logger = appContextService.getLogger(); + + if (options.removeProtection) { + logger.warn(`Setting tamper protection for Agent Policy ${id} to false`); + } + await validateOutputForPolicy( soClient, agentPolicy, @@ -145,11 +156,14 @@ class AgentPolicyService { await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentPolicy, ...(options.bumpRevision ? { revision: existingAgentPolicy.revision + 1 } : {}), + ...(options.removeProtection + ? { is_protected: false } + : { is_protected: agentPolicy.is_protected }), updated_at: new Date().toISOString(), updated_by: user ? user.username : 'system', }); - if (options.bumpRevision) { + if (options.bumpRevision || options.removeProtection) { await this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'updated', id); } @@ -239,6 +253,14 @@ class AgentPolicyService { this.checkTamperProtectionLicense(agentPolicy); + const logger = appContextService.getLogger(); + + if (agentPolicy?.is_protected) { + logger.warn( + 'Agent policy requires Elastic Defend integration to set tamper protection to true' + ); + } + await this.requireUniqueName(soClient, agentPolicy); await validateOutputForPolicy(soClient, agentPolicy); @@ -253,7 +275,7 @@ class AgentPolicyService { updated_at: new Date().toISOString(), updated_by: options?.user?.username || 'system', schema_version: FLEET_AGENT_POLICIES_SCHEMA_VERSION, - is_protected: agentPolicy.is_protected ?? false, + is_protected: false, } as AgentPolicy, options ); @@ -491,6 +513,16 @@ class AgentPolicyService { this.checkTamperProtectionLicense(agentPolicy); + const logger = appContextService.getLogger(); + + if (agentPolicy?.is_protected && !policyHasEndpointSecurity(existingAgentPolicy)) { + logger.warn( + 'Agent policy requires Elastic Defend integration to set tamper protection to true' + ); + // force agent policy to be false if elastic defend is not present + agentPolicy.is_protected = false; + } + if (existingAgentPolicy.is_managed && !options?.force) { Object.entries(agentPolicy) .filter(([key]) => !KEY_EDITABLE_FOR_MANAGED_POLICIES.includes(key)) @@ -586,9 +618,12 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; removeProtection?: boolean } ): Promise { - const res = await this._update(soClient, esClient, id, {}, options?.user); + const res = await this._update(soClient, esClient, id, {}, options?.user, { + bumpRevision: true, + removeProtection: options?.removeProtection ?? false, + }); return res; } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 72fddd0e5b6747..b64ae2a2a1c42b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -1161,13 +1161,22 @@ class PackagePolicyClientImpl implements PackagePolicyClient { ...new Set(result.filter((r) => r.success && r.policy_id).map((r) => r.policy_id!)), ]; + const agentPoliciesWithEndpointPackagePolicies = result.reduce((acc, cur) => { + if (cur.success && cur.policy_id && cur.package?.name === 'endpoint') { + return acc.add(cur.policy_id); + } + return acc; + }, new Set()); + const agentPolicies = await agentPolicyService.getByIDs(soClient, uniquePolicyIdsR); for (const policyId of uniquePolicyIdsR) { const agentPolicy = agentPolicies.find((p) => p.id === policyId); if (agentPolicy) { + // is the agent policy attached to package policy with endpoint await agentPolicyService.bumpRevision(soClient, esClient, policyId, { user: options?.user, + removeProtection: agentPoliciesWithEndpointPackagePolicies.has(policyId), }); } } diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts index 47d3f93fd8ddf5..90426d9bdfa3ed 100644 --- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts +++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts @@ -119,7 +119,6 @@ export default function (providerContext: FtrProviderContext) { expect(body.item.is_managed).to.equal(false); expect(body.item.inactivity_timeout).to.equal(1209600); expect(body.item.status).to.be('active'); - expect(body.item.is_protected).to.equal(false); }); it('sets given is_managed value', async () => { @@ -445,13 +444,13 @@ export default function (providerContext: FtrProviderContext) { status: 'active', description: 'Test', is_managed: false, - is_protected: false, namespace: 'default', monitoring_enabled: ['logs', 'metrics'], revision: 1, schema_version: FLEET_AGENT_POLICIES_SCHEMA_VERSION, updated_by: 'elastic', package_policies: [], + is_protected: false, }); }); @@ -732,7 +731,7 @@ export default function (providerContext: FtrProviderContext) { name: 'Updated name', description: 'Updated description', namespace: 'default', - is_protected: true, + is_protected: false, }) .expect(200); createdPolicyIds.push(updatedPolicy.id); @@ -750,7 +749,7 @@ export default function (providerContext: FtrProviderContext) { updated_by: 'elastic', inactivity_timeout: 1209600, package_policies: [], - is_protected: true, + is_protected: false, }); });