[Security Solution][Resolver] Word-break long titles in related event… (#75926)

* [Security Solution][Resolver] Word-break long titles in related event description lists

* word-break long titles at non-word boundaries

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Brent Kimmel 2020-08-26 21:06:38 -04:00 committed by GitHub
parent c08bf7f3ca
commit 42942327e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 225 additions and 122 deletions

View file

@ -11,6 +11,7 @@ import * as selectors from './selectors';
import { DataState } from '../../types';
import { DataAction } from './action';
import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types';
import * as eventModel from '../../../../common/endpoint/models/event';
/**
* Test the data reducer and selector.
@ -175,6 +176,24 @@ describe('Resolver Data Middleware', () => {
eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1
);
});
it('should return the correct related event detail metadata for a given related event', () => {
const relatedEventsByCategory = selectors.relatedEventsByCategory(store.getState());
const someRelatedEventForTheFirstChild = relatedEventsByCategory(firstChildNodeInTree.id)(
categoryToOverCount
)[0];
const relatedEventID = eventModel.eventId(someRelatedEventForTheFirstChild)!;
const relatedDisplayInfo = selectors.relatedEventDisplayInfoByEntityAndSelfID(
store.getState()
)(firstChildNodeInTree.id, relatedEventID);
const [, countOfSameType, , sectionData] = relatedDisplayInfo;
const hostEntries = sectionData.filter((section) => {
return section.sectionTitle === 'host';
})[0].entries;
expect(hostEntries).toContainEqual({ title: 'os.platform', description: 'Windows' });
expect(countOfSameType).toBe(
eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1
);
});
it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => {
const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState());
const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id)

View file

@ -14,6 +14,7 @@ import {
IndexedProcessNode,
AABB,
VisibleEntites,
SectionData,
} from '../../types';
import {
isGraphableProcess,
@ -29,11 +30,14 @@ import {
ResolverNodeStats,
ResolverRelatedEvents,
SafeResolverEvent,
EndpointEvent,
LegacyEndpointEvent,
} from '../../../../common/endpoint/types';
import * as resolverTreeModel from '../../models/resolver_tree';
import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout';
import * as eventModel from '../../../../common/endpoint/models/event';
import * as vector2 from '../../models/vector2';
import { formatDate } from '../../view/panels/panel_content_utilities';
/**
* If there is currently a request.
@ -173,6 +177,100 @@ export function relatedEventsByEntityId(data: DataState): Map<string, ResolverRe
return data.relatedEvents;
}
/**
* A helper function to turn objects into EuiDescriptionList entries.
* This reflects the strategy of more or less "dumping" metadata for related processes
* in description lists with little/no 'prettification'. This has the obvious drawback of
* data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields
* to the user "as they occur" in ECS, which may help them with e.g. EQL queries.
*
* Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so:
* {title: "a.b", description: "1"}, {title: "c", description: "d"}
*
* @param {object} obj The object to turn into `<dt><dd>` entries
*/
const objectToDescriptionListEntries = function* (
obj: object,
prefix = ''
): Generator<{ title: string; description: string }> {
const nextPrefix = prefix.length ? `${prefix}.` : '';
for (const [metaKey, metaValue] of Object.entries(obj)) {
if (typeof metaValue === 'number' || typeof metaValue === 'string') {
yield { title: nextPrefix + metaKey, description: `${metaValue}` };
} else if (metaValue instanceof Array) {
yield {
title: nextPrefix + metaKey,
description: metaValue
.filter((arrayEntry) => {
return typeof arrayEntry === 'number' || typeof arrayEntry === 'string';
})
.join(','),
};
} else if (typeof metaValue === 'object') {
yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey);
}
}
};
/**
* Returns a function that returns the information needed to display related event details based on
* the related event's entityID and its own ID.
*/
export const relatedEventDisplayInfoByEntityAndSelfID: (
state: DataState
) => (
entityId: string,
relatedEventId: string | number
) => [
EndpointEvent | LegacyEndpointEvent | undefined,
number,
string | undefined,
SectionData,
string
] = createSelector(relatedEventsByEntityId, function relatedEventDetails(
/* eslint-disable no-shadow */
relatedEventsByEntityId
/* eslint-enable no-shadow */
) {
return defaultMemoize((entityId: string, relatedEventId: string | number) => {
const relatedEventsForThisProcess = relatedEventsByEntityId.get(entityId);
if (!relatedEventsForThisProcess) {
return [undefined, 0, undefined, [], ''];
}
const specificEvent = relatedEventsForThisProcess.events.find(
(evt) => eventModel.eventId(evt) === relatedEventId
);
// For breadcrumbs:
const specificCategory = specificEvent && eventModel.primaryEventCategory(specificEvent);
const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => {
return eventModel.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal;
}, 0);
// Assuming these details (agent, ecs, process) aren't as helpful, can revisit
const { agent, ecs, process, ...relevantData } = specificEvent as ResolverEvent & {
// Type this with various unknown keys so that ts will let us delete those keys
ecs: unknown;
process: unknown;
};
let displayDate = '';
const sectionData: SectionData = Object.entries(relevantData)
.map(([sectionTitle, val]) => {
if (sectionTitle === '@timestamp') {
displayDate = formatDate(val);
return { sectionTitle: '', entries: [] };
}
if (typeof val !== 'object') {
return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] };
}
return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] };
})
.filter((v) => v.sectionTitle !== '' && v.entries.length);
return [specificEvent, countOfCategory, specificCategory, sectionData, displayDate];
});
});
/**
* Returns a function that returns a function (when supplied with an entity id for a node)
* that returns related events for a node that match an event.category (when supplied with the category)

View file

@ -122,6 +122,15 @@ export const relatedEventsByEntityId = composeSelectors(
dataSelectors.relatedEventsByEntityId
);
/**
* Returns a function that returns the information needed to display related event details based on
* the related event's entityID and its own ID.
*/
export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors(
dataStateSelector,
dataSelectors.relatedEventDisplayInfoByEntityAndSelfID
);
/**
* Returns a function that returns a function (when supplied with an entity id for a node)
* that returns related events for a node that match an event.category (when supplied with the category)

View file

@ -160,6 +160,22 @@ export interface IndexedProcessNode extends BBox {
position: Vector2;
}
/**
* A type describing the shape of section titles and entries for description lists
*/
export type SectionData = Array<{
sectionTitle: string;
entries: Array<{ title: string; description: string }>;
}>;
/**
* The two query parameters we read/write on to control which view the table presents:
*/
export interface CrumbInfo {
crumbId: string;
crumbEvent: string;
}
/**
* A type containing all things to actually be rendered to the DOM.
*/

View file

@ -8,10 +8,11 @@ import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities';
import { StyledBreadcrumbs } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import { CrumbInfo } from '../../types';
/**
* This view gives counts for all the related events of a process grouped by related event type.

View file

@ -7,7 +7,8 @@
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui';
import React, { memo, useMemo } from 'react';
import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities';
import { StyledBreadcrumbs } from './panel_content_utilities';
import { CrumbInfo } from '../../types';
/**
* Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state.

View file

@ -23,14 +23,6 @@ const BetaHeader = styled(`header`)`
margin-bottom: 1em;
`;
/**
* The two query parameters we read/write on to control which view the table presents:
*/
export interface CrumbInfo {
crumbId: string;
crumbEvent: string;
}
const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>`
&.euiBreadcrumbs {
background-color: ${(props) => props.background};

View file

@ -19,7 +19,7 @@ import { FormattedMessage } from 'react-intl';
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
import * as selectors from '../../store/selectors';
import * as event from '../../../../common/endpoint/models/event';
import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities';
import { formatDate, StyledBreadcrumbs } from './panel_content_utilities';
import {
processPath,
processPid,
@ -31,6 +31,7 @@ import {
import { CubeForProcess } from './cube_for_process';
import { ResolverEvent } from '../../../../common/endpoint/types';
import { useResolverTheme } from '../assets';
import { CrumbInfo } from '../../types';
const StyledDescriptionList = styled(EuiDescriptionList)`
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {

View file

@ -10,18 +10,13 @@ import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from
import { useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import styled from 'styled-components';
import {
CrumbInfo,
formatDate,
StyledBreadcrumbs,
BoldCode,
StyledTime,
} from './panel_content_utilities';
import { formatDate, StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { RelatedEventLimitWarning } from '../limit_warnings';
import { CrumbInfo } from '../../types';
/**
* This view presents a list of related events of a given type for a given process.

View file

@ -16,12 +16,13 @@ import { useSelector } from 'react-redux';
import styled from 'styled-components';
import * as event from '../../../../common/endpoint/models/event';
import * as selectors from '../../store/selectors';
import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities';
import { formatter, StyledBreadcrumbs } from './panel_content_utilities';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { SideEffectContext } from '../side_effect_context';
import { CubeForProcess } from './cube_for_process';
import { SafeResolverEvent } from '../../../../common/endpoint/types';
import { LimitWarning } from '../limit_warnings';
import { CrumbInfo } from '../../types';
const StyledLimitWarning = styled(LimitWarning)`
flex-flow: row wrap;

View file

@ -10,58 +10,19 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '
import styled from 'styled-components';
import { useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import {
CrumbInfo,
formatDate,
StyledBreadcrumbs,
BoldCode,
StyledTime,
} from './panel_content_utilities';
import { StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import { useResolverDispatch } from '../use_resolver_dispatch';
import { PanelContentError } from './panel_content_error';
/**
* A helper function to turn objects into EuiDescriptionList entries.
* This reflects the strategy of more or less "dumping" metadata for related processes
* in description lists with little/no 'prettification'. This has the obvious drawback of
* data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields
* to the user "as they occur" in ECS, which may help them with e.g. EQL queries.
*
* Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so:
* {title: "a.b", description: "1"}, {title: "c", description: "d"}
*
* @param {object} obj The object to turn into `<dt><dd>` entries
*/
const objectToDescriptionListEntries = function* (
obj: object,
prefix = ''
): Generator<{ title: string; description: string }> {
const nextPrefix = prefix.length ? `${prefix}.` : '';
for (const [metaKey, metaValue] of Object.entries(obj)) {
if (typeof metaValue === 'number' || typeof metaValue === 'string') {
yield { title: nextPrefix + metaKey, description: `${metaValue}` };
} else if (metaValue instanceof Array) {
yield {
title: nextPrefix + metaKey,
description: metaValue
.filter((arrayEntry) => {
return typeof arrayEntry === 'number' || typeof arrayEntry === 'string';
})
.join(','),
};
} else if (typeof metaValue === 'object') {
yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey);
}
}
};
import { CrumbInfo } from '../../types';
// Adding some styles to prevent horizontal scrollbars, per request from UX review
const StyledDescriptionList = memo(styled(EuiDescriptionList)`
&.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title {
max-width: 8em;
overflow-wrap: break-word;
}
&.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description {
max-width: calc(100% - 8.5em);
@ -69,6 +30,12 @@ const StyledDescriptionList = memo(styled(EuiDescriptionList)`
}
`);
// Also prevents horizontal scrollbars on long descriptive names
const StyledDescriptiveName = memo(styled(EuiText)`
padding-right: 1em;
overflow-wrap: break-word;
`);
// Styling subtitles, per UX review:
const StyledFlexTitle = memo(styled('h3')`
display: flex;
@ -90,6 +57,49 @@ const TitleHr = memo(() => {
});
TitleHr.displayName = 'TitleHR';
const GeneratedText = React.memo(function ({ children }) {
return <>{processedValue()}</>;
function processedValue() {
return React.Children.map(children, (child) => {
if (typeof child === 'string') {
const valueSplitByWordBoundaries = child.split(/\b/);
if (valueSplitByWordBoundaries.length < 2) {
return valueSplitByWordBoundaries[0];
}
return [
valueSplitByWordBoundaries[0],
...valueSplitByWordBoundaries
.splice(1)
.reduce(function (generatedTextMemo: Array<string | JSX.Element>, value, index) {
return [...generatedTextMemo, value, <wbr />];
}, []),
];
} else {
return child;
}
});
}
});
GeneratedText.displayName = 'GeneratedText';
/**
* Take description list entries and prepare them for display by
* seeding with `<wbr />` tags.
*
* @param entries {title: string, description: string}[]
*/
function entriesForDisplay(entries: Array<{ title: string; description: string }>) {
return entries.map((entry) => {
return {
description: <GeneratedText>{entry.description}</GeneratedText>,
title: <GeneratedText>{entry.title}</GeneratedText>,
};
});
}
/**
* This view presents a detailed view of all the available data for a related event, split and titled by the "section"
* it appears in the underlying ResolverEvent
@ -138,60 +148,17 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({
}
}, [relatedsReady, dispatch, processEntityId]);
const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get(
processEntityId!
const [
relatedEventToShowDetailsFor,
countBySameCategory,
relatedEventCategory = naString,
sections,
formattedDate,
] = useSelector(selectors.relatedEventDisplayInfoByEntityAndSelfId)(
processEntityId,
relatedEventId
);
const [relatedEventToShowDetailsFor, countBySameCategory, relatedEventCategory] = useMemo(() => {
if (!relatedEventsForThisProcess) {
return [undefined, 0];
}
const specificEvent = relatedEventsForThisProcess.events.find(
(evt) => event.eventId(evt) === relatedEventId
);
// For breadcrumbs:
const specificCategory = specificEvent && event.primaryEventCategory(specificEvent);
const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => {
return event.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal;
}, 0);
return [specificEvent, countOfCategory, specificCategory || naString];
}, [relatedEventsForThisProcess, naString, relatedEventId]);
const [sections, formattedDate] = useMemo(() => {
if (!relatedEventToShowDetailsFor) {
// This could happen if user relaods from URL param and requests an eventId that no longer exists
return [[], naString];
}
// Assuming these details (agent, ecs, process) aren't as helpful, can revisit
const {
agent,
ecs,
process,
...relevantData
} = relatedEventToShowDetailsFor as ResolverEvent & {
// Type this with various unknown keys so that ts will let us delete those keys
ecs: unknown;
process: unknown;
};
let displayDate = '';
const sectionData: Array<{
sectionTitle: string;
entries: Array<{ title: string; description: string }>;
}> = Object.entries(relevantData)
.map(([sectionTitle, val]) => {
if (sectionTitle === '@timestamp') {
displayDate = formatDate(val);
return { sectionTitle: '', entries: [] };
}
if (typeof val !== 'object') {
return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] };
}
return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] };
})
.filter((v) => v.sectionTitle !== '' && v.entries.length);
return [sectionData, displayDate];
}, [relatedEventToShowDetailsFor, naString]);
const waitCrumbs = useMemo(() => {
return [
{
@ -338,15 +305,18 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({
</StyledTime>
</EuiText>
<EuiSpacer size="m" />
<EuiText>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</EuiText>
<StyledDescriptiveName>
<GeneratedText>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</GeneratedText>
</StyledDescriptiveName>
<EuiSpacer size="l" />
{sections.map(({ sectionTitle, entries }, index) => {
const displayEntries = entriesForDisplay(entries);
return (
<Fragment key={index}>
{index === 0 ? null : <EuiSpacer size="m" />}
@ -364,7 +334,7 @@ export const RelatedEventDetail = memo(function RelatedEventDetail({
align="left"
titleProps={{ className: 'desc-title' }}
compressed
listItems={entries}
listItems={displayEntries}
/>
{index === sections.length - 1 ? null : <EuiSpacer size="m" />}
</Fragment>

View file

@ -7,7 +7,7 @@
import { useCallback, useMemo } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useQueryStringKeys } from './use_query_string_keys';
import { CrumbInfo } from './panels/panel_content_utilities';
import { CrumbInfo } from '../types';
export function useResolverQueryParams() {
/**