Task/policy response (#64838)

host details policy response
This commit is contained in:
Candace Park 2020-05-06 09:02:48 -04:00 committed by GitHub
parent a06c02f606
commit ce0d39bfdf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 501 additions and 31 deletions

View file

@ -560,7 +560,7 @@ export class EndpointDocGenerator {
applied: {
actions: {
configure_elasticsearch_connection: {
message: 'elasticsearch comms configured successfully',
message: 'elasticsearch comes configured successfully',
status: HostPolicyResponseActionStatus.success,
},
configure_kernel: {
@ -648,7 +648,7 @@ export class EndpointDocGenerator {
response: {
configurations: {
events: {
concerned_actions: this.randomHostPolicyResponseActions(),
concerned_actions: ['download_model'],
status: this.randomHostPolicyResponseActionStatus(),
},
logging: {

View file

@ -25,7 +25,7 @@ export type Immutable<T> = T extends undefined | null | boolean | string | numbe
? ImmutableSet<M>
: ImmutableObject<T>;
type ImmutableArray<T> = ReadonlyArray<Immutable<T>>;
export type ImmutableArray<T> = ReadonlyArray<Immutable<T>>;
type ImmutableMap<K, V> = ReadonlyMap<Immutable<K>, Immutable<V>>;
type ImmutableSet<T> = ReadonlySet<Immutable<T>>;
type ImmutableObject<T> = { readonly [K in keyof T]: Immutable<T[K]> };
@ -644,6 +644,8 @@ export interface HostPolicyResponseActions {
read_malware_config: HostPolicyResponseActionDetails;
}
export type HostPolicyResponseConfiguration = HostPolicyResponse['endpoint']['policy']['applied']['response']['configurations'];
interface HostPolicyResponseConfigurationStatus {
status: HostPolicyResponseActionStatus;
concerned_actions: Array<keyof HostPolicyResponseActions>;

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { HostResultList } from '../../../../../common/types';
import { HostResultList, HostPolicyResponseActionStatus } from '../../../../../common/types';
import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors';
import { HostState } from '../../types';
import { ImmutableMiddlewareFactory } from '../../types';
@ -77,7 +77,31 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostState> = core
endpoint: {
policy: {
applied: {
status: 'success',
version: '1.0.0',
status: HostPolicyResponseActionStatus.success,
id: '17d4b81d-9940-4b64-9de5-3e03ef1fb5cf',
actions: {
download_model: {
status: 'success',
message: 'Model downloaded',
},
ingest_events_config: {
status: 'failure',
message: 'No action taken',
},
},
response: {
configurations: {
malware: {
status: 'success',
concerned_actions: ['download_model'],
},
events: {
status: 'failure',
concerned_actions: ['ingest_events_config'],
},
},
},
},
},
},

View file

@ -5,7 +5,12 @@
*/
import querystring from 'querystring';
import { createSelector } from 'reselect';
import { Immutable } from '../../../../../common/types';
import {
Immutable,
HostPolicyResponseActions,
HostPolicyResponseConfiguration,
HostPolicyResponseActionStatus,
} from '../../../../../common/types';
import { HostState, HostIndexUIQueryParams } from '../../types';
const PAGE_SIZES = Object.freeze([10, 20, 50]);
@ -28,6 +33,61 @@ export const detailsLoading = (state: Immutable<HostState>): boolean => state.de
export const detailsError = (state: Immutable<HostState>) => state.detailsError;
/**
* Returns the full policy response from the endpoint after a user modifies a policy.
*/
const detailsPolicyAppliedResponse = (state: Immutable<HostState>) =>
state.policyResponse && state.policyResponse.endpoint.policy.applied;
/**
* Returns the response configurations from the endpoint after a user modifies a policy.
*/
export const policyResponseConfigurations: (
state: Immutable<HostState>
) => undefined | Immutable<HostPolicyResponseConfiguration> = createSelector(
detailsPolicyAppliedResponse,
applied => {
return applied?.response?.configurations;
}
);
/**
* Returns a map of the number of failed and warning policy response actions per configuration.
*/
export const policyResponseFailedOrWarningActionCount: (
state: Immutable<HostState>
) => Map<string, number> = createSelector(detailsPolicyAppliedResponse, applied => {
const failureOrWarningByConfigType = new Map<string, number>();
if (applied?.response?.configurations !== undefined && applied?.actions !== undefined) {
Object.entries(applied.response.configurations).map(([key, val]) => {
let count = 0;
for (const action of val.concerned_actions) {
const actionStatus = applied.actions[action]?.status;
if (
actionStatus === HostPolicyResponseActionStatus.failure ||
actionStatus === HostPolicyResponseActionStatus.warning
) {
count += 1;
}
}
return failureOrWarningByConfigType.set(key, count);
});
}
return failureOrWarningByConfigType;
});
/**
* Returns the actions taken by the endpoint for each response configuration after a user modifies a policy.
*/
export const policyResponseActions: (
state: Immutable<HostState>
) => undefined | Partial<HostPolicyResponseActions> = createSelector(
detailsPolicyAppliedResponse,
applied => {
return applied?.actions;
}
);
export const isOnHostPage = (state: Immutable<HostState>) =>
state.location ? state.location.pathname === '/hosts' : false;

View file

@ -9,7 +9,7 @@ import { EuiFlyoutHeader, CommonProps, EuiButtonEmpty } from '@elastic/eui';
import styled from 'styled-components';
export type FlyoutSubHeaderProps = CommonProps & {
children: React.ReactNode;
children?: React.ReactNode;
backButton?: {
title: string;
onClick: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
@ -25,6 +25,9 @@ const StyledEuiFlyoutHeader = styled(EuiFlyoutHeader)`
padding-bottom: ${props => props.theme.eui.paddingSizes.s};
}
.flyoutSubHeaderBackButton {
font-size: ${props => props.theme.eui.euiFontSizeXS};
}
.back-button-content {
padding-left: 0;
&-text {
@ -48,7 +51,7 @@ const BUTTON_TEXT_PROPS = Object.freeze({ className: 'back-button-content-text'
export const FlyoutSubHeader = memo<FlyoutSubHeaderProps>(
({ children, backButton, ...otherProps }) => {
return (
<StyledEuiFlyoutHeader hasBorder {...otherProps} className={backButton && `hasButtons`}>
<StyledEuiFlyoutHeader {...otherProps} className={backButton && `hasButtons`}>
{backButton && (
<div className="buttons">
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
@ -60,6 +63,7 @@ export const FlyoutSubHeader = memo<FlyoutSubHeaderProps>(
size="xs"
href={backButton?.href ?? ''}
onClick={backButton?.onClick}
className="flyoutSubHeaderBackButton"
>
{backButton?.title}
</EuiButtonEmpty>

View file

@ -0,0 +1,15 @@
/*
* 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 { HostPolicyResponseActionStatus } from '../../../../../../common/types';
export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze<
{ [key in keyof typeof HostPolicyResponseActionStatus]: string }
>({
success: 'success',
warning: 'warning',
failure: 'danger',
});

View file

@ -16,13 +16,14 @@ import {
import React, { memo, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { HostMetadata, HostPolicyResponseActionStatus } from '../../../../../../common/types';
import { HostMetadata } 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 { policyResponseStatus, uiQueryParams } from '../../../store/hosts/selectors';
import { useNavigateByRouterEventHandler } from '../../hooks/use_navigate_by_router_event_handler';
import { POLICY_STATUS_TO_HEALTH_COLOR } from './host_constants';
const HostIds = styled(EuiListGroupItem)`
margin-top: 0;
@ -31,14 +32,6 @@ 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);

View file

@ -9,8 +9,9 @@ import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiLoadingContent,
EuiTitle,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { useHistory } from 'react-router-dom';
@ -25,6 +26,9 @@ import {
detailsError,
showView,
detailsLoading,
policyResponseConfigurations,
policyResponseActions,
policyResponseFailedOrWarningActionCount,
} from '../../../store/hosts/selectors';
import { HostDetails } from './host_details';
import { PolicyResponse } from './policy_response';
@ -101,6 +105,9 @@ const PolicyResponseFlyoutPanel = memo<{
hostMeta: HostMetadata;
}>(({ hostMeta }) => {
const { show, ...queryParams } = useHostSelector(uiQueryParams);
const responseConfig = useHostSelector(policyResponseConfigurations);
const responseActionStatus = useHostSelector(policyResponseActions);
const responseAttentionCount = useHostSelector(policyResponseFailedOrWarningActionCount);
const detailsUri = useMemo(
() =>
urlFromQueryParams({
@ -125,18 +132,28 @@ const PolicyResponseFlyoutPanel = memo<{
<FlyoutSubHeader
backButton={backButtonProp}
data-test-subj="hostDetailsPolicyResponseFlyoutHeader"
>
<EuiTitle size="xxs" data-test-subj="hostDetailsPolicyResponseFlyoutTitle">
<h3>
/>
<EuiFlyoutBody data-test-subj="hostDetailsPolicyResponseFlyoutBody">
<EuiText data-test-subj="hostDetailsPolicyResponseFlyoutTitle">
<h4>
<FormattedMessage
id="xpack.endpoint.host.policyResponse.title"
defaultMessage="Policy Response"
/>
</h3>
</EuiTitle>
</FlyoutSubHeader>
<EuiFlyoutBody data-test-subj="hostDetailsPolicyResponseFlyoutBody">
<PolicyResponse />
</h4>
</EuiText>
{responseConfig !== undefined && responseActionStatus !== undefined ? (
<PolicyResponse
responseConfig={responseConfig}
responseActionStatus={responseActionStatus}
responseAttentionCount={responseAttentionCount}
/>
) : (
<FormattedMessage
id="xpack.endpoint.hostDetails.noPolicyResponse"
defaultMessage="No Policy Response Available"
/>
)}
</EuiFlyoutBody>
</>
);

View file

@ -3,8 +3,145 @@
* 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, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import styled from 'styled-components';
import { EuiAccordion, EuiNotificationBadge, EuiHealth } from '@elastic/eui';
import { EuiText } from '@elastic/eui';
import { htmlIdGenerator } from '@elastic/eui';
import {
HostPolicyResponseActions,
HostPolicyResponseConfiguration,
Immutable,
ImmutableArray,
} from '../../../../../../common/types';
import { formatResponse } from './policy_response_friendly_names';
import { POLICY_STATUS_TO_HEALTH_COLOR } from './host_constants';
export const PolicyResponse = memo(() => {
return <div>Policy Status to be displayed here soon.</div>;
});
/**
* Nested accordion in the policy response detailing any concerned
* actions the endpoint took to apply the policy configuration.
*/
const PolicyResponseConfigAccordion = styled(EuiAccordion)`
> .euiAccordion__triggerWrapper {
padding: ${props => props.theme.eui.paddingSizes.s};
}
&.euiAccordion-isOpen {
background-color: ${props => props.theme.eui.euiFocusBackgroundColor};
}
.euiAccordion__childWrapper {
background-color: ${props => props.theme.eui.euiColorLightestShade};
}
.policyResponseAttentionBadge {
background-color: ${props => props.theme.eui.euiColorDanger};
color: ${props => props.theme.eui.euiColorEmptyShade};
}
.euiAccordion__button {
:hover,
:focus {
text-decoration: none;
}
}
:hover:not(.euiAccordion-isOpen) {
background-color: ${props => props.theme.eui.euiColorLightestShade};
}
`;
const ResponseActions = memo(
({
actions,
actionStatus,
}: {
actions: ImmutableArray<keyof HostPolicyResponseActions>;
actionStatus: Partial<HostPolicyResponseActions>;
}) => {
return (
<>
{actions.map((action, index) => {
const statuses = actionStatus[action];
if (statuses === undefined) {
return undefined;
}
return (
<EuiAccordion
id={action + index}
key={action + index}
data-test-subj="hostDetailsPolicyResponseActionsAccordion"
buttonContent={
<EuiText size="xs" data-test-subj="policyResponseAction">
<h4>{formatResponse(action)}</h4>
</EuiText>
}
paddingSize="s"
extraAction={
<EuiHealth
color={POLICY_STATUS_TO_HEALTH_COLOR[statuses.status]}
data-test-subj="policyResponseStatusHealth"
>
<EuiText size="xs">
<p>{formatResponse(statuses.status)}</p>
</EuiText>
</EuiHealth>
}
>
<EuiText size="xs" data-test-subj="policyResponseMessage">
<p>{statuses.message}</p>
</EuiText>
</EuiAccordion>
);
})}
</>
);
}
);
/**
* A policy response is returned by the endpoint and shown in the host details after a user modifies a policy
*/
export const PolicyResponse = memo(
({
responseConfig,
responseActionStatus,
responseAttentionCount,
}: {
responseConfig: Immutable<HostPolicyResponseConfiguration>;
responseActionStatus: Partial<HostPolicyResponseActions>;
responseAttentionCount: Map<string, number>;
}) => {
return (
<>
{Object.entries(responseConfig).map(([key, val]) => {
const attentionCount = responseAttentionCount.get(key);
return (
<PolicyResponseConfigAccordion
id={useMemo(() => htmlIdGenerator()(), [])}
key={useMemo(() => htmlIdGenerator()(), [])}
data-test-subj="hostDetailsPolicyResponseConfigAccordion"
buttonContent={
<EuiText size="s">
<p>{formatResponse(key)}</p>
</EuiText>
}
paddingSize="m"
extraAction={
attentionCount &&
attentionCount > 0 && (
<EuiNotificationBadge
className="policyResponseAttentionBadge"
data-test-subj="hostDetailsPolicyResponseAttentionBadge"
>
{attentionCount}
</EuiNotificationBadge>
)
}
>
<ResponseActions
actions={val.concerned_actions}
actionStatus={responseActionStatus}
/>
</PolicyResponseConfigAccordion>
);
})}
</>
);
}
);

View file

@ -0,0 +1,170 @@
/*
* 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 { i18n } from '@kbn/i18n';
const responseMap = new Map();
responseMap.set(
'success',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.success', {
defaultMessage: 'Success',
})
);
responseMap.set(
'warning',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.warning', {
defaultMessage: 'Warning',
})
);
responseMap.set(
'failure',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.failed', {
defaultMessage: 'Failed',
})
);
responseMap.set(
'malware',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.malware', {
defaultMessage: 'Malware',
})
);
responseMap.set(
'events',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.events', {
defaultMessage: 'Events',
})
);
responseMap.set(
'configure_elasticsearch_connection',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.configureElasticSearchConnection', {
defaultMessage: 'Configure Elastic Search Connection',
})
);
responseMap.set(
'configure_logging',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.configureLogging', {
defaultMessage: 'Configure Logging',
})
);
responseMap.set(
'configure_kernel',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.configureKernel', {
defaultMessage: 'Configure Kernel',
})
);
responseMap.set(
'configure_malware',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.configureMalware', {
defaultMessage: 'Configure Malware',
})
);
responseMap.set(
'connect_kernel',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.connectKernel', {
defaultMessage: 'Connect Kernel',
})
);
responseMap.set(
'detect_file_open_events',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.detectFileOpenEvents', {
defaultMessage: 'Detect File Open Events',
})
);
responseMap.set(
'detect_file_write_events',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.detectFileWriteEvents', {
defaultMessage: 'Detect File Write Events',
})
);
responseMap.set(
'detect_image_load_events',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.detectImageLoadEvents', {
defaultMessage: 'Detect Image Load Events',
})
);
responseMap.set(
'detect_process_events',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.detectProcessEvents', {
defaultMessage: 'Detect Process Events',
})
);
responseMap.set(
'download_global_artifacts',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.downloadGlobalArtifacts', {
defaultMessage: 'Download Global Artifacts',
})
);
responseMap.set(
'load_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.loadConfig', {
defaultMessage: 'Load Config',
})
);
responseMap.set(
'load_malware_model',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.loadMalwareModel', {
defaultMessage: 'Load Malware Model',
})
);
responseMap.set(
'read_elasticsearch_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.readElasticSearchConfig', {
defaultMessage: 'Read ElasticSearch Config',
})
);
responseMap.set(
'read_events_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.readEventsConfig', {
defaultMessage: 'Read Events Config',
})
);
responseMap.set(
'read_kernel_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.readKernelConfig', {
defaultMessage: 'Read Kernel Config',
})
);
responseMap.set(
'read_logging_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.readLoggingConfig', {
defaultMessage: 'Read Logging Config',
})
);
responseMap.set(
'read_malware_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.readMalwareConfig', {
defaultMessage: 'Read Malware Config',
})
);
responseMap.set(
'workflow',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.workflow', {
defaultMessage: 'Workflow',
})
);
responseMap.set(
'download_model',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.downloadModel', {
defaultMessage: 'Download Model',
})
);
responseMap.set(
'ingest_events_config',
i18n.translate('xpack.endpoint.hostDetails.policyResponse.injestEventsConfig', {
defaultMessage: 'Injest Events Config',
})
);
/**
* Takes in the snake-cased response from the API and
* removes the underscores and capitalizes the string.
*/
export function formatResponse(responseString: string) {
if (responseMap.has(responseString)) {
return responseMap.get(responseString);
}
return responseString;
}

View file

@ -25,7 +25,7 @@ describe('when on the hosts page', () => {
let coreStart: AppContextTestRender['coreStart'];
let middlewareSpy: AppContextTestRender['middlewareSpy'];
beforeEach(async () => {
beforeEach(() => {
const mockedContext = createAppRootMockRenderer();
({ history, store, coreStart, middlewareSpy } = mockedContext);
render = () => mockedContext.render(<HostList />);
@ -127,6 +127,14 @@ describe('when on the hosts page', () => {
) => {
const policyResponse = docGenerator.generatePolicyResponse();
policyResponse.endpoint.policy.applied.status = overallStatus;
policyResponse.endpoint.policy.applied.response.configurations.malware.status = overallStatus;
policyResponse.endpoint.policy.applied.actions.download_model!.status = overallStatus;
if (
overallStatus === HostPolicyResponseActionStatus.failure ||
overallStatus === HostPolicyResponseActionStatus.warning
) {
policyResponse.endpoint.policy.applied.actions.download_model!.message = 'no action taken';
}
store.dispatch({
type: 'serverReturnedHostPolicyResponse',
payload: {
@ -281,6 +289,9 @@ describe('when on the hosts page', () => {
fireEvent.click(policyStatusLink);
});
await userChangedUrlChecker;
reactTestingLibrary.act(() => {
dispatchServerReturnedHostPolicyResponse();
});
});
it('should hide the host details panel', async () => {
const hostDetailsFlyout = await renderResult.queryByTestId('hostDetailsFlyoutBody');
@ -299,6 +310,43 @@ describe('when on the hosts page', () => {
(await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent
).toBe('Policy Response');
});
it('should show a configuration section for each protection', async () => {
const configAccordions = await renderResult.findAllByTestId(
'hostDetailsPolicyResponseConfigAccordion'
);
expect(configAccordions).not.toBeNull();
});
it('should show an actions section for each configuration', async () => {
const actionAccordions = await renderResult.findAllByTestId(
'hostDetailsPolicyResponseActionsAccordion'
);
const action = await renderResult.findAllByTestId('policyResponseAction');
const statusHealth = await renderResult.findAllByTestId('policyResponseStatusHealth');
const message = await renderResult.findAllByTestId('policyResponseMessage');
expect(actionAccordions).not.toBeNull();
expect(action).not.toBeNull();
expect(statusHealth).not.toBeNull();
expect(message).not.toBeNull();
});
it('should not show any numbered badges if all actions are succesful', () => {
return renderResult.findByTestId('hostDetailsPolicyResponseAttentionBadge').catch(e => {
expect(e).not.toBeNull();
});
});
it('should show a numbered badge if at least one action failed', () => {
reactTestingLibrary.act(() => {
dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.failure);
});
const attentionBadge = renderResult.findByTestId('hostDetailsPolicyResponseAttentionBadge');
expect(attentionBadge).not.toBeNull();
});
it('should show a numbered badge if at least one action has a warning', () => {
reactTestingLibrary.act(() => {
dispatchServerReturnedHostPolicyResponse(HostPolicyResponseActionStatus.warning);
});
const attentionBadge = renderResult.findByTestId('hostDetailsPolicyResponseAttentionBadge');
expect(attentionBadge).not.toBeNull();
});
it('should include the back to details link', async () => {
const subHeaderBackLink = await renderResult.findByTestId('flyoutSubHeaderBackButton');
expect(subHeaderBackLink.textContent).toBe('Endpoint Details');