diff --git a/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts new file mode 100644 index 0000000000000..f90f589486ff5 --- /dev/null +++ b/x-pack/plugins/session_view/common/utils/expand_dotted_object.ts @@ -0,0 +1,55 @@ +/* + * 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 { merge } from '@kbn/std'; + +const expandDottedField = (dottedFieldName: string, val: unknown): object => { + const parts = dottedFieldName.split('.'); + if (parts.length === 1) { + return { [parts[0]]: val }; + } else { + return { [parts[0]]: expandDottedField(parts.slice(1).join('.'), val) }; + } +}; + +/* + * Expands an object with "dotted" fields to a nested object with unflattened fields. + * + * Example: + * expandDottedObject({ + * "kibana.alert.depth": 1, + * "kibana.alert.ancestors": [{ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * }], + * }) + * + * => { + * kibana: { + * alert: { + * ancestors: [ + * id: "d5e8eb51-a6a0-456d-8a15-4b79bfec3d71", + * type: "event", + * index: "signal_index", + * depth: 0, + * ], + * depth: 1, + * }, + * }, + * } + */ +export const expandDottedObject = (dottedObj: object) => { + if (Array.isArray(dottedObj)) { + return dottedObj; + } + return Object.entries(dottedObj).reduce( + (acc, [key, val]) => merge(acc, expandDottedField(key, val)), + {} + ); +}; diff --git a/x-pack/plugins/session_view/public/components/ProcessTree/index.tsx b/x-pack/plugins/session_view/public/components/ProcessTree/index.tsx index e18b480dbf28f..bf1fe3dd54742 100644 --- a/x-pack/plugins/session_view/public/components/ProcessTree/index.tsx +++ b/x-pack/plugins/session_view/public/components/ProcessTree/index.tsx @@ -10,6 +10,8 @@ import { useProcessTree, ProcessEvent, Process } from '../../hooks/use_process_t import { useScroll } from '../../hooks/use_scroll'; import { useStyles } from './styles'; +const HIDE_ORPHANS = true; + interface ProcessTreeDeps { // process.entity_id to act as root node (typically a session (or entry session) leader). sessionEntityId: string; @@ -122,6 +124,21 @@ export const ProcessTree = ({ // eslint-disable-next-line no-console console.log(searchResults); + const renderOrphans = () => { + if (!HIDE_ORPHANS) { + return orphans.map((process) => { + return ( + + ); + }) + } + } + return (
{sessionLeader && ( @@ -131,16 +148,7 @@ export const ProcessTree = ({ onProcessSelected={onProcessSelected} /> )} - {orphans.map((process) => { - return ( - - ); - })} + {renderOrphans()}
); diff --git a/x-pack/plugins/session_view/public/components/ProcessTree/styles.ts b/x-pack/plugins/session_view/public/components/ProcessTree/styles.ts index 8304ed455ea37..2a8a76d3b5ce0 100644 --- a/x-pack/plugins/session_view/public/components/ProcessTree/styles.ts +++ b/x-pack/plugins/session_view/public/components/ProcessTree/styles.ts @@ -22,6 +22,7 @@ export const useStyles = () => { background-color: ${euiTheme.colors.lightestShade}; padding-top: ${padding}; padding-left: ${padding}; + padding-bottom: ${padding}; display: flex; flex-direction: column; `; diff --git a/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/index.tsx b/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/index.tsx new file mode 100644 index 0000000000000..9294669faf4e8 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/index.tsx @@ -0,0 +1,96 @@ +/* + * 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 React from 'react'; +import { + EuiButton, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useStyles } from './styles'; +import { ProcessEvent } from '../../hooks/use_process_tree'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../src/core/public'; + +interface ProcessTreeAlertsDeps { + alerts: ProcessEvent[]; +} + +export function ProcessTreeAlerts({ alerts }: ProcessTreeAlertsDeps) { + const styles = useStyles(); + const { http } = useKibana().services; + + if (alerts.length === 0) { + return null; + } + + const getRuleUrl = (alert: ProcessEvent) => { + return http.basePath.prepend(`/app/security/rules/id/${alert.kibana?.alert.rule.uuid}`); + }; + + const renderAlertDetails = (alert: ProcessEvent, index: number) => { + if (!alert.kibana) { + return null; + } + + const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; + const { name, query, severity } = rule; + + return ( + + + +
+ +
+ {name} +
+ +
+ {query} +
+ +
+ +
+ {severity} +
+ +
+ {status} +
+ +
+ +
+ {event.action} + +
+ + + +
+
+
+ {index < alerts.length - 1 && ( +
+ +
+ )} +
+ ); + }; + + return
{alerts.map(renderAlertDetails)}
; +} diff --git a/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/styles.ts b/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/styles.ts new file mode 100644 index 0000000000000..be9bd7a2c2c14 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/ProcessTreeAlerts/styles.ts @@ -0,0 +1,37 @@ +/* + * 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 { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { size, colors, border } = euiTheme; + + const container: CSSObject = { + marginTop: size.s, + marginRight: size.s, + color: colors.text, + padding: size.m, + borderStyle: 'solid', + borderColor: colors.lightShade, + borderWidth: border.width.thin, + borderRadius: border.radius.medium, + maxWidth: 800, + backgroundColor: 'white', + }; + + return { + container, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/ProcessTreeNode/index.tsx b/x-pack/plugins/session_view/public/components/ProcessTreeNode/index.tsx index 60146b48ff7f6..c48d8e0b9bd2a 100644 --- a/x-pack/plugins/session_view/public/components/ProcessTreeNode/index.tsx +++ b/x-pack/plugins/session_view/public/components/ProcessTreeNode/index.tsx @@ -16,6 +16,7 @@ import { EuiButton, EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Process } from '../../hooks/use_process_tree'; import { useStyles } from './styles'; +import { ProcessTreeAlerts } from '../ProcessTreeAlerts'; interface ProcessDeps { process: Process; @@ -36,16 +37,35 @@ export function ProcessTreeNode({ depth = 0, onProcessSelected, }: ProcessDeps) { - const styles = useStyles({ depth }); const textRef = useRef(null); const [childrenExpanded, setChildrenExpanded] = useState(isSessionLeader || process.autoExpand); + const [alertsExpanded, setAlertsExpanded] = useState(false); const { searchMatched } = process; useEffect(() => { setChildrenExpanded(isSessionLeader || process.autoExpand); }, [isSessionLeader, process.autoExpand]); + + const processDetails = useMemo(() => { + return process.getDetails(); + }, [process.events.length]); + + const hasExec = useMemo(() => { + return process.hasExec(); + }, [process.events.length]); + + const alerts = useMemo(() => { + return process.getAlerts(); + }, [process.events.length]); + + if (!processDetails) { + return null; + } + + const styles = useStyles({ depth, hasAlerts: !!alerts.length }); + useLayoutEffect(() => { if (searchMatched !== null && textRef.current) { const regex = new RegExp(searchMatched); @@ -59,18 +79,6 @@ export function ProcessTreeNode({ } }, [searchMatched]); - const processDetails = useMemo(() => { - return process.getDetails(); - }, [process.events.length]); - - const hasExec = useMemo(() => { - return process.hasExec(); - }, [process.events.length]); - - if (!processDetails) { - return null; - } - const { interactive } = processDetails.process; const renderChildren = () => { @@ -98,16 +106,27 @@ export function ProcessTreeNode({ ); }; + const getExpandedIcon = (expanded: boolean) => { + return expanded ? 'arrowUp' : 'arrowDown'; + } + const renderButtons = () => { const buttons = []; if (!isSessionLeader && process.children.length > 0) { - const childrenExpandedIcon = childrenExpanded ? 'arrowUp' : 'arrowDown'; - buttons.push( - setChildrenExpanded(!childrenExpanded)}> + setChildrenExpanded(!childrenExpanded)}> - + + + ); + } + + if (alerts.length) { + buttons.push( + setAlertsExpanded(!alertsExpanded)}> + + ); } @@ -166,6 +185,16 @@ export function ProcessTreeNode({ ); }; + const renderRootEscalation = () => { + const { user, parent } = processDetails.process; + + if (user.name === 'root' && user.id !== parent.user.id) { + return + + + } + } + const onProcessClicked = (e: MouseEvent) => { e.stopPropagation(); @@ -187,9 +216,11 @@ export function ProcessTreeNode({ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
{isSessionLeader ? renderSessionLeader() : renderProcess()} + {renderRootEscalation()} {renderButtons()}
+ {alertsExpanded && } {renderChildren()} ); diff --git a/x-pack/plugins/session_view/public/components/ProcessTreeNode/styles.ts b/x-pack/plugins/session_view/public/components/ProcessTreeNode/styles.ts index c451460dc8f2c..7407c2feac680 100644 --- a/x-pack/plugins/session_view/public/components/ProcessTreeNode/styles.ts +++ b/x-pack/plugins/session_view/public/components/ProcessTreeNode/styles.ts @@ -13,14 +13,22 @@ const TREE_INDENT = 32; interface StylesDeps { depth: number; + hasAlerts: boolean; } -export const useStyles = ({ depth }: StylesDeps) => { +export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { const { colors, border, font, size } = euiTheme; + enum ButtonType { + children = 'children', + alerts = 'alerts', + output = 'output', + userChanged = 'user' + } + const darkText: CSSObject = { color: colors.text, }; @@ -56,25 +64,51 @@ export const useStyles = ({ depth }: StylesDeps) => { fontSize: '11px', fontFamily: font.familyCode, borderRadius: border.radius.medium, - background: 'rgba(0, 119, 204, 0.1)', - border: '1px solid rgba(96, 146, 192, 0.3)', color: colors.text, marginLeft: size.s, + minWidth: 0, }; const buttonArrow: CSSObject = { marginLeft: size.s, }; + const getButtonStyle = (type: string) => { + let background = 'rgba(170, 101, 86, 0.04)'; + let borderStyle = '1px solid rgba(170, 101, 86, 0.48)'; + + switch (type) { + case ButtonType.alerts: + background = 'rgba(189, 39, 30, 0.04)'; + borderStyle = '1px solid rgba(189, 39, 30, 0.48)'; + break; + case ButtonType.userChanged: + case ButtonType.output: + background = 'rgba(0, 119, 204, 0.04)'; + borderStyle = '1px solid rgba(0, 119, 204, 0.48)'; + break; + } + + return { + ...button, + background, + border: borderStyle, + }; + }; + /** * gets border, bg and hover colors for a process */ const getHighlightColors = () => { - const bgColor = 'none'; - const hoverColor = '#6B5FC6'; - const borderColor = 'transparent'; + let bgColor = 'none'; + let hoverColor = '#6B5FC6'; + let borderColor = 'transparent'; // TODO: alerts highlight colors + if (hasAlerts) { + bgColor = 'rgba(189, 39, 30, 0.04)'; + borderColor = 'rgba(189, 39, 30, 0.48)'; + } return { bgColor, borderColor, hoverColor }; }; @@ -126,18 +160,26 @@ export const useStyles = ({ depth }: StylesDeps) => { marginTop: '8px', }; + const alertDetails: CSSObject = { + padding: size.s, + border: `3px dotted ${colors.lightShade}`, + borderRadius: border.radius.medium, + }; + return { darkText, searchHighlight, children, - button, - buttonArrow, processNode, wrapper, workingDir, userEnteredIcon, + buttonArrow, + ButtonType, + getButtonStyle, + alertDetails, }; - }, [depth, euiTheme]); + }, [depth, euiTheme, hasAlerts]); return cached; }; diff --git a/x-pack/plugins/session_view/public/components/SessionView/index.tsx b/x-pack/plugins/session_view/public/components/SessionView/index.tsx index 3bb23c3da3535..d1b6e2606995e 100644 --- a/x-pack/plugins/session_view/public/components/SessionView/index.tsx +++ b/x-pack/plugins/session_view/public/components/SessionView/index.tsx @@ -21,8 +21,14 @@ interface SessionViewDeps { } interface ProcessEventResults { - hits: any[]; - length: number; + events: { + hits: any[]; + total: number; + }; + alerts: { + hits: any[]; + total: number; + }; } /** @@ -61,23 +67,34 @@ export const SessionView = ({ sessionEntityId, height }: SessionViewDeps) => { () => http.get(PROCESS_EVENTS_ROUTE, { query: { - indexes: ['cmd*', '.siem-signals-*'], - sessionEntityId + sessionEntityId, }, }) ); - useEffect(() => { - if (!getData) { - return; + const sortEvents = (a: ProcessEvent, b: ProcessEvent) => { + if (a['@timestamp'].valueOf() < b['@timestamp'].valueOf()) { + return -1; + } else if (a['@timestamp'].valueOf() > b['@timestamp'].valueOf()) { + return 1; } - if (getData.length <= data.length) { + return 0; + }; + + useEffect(() => { + if (!getData) { return; } - setData(getData.hits.map((event: any) => event._source as ProcessEvent)); - // eslint-disable-next-line react-hooks/exhaustive-deps + const events: ProcessEvent[] = getData.events.hits.map( + (event: any) => event._source as ProcessEvent + ); + const alerts: ProcessEvent[] = getData.alerts.hits.map((event: any) => { + return event._source as ProcessEvent; + }); + const all: ProcessEvent[] = events.concat(alerts).sort(sortEvents); + setData(all); }, [getData]); const renderNoData = () => { @@ -96,15 +113,17 @@ export const SessionView = ({ sessionEntityId, height }: SessionViewDeps) => { return ( <> -
- -
+ {data && ( +
+ +
+ )} ); }; diff --git a/x-pack/plugins/session_view/public/components/SessionViewPage/index.tsx b/x-pack/plugins/session_view/public/components/SessionViewPage/index.tsx index 9c97a1e894b94..cadfcdafe7b4e 100644 --- a/x-pack/plugins/session_view/public/components/SessionViewPage/index.tsx +++ b/x-pack/plugins/session_view/public/components/SessionViewPage/index.tsx @@ -52,15 +52,14 @@ export const SessionViewPage = (props: RouteComponentProps) => { } }, [data]); + return ( {sessionEntityId && } diff --git a/x-pack/plugins/session_view/public/hooks/use_process_tree.ts b/x-pack/plugins/session_view/public/hooks/use_process_tree.ts index 03b3c774a7dee..dd6f02ba24477 100644 --- a/x-pack/plugins/session_view/public/hooks/use_process_tree.ts +++ b/x-pack/plugins/session_view/public/hooks/use_process_tree.ts @@ -55,7 +55,7 @@ interface ProcessSelf extends ProcessFields { } export interface ProcessEvent { - '@timestamp': string; + '@timestamp': Date; event: { kind: EventKind; category: string; @@ -80,8 +80,29 @@ export interface ProcessEvent { }; }; process: ProcessSelf; - - // TODO: alerts? output? file_descriptors? + kibana?: { + alert: { + uuid: string; + reason: string; + workflow_status: string; + status: string; + original_time: Date; + original_event: { + action: string; + }, + rule: { + category: string; + consumer: string; + description: string; + enabled: boolean; + name: string; + query: string; + risk_score: number; + severity: string; + uuid: string; + } + } + } } export interface Process { @@ -93,6 +114,7 @@ export interface Process { searchMatched: string | null; // either false, or set to searchQuery hasOutput(): boolean; hasAlerts(): boolean; + getAlerts(): ProcessEvent[]; hasExec(): boolean; getOutput(): string; getDetails(): ProcessEvent; @@ -124,6 +146,10 @@ class ProcessImpl implements Process { hasAlerts() { return !!this.events.find(({ event }) => event.kind === EventKind.signal); } + + getAlerts() { + return this.events.filter(({ event }) => event.kind === EventKind.signal); + } hasExec() { return !!this.events.find(({ event }) => event.action === EventAction.exec); @@ -134,8 +160,12 @@ class ProcessImpl implements Process { } getDetails() { - const execsForks = this.events.filter(({ event }) => [EventAction.exec, EventAction.fork].includes(event.action)); + const execsForks = this.events.filter(({ event }) => event.action === EventAction.exec || event.action === EventAction.fork); + if (execsForks.length === 0) { + debugger; + } + return execsForks[execsForks.length - 1]; } @@ -173,8 +203,19 @@ export const useProcessTree = ({ searchQuery, }: UseProcessTreeDeps) => { // initialize map, as well as a placeholder for session leader process + // we add a fake session leader event, sourced from wide event data. + // this is because we might not always have a session leader event + // especially if we are paging in reverse from deep within a large session + const fakeLeaderEvent = forward.find(event => event.event.kind === EventKind.event); + const sessionLeaderProcess = new ProcessImpl(sessionEntityId); + + if (fakeLeaderEvent) { + fakeLeaderEvent.process = { ...fakeLeaderEvent.process, ...fakeLeaderEvent.process.entry}; + sessionLeaderProcess.events.push(fakeLeaderEvent); + } + const initializedProcessMap: ProcessMap = { - [sessionEntityId]: new ProcessImpl(sessionEntityId), + [sessionEntityId]: sessionLeaderProcess, }; const [processMap, setProcessMap] = useState(initializedProcessMap); @@ -195,13 +236,6 @@ export const useProcessTree = ({ process.events.push(event); }); - - if (processMap[sessionEntityId].events.length === 0) { - processMap[sessionEntityId].events.push({ - ...events[0], - ...events[0].process.entry - }) - } }; const buildProcessTree = (events: ProcessEvent[], backwardDirection: boolean = false) => { diff --git a/x-pack/plugins/session_view/server/routes/process_events_route.ts b/x-pack/plugins/session_view/server/routes/process_events_route.ts index 65c04b3fefaad..5d53ebf67d096 100644 --- a/x-pack/plugins/session_view/server/routes/process_events_route.ts +++ b/x-pack/plugins/session_view/server/routes/process_events_route.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../src/core/server'; import { PROCESS_EVENTS_ROUTE, PROCESS_EVENTS_PER_PAGE } from '../../common/constants'; +import { expandDottedObject } from '../../common/utils/expand_dotted_object'; export const registerProcessEventsRoute = (router: IRouter) => { router.get( @@ -14,28 +15,58 @@ export const registerProcessEventsRoute = (router: IRouter) => { path: PROCESS_EVENTS_ROUTE, validate: { query: schema.object({ - indexes: schema.maybe(schema.arrayOf(schema.string())), sessionEntityId: schema.maybe(schema.string()), }), }, }, async (context, request, response) => { const client = context.core.elasticsearch.client.asCurrentUser; + + // TODO: would be good to figure out how to add securitySolution as a dep + // and make use of this way of getting the siem-signals index, instead of + // hardcoding it. + // const siemClient = context.securitySolution.getAppClient(); + // const alertsIndex = siemClient.getSignalsIndex(), - const { indexes, sessionEntityId } = request.query; + const { sessionEntityId } = request.query; const search = await client.search({ - index: indexes, - query: { - match: { - 'process.entry.entity_id': sessionEntityId, + index: ['cmd'], + body: { + query: { + match: { + 'process.entry.entity_id': sessionEntityId, + }, }, - }, - size: PROCESS_EVENTS_PER_PAGE, - sort: '@timestamp', + size: PROCESS_EVENTS_PER_PAGE, + sort: [{ '@timestamp': 'asc' }], + } + }); + + // temporary approach. ideally we'd pull from both these indexes above, but unfortunately + // our new fields like process.entry.entity_id won't have a mapping in the .siem-signals index + // this should hopefully change once we update ECS or endpoint-package.. + // for demo purpose we just load all alerts, and stich it together on the frontend. + const alerts = await client.search({ + index: ['.siem-signals-default'], + body: { + size: PROCESS_EVENTS_PER_PAGE, + sort: [{ '@timestamp': 'asc' }], + } + }); + + alerts.body.hits.hits = alerts.body.hits.hits.map((hit: any) => { + hit._source = expandDottedObject(hit._source); + + return hit; }); - return response.ok({ body: search.body.hits }); + return response.ok({ + body: { + events: search.body.hits, + alerts: alerts.body.hits, + }, + }); } ); }; diff --git a/x-pack/plugins/session_view/server/routes/recent_session_route.ts b/x-pack/plugins/session_view/server/routes/recent_session_route.ts index 71a95f3d4d0b9..6eb1b415f9002 100644 --- a/x-pack/plugins/session_view/server/routes/recent_session_route.ts +++ b/x-pack/plugins/session_view/server/routes/recent_session_route.ts @@ -25,12 +25,17 @@ export const registerRecentSessionRoute = (router: IRouter) => { const search = await client.search({ index: indexes, - query: { - match: { - 'process.entry.interactive': true, + body: { + query: { + match: { + 'process.entry.interactive': true, + }, }, + size: 1, + sort: [ + {'@timestamp' :'desc'} + ], }, - size: 1 }); return response.ok({ body: search.body.hits });