[Security Solution][Hosts] Show Fleet Agent status and Isolation status for Endpoint Hosts when on the Host Details page (#103781)

* Refactor: extract agent status to endpoint host status to reusable utiltiy
* Show Fleet Agent status + isolation status
* Refactor EndpoinAgentStatus component to use `<AgentStatus>` common component
* Move actions service to `endpoint/services` directory
* Add pending actions to the search strategy for endpoint data
This commit is contained in:
Paul Tavares 2021-06-30 18:29:25 -04:00 committed by GitHub
parent a3f86bda3e
commit aa5c56c418
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 233 additions and 155 deletions

View file

@ -8,6 +8,7 @@
import { CloudEcs } from '../../../../ecs/cloud';
import { HostEcs, OsEcs } from '../../../../ecs/host';
import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../../common';
import { EndpointPendingActions, HostStatus } from '../../../../endpoint/types';
export enum HostPolicyResponseActionStatus {
success = 'success',
@ -25,6 +26,11 @@ export interface EndpointFields {
endpointPolicy?: Maybe<string>;
sensorVersion?: Maybe<string>;
policyStatus?: Maybe<HostPolicyResponseActionStatus>;
/** if the host is currently isolated */
isolation?: Maybe<boolean>;
/** A count of pending endpoint actions against the host */
pendingActions?: Maybe<EndpointPendingActions['pending_actions']>;
elasticAgentStatus?: Maybe<HostStatus>;
id?: Maybe<string>;
}

View file

@ -6,14 +6,13 @@
*/
import React, { memo } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { HostInfo, HostMetadata } from '../../../../../../common/endpoint/types';
import { HOST_STATUS_TO_BADGE_COLOR } from '../host_constants';
import { EndpointHostIsolationStatus } from '../../../../../common/components/endpoint/host_isolation';
import { useEndpointSelector } from '../hooks';
import { getEndpointHostIsolationStatusPropsCallback } from '../../store/selectors';
import { AgentStatus } from '../../../../../common/components/endpoint/agent_status';
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
.isolation-status {
@ -34,16 +33,7 @@ export const EndpointAgentStatus = memo<EndpointAgentStatusProps>(
return (
<EuiFlexGroupStyled gutterSize="none" responsive={false} className="eui-textTruncate">
<EuiFlexItem grow={false}>
<EuiBadge
color={hostStatus != null ? HOST_STATUS_TO_BADGE_COLOR[hostStatus] : 'warning'}
data-test-subj="rowHostStatus"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.list.hostStatusValue"
defaultMessage="{hostStatus, select, healthy {Healthy} unhealthy {Unhealthy} updating {Updating} offline {Offline} inactive {Inactive} other {Unhealthy}}"
values={{ hostStatus }}
/>
</EuiBadge>
<AgentStatus hostStatus={hostStatus} />
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textTruncate isolation-status">
<EndpointHostIsolationStatus

View file

@ -13,42 +13,71 @@ import '../../../../common/mock/react_beautiful_dnd';
import { TestProviders } from '../../../../common/mock';
import { EndpointOverview } from './index';
import { HostPolicyResponseActionStatus } from '../../../../../common/search_strategy/security_solution/hosts';
import {
EndpointFields,
HostPolicyResponseActionStatus,
} from '../../../../../common/search_strategy/security_solution/hosts';
import { HostStatus } from '../../../../../common/endpoint/types';
jest.mock('../../../../common/lib/kibana');
describe('EndpointOverview Component', () => {
test('it renders with endpoint data', () => {
const endpointData = {
let endpointData: EndpointFields;
let wrapper: ReturnType<typeof mount>;
let findData: ReturnType<typeof wrapper['find']>;
const render = (data: EndpointFields | null = endpointData) => {
wrapper = mount(
<TestProviders>
<EndpointOverview data={data} />
</TestProviders>
);
findData = wrapper.find(
'dl[data-test-subj="endpoint-overview"] dd.euiDescriptionList__description'
);
return wrapper;
};
beforeEach(() => {
endpointData = {
endpointPolicy: 'demo',
policyStatus: HostPolicyResponseActionStatus.success,
sensorVersion: '7.9.0-SNAPSHOT',
isolation: false,
elasticAgentStatus: HostStatus.HEALTHY,
pendingActions: {},
};
const wrapper = mount(
<TestProviders>
<EndpointOverview data={endpointData} />
</TestProviders>
);
});
const findData = wrapper.find(
'dl[data-test-subj="endpoint-overview"] dd.euiDescriptionList__description'
);
test('it renders with endpoint data', () => {
render();
expect(findData.at(0).text()).toEqual(endpointData.endpointPolicy);
expect(findData.at(1).text()).toEqual(endpointData.policyStatus);
expect(findData.at(2).text()).toContain(endpointData.sensorVersion); // contain because drag adds a space
expect(findData.at(3).text()).toEqual('Healthy');
});
test('it renders with null data', () => {
const wrapper = mount(
<TestProviders>
<EndpointOverview data={null} />
</TestProviders>
);
const findData = wrapper.find(
'dl[data-test-subj="endpoint-overview"] dd.euiDescriptionList__description'
);
test('it renders with null data', () => {
render(null);
expect(findData.at(0).text()).toEqual('—');
expect(findData.at(1).text()).toEqual('—');
expect(findData.at(2).text()).toContain('—'); // contain because drag adds a space
expect(findData.at(3).text()).toEqual('—');
});
test('it shows isolation status', () => {
endpointData.isolation = true;
render();
expect(findData.at(3).text()).toEqual('HealthyIsolated');
});
test.each([
['isolate', 'Isolating'],
['unisolate', 'Releasing'],
])('it shows pending %s status', (action, expectedLabel) => {
endpointData.isolation = true;
endpointData.pendingActions![action] = 1;
render();
expect(findData.at(3).text()).toEqual(`Healthy${expectedLabel}`);
});
});

View file

@ -18,6 +18,8 @@ import {
EndpointFields,
HostPolicyResponseActionStatus,
} from '../../../../../common/search_strategy/security_solution/hosts';
import { AgentStatus } from '../../../../common/components/endpoint/agent_status';
import { EndpointHostIsolationStatus } from '../../../../common/components/endpoint/host_isolation';
interface Props {
contextID?: string;
@ -73,7 +75,24 @@ export const EndpointOverview = React.memo<Props>(({ contextID, data }) => {
: getEmptyTagValue(),
},
],
[], // needs 4 columns for design
[
{
title: i18n.FLEET_AGENT_STATUS,
description:
data != null && data.elasticAgentStatus ? (
<>
<AgentStatus hostStatus={data.elasticAgentStatus} />
<EndpointHostIsolationStatus
isIsolated={Boolean(data.isolation)}
pendingIsolate={data.pendingActions?.isolate ?? 0}
pendingUnIsolate={data.pendingActions?.unisolate ?? 0}
/>
</>
) : (
getEmptyTagValue()
),
},
],
],
[data, getDefaultRenderer]
);

View file

@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n';
export const ENDPOINT_POLICY = i18n.translate(
'xpack.securitySolution.host.details.endpoint.endpointPolicy',
{
defaultMessage: 'Integration',
defaultMessage: 'Endpoint integration policy',
}
);
@ -24,6 +24,13 @@ export const POLICY_STATUS = i18n.translate(
export const SENSORVERSION = i18n.translate(
'xpack.securitySolution.host.details.endpoint.sensorversion',
{
defaultMessage: 'Sensor Version',
defaultMessage: 'Endpoint version',
}
);
export const FLEET_AGENT_STATUS = i18n.translate(
'xpack.securitySolution.host.details.endpoint.fleetAgentStatus',
{
defaultMessage: 'Agent status',
}
);

View file

@ -10,7 +10,7 @@ import {
EndpointActionLogRequestParams,
EndpointActionLogRequestQuery,
} from '../../../../common/endpoint/schema/actions';
import { getAuditLogResponse } from './service';
import { getAuditLogResponse } from '../../services';
import { SecuritySolutionRequestHandlerContext } from '../../../types';
import { EndpointAppContext } from '../../types';

View file

@ -5,15 +5,8 @@
* 2.0.
*/
import { ElasticsearchClient, RequestHandler } from 'kibana/server';
import { RequestHandler } from 'kibana/server';
import { TypeOf } from '@kbn/config-schema';
import { SearchRequest } from '@elastic/elasticsearch/api/types';
import {
EndpointAction,
EndpointActionResponse,
EndpointPendingActions,
} from '../../../../common/endpoint/types';
import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common';
import { ActionStatusRequestSchema } from '../../../../common/endpoint/schema/actions';
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
import {
@ -21,6 +14,7 @@ import {
SecuritySolutionRequestHandlerContext,
} from '../../../types';
import { EndpointAppContext } from '../../types';
import { getPendingActionCounts } from '../../services';
/**
* Registers routes for checking status of endpoints based on pending actions
@ -53,7 +47,7 @@ export const actionStatusRequestHandler = function (
? [...new Set(req.query.agent_ids)]
: [req.query.agent_ids];
const response = await getPendingActions(esClient, agentIDs);
const response = await getPendingActionCounts(esClient, agentIDs);
return res.ok({
body: {
@ -62,94 +56,3 @@ export const actionStatusRequestHandler = function (
});
};
};
const getPendingActions = async (
esClient: ElasticsearchClient,
agentIDs: string[]
): Promise<EndpointPendingActions[]> => {
// retrieve the unexpired actions for the given hosts
const recentActions = await searchUntilEmpty<EndpointAction>(esClient, {
index: AGENT_ACTIONS_INDEX,
body: {
query: {
bool: {
filter: [
{ term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children
{ term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions
{ range: { expiration: { gte: 'now' } } }, // that have not expired yet
{ terms: { agents: agentIDs } }, // for the requested agent IDs
],
},
},
},
});
// retrieve any responses to those action IDs from these agents
const actionIDs = recentActions.map((a) => a.action_id);
const responses = await searchUntilEmpty<EndpointActionResponse>(esClient, {
index: '.fleet-actions-results',
body: {
query: {
bool: {
filter: [
{ terms: { action_id: actionIDs } }, // get results for these actions
{ terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for
],
},
},
},
});
// respond with action-count per agent
const pending: EndpointPendingActions[] = agentIDs.map((aid) => {
const responseIDsFromAgent = responses
.filter((r) => r.agent_id === aid)
.map((r) => r.action_id);
return {
agent_id: aid,
pending_actions: recentActions
.filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id))
.map((a) => a.data.command)
.reduce((acc, cur) => {
if (cur in acc) {
acc[cur] += 1;
} else {
acc[cur] = 1;
}
return acc;
}, {} as EndpointPendingActions['pending_actions']),
};
});
return pending;
};
const searchUntilEmpty = async <T>(
esClient: ElasticsearchClient,
query: SearchRequest,
pageSize: number = 1000
): Promise<T[]> => {
const results: T[] = [];
for (let i = 0; ; i++) {
const result = await esClient.search<T>(
{
size: pageSize,
from: i * pageSize,
...query,
},
{
ignore: [404],
}
);
if (!result || !result.body?.hits?.hits || result.body?.hits?.hits?.length === 0) {
break;
}
const response = result.body?.hits?.hits?.map((a) => a._source!) || [];
results.push(...response);
}
return results;
};

View file

@ -25,13 +25,14 @@ import {
import type { SecuritySolutionRequestHandlerContext } from '../../../types';
import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders';
import { Agent, AgentStatus, PackagePolicy } from '../../../../../fleet/common/types/models';
import { Agent, PackagePolicy } from '../../../../../fleet/common/types/models';
import { AgentNotFoundError } from '../../../../../fleet/server';
import { EndpointAppContext, HostListQueryResult } from '../../types';
import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index';
import { findAllUnenrolledAgentIds } from './support/unenroll';
import { findAgentIDsByStatus } from './support/agent_status';
import { EndpointAppContextService } from '../../endpoint_app_context_services';
import { fleetAgentStatusToEndpointHostStatus } from '../../utils';
export interface MetadataRequestContext {
esClient?: IScopedClusterClient;
@ -41,18 +42,6 @@ export interface MetadataRequestContext {
savedObjectsClient?: SavedObjectsClientContract;
}
const HOST_STATUS_MAPPING = new Map<AgentStatus, HostStatus>([
['online', HostStatus.HEALTHY],
['offline', HostStatus.OFFLINE],
['inactive', HostStatus.INACTIVE],
['unenrolling', HostStatus.UPDATING],
['enrolling', HostStatus.UPDATING],
['updating', HostStatus.UPDATING],
['warning', HostStatus.UNHEALTHY],
['error', HostStatus.UNHEALTHY],
['degraded', HostStatus.UNHEALTHY],
]);
/**
* 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured
* 11111111-1111-1111-1111-111111111111 is Elastic Agent id sent by Endpoint when policy does not contain an id
@ -375,7 +364,7 @@ export async function enrichHostMetadata(
const status = await metadataRequestContext.endpointAppContextService
?.getAgentService()
?.getAgentStatusById(esClient.asCurrentUser, elasticAgentId);
hostStatus = HOST_STATUS_MAPPING.get(status!) || HostStatus.UNHEALTHY;
hostStatus = fleetAgentStatusToEndpointHostStatus(status!);
} catch (e) {
if (e instanceof AgentNotFoundError) {
log.warn(`agent with id ${elasticAgentId} not found`);

View file

@ -6,9 +6,14 @@
*/
import { ElasticsearchClient, Logger } from 'kibana/server';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../../fleet/common';
import { SecuritySolutionRequestHandlerContext } from '../../../types';
import { ActivityLog, EndpointAction } from '../../../../common/endpoint/types';
import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common';
import { SecuritySolutionRequestHandlerContext } from '../../types';
import {
ActivityLog,
EndpointAction,
EndpointActionResponse,
EndpointPendingActions,
} from '../../../common/endpoint/types';
export const getAuditLogResponse = async ({
elasticAgentId,
@ -135,3 +140,78 @@ const getActivityLog = async ({
return sortedData;
};
export const getPendingActionCounts = async (
esClient: ElasticsearchClient,
agentIDs: string[]
): Promise<EndpointPendingActions[]> => {
// retrieve the unexpired actions for the given hosts
const recentActions = await esClient
.search<EndpointAction>(
{
index: AGENT_ACTIONS_INDEX,
size: 10000,
from: 0,
body: {
query: {
bool: {
filter: [
{ term: { type: 'INPUT_ACTION' } }, // actions that are directed at agent children
{ term: { input_type: 'endpoint' } }, // filter for agent->endpoint actions
{ range: { expiration: { gte: 'now' } } }, // that have not expired yet
{ terms: { agents: agentIDs } }, // for the requested agent IDs
],
},
},
},
},
{ ignore: [404] }
)
.then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []);
// retrieve any responses to those action IDs from these agents
const actionIDs = recentActions.map((a) => a.action_id);
const responses = await esClient
.search<EndpointActionResponse>(
{
index: AGENT_ACTIONS_RESULTS_INDEX,
size: 10000,
from: 0,
body: {
query: {
bool: {
filter: [
{ terms: { action_id: actionIDs } }, // get results for these actions
{ terms: { agent_id: agentIDs } }, // ignoring responses from agents we're not looking for
],
},
},
},
},
{ ignore: [404] }
)
.then((result) => result.body?.hits?.hits?.map((a) => a._source!) || []);
// respond with action-count per agent
const pending: EndpointPendingActions[] = agentIDs.map((aid) => {
const responseIDsFromAgent = responses
.filter((r) => r.agent_id === aid)
.map((r) => r.action_id);
return {
agent_id: aid,
pending_actions: recentActions
.filter((a) => a.agents.includes(aid) && !responseIDsFromAgent.includes(a.action_id))
.map((a) => a.data.command)
.reduce((acc, cur) => {
if (cur in acc) {
acc[cur] += 1;
} else {
acc[cur] = 1;
}
return acc;
}, {} as EndpointPendingActions['pending_actions']),
};
});
return pending;
};

View file

@ -7,3 +7,4 @@
export * from './artifacts';
export { getMetadataForEndpoints } from './metadata';
export * from './actions';

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AgentStatus } from '../../../../fleet/common';
import { HostStatus } from '../../../common/endpoint/types';
const STATUS_MAPPING: ReadonlyMap<AgentStatus, HostStatus> = new Map([
['online', HostStatus.HEALTHY],
['offline', HostStatus.OFFLINE],
['inactive', HostStatus.INACTIVE],
['unenrolling', HostStatus.UPDATING],
['enrolling', HostStatus.UPDATING],
['updating', HostStatus.UPDATING],
['warning', HostStatus.UNHEALTHY],
['error', HostStatus.UNHEALTHY],
['degraded', HostStatus.UNHEALTHY],
]);
/**
* A Map of Fleet Agent Status to Endpoint Host Status.
* Default status is `HostStatus.UNHEALTHY`
*/
export const fleetAgentStatusToEndpointHostStatus = (status: AgentStatus): HostStatus => {
return STATUS_MAPPING.get(status) || HostStatus.UNHEALTHY;
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './fleet_agent_status_to_endpoint_host_status';

View file

@ -24,6 +24,8 @@ import {
import { toObjectArrayOfStrings } from '../../../../../../common/utils/to_array';
import { getHostMetaData } from '../../../../../endpoint/routes/metadata/handlers';
import { EndpointAppContext } from '../../../../../endpoint/types';
import { fleetAgentStatusToEndpointHostStatus } from '../../../../../endpoint/utils';
import { getPendingActionCounts } from '../../../../../endpoint/services';
export const HOST_FIELDS = [
'_id',
@ -200,15 +202,30 @@ export const getHostEndpoint = async (
? await getHostMetaData(metadataRequestContext, id, undefined)
: null;
const fleetAgentId = endpointData?.metadata.elastic.agent.id;
const [fleetAgentStatus, pendingActions] = !fleetAgentId
? [undefined, {}]
: await Promise.all([
// Get Agent Status
agentService.getAgentStatusById(esClient.asCurrentUser, fleetAgentId),
// Get a list of pending actions (if any)
getPendingActionCounts(esClient.asCurrentUser, [fleetAgentId]).then((results) => {
return results[0].pending_actions;
}),
]);
return endpointData != null && endpointData.metadata
? {
endpointPolicy: endpointData.metadata.Endpoint.policy.applied.name,
policyStatus: endpointData.metadata.Endpoint.policy.applied.status,
sensorVersion: endpointData.metadata.agent.version,
elasticAgentStatus: fleetAgentStatusToEndpointHostStatus(fleetAgentStatus!),
isolation: endpointData.metadata.Endpoint.state?.isolation ?? false,
pendingActions,
}
: null;
} catch (err) {
logger.warn(JSON.stringify(err, null, 2));
logger.warn(err);
return null;
}
};