diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 3d30acd3f8e01..1fe7013944fd7 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -13,7 +13,13 @@ import { } from '../common'; export { default as apm } from 'elastic-apm-node'; -export { AgentService, ESIndexPatternService, getRegistryUrl, PackageService } from './services'; +export { + AgentService, + ESIndexPatternService, + getRegistryUrl, + PackageService, + AgentPolicyServiceInterface, +} from './services'; export { FleetSetupContract, FleetSetupDeps, FleetStartContract, ExternalCallback } from './plugin'; export const config: PluginConfigDescriptor = { diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index c8aef287e4432..91098c87c312a 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -9,6 +9,7 @@ import { FleetAppContext } from './plugin'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { securityMock } from '../../security/server/mocks'; import { PackagePolicyServiceInterface } from './services/package_policy'; +import { AgentPolicyServiceInterface, AgentService } from './services'; export const createAppContextStartContractMock = (): FleetAppContext => { return { @@ -35,3 +36,28 @@ export const createPackagePolicyServiceMock = () => { update: jest.fn(), } as jest.Mocked; }; + +/** + * Create mock AgentPolicyService + */ + +export const createMockAgentPolicyService = (): jest.Mocked => { + return { + get: jest.fn(), + list: jest.fn(), + getDefaultAgentPolicyId: jest.fn(), + getFullAgentPolicy: jest.fn(), + }; +}; + +/** + * Creates a mock AgentService + */ +export const createMockAgentService = (): jest.Mocked => { + return { + getAgentStatusById: jest.fn(), + authenticateAgentWithAccessToken: jest.fn(), + getAgent: jest.fn(), + listAgents: jest.fn(), + }; +}; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index e4ed386802c3a..90fb34efd4817 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -58,6 +58,8 @@ import { ESIndexPatternSavedObjectService, ESIndexPatternService, AgentService, + AgentPolicyServiceInterface, + agentPolicyService, packagePolicyService, PackageService, } from './services'; @@ -134,6 +136,7 @@ export interface FleetStartContract { * Services for Fleet's package policies */ packagePolicyService: typeof packagePolicyService; + agentPolicyService: AgentPolicyServiceInterface; /** * Register callbacks for inclusion in fleet API processing * @param args @@ -292,6 +295,12 @@ export class FleetPlugin getAgentStatusById, authenticateAgentWithAccessToken, }, + agentPolicyService: { + get: agentPolicyService.get, + list: agentPolicyService.list, + getDefaultAgentPolicyId: agentPolicyService.getDefaultAgentPolicyId, + getFullAgentPolicy: agentPolicyService.getFullAgentPolicy, + }, packagePolicyService, registerExternalCallback: (...args: ExternalCallback) => { return appContextService.addExternalCallback(...args); diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index dd5d586f6fc56..a972b1ce17fab 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -10,6 +10,7 @@ import * as settingsService from './settings'; import { getAgent, listAgents } from './agents'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; +import { agentPolicyService } from './agent_policy'; export { getRegistryUrl } from './epm/registry/registry_url'; @@ -60,6 +61,13 @@ export interface AgentService { listAgents: typeof listAgents; } +export interface AgentPolicyServiceInterface { + get: typeof agentPolicyService['get']; + list: typeof agentPolicyService['list']; + getDefaultAgentPolicyId: typeof agentPolicyService['getDefaultAgentPolicyId']; + getFullAgentPolicy: typeof agentPolicyService['getFullAgentPolicy']; +} + // Saved object services export { agentPolicyService } from './agent_policy'; export { packagePolicyService } from './package_policy'; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 082f5100952ab..a4bdc4fc59a7c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -118,21 +118,29 @@ const APPLIED_POLICIES: Array<{ name: string; id: string; status: HostPolicyResponseActionStatus; + endpoint_policy_version: number; + version: number; }> = [ { name: 'Default', id: '00000000-0000-0000-0000-000000000000', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 1, + version: 3, }, { name: 'With Eventing', id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 3, + version: 5, }, { name: 'Detect Malware Only', id: '47d7965d-6869-478b-bd9c-fb0d2bb3959f', status: HostPolicyResponseActionStatus.success, + endpoint_policy_version: 4, + version: 9, }, ]; @@ -251,6 +259,8 @@ interface HostInfo { id: string; status: HostPolicyResponseActionStatus; name: string; + endpoint_policy_version: number; + version: number; }; }; }; @@ -1332,7 +1342,7 @@ export class EndpointDocGenerator { allStatus?: HostPolicyResponseActionStatus; policyDataStream?: DataStream; } = {}): HostPolicyResponse { - const policyVersion = this.seededUUIDv4(); + const policyVersion = this.randomN(10); const status = () => { return allStatus || this.randomHostPolicyResponseActionStatus(); }; @@ -1501,6 +1511,8 @@ export class EndpointDocGenerator { status: this.commonInfo.Endpoint.policy.applied.status, version: policyVersion, name: this.commonInfo.Endpoint.policy.applied.name, + endpoint_policy_version: this.commonInfo.Endpoint.policy.applied + .endpoint_policy_version, }, }, }, 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 66ba15431e603..f873a701eb9bd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -299,6 +299,8 @@ export interface HostResultList { request_page_index: number; /* the version of the query strategy */ query_strategy_version: MetadataQueryStrategyVersions; + /* policy IDs and versions */ + policy_info?: HostInfo['policy_info']; } /** @@ -520,9 +522,30 @@ export enum MetadataQueryStrategyVersions { VERSION_2 = 'v2', } +export type PolicyInfo = Immutable<{ + revision: number; + id: string; +}>; + export type HostInfo = Immutable<{ metadata: HostMetadata; host_status: HostStatus; + policy_info?: { + agent: { + /** + * As set in Kibana + */ + configured: PolicyInfo; + /** + * Last reported running in agent (may lag behind configured) + */ + applied: PolicyInfo; + }; + /** + * Current intended 'endpoint' package policy + */ + endpoint: PolicyInfo; + }; /* the version of the query strategy */ query_strategy_version: MetadataQueryStrategyVersions; }>; @@ -558,6 +581,8 @@ export type HostMetadata = Immutable<{ id: string; status: HostPolicyResponseActionStatus; name: string; + endpoint_policy_version: number; + version: number; }; }; }; @@ -1068,7 +1093,8 @@ export interface HostPolicyResponse { Endpoint: { policy: { applied: { - version: string; + version: number; + endpoint_policy_version: number; id: string; name: string; status: HostPolicyResponseActionStatus; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index 84d1dabe86910..2e9206d945cad 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -63,6 +63,7 @@ describe('EndpointList store concerns', () => { agentsWithEndpointsTotalError: undefined, endpointsTotalError: undefined, queryStrategyVersion: undefined, + policyVersionInfo: undefined, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 26d8dda2f4aec..33772f4463543 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -41,6 +41,7 @@ export const initialEndpointListState: Immutable = { endpointsTotal: 0, endpointsTotalError: undefined, queryStrategyVersion: undefined, + policyVersionInfo: undefined, }; /* eslint-disable-next-line complexity */ @@ -55,6 +56,7 @@ export const endpointListReducer: ImmutableReducer = ( request_page_size: pageSize, request_page_index: pageIndex, query_strategy_version: queryStrategyVersion, + policy_info: policyVersionInfo, } = action.payload; return { ...state, @@ -63,6 +65,7 @@ export const endpointListReducer: ImmutableReducer = ( pageSize, pageIndex, queryStrategyVersion, + policyVersionInfo, loading: false, error: undefined, }; @@ -104,6 +107,7 @@ export const endpointListReducer: ImmutableReducer = ( return { ...state, details: action.payload.metadata, + policyVersionInfo: action.payload.policy_info, detailsLoading: false, detailsError: undefined, }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 29d9185b6cea5..1901f3589104a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -55,6 +55,8 @@ export const isAutoRefreshEnabled = (state: Immutable) => state.i export const autoRefreshInterval = (state: Immutable) => state.autoRefreshInterval; +export const policyVersionInfo = (state: Immutable) => state.policyVersionInfo; + export const areEndpointsEnrolling = (state: Immutable) => { return state.agentsWithEndpointsTotal > state.endpointsTotal; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index ec22c522c3d0a..63ec991ecf6d1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -76,6 +76,8 @@ export interface EndpointState { endpointsTotalError?: ServerApiError; /** The query strategy version that informs whether the transform for KQL is enabled or not */ queryStrategyVersion?: MetadataQueryStrategyVersions; + /** The policy IDs and revision number of the corresponding agent, and endpoint. May be more recent than what's running */ + policyVersionInfo?: HostInfo['policy_info']; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts new file mode 100644 index 0000000000000..ce6d2f354cc45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HostInfo, HostMetadata } from '../../../../common/endpoint/types'; + +export const isPolicyOutOfDate = ( + reported: HostMetadata['Endpoint']['policy']['applied'], + current: HostInfo['policy_info'] +): boolean => { + if (current === undefined || current === null) { + return false; // we don't know, can't declare it out-of-date + } + return !( + reported.id === current.endpoint.id && // endpoint package policy not reassigned + current.agent.configured.id === current.agent.applied.id && // agent policy wasn't reassigned and not-yet-applied + // all revisions match up + reported.version >= current.agent.applied.revision && + reported.version >= current.agent.configured.revision && + reported.endpoint_policy_version >= current.endpoint.revision + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx new file mode 100644 index 0000000000000..6718dfe4cb9b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/out_of_date.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const OutOfDate = React.memo<{ style?: React.CSSProperties }>(({ style, ...otherProps }) => { + return ( + + + + + ); +}); + +OutOfDate.displayName = 'OutOfDate'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index dd7475361b950..dbb242845626e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -18,7 +18,8 @@ import { import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { HostMetadata } from '../../../../../../common/endpoint/types'; +import { isPolicyOutOfDate } from '../../utils'; +import { HostInfo, HostMetadata } from '../../../../../../common/endpoint/types'; import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; @@ -31,6 +32,7 @@ import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { AgentDetailsReassignPolicyAction } from '../../../../../../../fleet/public'; import { EndpointPolicyLink } from '../components/endpoint_policy_link'; +import { OutOfDate } from '../components/out_of_date'; const HostIds = styled(EuiListGroupItem)` margin-top: 0; @@ -51,187 +53,190 @@ const LinkToExternalApp = styled.div` const openReassignFlyoutSearch = '?openReassignFlyout=true'; -export const EndpointDetails = memo(({ details }: { details: HostMetadata }) => { - const agentId = details.elastic.agent.id; - const { - url: agentDetailsUrl, - appId: ingestAppId, - appPath: agentDetailsAppPath, - } = useAgentDetailsIngestUrl(agentId); - const queryParams = useEndpointSelector(uiQueryParams); - const policyStatus = useEndpointSelector( - policyResponseStatus - ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; - const { formatUrl } = useFormatUrl(SecurityPageName.administration); +export const EndpointDetails = memo( + ({ details, policyInfo }: { details: HostMetadata; policyInfo?: HostInfo['policy_info'] }) => { + const agentId = details.elastic.agent.id; + const { + url: agentDetailsUrl, + appId: ingestAppId, + appPath: agentDetailsAppPath, + } = useAgentDetailsIngestUrl(agentId); + const queryParams = useEndpointSelector(uiQueryParams); + const policyStatus = useEndpointSelector( + policyResponseStatus + ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; + const { formatUrl } = useFormatUrl(SecurityPageName.administration); - const detailsResultsUpper = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.os', { - defaultMessage: 'OS', - }), - description: details.host.os.full, - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { - defaultMessage: 'Last Seen', - }), - description: , - }, - ]; - }, [details]); + const detailsResultsUpper = useMemo(() => { + return [ + { + title: i18n.translate('xpack.securitySolution.endpoint.details.os', { + defaultMessage: 'OS', + }), + description: details.host.os.full, + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { + defaultMessage: 'Last Seen', + }), + description: , + }, + ]; + }, [details]); - const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { selected_endpoint, show, ...currentUrlParams } = queryParams; - return [ - formatUrl( + const [policyResponseUri, policyResponseRoutePath] = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { selected_endpoint, show, ...currentUrlParams } = queryParams; + return [ + formatUrl( + getEndpointDetailsPath({ + name: 'endpointPolicyResponse', + ...currentUrlParams, + selected_endpoint: details.agent.id, + }) + ), getEndpointDetailsPath({ name: 'endpointPolicyResponse', ...currentUrlParams, selected_endpoint: details.agent.id, - }) - ), - getEndpointDetailsPath({ - name: 'endpointPolicyResponse', - ...currentUrlParams, - selected_endpoint: details.agent.id, - }), - ]; - }, [details.agent.id, formatUrl, queryParams]); + }), + ]; + }, [details.agent.id, formatUrl, queryParams]); + + const agentDetailsWithFlyoutPath = `${agentDetailsAppPath}${openReassignFlyoutSearch}`; + const agentDetailsWithFlyoutUrl = `${agentDetailsUrl}${openReassignFlyoutSearch}`; + const handleReassignEndpointsClick = useNavigateToAppEventHandler< + AgentDetailsReassignPolicyAction + >(ingestAppId, { + path: agentDetailsWithFlyoutPath, + state: { + onDoneNavigateTo: [ + 'securitySolution:administration', + { + path: getEndpointDetailsPath({ + name: 'endpointDetails', + selected_endpoint: details.agent.id, + }), + }, + ], + }, + }); - const agentDetailsWithFlyoutPath = `${agentDetailsAppPath}${openReassignFlyoutSearch}`; - const agentDetailsWithFlyoutUrl = `${agentDetailsUrl}${openReassignFlyoutSearch}`; - const handleReassignEndpointsClick = useNavigateToAppEventHandler< - AgentDetailsReassignPolicyAction - >(ingestAppId, { - path: agentDetailsWithFlyoutPath, - state: { - onDoneNavigateTo: [ - 'securitySolution:administration', + const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); + + const detailsResultsPolicy = useMemo(() => { + return [ { - path: getEndpointDetailsPath({ - name: 'endpointDetails', - selected_endpoint: details.agent.id, + title: i18n.translate('xpack.securitySolution.endpoint.details.policy', { + defaultMessage: 'Integration Policy', }), + description: ( + <> + + {details.Endpoint.policy.applied.name} + + {isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && } + + ), }, - ], - }, - }); - - const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - - const detailsResultsPolicy = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.policy', { - defaultMessage: 'Integration Policy', - }), - description: ( - <> - - {details.Endpoint.policy.applied.name} - - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', { - defaultMessage: 'Policy Response', - }), - description: ( - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - - - - - - ), - }, - ]; - }, [details, policyResponseUri, policyStatus, policyStatusClickHandler]); - const detailsResultsLower = useMemo(() => { - return [ - { - title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', { - defaultMessage: 'IP Address', - }), - description: ( - - {details.host.ip.map((ip: string, index: number) => ( - - ))} - - ), - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { - defaultMessage: 'Hostname', - }), - description: details.host.hostname, - }, - { - title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { - defaultMessage: 'Endpoint Version', - }), - description: details.agent.version, - }, - ]; - }, [details.agent.version, details.host.hostname, details.host.ip]); + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + + ), + }, + ]; + }, [details, policyResponseUri, policyStatus, policyStatusClickHandler, policyInfo]); + const detailsResultsLower = useMemo(() => { + return [ + { + title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', { + defaultMessage: 'IP Address', + }), + description: ( + + {details.host.ip.map((ip: string, index: number) => ( + + ))} + + ), + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { + defaultMessage: 'Hostname', + }), + description: details.host.hostname, + }, + { + title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { + defaultMessage: 'Endpoint Version', + }), + description: details.agent.version, + }, + ]; + }, [details.agent.version, details.host.hostname, details.host.ip]); - return ( - <> - - - - - - - - - - - - - - ); -}); + return ( + <> + + + + + + + + + + + + + + ); + } +); EndpointDetails.displayName = 'EndpointDetails'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 6bc3445c8e745..edc15e22a699e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -33,6 +33,7 @@ import { policyResponseError, policyResponseLoading, policyResponseTimestamp, + policyVersionInfo, } from '../../store/selectors'; import { EndpointDetails } from './endpoint_details'; import { PolicyResponse } from './policy_response'; @@ -53,6 +54,7 @@ export const EndpointDetailsFlyout = memo(() => { ...queryParamsWithoutSelectedEndpoint } = queryParams; const details = useEndpointSelector(detailsData); + const policyInfo = useEndpointSelector(policyVersionInfo); const loading = useEndpointSelector(detailsLoading); const error = useEndpointSelector(detailsError); const show = useEndpointSelector(showView); @@ -101,7 +103,7 @@ export const EndpointDetailsFlyout = memo(() => { {show === 'details' && ( <> - + )} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 4b955f2fe2959..69889d3d0a881 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -228,15 +228,58 @@ describe('when on the list page', () => { firstPolicyID = hostListData[0].metadata.Endpoint.policy.applied.id; - [HostStatus.ERROR, HostStatus.ONLINE, HostStatus.OFFLINE, HostStatus.UNENROLLING].forEach( - (status, index) => { - hostListData[index] = { - metadata: hostListData[index].metadata, - host_status: status, - query_strategy_version: queryStrategyVersion, - }; - } - ); + // add ability to change (immutable) policy + type DeepMutable = { -readonly [P in keyof T]: DeepMutable }; + type Policy = DeepMutable>; + + const makePolicy = ( + applied: HostInfo['metadata']['Endpoint']['policy']['applied'], + cb: (policy: Policy) => Policy + ): Policy => { + return cb({ + agent: { + applied: { id: 'xyz', revision: applied.version }, + configured: { id: 'xyz', revision: applied.version }, + }, + endpoint: { id: applied.id, revision: applied.endpoint_policy_version }, + }); + }; + + [ + { status: HostStatus.ERROR, policy: (p: Policy) => p }, + { + status: HostStatus.ONLINE, + policy: (p: Policy) => { + p.endpoint.id = 'xyz'; // represents change in endpoint policy assignment + p.endpoint.revision = 1; + return p; + }, + }, + { + status: HostStatus.OFFLINE, + policy: (p: Policy) => { + p.endpoint.revision += 1; // changes made to endpoint policy + return p; + }, + }, + { + status: HostStatus.UNENROLLING, + policy: (p: Policy) => { + p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet + return p; + }, + }, + ].forEach((setup, index) => { + hostListData[index] = { + metadata: hostListData[index].metadata, + host_status: setup.status, + policy_info: makePolicy( + hostListData[index].metadata.Endpoint.policy.applied, + setup.policy + ), + query_strategy_version: queryStrategyVersion, + }; + }); hostListData.forEach((item, index) => { generatedPolicyStatuses[index] = item.metadata.Endpoint.policy.applied.status; }); @@ -316,6 +359,20 @@ describe('when on the list page', () => { }); }); + it('should display policy out-of-date warning when changes pending', async () => { + const renderResult = render(); + await reactTestingLibrary.act(async () => { + await middlewareSpy.waitForAction('serverReturnedEndpointList'); + }); + const outOfDates = await renderResult.findAllByTestId('rowPolicyOutOfDate'); + expect(outOfDates).toHaveLength(3); + + outOfDates.forEach((item, index) => { + expect(item.textContent).toEqual('Out-of-date'); + expect(item.querySelector(`[data-euiicon-type][color=warning]`)).not.toBeNull(); + }); + }); + it('should display policy name as a link', async () => { const renderResult = render(); await reactTestingLibrary.act(async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 2b40a7507da88..492b50af3dbd7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -35,6 +35,7 @@ import { NavigateToAppOptions } from 'kibana/public'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; +import { isPolicyOutOfDate } from '../utils'; import { HOST_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_HEALTH_COLOR, @@ -57,6 +58,7 @@ import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/rou import { useFormatUrl } from '../../../../common/components/link_to'; import { EndpointAction } from '../store/action'; import { EndpointPolicyLink } from './components/endpoint_policy_link'; +import { OutOfDate } from './components/out_of_date'; import { AdminSearchBar } from './components/search_bar'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; @@ -322,17 +324,22 @@ export const EndpointList = () => { }), truncateText: true, // eslint-disable-next-line react/display-name - render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied']) => { + render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { return ( - - - {policy.name} - - + <> + + + {policy.name} + + + {isPolicyOutOfDate(policy, item.policy_info) && ( + + )} + ); }, }, 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 11964ab4d7b28..58e2ea6111a38 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 @@ -10,7 +10,13 @@ import { SavedObjectsClientContract, } from 'src/core/server'; import { SecurityPluginSetup } from '../../../security/server'; -import { AgentService, FleetStartContract, PackageService } from '../../../fleet/server'; +import { + AgentService, + FleetStartContract, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; import { PluginStartContract as AlertsPluginStartContract } from '../../../alerts/server'; import { getPackagePolicyCreateCallback } from './ingest_integration'; import { ManifestManager } from './services/artifacts'; @@ -66,7 +72,10 @@ export const createMetadataService = (packageService: PackageService): MetadataS }; export type EndpointAppContextServiceStartContract = Partial< - Pick + Pick< + FleetStartContract, + 'agentService' | 'packageService' | 'packagePolicyService' | 'agentPolicyService' + > > & { logger: Logger; manifestManager?: ManifestManager; @@ -85,11 +94,15 @@ export type EndpointAppContextServiceStartContract = Partial< export class EndpointAppContextService { private agentService: AgentService | undefined; private manifestManager: ManifestManager | undefined; + private packagePolicyService: PackagePolicyServiceInterface | undefined; + private agentPolicyService: AgentPolicyServiceInterface | undefined; private savedObjectsStart: SavedObjectsServiceStart | undefined; private metadataService: MetadataService | undefined; public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; this.manifestManager = dependencies.manifestManager; this.savedObjectsStart = dependencies.savedObjectsStart; this.metadataService = createMetadataService(dependencies.packageService!); @@ -115,6 +128,14 @@ export class EndpointAppContextService { return this.agentService; } + public getPackagePolicyService(): PackagePolicyServiceInterface | undefined { + return this.packagePolicyService; + } + + public getAgentPolicyService(): AgentPolicyServiceInterface | undefined { + return this.agentPolicyService; + } + public getMetadataService(): MetadataService | undefined { return this.metadataService; } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index 7a1a0f06a2267..1268c8a4bc576 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -9,13 +9,12 @@ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mock import { securityMock } from '../../../security/server/mocks'; import { alertsMock } from '../../../alerts/server/mocks'; import { xpackMocks } from '../../../../mocks'; +import { FleetStartContract, ExternalCallback, PackageService } from '../../../fleet/server'; import { - AgentService, - FleetStartContract, - ExternalCallback, - PackageService, -} from '../../../fleet/server'; -import { createPackagePolicyServiceMock } from '../../../fleet/server/mocks'; + createPackagePolicyServiceMock, + createMockAgentPolicyService, + createMockAgentService, +} from '../../../fleet/server/mocks'; import { AppClientFactory } from '../client'; import { createMockConfig } from '../lib/detection_engine/routes/__mocks__'; import { @@ -25,6 +24,7 @@ import { import { ManifestManager } from './services/artifacts/manifest_manager/manifest_manager'; import { getManifestManagerMock } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { EndpointAppContext } from './types'; +import { MetadataRequestContext } from './routes/metadata/handlers'; /** * Creates a mocked EndpointAppContext. @@ -49,6 +49,7 @@ export const createMockEndpointAppContextService = ( start: jest.fn(), stop: jest.fn(), getAgentService: jest.fn(), + getAgentPolicyService: jest.fn(), getManifestManager: jest.fn().mockReturnValue(mockManifestManager ?? jest.fn()), getScopedSavedObjectsClient: jest.fn(), } as unknown) as jest.Mocked; @@ -90,18 +91,6 @@ export const createMockPackageService = (): jest.Mocked => { }; }; -/** - * Creates a mock AgentService - */ -export const createMockAgentService = (): jest.Mocked => { - return { - getAgentStatusById: jest.fn(), - authenticateAgentWithAccessToken: jest.fn(), - getAgent: jest.fn(), - listAgents: jest.fn(), - }; -}; - /** * Creates a mock IndexPatternService for use in tests that need to interact with the Ingest Manager's * ESIndexPatternService. @@ -116,11 +105,20 @@ export const createMockFleetStartContract = (indexPattern: string): FleetStartCo }, agentService: createMockAgentService(), packageService: createMockPackageService(), + agentPolicyService: createMockAgentPolicyService(), registerExternalCallback: jest.fn((...args: ExternalCallback) => {}), packagePolicyService: createPackagePolicyServiceMock(), }; }; +export const createMockMetadataRequestContext = (): jest.Mocked => { + return { + endpointAppContextService: createMockEndpointAppContextService(), + logger: loggingSystemMock.create().get('mock_endpoint_app_context'), + requestHandlerContext: xpackMocks.createRequestHandlerContext(), + }; +}; + export function createRouteHandlerContext( dataClient: jest.Mocked, savedObjectsClient: jest.Mocked diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts new file mode 100644 index 0000000000000..5dd668b857229 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/enrichment.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/endpoint/types'; +import { createMockMetadataRequestContext } from '../../mocks'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { enrichHostMetadata, MetadataRequestContext } from './handlers'; + +describe('test document enrichment', () => { + let metaReqCtx: jest.Mocked; + const docGen = new EndpointDocGenerator(); + + beforeEach(() => { + metaReqCtx = createMockMetadataRequestContext(); + }); + + // verify query version passed through + describe('metadata query strategy enrichment', () => { + it('should match v1 strategy when directed', async () => { + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_1 + ); + expect(enrichedHostList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); + }); + it('should match v2 strategy when directed', async () => { + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_2 + ); + }); + }); + + describe('host status enrichment', () => { + let statusFn: jest.Mock; + + beforeEach(() => { + statusFn = jest.fn(); + (metaReqCtx.endpointAppContextService.getAgentService as jest.Mock).mockImplementation(() => { + return { + getAgentStatusById: statusFn, + }; + }); + }); + + it('should return host online for online agent', async () => { + statusFn.mockImplementation(() => 'online'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ONLINE); + }); + + it('should return host offline for offline agent', async () => { + statusFn.mockImplementation(() => 'offline'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.OFFLINE); + }); + + it('should return host unenrolling for unenrolling agent', async () => { + statusFn.mockImplementation(() => 'unenrolling'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.UNENROLLING); + }); + + it('should return host error for degraded agent', async () => { + statusFn.mockImplementation(() => 'degraded'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for erroring agent', async () => { + statusFn.mockImplementation(() => 'error'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for warning agent', async () => { + statusFn.mockImplementation(() => 'warning'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return host error for invalid agent', async () => { + statusFn.mockImplementation(() => 'asliduasofb'); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.host_status).toEqual(HostStatus.ERROR); + }); + }); + + describe('policy info enrichment', () => { + let agentMock: jest.Mock; + let agentPolicyMock: jest.Mock; + + beforeEach(() => { + agentMock = jest.fn(); + agentPolicyMock = jest.fn(); + (metaReqCtx.endpointAppContextService.getAgentService as jest.Mock).mockImplementation(() => { + return { + getAgent: agentMock, + getAgentStatusById: jest.fn(), + }; + }); + (metaReqCtx.endpointAppContextService.getAgentPolicyService as jest.Mock).mockImplementation( + () => { + return { + get: agentPolicyMock, + }; + } + ); + }); + + it('reflects current applied agent info', async () => { + const policyID = 'abc123'; + const policyRev = 9; + agentMock.mockImplementation(() => { + return { + policy_id: policyID, + policy_revision: policyRev, + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.agent.applied.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.agent.applied.revision).toEqual(policyRev); + }); + + it('reflects current fleet agent info', async () => { + const policyID = 'xyz456'; + const policyRev = 15; + agentPolicyMock.mockImplementation(() => { + return { + id: policyID, + revision: policyRev, + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.agent.configured.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.agent.configured.revision).toEqual(policyRev); + }); + + it('reflects current endpoint policy info', async () => { + const policyID = 'endpoint-b33f'; + const policyRev = 2; + agentPolicyMock.mockImplementation(() => { + return { + package_policies: [ + { + package: { name: 'endpoint' }, + id: policyID, + revision: policyRev, + }, + ], + }; + }); + + const enrichedHostList = await enrichHostMetadata( + docGen.generateHostMetadata(), + metaReqCtx, + MetadataQueryStrategyVersions.VERSION_2 + ); + expect(enrichedHostList.policy_info).toBeDefined(); + expect(enrichedHostList.policy_info!.endpoint.id).toEqual(policyID); + expect(enrichedHostList.policy_info!.endpoint.revision).toEqual(policyRev); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index f2011e99565c8..a79175b178c38 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -15,7 +15,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; -import { Agent, AgentStatus } from '../../../../../fleet/common/types/models'; +import { Agent, AgentStatus, PackagePolicy } from '../../../../../fleet/common/types/models'; import { EndpointAppContext, HostListQueryResult } from '../../types'; import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index'; import { findAllUnenrolledAgentIds } from './support/unenroll'; @@ -245,7 +245,7 @@ export async function mapToHostResultList( } } -async function enrichHostMetadata( +export async function enrichHostMetadata( hostMetadata: HostMetadata, metadataRequestContext: MetadataRequestContext, metadataQueryStrategyVersion: MetadataQueryStrategyVersions @@ -282,9 +282,53 @@ async function enrichHostMetadata( throw e; } } + + let policyInfo: HostInfo['policy_info']; + try { + const agent = await metadataRequestContext.endpointAppContextService + ?.getAgentService() + ?.getAgent( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + elasticAgentId + ); + const agentPolicy = await metadataRequestContext.endpointAppContextService + .getAgentPolicyService() + ?.get( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + agent?.policy_id!, + true + ); + const endpointPolicy = ((agentPolicy?.package_policies || []) as PackagePolicy[]).find( + (policy: PackagePolicy) => policy.package?.name === 'endpoint' + ); + + policyInfo = { + agent: { + applied: { + revision: agent?.policy_revision || 0, + id: agent?.policy_id || '', + }, + configured: { + revision: agentPolicy?.revision || 0, + id: agentPolicy?.id || '', + }, + }, + endpoint: { + revision: endpointPolicy?.revision || 0, + id: endpointPolicy?.id || '', + }, + }; + } catch (e) { + // this is a non-vital enrichment of expected policy revisions. + // if we fail just fetching these, the rest of the endpoint + // data should still be returned. log the error and move on + log.error(e); + } + return { metadata: hostMetadata, host_status: hostStatus, + policy_info: policyInfo, query_strategy_version: metadataQueryStrategyVersion, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts index ed3c48ed6c677..e9a1f1e24fa55 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.test.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { findAgentIDsByStatus } from './agent_status'; import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { AgentService } from '../../../../../../fleet/server/services'; -import { createMockAgentService } from '../../../mocks'; +import { createMockAgentService } from '../../../../../../fleet/server/mocks'; import { Agent } from '../../../../../../fleet/common/types/models'; import { AgentStatusKueryHelper } from '../../../../../../fleet/common/services'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts index cd273f785033c..c88f11422d0f0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/unenroll.test.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { findAllUnenrolledAgentIds } from './unenroll'; import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { AgentService } from '../../../../../../fleet/server/services'; -import { createMockAgentService } from '../../../mocks'; +import { createMockAgentService } from '../../../../../../fleet/server/mocks'; import { Agent } from '../../../../../../fleet/common/types/models'; describe('test find all unenrolled Agent id', () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts index 009ce043db85e..0fc3f5135c8f6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/policy/handlers.test.ts @@ -5,10 +5,10 @@ */ import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { - createMockAgentService, createMockEndpointAppContextServiceStartContract, createRouteHandlerContext, } from '../../mocks'; +import { createMockAgentService } from '../../../../../fleet/server/mocks'; import { getHostPolicyResponseHandler, getAgentPolicySummaryHandler } from './handlers'; import { ILegacyScopedClusterClient, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 8a33b1df4caa8..d963b3b093d81 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -347,6 +347,8 @@ export class Plugin implements IPlugin