From d77adb75620b7a14e6c5154317a069ca487747db Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 27 Apr 2020 11:15:15 -0400 Subject: [PATCH] [Endpoint] Show Policy Status on Host Details using Policy Response API (#64116) * Add policyStatus to store + drive UI color/label from status value * Add Middleware placeholder to handle API call (once available) --- .../plugins/endpoint/common/generate_data.ts | 117 +++++++++++++++++- x-pack/plugins/endpoint/common/types.ts | 100 +++++++++++++++ .../endpoint/store/hosts/action.ts | 10 +- .../endpoint/store/hosts/middleware.ts | 16 +++ .../endpoint/store/hosts/reducer.ts | 6 + .../endpoint/store/hosts/selectors.ts | 10 ++ .../public/applications/endpoint/types.ts | 3 + .../view/hosts/details/host_details.tsx | 26 +++- .../endpoint/view/hosts/index.test.tsx | 72 ++++++++++- 9 files changed, 349 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 1978d780f54f5..4f5a6eaeb0a5e 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -6,7 +6,16 @@ import uuid from 'uuid'; import seedrandom from 'seedrandom'; -import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields, PolicyData } from './types'; +import { + AlertEvent, + EndpointEvent, + HostFields, + HostMetadata, + OSFields, + PolicyData, + HostPolicyResponse, + HostPolicyResponseActionStatus, +} from './types'; import { factory as policyFactory } from './models/policy_config'; export type Event = AlertEvent | EndpointEvent; @@ -486,6 +495,112 @@ export class EndpointDocGenerator { }; } + /** + * Generates a Host Policy response message + */ + generatePolicyResponse(): HostPolicyResponse { + return { + '@timestamp': new Date().toISOString(), + elastic: { + agent: { + id: 'c2a9093e-e289-4c0a-aa44-8c32a414fa7a', + }, + }, + ecs: { + version: '1.0.0', + }, + event: { + created: '2015-01-01T12:10:30Z', + kind: 'policy_response', + }, + agent: { + version: '6.0.0-rc2', + id: '8a4f500d', + }, + endpoint: { + artifacts: { + 'global-manifest': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'endpointpe-v4-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'user-whitelist-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + 'global-whitelist-windows': { + version: '1.2.3', + sha256: 'abcdef', + }, + }, + policy: { + applied: { + version: '1.0.0', + id: '17d4b81d-9940-4b64-9de5-3e03ef1fb5cf', + status: HostPolicyResponseActionStatus.success, + response: { + configurations: { + malware: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['download_model', 'workflow', 'a_custom_future_action'], + }, + events: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['ingest_events_config', 'workflow'], + }, + logging: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: ['configure_elasticsearch_connection'], + }, + streaming: { + status: HostPolicyResponseActionStatus.success, + concerned_actions: [ + 'detect_file_open_events', + 'download_global_artifacts', + 'a_custom_future_action', + ], + }, + }, + actions: { + download_model: { + status: HostPolicyResponseActionStatus.success, + message: 'model downloaded', + }, + ingest_events_config: { + status: HostPolicyResponseActionStatus.success, + message: 'no action taken', + }, + workflow: { + status: HostPolicyResponseActionStatus.success, + message: 'the flow worked well', + }, + a_custom_future_action: { + status: HostPolicyResponseActionStatus.success, + message: 'future message', + }, + configure_elasticsearch_connection: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + detect_file_open_events: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + download_global_artifacts: { + status: HostPolicyResponseActionStatus.success, + message: 'some message', + }, + }, + }, + }, + }, + }, + }; + } + private randomN(n: number): number { return Math.floor(this.random() * n); } diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 49f8ebbd580d8..4da4f9bc797ca 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -573,3 +573,103 @@ export type NewPolicyData = NewDatasource & { } ]; }; + +/** + * the possible status for actions, configurations and overall Policy Response + */ +export enum HostPolicyResponseActionStatus { + success = 'success', + failure = 'failure', + warning = 'warning', +} + +/** + * The details of a given action + */ +interface HostPolicyResponseActionDetails { + status: HostPolicyResponseActionStatus; + message: string; +} + +/** + * A known list of possible Endpoint actions + */ +interface HostPolicyResponseActions { + download_model: HostPolicyResponseActionDetails; + ingest_events_config: HostPolicyResponseActionDetails; + workflow: HostPolicyResponseActionDetails; + configure_elasticsearch_connection: HostPolicyResponseActionDetails; + configure_kernel: HostPolicyResponseActionDetails; + configure_logging: HostPolicyResponseActionDetails; + configure_malware: HostPolicyResponseActionDetails; + connect_kernel: HostPolicyResponseActionDetails; + detect_file_open_events: HostPolicyResponseActionDetails; + detect_file_write_events: HostPolicyResponseActionDetails; + detect_image_load_events: HostPolicyResponseActionDetails; + detect_process_events: HostPolicyResponseActionDetails; + download_global_artifacts: HostPolicyResponseActionDetails; + load_config: HostPolicyResponseActionDetails; + load_malware_model: HostPolicyResponseActionDetails; + read_elasticsearch_config: HostPolicyResponseActionDetails; + read_events_config: HostPolicyResponseActionDetails; + read_kernel_config: HostPolicyResponseActionDetails; + read_logging_config: HostPolicyResponseActionDetails; + read_malware_config: HostPolicyResponseActionDetails; + // The list of possible Actions will change rapidly, so the below entry will allow + // them without us defining them here statically + [key: string]: HostPolicyResponseActionDetails; +} + +interface HostPolicyResponseConfigurationStatus { + status: HostPolicyResponseActionStatus; + concerned_actions: Array; +} + +/** + * Information about the applying of a policy to a given host + */ +export interface HostPolicyResponse { + '@timestamp': string; + elastic: { + agent: { + id: string; + }; + }; + ecs: { + version: string; + }; + event: { + created: string; + kind: string; + }; + agent: { + version: string; + id: string; + }; + endpoint: { + artifacts: {}; + policy: { + applied: { + version: string; + id: string; + status: HostPolicyResponseActionStatus; + response: { + configurations: { + malware: HostPolicyResponseConfigurationStatus; + events: HostPolicyResponseConfigurationStatus; + logging: HostPolicyResponseConfigurationStatus; + streaming: HostPolicyResponseConfigurationStatus; + }; + actions: Partial; + }; + }; + }; + }; +} + +/** + * REST API response for retrieving a host's Policy Response status + */ +export interface GetHostPolicyResponse { + policy_response: HostPolicyResponse; +} diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts index 56a49df3bdab4..16a1f96c926b8 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/action.ts @@ -5,7 +5,7 @@ */ import { ServerApiError } from '../../types'; -import { HostResultList, HostInfo } from '../../../../../common/types'; +import { HostResultList, HostInfo, GetHostPolicyResponse } from '../../../../../common/types'; interface ServerReturnedHostList { type: 'serverReturnedHostList'; @@ -27,8 +27,14 @@ interface ServerFailedToReturnHostDetails { payload: ServerApiError; } +interface ServerReturnedHostPolicyResponse { + type: 'serverReturnedHostPolicyResponse'; + payload: GetHostPolicyResponse; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList | ServerReturnedHostDetails - | ServerFailedToReturnHostDetails; + | ServerFailedToReturnHostDetails + | ServerReturnedHostPolicyResponse; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts index bb1cfc4dd10af..d1b9a2cde4b31 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/middleware.ts @@ -8,6 +8,7 @@ import { HostResultList } from '../../../../../common/types'; import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors'; import { HostState } from '../../types'; import { ImmutableMiddlewareFactory } from '../../types'; +import { HostPolicyResponse } from '../../../../../common/types'; export const hostMiddlewareFactory: ImmutableMiddlewareFactory = coreStart => { return ({ getState, dispatch }) => next => async action => { @@ -69,6 +70,21 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = core type: 'serverReturnedHostDetails', payload: response, }); + // FIXME: once we have the API implementation in place, we should call it parallel with the above api call and then dispatch this with the results of the second call + dispatch({ + type: 'serverReturnedHostPolicyResponse', + payload: { + policy_response: ({ + endpoint: { + policy: { + applied: { + status: 'success', + }, + }, + }, + } as unknown) as HostPolicyResponse, // Temporary until we get API + }, + }); } catch (error) { dispatch({ type: 'serverFailedToReturnHostDetails', diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts index adf18fa50c24f..eb74c40ff3687 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/reducer.ts @@ -20,6 +20,7 @@ const initialState = (): HostState => { details: undefined, detailsLoading: false, detailsError: undefined, + policyResponse: undefined, location: undefined, }; }; @@ -63,6 +64,11 @@ export const hostListReducer: ImmutableReducer = ( detailsError: action.payload, detailsLoading: false, }; + } else if (action.type === 'serverReturnedHostPolicyResponse') { + return { + ...state, + policyResponse: action.payload.policy_response, + }; } else if (action.type === 'userChangedUrl') { const newState: Immutable = { ...state, diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts index b0f949ebbe757..b0711baf9cdff 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/hosts/selectors.ts @@ -86,3 +86,13 @@ export const showView: (state: HostState) => 'policy_response' | 'details' = cre return searchParams.show === 'policy_response' ? 'policy_response' : 'details'; } ); + +/** + * Returns the Policy Response overall status + */ +export const policyResponseStatus: (state: Immutable) => string = createSelector( + state => state.policyResponse, + policyResponse => { + return (policyResponse && policyResponse?.endpoint?.policy?.applied?.status) || ''; + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index e5e600f6c6288..0598ce5f38efa 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -22,6 +22,7 @@ import { MalwareFields, UIPolicyConfig, PolicyData, + HostPolicyResponse, } from '../../../common/types'; import { EndpointPluginStartDependencies } from '../../plugin'; import { AppAction } from './store/action'; @@ -107,6 +108,8 @@ export interface HostState { detailsLoading: boolean; /** api error from retrieving host details */ detailsError?: ServerApiError; + /** Holds the Policy Response for the Host currently being displayed in the details */ + policyResponse?: HostPolicyResponse; /** current location info */ location?: Immutable; } diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx index e4da39d50304a..7d948f54bd0bc 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/host_details.tsx @@ -16,12 +16,12 @@ import { import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { HostMetadata } from '../../../../../../common/types'; +import { HostMetadata, HostPolicyResponseActionStatus } from '../../../../../../common/types'; import { FormattedDateAndTime } from '../../formatted_date_time'; import { LinkToApp } from '../../components/link_to_app'; import { useHostSelector, useHostLogsUrl } from '../hooks'; import { urlFromQueryParams } from '../url_from_query_params'; -import { uiQueryParams } from '../../../store/hosts/selectors'; +import { policyResponseStatus, uiQueryParams } from '../../../store/hosts/selectors'; import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler'; const HostIds = styled(EuiListGroupItem)` @@ -31,9 +31,20 @@ const HostIds = styled(EuiListGroupItem)` } `; +const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< + { [key in keyof typeof HostPolicyResponseActionStatus]: string } +>({ + success: 'success', + warning: 'warning', + failure: 'danger', +}); + export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const { appId, appPath, url } = useHostLogsUrl(details.host.id); const queryParams = useHostSelector(uiQueryParams); + const policyStatus = useHostSelector( + policyResponseStatus + ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; const detailsResultsUpper = useMemo(() => { return [ { @@ -79,7 +90,10 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { defaultMessage: 'Policy Status', }), description: ( - + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { onClick={policyStatusClickHandler} > @@ -126,6 +141,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { details.host.ip, policyResponseUri.search, policyStatusClickHandler, + policyStatus, ]); return ( diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx index 11dbed716c527..499efb4f4b8ed 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/index.test.tsx @@ -14,9 +14,11 @@ import { mockHostResultList, } from '../../store/hosts/mock_host_result_list'; import { AppContextTestRender, createAppRootMockRenderer } from '../../mocks'; -import { HostInfo } from '../../../../../common/types'; +import { HostInfo, HostPolicyResponseActionStatus } from '../../../../../common/types'; +import { EndpointDocGenerator } from '../../../../../common/generate_data'; describe('when on the hosts page', () => { + const docGenerator = new EndpointDocGenerator(); let render: () => ReturnType; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; @@ -91,6 +93,19 @@ describe('when on the hosts page', () => { describe('when there is a selected host in the url', () => { let hostDetails: HostInfo; + const dispatchServerReturnedHostPolicyResponse = ( + overallStatus: HostPolicyResponseActionStatus = HostPolicyResponseActionStatus.success + ) => { + const policyResponse = docGenerator.generatePolicyResponse(); + policyResponse.endpoint.policy.applied.status = overallStatus; + store.dispatch({ + type: 'serverReturnedHostPolicyResponse', + payload: { + policy_response: policyResponse, + }, + }); + }; + beforeEach(() => { const { host_status, @@ -137,7 +152,6 @@ describe('when on the hosts page', () => { const renderResult = render(); const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); expect(policyStatusLink).not.toBeNull(); - expect(policyStatusLink.textContent).toEqual('Successful'); expect(policyStatusLink.getAttribute('href')).toEqual( '?page_index=0&page_size=10&selected_host=1&show=policy_response' ); @@ -154,6 +168,58 @@ describe('when on the hosts page', () => { '?page_index=0&page_size=10&selected_host=1&show=policy_response' ); }); + it('should display Success overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.success); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Success'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="success"]') + ).not.toBeNull(); + }); + it('should display Warning overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Warning'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="warning"]') + ).not.toBeNull(); + }); + it('should display Failed overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Failed'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="danger"]') + ).not.toBeNull(); + }); + it('should display Unknown overall policy status', async () => { + const renderResult = render(); + reactTestingLibrary.act(() => { + dispatchServerReturnedHostPolicyResponse('' as HostPolicyResponseActionStatus); + }); + const policyStatusLink = await renderResult.findByTestId('policyStatusValue'); + expect(policyStatusLink.textContent).toEqual('Unknown'); + + const policyStatusHealth = await renderResult.findByTestId('policyStatusHealth'); + expect( + policyStatusHealth.querySelector('[data-euiicon-type][color="subdued"]') + ).not.toBeNull(); + }); it('should include the link to logs', async () => { const renderResult = render(); const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs'); @@ -176,7 +242,7 @@ describe('when on the hosts page', () => { expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1); }); }); - describe('when showing host Policy Response', () => { + describe('when showing host Policy Response panel', () => { let renderResult: ReturnType; beforeEach(async () => { renderResult = render();