[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
<img width="970" alt="image"
src="910be766-1a1e-4580-9ace-306089b4626d">
This commit is contained in:
Candace Park 2023-08-14 01:45:32 -04:00 committed by GitHub
parent 4802b0dbfe
commit 5dd5ec2182
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 150 additions and 31 deletions

View file

@ -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<NewAgentPolicy | AgentPolicy>) {
return policyHasIntegration(agentPolicy as AgentPolicy, FLEET_ENDPOINT_PACKAGE);
}
function policyHasIntegration(agentPolicy: AgentPolicy, packageName: string) {
if (!agentPolicy.package_policies) {
return false;

View file

@ -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 };

View file

@ -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,
});
});
});

View file

@ -66,6 +66,7 @@ export { agentStatusesToSummary } from './agent_statuses_to_summary';
export {
policyHasFleetServer,
policyHasAPMIntegration,
policyHasEndpointSecurity,
policyHasSyntheticsIntegration,
} from './agent_policies_helpers';

View file

@ -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<typeof useLicense>;
describe('Agent policy advanced options content', () => {
let testRender: TestRenderer;
let renderResult: RenderResult;
const mockAgentPolicy: Partial<NewAgentPolicy | AgentPolicy> = {
name: 'some-agent-policy',
is_managed: false,
};
let mockAgentPolicy: Partial<NewAgentPolicy | AgentPolicy>;
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(
<AgentPolicyAdvancedOptionsContent
agentPolicy={{ ...mockAgentPolicy, is_protected: isProtected, id: policyId }}
agentPolicy={{
...mockAgentPolicy,
is_protected: isProtected,
}}
updateAgentPolicy={mockUpdateAgentPolicy}
validation={mockValidation}
/>
@ -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();
});
});
});
});

View file

@ -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<Props> =
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<Props> =
}
>
<EuiSwitch
label={i18n.translate('xpack.fleet.agentPolicyForm.tamperingSwitchLabel', {
defaultMessage: 'Prevent agent tampering',
})}
label={
<>
<FormattedMessage
id="xpack.fleet.agentPolicyForm.tamperingSwitchLabel"
defaultMessage="Prevent agent tampering"
/>{' '}
{!policyHasElasticDefend && (
<span data-test-subj="tamperMissingIntegrationTooltip">
<EuiIconTip
type="iInCircle"
color="subdued"
content={i18n.translate(
'xpack.fleet.agentPolicyForm.tamperingSwitchLabel.disabledWarning',
{
defaultMessage:
'Elastic Defend integration is required to enable this feature',
}
)}
/>
</span>
)}
</>
}
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<Props> =
onClick={() => {
setIsUninstallCommandFlyoutOpen(true);
}}
disabled={agentPolicy.is_protected === false}
disabled={!agentPolicy.is_protected || !policyHasElasticDefend}
data-test-subj="uninstallCommandLink"
>
{i18n.translate('xpack.fleet.agentPolicyForm.tamperingUninstallLink', {

View file

@ -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<AgentPolicySOAttributes>,
user?: AuthenticatedUser,
options: { bumpRevision: boolean } = { bumpRevision: true }
options: { bumpRevision: boolean; removeProtection: boolean } = {
bumpRevision: true,
removeProtection: false,
}
): Promise<AgentPolicy> {
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<AgentPolicySOAttributes>(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<AgentPolicy> {
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;
}

View file

@ -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),
});
}
}

View file

@ -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,
});
});