[Security Solution] Give notice when endpoint policy is out of date (#83469)

This commit is contained in:
Dan Panzarella 2020-11-20 15:21:23 -05:00 committed by GitHub
parent a11f70f9bb
commit 2cd2528ac8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 715 additions and 219 deletions

View file

@ -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 = {

View file

@ -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<PackagePolicyServiceInterface>;
};
/**
* Create mock AgentPolicyService
*/
export const createMockAgentPolicyService = (): jest.Mocked<AgentPolicyServiceInterface> => {
return {
get: jest.fn(),
list: jest.fn(),
getDefaultAgentPolicyId: jest.fn(),
getFullAgentPolicy: jest.fn(),
};
};
/**
* Creates a mock AgentService
*/
export const createMockAgentService = (): jest.Mocked<AgentService> => {
return {
getAgentStatusById: jest.fn(),
authenticateAgentWithAccessToken: jest.fn(),
getAgent: jest.fn(),
listAgents: jest.fn(),
};
};

View file

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

View file

@ -9,6 +9,7 @@ import { AgentStatus, Agent, EsAssetReference } from '../types';
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';
@ -59,6 +60,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';

View file

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

View file

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

View file

@ -63,6 +63,7 @@ describe('EndpointList store concerns', () => {
agentsWithEndpointsTotalError: undefined,
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
});
});

View file

@ -41,6 +41,7 @@ export const initialEndpointListState: Immutable<EndpointState> = {
endpointsTotal: 0,
endpointsTotalError: undefined,
queryStrategyVersion: undefined,
policyVersionInfo: undefined,
};
/* eslint-disable-next-line complexity */
@ -55,6 +56,7 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
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<EndpointState, AppAction> = (
pageSize,
pageIndex,
queryStrategyVersion,
policyVersionInfo,
loading: false,
error: undefined,
};
@ -104,6 +107,7 @@ export const endpointListReducer: ImmutableReducer<EndpointState, AppAction> = (
return {
...state,
details: action.payload.metadata,
policyVersionInfo: action.payload.policy_info,
detailsLoading: false,
detailsError: undefined,
};

View file

@ -55,6 +55,8 @@ export const isAutoRefreshEnabled = (state: Immutable<EndpointState>) => state.i
export const autoRefreshInterval = (state: Immutable<EndpointState>) => state.autoRefreshInterval;
export const policyVersionInfo = (state: Immutable<EndpointState>) => state.policyVersionInfo;
export const areEndpointsEnrolling = (state: Immutable<EndpointState>) => {
return state.agentsWithEndpointsTotal > state.endpointsTotal;
};

View file

@ -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'];
}
/**

View file

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

View file

@ -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 (
<EuiText color="subdued" size="xs" className="eui-textNoWrap" style={style} {...otherProps}>
<EuiIcon size="m" type="alert" color="warning" />
<FormattedMessage id="xpack.securitySolution.outOfDateLabel" defaultMessage="Out-of-date" />
</EuiText>
);
});
OutOfDate.displayName = 'OutOfDate';

View file

@ -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: <FormattedDateAndTime date={new Date(details['@timestamp'])} />,
},
];
}, [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: <FormattedDateAndTime date={new Date(details['@timestamp'])} />,
},
];
}, [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',
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 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: (
<>
<EndpointPolicyLink
policyId={details.Endpoint.policy.applied.id}
data-test-subj="policyDetailsValue"
>
{details.Endpoint.policy.applied.name}
</EndpointPolicyLink>
{isPolicyOutOfDate(details.Endpoint.policy.applied, policyInfo) && <OutOfDate />}
</>
),
},
],
},
});
const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath);
const detailsResultsPolicy = useMemo(() => {
return [
{
title: i18n.translate('xpack.securitySolution.endpoint.details.policy', {
defaultMessage: 'Integration Policy',
}),
description: (
<>
<EndpointPolicyLink
policyId={details.Endpoint.policy.applied.id}
data-test-subj="policyDetailsValue"
{
title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', {
defaultMessage: 'Policy Response',
}),
description: (
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policyStatus] || 'subdued'}
data-test-subj="policyStatusHealth"
>
{details.Endpoint.policy.applied.name}
</EndpointPolicyLink>
</>
),
},
{
title: i18n.translate('xpack.securitySolution.endpoint.details.policyStatus', {
defaultMessage: 'Policy Response',
}),
description: (
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[policyStatus] || 'subdued'}
data-test-subj="policyStatusHealth"
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
data-test-subj="policyStatusValue"
href={policyResponseUri}
onClick={policyStatusClickHandler}
>
<EuiText size="m">
<FormattedMessage
id="xpack.securitySolution.endpoint.details.policyStatusValue"
defaultMessage="{policyStatus, select, success {Success} warning {Warning} failure {Failed} other {Unknown}}"
values={{ policyStatus }}
/>
</EuiText>
</EuiLink>
</EuiHealth>
),
},
];
}, [details, policyResponseUri, policyStatus, policyStatusClickHandler, policyInfo]);
const detailsResultsLower = useMemo(() => {
return [
{
title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', {
defaultMessage: 'IP Address',
}),
description: (
<EuiListGroup flush>
{details.host.ip.map((ip: string, index: number) => (
<HostIds key={index} label={ip} />
))}
</EuiListGroup>
),
},
{
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 (
<>
<EuiDescriptionList
type="column"
listItems={detailsResultsUpper}
data-test-subj="endpointDetailsUpperList"
/>
<EuiHorizontalRule margin="m" />
<EuiDescriptionList
type="column"
listItems={detailsResultsPolicy}
data-test-subj="endpointDetailsPolicyList"
/>
<LinkToExternalApp>
<LinkToApp
appId={ingestAppId}
appPath={agentDetailsWithFlyoutPath}
href={agentDetailsWithFlyoutUrl}
onClick={handleReassignEndpointsClick}
data-test-subj="endpointDetailsLinkToIngest"
>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
data-test-subj="policyStatusValue"
href={policyResponseUri}
onClick={policyStatusClickHandler}
>
<EuiText size="m">
<FormattedMessage
id="xpack.securitySolution.endpoint.details.policyStatusValue"
defaultMessage="{policyStatus, select, success {Success} warning {Warning} failure {Failed} other {Unknown}}"
values={{ policyStatus }}
/>
</EuiText>
</EuiLink>
</EuiHealth>
),
},
];
}, [details, policyResponseUri, policyStatus, policyStatusClickHandler]);
const detailsResultsLower = useMemo(() => {
return [
{
title: i18n.translate('xpack.securitySolution.endpoint.details.ipAddress', {
defaultMessage: 'IP Address',
}),
description: (
<EuiListGroup flush>
{details.host.ip.map((ip: string, index: number) => (
<HostIds key={index} label={ip} />
))}
</EuiListGroup>
),
},
{
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 (
<>
<EuiDescriptionList
type="column"
listItems={detailsResultsUpper}
data-test-subj="endpointDetailsUpperList"
/>
<EuiHorizontalRule margin="m" />
<EuiDescriptionList
type="column"
listItems={detailsResultsPolicy}
data-test-subj="endpointDetailsPolicyList"
/>
<LinkToExternalApp>
<LinkToApp
appId={ingestAppId}
appPath={agentDetailsWithFlyoutPath}
href={agentDetailsWithFlyoutUrl}
onClick={handleReassignEndpointsClick}
data-test-subj="endpointDetailsLinkToIngest"
>
<EuiIcon type="savedObjectsApp" className="linkToAppIcon" />
<FormattedMessage
id="xpack.securitySolution.endpoint.details.linkToIngestTitle"
defaultMessage="Reassign Policy"
/>
<EuiIcon type="popout" className="linkToAppPopoutIcon" />
</LinkToApp>
</LinkToExternalApp>
<EuiHorizontalRule margin="m" />
<EuiDescriptionList
type="column"
listItems={detailsResultsLower}
data-test-subj="endpointDetailsLowerList"
/>
</>
);
});
<EuiIcon type="savedObjectsApp" className="linkToAppIcon" />
<FormattedMessage
id="xpack.securitySolution.endpoint.details.linkToIngestTitle"
defaultMessage="Reassign Policy"
/>
<EuiIcon type="popout" className="linkToAppPopoutIcon" />
</LinkToApp>
</LinkToExternalApp>
<EuiHorizontalRule margin="m" />
<EuiDescriptionList
type="column"
listItems={detailsResultsLower}
data-test-subj="endpointDetailsLowerList"
/>
</>
);
}
);
EndpointDetails.displayName = 'EndpointDetails';

View file

@ -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' && (
<>
<EuiFlyoutBody data-test-subj="endpointDetailsFlyoutBody">
<EndpointDetails details={details} />
<EndpointDetails details={details} policyInfo={policyInfo} />
</EuiFlyoutBody>
</>
)}

View file

@ -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<T> = { -readonly [P in keyof T]: DeepMutable<T[P]> };
type Policy = DeepMutable<NonNullable<HostInfo['policy_info']>>;
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 () => {

View file

@ -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 (
<EuiToolTip content={policy.name} anchorClassName="eui-textTruncate">
<EndpointPolicyLink
policyId={policy.id}
className="eui-textTruncate"
data-test-subj="policyNameCellLink"
>
{policy.name}
</EndpointPolicyLink>
</EuiToolTip>
<>
<EuiToolTip content={policy.name} anchorClassName="eui-textTruncate">
<EndpointPolicyLink
policyId={policy.id}
className="eui-textTruncate"
data-test-subj="policyNameCellLink"
>
{policy.name}
</EndpointPolicyLink>
</EuiToolTip>
{isPolicyOutOfDate(policy, item.policy_info) && (
<OutOfDate style={{ paddingLeft: '6px' }} data-test-subj="rowPolicyOutOfDate" />
)}
</>
);
},
},

View file

@ -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<FleetStartContract, 'agentService' | 'packageService'>
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;
}

View file

@ -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<EndpointAppContextService>;
@ -90,18 +91,6 @@ export const createMockPackageService = (): jest.Mocked<PackageService> => {
};
};
/**
* Creates a mock AgentService
*/
export const createMockAgentService = (): jest.Mocked<AgentService> => {
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<MetadataRequestContext> => {
return {
endpointAppContextService: createMockEndpointAppContextService(),
logger: loggingSystemMock.create().get('mock_endpoint_app_context'),
requestHandlerContext: xpackMocks.createRequestHandlerContext(),
};
};
export function createRouteHandlerContext(
dataClient: jest.Mocked<ILegacyScopedClusterClient>,
savedObjectsClient: jest.Mocked<SavedObjectsClientContract>

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

@ -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,

View file

@ -347,6 +347,8 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.endpointAppContextService.start({
agentService: plugins.fleet?.agentService,
packageService: plugins.fleet?.packageService,
packagePolicyService: plugins.fleet?.packagePolicyService,
agentPolicyService: plugins.fleet?.agentPolicyService,
appClientFactory: this.appClientFactory,
security: this.setupPlugins!.security!,
alerts: plugins.alerts,