diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 672c25d4e8fa..0e61cba7511a 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -12,6 +12,7 @@ const KIBANA_NAMESPACE = 'kibana' as const; const ALERT_NAMESPACE = `${KIBANA_NAMESPACE}.alert` as const; const ALERT_RULE_NAMESPACE = `${ALERT_NAMESPACE}.rule` as const; +const ALERT_RULE_THREAT_NAMESPACE = `${ALERT_RULE_NAMESPACE}.threat` as const; const ECS_VERSION = 'ecs.version' as const; const EVENT_ACTION = 'event.action' as const; @@ -68,6 +69,23 @@ const ALERT_RULE_TYPE_ID = `${ALERT_RULE_NAMESPACE}.rule_type_id` as const; const ALERT_RULE_UPDATED_AT = `${ALERT_RULE_NAMESPACE}.updated_at` as const; const ALERT_RULE_UPDATED_BY = `${ALERT_RULE_NAMESPACE}.updated_by` as const; const ALERT_RULE_VERSION = `${ALERT_RULE_NAMESPACE}.version` as const; + +// Fields pertaining to the threat tactic associated with the rule +const ALERT_THREAT_FRAMEWORK = `${ALERT_RULE_THREAT_NAMESPACE}.framework` as const; +const ALERT_THREAT_TACTIC_ID = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.id` as const; +const ALERT_THREAT_TACTIC_NAME = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.name` as const; +const ALERT_THREAT_TACTIC_REFERENCE = `${ALERT_RULE_THREAT_NAMESPACE}.tactic.reference` as const; +const ALERT_THREAT_TECHNIQUE_ID = `${ALERT_RULE_THREAT_NAMESPACE}.technique.id` as const; +const ALERT_THREAT_TECHNIQUE_NAME = `${ALERT_RULE_THREAT_NAMESPACE}.technique.name` as const; +const ALERT_THREAT_TECHNIQUE_REFERENCE = + `${ALERT_RULE_THREAT_NAMESPACE}.technique.reference` as const; +const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID = + `${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.id` as const; +const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME = + `${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.name` as const; +const ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE = + `${ALERT_RULE_THREAT_NAMESPACE}.technique.subtechnique.reference` as const; + // the feature instantiating a rule type. // Rule created in stack --> alerts // Rule created in siem --> siem @@ -137,6 +155,16 @@ const fields = { ALERT_WORKFLOW_USER, ALERT_RULE_UUID, ALERT_RULE_CATEGORY, + ALERT_THREAT_FRAMEWORK, + ALERT_THREAT_TACTIC_ID, + ALERT_THREAT_TACTIC_NAME, + ALERT_THREAT_TACTIC_REFERENCE, + ALERT_THREAT_TECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_REFERENCE, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, SPACE_IDS, VERSION, }; @@ -195,6 +223,16 @@ export { KIBANA_NAMESPACE, ALERT_RULE_UUID, ALERT_RULE_CATEGORY, + ALERT_THREAT_FRAMEWORK, + ALERT_THREAT_TACTIC_ID, + ALERT_THREAT_TACTIC_NAME, + ALERT_THREAT_TACTIC_REFERENCE, + ALERT_THREAT_TECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_REFERENCE, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, TAGS, TIMESTAMP, SPACE_IDS, diff --git a/x-pack/plugins/graph/kibana.json b/x-pack/plugins/graph/kibana.json index 6db4ca194d7d..ba1a2cee77be 100644 --- a/x-pack/plugins/graph/kibana.json +++ b/x-pack/plugins/graph/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "server": true, "ui": true, + "extraPublicDirs": ["public/components/graph_visualization", "public/services/workspace"], "requiredPlugins": [ "licensing", "data", diff --git a/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js index accb9f74539a..cb3d50d4f763 100644 --- a/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/services/workspace/graph_client_workspace.js @@ -522,6 +522,7 @@ function GraphWorkspace(options) { const visibleNodes = self.nodes.filter(function (n) { return n.parent === undefined; }); + console.log("VISIBLE NODES: ", visibleNodes); //reset then roll-up all the counts const allNodes = self.nodes; allNodes.forEach((node) => { @@ -890,6 +891,7 @@ function GraphWorkspace(options) { self.addUndoLogEntry(lastOps); } + console.log("MERGED!!: ", this); this.runLayout(); }; diff --git a/x-pack/plugins/graph/public/services/workspace/index.d.ts b/x-pack/plugins/graph/public/services/workspace/index.d.ts new file mode 100644 index 000000000000..18d08d8da31d --- /dev/null +++ b/x-pack/plugins/graph/public/services/workspace/index.d.ts @@ -0,0 +1,10 @@ +/* + * 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 { Workspace, WorkspaceOptions } from '../../types'; + +declare function createWorkspace(options: WorkspaceOptions): Workspace; diff --git a/x-pack/plugins/graph/public/services/workspace/index.js b/x-pack/plugins/graph/public/services/workspace/index.js new file mode 100644 index 000000000000..fee44f271f04 --- /dev/null +++ b/x-pack/plugins/graph/public/services/workspace/index.js @@ -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 './graph_client_workspace'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index aa581971e5f0..15482027ddd9 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -64,6 +64,11 @@ export const allowedExperimentalValues = Object.freeze({ * Enables endpoint package level rbac */ endpointRbacEnabled: false, + + /** + * Enables the alert details page currently only accessible via the alert details flyout + */ + alertDetailsPageEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 9136827dfd76..e8294fa374c9 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -318,6 +318,7 @@ export enum TimelineId { networkPageEvents = 'network-page-events', hostsPageSessions = 'hosts-page-sessions-v2', // the v2 is to cache bust localstorage settings as default columns were reworked. detectionsRulesDetailsPage = 'detections-rules-details-page', + detectionsAlertDetailsPage = 'detections-alert-details-page', detectionsPage = 'detections-page', active = 'timeline-1', casePage = 'timeline-case', diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_alert_to_case.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_alert_to_case.cy.ts index 8a6d3d9a5fed..b2be03df62a9 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_alert_to_case.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_alert_to_case.cy.ts @@ -41,8 +41,9 @@ describe('Alerts timeline', () => { }); it('should not allow user with read only privileges to attach alerts to cases', () => { - // Disabled actions for read only users are hidden, so actions button should not show - cy.get(TIMELINE_CONTEXT_MENU_BTN).should('not.exist'); + // Disabled actions for read only users are hidden, so only open alert details button should show + expandFirstAlertActions(); + cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts new file mode 100644 index 000000000000..a5a5e08490e9 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts @@ -0,0 +1,75 @@ +/* + * 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 { expandFirstAlert } from '../../tasks/alerts'; +import { setStartDate } from '../../tasks/date_picker'; +import { closeTimeline } from '../../tasks/timeline'; +import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; +import { login, visitWithoutDateRange } from '../../tasks/login'; + +import { getNewRule } from '../../objects/rule'; +import type { CustomRule } from '../../objects/rule'; + +import { ALERTS_URL } from '../../urls/navigation'; +import { + ALERT_DETAILS_PAGE_BACK_TO_ALERTS, + OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN, + TIMELINE_CONTEXT_MENU_BTN, +} from '../../screens/alerts'; +import { PAGE_TITLE } from '../../screens/common/page'; +import { OPEN_ALERT_DETAILS_PAGE } from '../../screens/alerts_details'; + +describe('Alert Details Page Navigation', () => { + describe('navigating to alert details page', () => { + let rule: CustomRule; + before(() => { + rule = getNewRule(); + cleanKibana(); + login(); + createCustomRuleEnabled(rule, 'rule1'); + visitWithoutDateRange(ALERTS_URL); + const dateContainingAllEvents = 'Jul 27, 2015 @ 00:00:00.000'; + setStartDate(dateContainingAllEvents); + waitForAlertsToPopulate(); + }); + + afterEach(() => { + closeTimeline(); + }); + + it('should navigate to the details page from the alert context menu', () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click(); + // It opens in a new tab by default, for testing purposes, we want it to open in the same tab + cy.get(OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN).invoke('removeAttr', 'target').click(); + cy.get(PAGE_TITLE).should('contain.text', rule.name); + cy.url().should('include', '/summary'); + }); + + it('should navigate to the details page from the alert flyout', () => { + visitWithoutDateRange(ALERTS_URL); + waitForAlertsToPopulate(); + expandFirstAlert(); + // It opens in a new tab by default, for testing purposes, we want it to open in the same tab + cy.get(OPEN_ALERT_DETAILS_PAGE).invoke('removeAttr', 'target').click(); + cy.get(PAGE_TITLE).should('contain.text', rule.name); + cy.url().should('include', '/summary'); + }); + + it('should navigate back to the alert table from the details page', () => { + visitWithoutDateRange(ALERTS_URL); + waitForAlertsToPopulate(); + expandFirstAlert(); + // It opens in a new tab by default, for testing purposes, we want it to open in the same tab + cy.get(OPEN_ALERT_DETAILS_PAGE).invoke('removeAttr', 'target').click(); + cy.get(ALERT_DETAILS_PAGE_BACK_TO_ALERTS).click(); + cy.url().should('include', ALERTS_URL); + cy.url().should('not.include', 'summary'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/urls/not_found.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/urls/not_found.cy.ts index 8bd3e6a9ed93..4dc7d5231dcd 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/urls/not_found.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/urls/not_found.cy.ts @@ -30,7 +30,8 @@ describe('Display not found page', () => { visit(TIMELINES_URL); }); - it('navigates to the alerts page with incorrect link', () => { + // TODO: We need to determine what we want the behavior to be here + it.skip('navigates to the alerts page with incorrect link', () => { visit(`${ALERTS_URL}/randomUrl`); cy.get(NOT_FOUND).should('exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 2ceeaac0e8ca..29cf6b80ae78 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -63,6 +63,12 @@ export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]'; export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]'; +export const OPEN_ALERT_DETAILS_PAGE_CONTEXT_MENU_BTN = + '[data-test-subj="open-alert-details-page-menu-item"]'; + +export const ALERT_DETAILS_PAGE_BACK_TO_ALERTS = + '[data-test-subj="alert-details-back-to-alerts-link"]'; + export const PROCESS_NAME_COLUMN = '[data-test-subj="dataGridHeaderCell-process.name"]'; export const PROCESS_NAME = '[data-test-subj="formatted-field-process.name"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index 596330707511..9a1ac0b8d08f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -81,3 +81,5 @@ export const INSIGHTS_RELATED_ALERTS_BY_ANCESTRY = `[data-test-subj='related-ale export const INSIGHTS_INVESTIGATE_ANCESTRY_ALERTS_IN_TIMELINE_BUTTON = `[data-test-subj='investigate-ancestry-in-timeline']`; export const ENRICHED_DATA_ROW = `[data-test-subj='EnrichedDataRow']`; + +export const OPEN_ALERT_DETAILS_PAGE = `[data-test-subj="open-alert-details-page"]`; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 4dce859a3efd..618f6950af43 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -19,6 +19,7 @@ "embeddable", "eventLog", "features", + "graph", "inspector", "kubernetesSecurity", "lens", diff --git a/x-pack/plugins/security_solution/public/app/font_awesome/font-awesome.scss b/x-pack/plugins/security_solution/public/app/font_awesome/font-awesome.scss new file mode 100644 index 000000000000..1037dbb393f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/font_awesome/font-awesome.scss @@ -0,0 +1,139 @@ +@font-face { + font-family: 'FontAwesome'; + src: url('~font-awesome/fonts/fontawesome-webfont.eot?v=4.7.0'); + src: url('~font-awesome/fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), + url('~font-awesome/fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), + url('~font-awesome/fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), + url('~font-awesome/fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), + url('~font-awesome/fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg'); + font-weight: normal; + font-style: normal; + } + + @import 'font-awesome/scss/variables'; + @import 'font-awesome/scss/core'; + @import 'font-awesome/scss/icons'; + + // new file icon + .#{$fa-css-prefix}-file-new-o:before { content: $fa-var-file-o; } + .#{$fa-css-prefix}-file-new-o:after { content: $fa-var-plus; position: relative; margin-left: -1.0em; font-size: .5em; } + + // alias for alert types - allows class="fa fa-{{alertType}}" + .fa-success:before { content: $fa-var-check; } + .fa-danger:before { content: $fa-var-exclamation-circle; } + + /** + * THE SVG Graph + * 1. Calculated px values come from the open/closed state of the global nav sidebar + */ + + #graphBasic { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.gphGraph__container { + display: flex; + flex-direction: column; + background: $euiColorEmptyShade; + position: relative; + flex: 1; +} + +.gphGraph__menus { + margin: $euiSizeS; +} + +.gphGraph__flexGroup { + display: flex; + width: 100%; +} + +.gphGraph__flexGroupFiller { + flex: 1 1 auto; +} + +@mixin gphSvgText() { + font-family: $euiFontFamily; + font-size: $euiSizeS; + line-height: $euiSizeM; + fill: $euiColorDarkShade; + color: $euiColorDarkShade; +} + +.gphVisualization { + flex: 1; + display: flex; + flex-direction: column; +} + +.gphGraph { + flex: 1; + overflow: hidden; +} + +.gphEdge { + fill: $euiColorMediumShade; + stroke: $euiColorMediumShade; + stroke-width: 2; + stroke-opacity: .5; + + &--selected { + stroke: $euiColorDarkShade; + stroke-opacity: .95; + } +} + +.gphEdge--clickable { + fill: transparent; + opacity: 0; +} + +.gphEdge--wrapper:hover { + .gphEdge { + stroke-opacity: .95; + cursor: pointer; + } +} + +.gphNode { + cursor: pointer; +} + +.gphNode__label { + @include gphSvgText; + cursor: pointer; + &--html { + @include euiTextTruncate; + text-align: center; + } +} + +.gphNode__markerCircle { + fill: $euiColorDarkShade; + stroke: $euiColorEmptyShade; +} + +.gphNode__markerText { + @include gphSvgText; + font-size: $euiSizeS - 2px; + fill: $euiColorEmptyShade; +} + +.gphNode__circle { + fill: $euiColorMediumShade; + &--selected { + stroke-width: $euiSizeXS; + stroke: transparentize($euiColorPrimary, .25); + } +} + +.gphNode__text { + fill: $euiColorInk; + + &--inverse { + fill: $euiColorGhost; + } +} diff --git a/x-pack/plugins/security_solution/public/app/font_awesome/index.ts b/x-pack/plugins/security_solution/public/app/font_awesome/index.ts new file mode 100644 index 000000000000..99694a2bb3b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/app/font_awesome/index.ts @@ -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. + */ + +import './font-awesome.scss'; diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 98b82a8d5b8f..a238f9e53992 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -13,6 +13,7 @@ import { Route } from '@kbn/kibana-react-plugin/public'; import { NotFoundPage } from './404'; import { SecurityApp } from './app'; import type { RenderAppProps } from './types'; +import('./font_awesome'); export const renderApp = ({ element, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index c63fcf7bf5c3..228c0027c8f3 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -15,11 +15,14 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, + EuiTitle, } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { css } from '@emotion/css'; import { isEmpty } from 'lodash'; +import { getMitreTitleAndDescription } from '../../../detections/pages/alert_details/tabs/summary/get_mitre_threat_component'; import type { AlertRawEventData } from './osquery_tab'; import { useOsqueryTab } from './osquery_tab'; import { EventFieldsBrowser } from './event_fields_browser'; @@ -50,6 +53,13 @@ import { defaultRowRenderers } from '../../../timelines/components/timeline/body export const EVENT_DETAILS_CONTEXT_ID = 'event-details'; +const threatTacticContainerStyles = css` + flex-wrap: nowrap; + & .euiFlexGroup { + flex-wrap: nowrap; + } +`; + type EventViewTab = EuiTabbedContentTab; export type EventViewId = @@ -160,6 +170,7 @@ const EventDetailsComponent: React.FC = ({ range, } = useInvestigationTimeEnrichment(eventFields); + const threatDetails = useMemo(() => getMitreTitleAndDescription(data), [data]); const allEnrichments = useMemo(() => { if (isEnrichmentsLoading || !enrichmentsResponse?.enrichments) { return existingEnrichments; @@ -227,7 +238,22 @@ const EventDetailsComponent: React.FC = ({ }} goToTable={goToTableTab} /> - + + + {threatDetails && threatDetails[0] && ( + <> + +
{threatDetails[0].title}
+
+ {threatDetails[0].description} + + )} +
= ({ isLicenseValid, isReadOnly, renderer, + threatDetails, timelineId, userRisk, ] diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index 0e6a51fdb268..4b79d36dfef8 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -65,6 +65,7 @@ export interface HeaderPageProps extends HeaderProps { badgeOptions?: BadgeOptions; children?: React.ReactNode; draggableArguments?: DraggableArguments; + rightSideItems?: React.ReactNode[]; subtitle?: SubtitleProps['items']; subtitle2?: SubtitleProps['items']; title: TitleProp; @@ -104,13 +105,14 @@ const HeaderPageComponent: React.FC = ({ children, draggableArguments, isLoading, + rightSideItems, subtitle, subtitle2, title, titleNode, }) => ( <> - + {backOptions && } {!backOptions && backComponent && <>{backComponent}} diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts index 52ed72dc1a2b..b7bfb751e4fa 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts @@ -12,6 +12,7 @@ export { getAppLandingUrl } from '../redirect_to_landing'; export { getHostDetailsUrl, getHostsUrl } from '../redirect_to_hosts'; export { getNetworkUrl, getNetworkDetailsUrl } from '../redirect_to_network'; export { getTimelineTabsUrl, getTimelineUrl } from '../redirect_to_timelines'; +export { getAlertDetailsUrl, getAlertDetailsTabUrl } from '../redirect_to_alerts'; export { getCaseDetailsUrl, getCaseUrl, diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 1d0374711621..4630ae0f6f71 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -12,6 +12,7 @@ import { useAppUrl } from '../../lib/kibana/hooks'; import type { SecurityPageName } from '../../../app/types'; import { needsUrlState } from '../../links'; +export { getAlertDetailsUrl, getAlertDetailsTabUrl } from './redirect_to_alerts'; export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine'; export { getHostDetailsUrl, getTabsOnHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; export { getKubernetesUrl, getKubernetesDetailsUrl } from './redirect_to_kubernetes'; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_alerts.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_alerts.tsx new file mode 100644 index 000000000000..d29530f2cdfc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_alerts.tsx @@ -0,0 +1,19 @@ +/* + * 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 { ALERTS_PATH } from '../../../../common/constants'; +import type { AlertDetailRouteType } from '../../../detections/pages/alert_details/types'; +import { appendSearch } from './helpers'; + +export const getAlertDetailsUrl = (alertId: string, search?: string) => + `/${alertId}/summary${appendSearch(search)}`; + +export const getAlertDetailsTabUrl = ( + detailName: string, + tabName: AlertDetailRouteType, + search?: string +) => `${ALERTS_PATH}/${detailName}/${tabName}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index fa2178c52d94..afcaff3f3d06 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -15,6 +15,7 @@ import { getTrailingBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../n import { getTrailingBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../users/pages/details/utils'; import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs'; +import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs'; import { SecurityPageName } from '../../../../app/types'; import type { RouteSpyState, @@ -22,6 +23,7 @@ import type { NetworkRouteSpyState, AdministrationRouteSpyState, UsersRouteSpyState, + AlertDetailRouteSpyState, } from '../../../utils/route/types'; import { timelineActions } from '../../../../timelines/store/timeline'; import { TimelineId } from '../../../../../common/types/timeline'; @@ -132,6 +134,9 @@ const getTrailingBreadcrumbsForRoutes = ( if (isKubernetesRoutes(spyState)) { return getKubernetesBreadcrumbs(spyState, getSecuritySolutionUrl); } + if (isAlertRoutes(spyState)) { + return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl); + } return []; }; @@ -150,6 +155,9 @@ const isCaseRoutes = (spyState: RouteSpyState) => spyState.pageName === Security const isKubernetesRoutes = (spyState: RouteSpyState) => spyState.pageName === SecurityPageName.kubernetes; +const isAlertRoutes = (spyState: RouteSpyState): spyState is AlertDetailRouteSpyState => + spyState.pageName === SecurityPageName.alerts; + const isRulesRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => spyState.pageName === SecurityPageName.rules || spyState.pageName === SecurityPageName.rulesCreate; diff --git a/x-pack/plugins/security_solution/public/common/containers/cases/use_get_related_cases_by_event.ts b/x-pack/plugins/security_solution/public/common/containers/cases/use_get_related_cases_by_event.ts new file mode 100644 index 000000000000..2073f48f1d70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/cases/use_get_related_cases_by_event.ts @@ -0,0 +1,54 @@ +/* + * 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 { useCallback, useState, useEffect } from 'react'; +import { useKibana, useToasts } from '../../lib/kibana'; +import { CASES_ERROR_TOAST } from '../../components/event_details/insights/translations'; +import { APP_ID } from '../../../../common/constants'; + +type RelatedCases = Array<{ id: string; title: string }>; + +export const useGetRelatedCasesByEvent = (eventId: string) => { + const { + services: { cases }, + } = useKibana(); + const toasts = useToasts(); + + const [relatedCases, setRelatedCases] = useState(undefined); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const getRelatedCases = useCallback(async () => { + setLoading(true); + let relatedCasesResponse: RelatedCases = []; + try { + if (eventId) { + relatedCasesResponse = + (await cases.api.getRelatedCases(eventId, { + owner: APP_ID, + })) ?? []; + } + } catch (err) { + setError(err); + toasts.addWarning(CASES_ERROR_TOAST(err)); + } finally { + setLoading(false); + } + setRelatedCases(relatedCasesResponse); + }, [eventId, cases.api, toasts]); + + useEffect(() => { + getRelatedCases(); + }, [eventId, getRelatedCases]); + + return { + loading, + error, + relatedCases, + refetchRelatedCases: getRelatedCases, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts b/x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts new file mode 100644 index 000000000000..4190010301a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts @@ -0,0 +1,135 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import { getOr } from 'lodash/fp'; +import type { SearchHit } from '../../../common/search_strategy'; + +/** + * Since the fields api may return a string array as well as an object array + * Getting the nestedPath of an object array would require first getting the top level `fields` key + * The field api keys do not provide an index value for the original order of each object + * for example, we might expect fields to reference kibana.alert.parameters.0.index, but the index information is represented by the array position. + * This should be generally fine, but given the flattened nature of the top level key, utilities like `get` or `getOr` won't work since the path isn't actually nested + * This utility allows users to not only get simple fields, but if they provide a path like `kibana.alert.parameters.index`, it will return an array of all index values + * for each object in the parameters array. As an added note, this work stemmed from a hope to be able to purely use the fields api in place of the data produced by + * `getDataFromFieldsHits` found in `x-pack/plugins/timelines/common/utils/field_formatters.ts` + */ +const getAllDotIndicesInReverse = (dotField: string): number[] => { + const dotRegx = RegExp('[.]', 'g'); + const indicesOfAllDotsInString = []; + let result = dotRegx.exec(dotField); + while (result) { + indicesOfAllDotsInString.push(result.index); + result = dotRegx.exec(dotField); + } + /** + * Put in reverse so we start look up from the most likely to be found; + * [[kibana.alert.parameters, index], ['kibana.alert', 'parameters.index'], ['kibana', 'alert.parameters.index']] + */ + return indicesOfAllDotsInString.reverse(); +}; + +/** + * We get the dot paths so we can look up each path to see if any of the nested fields exist + * */ + +const getAllPotentialDotPaths = (dotField: string): string[][] => { + const reverseDotIndices = getAllDotIndicesInReverse(dotField); + + // The nested array paths seem to be at most a tuple (i.e.: `kibana.alert.parameters`, `some.nested.parameters.field`) + const pathTuples = reverseDotIndices.map((dotIndex: number) => { + return [dotField.slice(0, dotIndex), dotField.slice(dotIndex + 1)]; + }); + + return pathTuples; +}; + +const getNestedValue = (startPath: string, endPath: string, data: Record) => { + const foundPrimaryPath = data[startPath]; + if (Array.isArray(foundPrimaryPath)) { + // If the nested path points to an array of objects return the nested value of every object in the array + return foundPrimaryPath + .map((nestedObj) => getOr(null, endPath, nestedObj)) // TODO:QUESTION: does it make sense to leave undefined or null values as array position could be important? + .filter((val) => val !== null); + } else { + // The nested path is just a nested object, so use getOr + return getOr(undefined, endPath, foundPrimaryPath); + } +}; + +/** + * we get the field value from a fields response and by breaking down to look at each individual path, + * we're able to get both top level fields as well as nested fields that don't provide index information. + * In the case where a user enters kibana.alert.parameters.someField, a mapped array of the subfield value will be returned + */ +const getFieldsValue = ( + dotField: string, + data: SearchHit['fields'] | undefined, + cacheNestedField: (fullPath: string, value: unknown) => void +) => { + if (!dotField || !data) return undefined; + + // If the dotField exists and is not a nested object return it + if (Object.hasOwn(data, dotField)) return data[dotField]; + else { + const pathTuples = getAllPotentialDotPaths(dotField); + for (const [startPath, endPath] of pathTuples) { + const foundPrimaryPath = Object.hasOwn(data, startPath) ? data[startPath] : null; + if (foundPrimaryPath) { + const nestedValue = getNestedValue(startPath, endPath, data); + // We cache only the values that need extra work to find. This can be an array of values or a single value + cacheNestedField(dotField, nestedValue); + return nestedValue; + } + } + } + + // Return undefined if nothing is found + return undefined; +}; + +export type GetFieldsDataValue = string | string[] | null | undefined; +export type GetFieldsData = (field: string) => GetFieldsDataValue; + +export const useGetFieldsData = (fieldsData: SearchHit['fields'] | undefined): GetFieldsData => { + // TODO: Move cache to top level container such as redux or context. Make it store type agnostic if possible + // TODO: Handle updates where data is re-requested and the cache is reset. + const cachedOriginalData = useMemo(() => fieldsData, [fieldsData]); + const cachedExpensiveNestedValues: Record = useMemo(() => ({}), []); + + // Speed up any lookups elsewhere by caching the field. + const cacheNestedValues = useCallback( + (fullPath: string, value: unknown) => { + cachedExpensiveNestedValues[fullPath] = value; + }, + [cachedExpensiveNestedValues] + ); + + return useCallback( + (field: string) => { + let fieldsValue; + // Get an expensive value from the cache if it exists, otherwise search for the value + if (Object.hasOwn(cachedExpensiveNestedValues, field)) { + fieldsValue = cachedExpensiveNestedValues[field]; + } else { + fieldsValue = cachedOriginalData + ? getFieldsValue(field, cachedOriginalData, cacheNestedValues) + : undefined; + } + + if (Array.isArray(fieldsValue)) { + // Return the value if it's singular, otherwise return an expected array of values + if (fieldsValue.length === 0) return undefined; + else return fieldsValue; + } + // Otherwise return the given fieldsValue if it isn't an array + return fieldsValue; + }, + [cacheNestedValues, cachedExpensiveNestedValues, cachedOriginalData] + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 71d58f487da6..168c91854584 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -13,6 +13,7 @@ import type { TimelineType } from '../../../../common/types/timeline'; import type { HostsTableType } from '../../../hosts/store/model'; import type { NetworkRouteType } from '../../../network/pages/navigation/types'; +import type { AlertDetailRouteType } from '../../../detections/pages/alert_details/types'; import type { AdministrationSubTab as AdministrationType } from '../../../management/types'; import type { FlowTarget } from '../../../../common/search_strategy'; import type { UsersTableType } from '../../../users/store/model'; @@ -21,6 +22,7 @@ import type { SecurityPageName } from '../../../app/types'; export type SiemRouteType = | HostsTableType | NetworkRouteType + | AlertDetailRouteType | TimelineType | AdministrationType | UsersTableType; @@ -47,6 +49,10 @@ export interface NetworkRouteSpyState extends RouteSpyState { tabName: NetworkRouteType | undefined; } +export interface AlertDetailRouteSpyState extends RouteSpyState { + tabName: AlertDetailRouteType | undefined; +} + export interface AdministrationRouteSpyState extends RouteSpyState { tabName: AdministrationType | undefined; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index 8d0ace8a3dcf..7bb368e2b02b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -18,6 +18,15 @@ import { useUserPrivileges } from '../../../../common/components/user_privileges jest.mock('../../../../common/components/user_privileges'); +const testSecuritySolutionLinkHref = 'test-url'; +jest.mock('../../../../common/components/links', () => ({ + useGetSecuritySolutionLinkProps: () => () => ({ href: testSecuritySolutionLinkHref }), +})); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + const ecsRowData: Ecs = { _id: '1', agent: { type: ['blah'] }, @@ -83,182 +92,238 @@ const markAsOpenButton = '[data-test-subj="open-alert-status"]'; const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]'; const markAsClosedButton = '[data-test-subj="close-alert-status"]'; const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]'; - -describe('InvestigateInResolverAction', () => { - test('it render AddToCase context menu item if timelineId === TimelineId.detectionsPage', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders, +const openAlertDetailsPageButton = '[data-test-subj="open-alert-details-page-menu-item"]'; + +describe('Alert table context menu', () => { + describe('Case actions', () => { + test('it render AddToCase context menu item if timelineId === TimelineId.detectionsPage', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); + expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); }); - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); - expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); - }); + test('it render AddToCase context menu item if timelineId === TimelineId.detectionsRulesDetailsPage', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); + expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); + }); - test('it render AddToCase context menu item if timelineId === TimelineId.detectionsRulesDetailsPage', () => { - const wrapper = mount( - , - { + test('it render AddToCase context menu item if timelineId === TimelineId.active', () => { + const wrapper = mount(, { wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); - expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); - }); + }); - test('it render AddToCase context menu item if timelineId === TimelineId.active', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders, + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); + expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); }); - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(true); - expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(true); + test('it does NOT render AddToCase context menu item when timelineId is not in the allowed list', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(false); + expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(false); + }); }); - test('it does NOT render AddToCase context menu item when timelineId is not in the allowed list', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders, + describe('Alert status actions', () => { + test('it renders the correct status action buttons', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + wrapper.find(actionMenuButton).simulate('click'); + + expect(wrapper.find(markAsOpenButton).first().exists()).toEqual(false); + expect(wrapper.find(markAsAcknowledgedButton).first().exists()).toEqual(true); + expect(wrapper.find(markAsClosedButton).first().exists()).toEqual(true); }); - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addToExistingCaseButton).first().exists()).toEqual(false); - expect(wrapper.find(addToNewCaseButton).first().exists()).toEqual(false); }); - test('it renders the correct status action buttons', () => { - const wrapper = mount(, { - wrappingComponent: TestProviders, - }); + describe('Endpoint event filter actions', () => { + describe('AddEndpointEventFilter', () => { + const endpointEventProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } }, + }; + + describe('when users can access endpoint management', () => { + beforeEach(() => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { loading: false, canAccessEndpointManagement: true }, + }); + }); - wrapper.find(actionMenuButton).simulate('click'); + test('it disables AddEndpointEventFilter when timeline id is not host events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); - expect(wrapper.find(markAsOpenButton).first().exists()).toEqual(false); - expect(wrapper.find(markAsAcknowledgedButton).first().exists()).toEqual(true); - expect(wrapper.find(markAsClosedButton).first().exists()).toEqual(true); - }); + test('it enables AddEndpointEventFilter when timeline id is host events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual( + false + ); + }); - describe('AddEndpointEventFilter', () => { - const endpointEventProps = { - ...props, - ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } }, - }; - - describe('when users can access endpoint management', () => { - beforeEach(() => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - ...mockInitialUserPrivilegesState(), - endpointPrivileges: { loading: false, canAccessEndpointManagement: true }, + test('it disables AddEndpointEventFilter when timeline id is host events page but is not from endpoint', () => { + const customProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, + }; + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); }); - }); - test('it disables AddEndpointEventFilter when timeline id is not host events page', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); - }); + test('it enables AddEndpointEventFilter when timeline id is user events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual( + false + ); + }); - test('it enables AddEndpointEventFilter when timeline id is host events page', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false); + test('it disables AddEndpointEventFilter when timeline id is user events page but is not from endpoint', () => { + const customProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, + }; + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); }); - test('it disables AddEndpointEventFilter when timeline id is host events page but is not from endpoint', () => { - const customProps = { - ...props, - ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, - }; - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); - }); + describe('when users can NOT access endpoint management', () => { + beforeEach(() => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + }); + }); - test('it enables AddEndpointEventFilter when timeline id is user events page', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false); - }); + test('it disables AddEndpointEventFilter when timeline id is host events page but cannot acces endpoint management', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); - test('it disables AddEndpointEventFilter when timeline id is user events page but is not from endpoint', () => { - const customProps = { - ...props, - ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, - }; - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); - }); - }); - describe('when users can NOT access endpoint management', () => { - beforeEach(() => { - (useUserPrivileges as jest.Mock).mockReturnValue({ - ...mockInitialUserPrivilegesState(), - endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + test('it disables AddEndpointEventFilter when timeline id is user events page but cannot acces endpoint management', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); }); }); + }); + }); - test('it disables AddEndpointEventFilter when timeline id is host events page but cannot acces endpoint management', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); - }); + describe('Open alert details action', () => { + test('it does not render the open alert details page action if kibana.alert.rule.uuid is not set', () => { + const nonAlertProps = { + ...props, + ecsRowData: { + ...ecsRowData, + kibana: { + alert: { + workflow_status: ['open'], + rule: { + parameters: {}, + uuid: [], + }, + }, + }, + }, + }; + + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + + expect(wrapper.find(openAlertDetailsPageButton).first().exists()).toEqual(false); + }); - test('it disables AddEndpointEventFilter when timeline id is user events page but cannot acces endpoint management', () => { - const wrapper = mount( - , - { - wrappingComponent: TestProviders, - } - ); - - wrapper.find(actionMenuButton).simulate('click'); - expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); - expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + test('it renders the open alert details action button', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, }); + + wrapper.find(actionMenuButton).simulate('click'); + + expect(wrapper.find(openAlertDetailsPageButton).first().exists()).toEqual(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 3759178c163b..5f9bf799d3a7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -41,6 +41,7 @@ import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/t import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check'; +import { useOpenAlertDetailsAction } from './use_open_alert_details'; interface AlertContextMenuProps { ariaLabel?: string; @@ -201,6 +202,12 @@ const AlertContextMenuComponent: React.FC !isEvent && ruleId @@ -209,6 +216,7 @@ const AlertContextMenuComponent: React.FC void; + alertId: string | null; +} + +export const ACTION_OPEN_ALERT_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.openAlertDetails', + { + defaultMessage: 'Open alert details page', + } +); + +export const useOpenAlertDetailsAction = ({ ruleId, closePopover, alertId }: Props) => { + const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); + const alertDetailsActionItems = []; + const { href } = useGetSecuritySolutionLinkProps()({ + deepLinkId: SecurityPageName.alerts, + path: alertId ? getAlertDetailsUrl(alertId) : '', + }); + + // We check ruleId to confirm this is an alert, as this page does not support events as of 8.6 + if (ruleId && alertId && isAlertDetailsPageEnabled) { + alertDetailsActionItems.push( + + {ACTION_OPEN_ALERT_DETAILS_PAGE} + + ); + } + + return { + alertDetailsActionItems, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/alert_details_response.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/alert_details_response.ts new file mode 100644 index 000000000000..67c5415fb2b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/alert_details_response.ts @@ -0,0 +1,2020 @@ +/* + * 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 type { Ecs } from '../../../../../common/ecs'; + +// This data was generated using the endpoint test alert generator +export const getMockAlertDetailsFieldsResponse = () => ({ + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325', + _score: 1, + fields: { + 'kibana.alert.severity': ['medium'], + 'process.hash.md5': ['fake md5'], + 'kibana.alert.rule.updated_by': ['elastic'], + 'signal.ancestors.depth': [0], + 'event.category': ['malware'], + 'kibana.alert.rule.rule_name_override': ['message'], + 'Endpoint.capabilities': ['isolation', 'kill_process', 'suspend_process', 'running_processes'], + 'process.parent.pid': [1], + 'process.hash.sha256': ['fake sha256'], + 'host.hostname': ['Host-4cfuh42w7g'], + 'kibana.alert.rule.tags': ['Elastic', 'Endpoint Security'], + 'host.mac': ['f2-32-1b-dc-ec-80'], + 'elastic.agent.id': ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + 'dll.hash.sha256': ['8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2'], + 'kibana.alert.ancestors.depth': [0], + 'signal.rule.enabled': ['true'], + 'signal.rule.max_signals': [10000], + 'host.os.version': ['10.0'], + 'signal.rule.updated_at': ['2022-09-29T19:39:38.137Z'], + 'kibana.alert.risk_score': [47], + 'Endpoint.policy.applied.id': ['C2A9093E-E289-4C0A-AA44-8C32A414FA7A'], + 'kibana.alert.rule.severity_mapping.severity': ['low', 'medium', 'high', 'critical'], + 'event.agent_id_status': ['auth_metadata_missing'], + 'kibana.alert.original_event.id': ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + 'kibana.alert.rule.risk_score_mapping.value': [''], + 'process.Ext.ancestry': ['kj0le842x0', '1r4s9i1br4'], + 'signal.original_event.code': ['memory_signature'], + 'kibana.alert.original_event.module': ['endpoint'], + 'kibana.alert.rule.interval': ['5m'], + 'kibana.alert.rule.type': ['query'], + 'signal.original_event.sequence': [1232], + 'Endpoint.state.isolation': [true], + 'host.architecture': ['x7n6yt4fol'], + 'kibana.alert.rule.immutable': ['true'], + 'kibana.alert.original_event.type': ['info'], + 'event.code': ['memory_signature'], + 'agent.id': ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + 'signal.original_event.module': ['endpoint'], + 'kibana.alert.rule.exceptions_list.list_id': ['endpoint_list'], + 'signal.rule.from': ['now-10m'], + 'kibana.alert.rule.exceptions_list.type': ['endpoint'], + 'process.group_leader.entity_id': ['b74mw1jkrm'], + 'dll.Ext.malware_classification.version': ['3.0.0'], + 'kibana.alert.rule.enabled': ['true'], + 'kibana.alert.rule.version': ['100'], + 'kibana.alert.ancestors.type': ['event'], + 'process.entry_leader.name': ['fake entry'], + 'dll.Ext.compile_time': [1534424710], + 'signal.ancestors.index': ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + 'dll.Ext.malware_classification.score': [0], + 'process.entity_id': ['d3v4to81q9'], + 'host.ip': ['10.184.3.36', '10.170.218.86'], + 'agent.type': ['endpoint'], + 'signal.original_event.category': ['malware'], + 'signal.original_event.id': ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + 'process.uptime': [0], + 'Endpoint.policy.applied.name': ['With Eventing'], + 'host.id': ['04794e4e-59cb-4c4a-a8ee-3e6c5b65743c'], + 'process.Ext.code_signature.subject_name': ['bad signer'], + 'process.Ext.token.integrity_level_name': ['high'], + 'signal.original_event.type': ['info'], + 'kibana.alert.rule.max_signals': [10000], + 'signal.rule.author': ['Elastic'], + 'kibana.alert.rule.risk_score': [47], + 'dll.Ext.malware_classification.identifier': ['Whitelisted'], + 'dll.Ext.mapped_address': [5362483200], + 'signal.original_event.dataset': ['endpoint'], + 'kibana.alert.rule.consumer': ['siem'], + 'kibana.alert.rule.indices': ['logs-endpoint.alerts-*'], + 'kibana.alert.rule.category': ['Custom Query Rule'], + 'host.os.Ext.variant': ['Windows Server'], + 'event.ingested': ['2022-09-29T19:37:00.000Z'], + 'event.action': ['start'], + 'signal.rule.updated_by': ['elastic'], + '@timestamp': ['2022-09-29T19:40:26.051Z'], + 'kibana.alert.original_event.action': ['start'], + 'host.os.platform': ['Windows'], + 'process.session_leader.entity_id': ['b74mw1jkrm'], + 'kibana.alert.rule.severity': ['medium'], + 'kibana.alert.original_event.agent_id_status': ['auth_metadata_missing'], + 'Endpoint.status': ['enrolled'], + 'data_stream.dataset': ['endpoint.alerts'], + 'signal.rule.timestamp_override': ['event.ingested'], + 'kibana.alert.rule.execution.uuid': ['abf39d36-0f1c-4bf9-ae42-1039285380b5'], + 'kibana.alert.uuid': ['f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325'], + 'kibana.version': ['8.6.0'], + 'process.hash.sha1': ['fake sha1'], + 'event.id': ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + 'process.entry_leader.pid': [865], + 'signal.rule.license': ['Elastic License v2'], + 'signal.ancestors.type': ['event'], + 'kibana.alert.rule.rule_id': ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + 'process.session_leader.pid': [745], + 'signal.rule.type': ['query'], + 'Endpoint.policy.applied.version': [5], + 'dll.hash.md5': ['1f2d082566b0fc5f2c238a5180db7451'], + 'kibana.alert.ancestors.id': ['7L3AioMBWJvcpv7vlX2O'], + 'user.name': ['root'], + 'source.ip': ['10.184.3.46'], + 'signal.rule.rule_name_override': ['message'], + 'process.group_leader.name': ['fake leader'], + 'host.os.full': ['Windows Server 2016'], + 'kibana.alert.original_event.code': ['memory_signature'], + 'kibana.alert.rule.risk_score_mapping.field': ['event.risk_score'], + 'kibana.alert.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'process.pid': [2], + 'kibana.alert.rule.producer': ['siem'], + 'kibana.alert.rule.to': ['now'], + 'signal.rule.interval': ['5m'], + 'signal.rule.created_by': ['elastic'], + 'kibana.alert.rule.created_by': ['elastic'], + 'kibana.alert.rule.timestamp_override': ['event.ingested'], + 'kibana.alert.original_event.ingested': ['2022-09-29T19:37:00.000Z'], + 'signal.rule.id': ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + 'process.parent.entity_id': ['kj0le842x0'], + 'signal.rule.risk_score': [47], + 'signal.reason': [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + 'host.os.name': ['Windows'], + 'kibana.alert.rule.name': ['Endpoint Security'], + 'host.name': ['Host-4cfuh42w7g'], + 'signal.status': ['open'], + 'event.kind': ['signal'], + 'kibana.alert.rule.severity_mapping.value': ['21', '47', '73', '99'], + 'signal.rule.tags': ['Elastic', 'Endpoint Security'], + 'signal.rule.created_at': ['2022-09-29T19:39:38.137Z'], + 'kibana.alert.workflow_status': ['open'], + 'Endpoint.policy.applied.status': ['warning'], + 'kibana.alert.rule.uuid': ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + 'kibana.alert.original_event.category': ['malware'], + 'dll.Ext.malware_classification.threshold': [0], + 'kibana.alert.reason': [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + 'dll.pe.architecture': ['x64'], + 'data_stream.type': ['logs'], + 'signal.original_time': ['2022-10-09T07:14:42.194Z'], + 'signal.ancestors.id': ['7L3AioMBWJvcpv7vlX2O'], + 'process.name': ['explorer.exe'], + 'ecs.version': ['1.6.0'], + 'signal.rule.severity': ['medium'], + 'kibana.alert.ancestors.index': ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + 'Endpoint.configuration.isolation': [true], + 'Memory_protection.feature': ['signature'], + 'dll.code_signature.trusted': [true], + 'process.Ext.code_signature.trusted': [false], + 'kibana.alert.depth': [1], + 'agent.version': ['8.6.0'], + 'kibana.alert.rule.risk_score_mapping.operator': ['equals'], + 'host.os.family': ['windows'], + 'kibana.alert.rule.from': ['now-10m'], + 'Memory_protection.self_injection': [true], + 'process.start': ['2022-10-09T07:14:42.194Z'], + 'kibana.alert.rule.parameters': [ + { + severity_mapping: [ + { + severity: 'low', + field: 'event.severity', + value: '21', + operator: 'equals', + }, + { + severity: 'medium', + field: 'event.severity', + value: '47', + operator: 'equals', + }, + { + severity: 'high', + field: 'event.severity', + value: '73', + operator: 'equals', + }, + { + severity: 'critical', + field: 'event.severity', + value: '99', + operator: 'equals', + }, + ], + references: [], + description: + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + language: 'kuery', + type: 'query', + rule_name_override: 'message', + exceptions_list: [ + { + list_id: 'endpoint_list', + namespace_type: 'agnostic', + id: 'endpoint_list', + type: 'endpoint', + }, + ], + timestamp_override: 'event.ingested', + from: 'now-10m', + severity: 'medium', + max_signals: 10000, + risk_score: 47, + risk_score_mapping: [ + { + field: 'event.risk_score', + value: '', + operator: 'equals', + }, + ], + author: ['Elastic'], + query: 'event.kind:alert and event.module:(endpoint and not endgame)\n', + index: ['logs-endpoint.alerts-*'], + version: 100, + rule_id: '9a1a2dae-0b5f-4c3d-8305-a268d404c306', + license: 'Elastic License v2', + required_fields: [ + { + ecs: true, + name: 'event.kind', + type: 'keyword', + }, + { + ecs: true, + name: 'event.module', + type: 'keyword', + }, + ], + immutable: true, + related_integrations: [], + setup: '', + false_positives: [], + threat: [], + to: 'now', + }, + ], + 'signal.rule.version': ['100'], + 'signal.original_event.kind': ['alert'], + 'kibana.alert.status': ['active'], + 'kibana.alert.rule.severity_mapping.field': [ + 'event.severity', + 'event.severity', + 'event.severity', + 'event.severity', + ], + 'kibana.alert.original_event.dataset': ['endpoint'], + 'signal.depth': [1], + 'signal.rule.immutable': ['true'], + 'process.group_leader.pid': [116], + 'event.sequence': [1232], + 'kibana.alert.rule.rule_type_id': ['siem.queryRule'], + 'process.session_leader.name': ['fake session'], + 'signal.rule.name': ['Endpoint Security'], + 'signal.rule.rule_id': ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + 'event.module': ['endpoint'], + 'dll.hash.sha1': ['ca85243c0af6a6471bdaa560685c51eefd6dbc0d'], + 'kibana.alert.rule.severity_mapping.operator': ['equals', 'equals', 'equals', 'equals'], + 'process.Ext.malware_signature.all_names': ['Windows.Trojan.FakeAgent'], + 'kibana.alert.rule.license': ['Elastic License v2'], + 'kibana.alert.original_event.kind': ['alert'], + 'process.executable': ['C:/fake/explorer.exe'], + 'kibana.alert.rule.updated_at': ['2022-09-29T19:39:38.137Z'], + 'signal.rule.description': [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + 'dll.Ext.mapped_size': [0], + 'data_stream.namespace': ['default'], + 'kibana.alert.rule.author': ['Elastic'], + 'dll.code_signature.subject_name': ['Cybereason Inc'], + 'Endpoint.policy.applied.endpoint_policy_version': [3], + 'kibana.alert.original_event.sequence': [1232], + 'dll.path': ['C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe'], + 'process.Ext.user': ['SYSTEM'], + 'signal.original_event.action': ['start'], + 'signal.rule.to': ['now'], + 'kibana.alert.rule.created_at': ['2022-09-29T19:39:38.137Z'], + 'process.Ext.malware_signature.identifier': ['diagnostic-malware-signature-v1-fake'], + 'kibana.alert.rule.exceptions_list.namespace_type': ['agnostic'], + 'event.type': ['info'], + 'kibana.space_ids': ['default'], + 'process.entry_leader.entity_id': ['b74mw1jkrm'], + 'kibana.alert.rule.exceptions_list.id': ['endpoint_list'], + 'event.dataset': ['endpoint'], + 'kibana.alert.original_time': ['2022-10-09T07:14:42.194Z'], + }, +}); + +export const getMockAlertDetailsTimelineResponse = () => [ + { + category: 'kibana', + field: 'kibana.alert.severity', + values: ['medium'], + originalValue: ['medium'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.hash.md5', + values: ['fake md5'], + originalValue: ['fake md5'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.updated_by', + values: ['elastic'], + originalValue: ['elastic'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.ancestors.depth', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.category', + values: ['malware'], + originalValue: ['malware'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.rule_name_override', + values: ['message'], + originalValue: ['message'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.capabilities', + values: ['isolation', 'kill_process', 'suspend_process', 'running_processes'], + originalValue: ['isolation', 'kill_process', 'suspend_process', 'running_processes'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.parent.pid', + values: ['1'], + originalValue: ['1'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.hash.sha256', + values: ['fake sha256'], + originalValue: ['fake sha256'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.hostname', + values: ['Host-4cfuh42w7g'], + originalValue: ['Host-4cfuh42w7g'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.tags', + values: ['Elastic', 'Endpoint Security'], + originalValue: ['Elastic', 'Endpoint Security'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.mac', + values: ['f2-32-1b-dc-ec-80'], + originalValue: ['f2-32-1b-dc-ec-80'], + isObjectArray: false, + }, + { + category: 'elastic', + field: 'elastic.agent.id', + values: ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + originalValue: ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.hash.sha256', + values: ['8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2'], + originalValue: ['8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.ancestors.depth', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.enabled', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.max_signals', + values: ['10000'], + originalValue: ['10000'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.version', + values: ['10.0'], + originalValue: ['10.0'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.updated_at', + values: ['2022-09-29T19:39:38.137Z'], + originalValue: ['2022-09-29T19:39:38.137Z'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.risk_score', + values: ['47'], + originalValue: ['47'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.policy.applied.id', + values: ['C2A9093E-E289-4C0A-AA44-8C32A414FA7A'], + originalValue: ['C2A9093E-E289-4C0A-AA44-8C32A414FA7A'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity_mapping.severity', + values: ['low', 'medium', 'high', 'critical'], + originalValue: ['low', 'medium', 'high', 'critical'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.agent_id_status', + values: ['auth_metadata_missing'], + originalValue: ['auth_metadata_missing'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.id', + values: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + originalValue: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.risk_score_mapping.value', + values: [''], + originalValue: [''], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.ancestry', + values: ['kj0le842x0', '1r4s9i1br4'], + originalValue: ['kj0le842x0', '1r4s9i1br4'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.code', + values: ['memory_signature'], + originalValue: ['memory_signature'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.module', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.interval', + values: ['5m'], + originalValue: ['5m'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.type', + values: ['query'], + originalValue: ['query'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.sequence', + values: ['1232'], + originalValue: ['1232'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.state.isolation', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.architecture', + values: ['x7n6yt4fol'], + originalValue: ['x7n6yt4fol'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.immutable', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.type', + values: ['info'], + originalValue: ['info'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.code', + values: ['memory_signature'], + originalValue: ['memory_signature'], + isObjectArray: false, + }, + { + category: 'agent', + field: 'agent.id', + values: ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + originalValue: ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.module', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.exceptions_list.list_id', + values: ['endpoint_list'], + originalValue: ['endpoint_list'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.from', + values: ['now-10m'], + originalValue: ['now-10m'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.exceptions_list.type', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.group_leader.entity_id', + values: ['b74mw1jkrm'], + originalValue: ['b74mw1jkrm'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.malware_classification.version', + values: ['3.0.0'], + originalValue: ['3.0.0'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.enabled', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.version', + values: ['100'], + originalValue: ['100'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.ancestors.type', + values: ['event'], + originalValue: ['event'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.entry_leader.name', + values: ['fake entry'], + originalValue: ['fake entry'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.compile_time', + values: ['1534424710'], + originalValue: ['1534424710'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.ancestors.index', + values: ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + originalValue: ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.malware_classification.score', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.entity_id', + values: ['d3v4to81q9'], + originalValue: ['d3v4to81q9'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.ip', + values: ['10.184.3.36', '10.170.218.86'], + originalValue: ['10.184.3.36', '10.170.218.86'], + isObjectArray: false, + }, + { + category: 'agent', + field: 'agent.type', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.category', + values: ['malware'], + originalValue: ['malware'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.id', + values: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + originalValue: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.uptime', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.policy.applied.name', + values: ['With Eventing'], + originalValue: ['With Eventing'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.id', + values: ['04794e4e-59cb-4c4a-a8ee-3e6c5b65743c'], + originalValue: ['04794e4e-59cb-4c4a-a8ee-3e6c5b65743c'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.code_signature.subject_name', + values: ['bad signer'], + originalValue: ['bad signer'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.token.integrity_level_name', + values: ['high'], + originalValue: ['high'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.type', + values: ['info'], + originalValue: ['info'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.max_signals', + values: ['10000'], + originalValue: ['10000'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.author', + values: ['Elastic'], + originalValue: ['Elastic'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.risk_score', + values: ['47'], + originalValue: ['47'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.malware_classification.identifier', + values: ['Whitelisted'], + originalValue: ['Whitelisted'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.mapped_address', + values: ['5362483200'], + originalValue: ['5362483200'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.dataset', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.consumer', + values: ['siem'], + originalValue: ['siem'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.indices', + values: ['logs-endpoint.alerts-*'], + originalValue: ['logs-endpoint.alerts-*'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.category', + values: ['Custom Query Rule'], + originalValue: ['Custom Query Rule'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.Ext.variant', + values: ['Windows Server'], + originalValue: ['Windows Server'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.ingested', + values: ['2022-09-29T19:37:00.000Z'], + originalValue: ['2022-09-29T19:37:00.000Z'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.action', + values: ['start'], + originalValue: ['start'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.updated_by', + values: ['elastic'], + originalValue: ['elastic'], + isObjectArray: false, + }, + { + category: 'base', + field: '@timestamp', + values: ['2022-09-29T19:40:26.051Z'], + originalValue: ['2022-09-29T19:40:26.051Z'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.action', + values: ['start'], + originalValue: ['start'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.platform', + values: ['Windows'], + originalValue: ['Windows'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.session_leader.entity_id', + values: ['b74mw1jkrm'], + originalValue: ['b74mw1jkrm'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity', + values: ['medium'], + originalValue: ['medium'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.agent_id_status', + values: ['auth_metadata_missing'], + originalValue: ['auth_metadata_missing'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.status', + values: ['enrolled'], + originalValue: ['enrolled'], + isObjectArray: false, + }, + { + category: 'data_stream', + field: 'data_stream.dataset', + values: ['endpoint.alerts'], + originalValue: ['endpoint.alerts'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.timestamp_override', + values: ['event.ingested'], + originalValue: ['event.ingested'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.execution.uuid', + values: ['abf39d36-0f1c-4bf9-ae42-1039285380b5'], + originalValue: ['abf39d36-0f1c-4bf9-ae42-1039285380b5'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.uuid', + values: ['f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325'], + originalValue: ['f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.version', + values: ['8.6.0'], + originalValue: ['8.6.0'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.hash.sha1', + values: ['fake sha1'], + originalValue: ['fake sha1'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.id', + values: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + originalValue: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.entry_leader.pid', + values: ['865'], + originalValue: ['865'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.license', + values: ['Elastic License v2'], + originalValue: ['Elastic License v2'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.ancestors.type', + values: ['event'], + originalValue: ['event'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.rule_id', + values: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + originalValue: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.session_leader.pid', + values: ['745'], + originalValue: ['745'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.type', + values: ['query'], + originalValue: ['query'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.policy.applied.version', + values: ['5'], + originalValue: ['5'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.hash.md5', + values: ['1f2d082566b0fc5f2c238a5180db7451'], + originalValue: ['1f2d082566b0fc5f2c238a5180db7451'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.ancestors.id', + values: ['7L3AioMBWJvcpv7vlX2O'], + originalValue: ['7L3AioMBWJvcpv7vlX2O'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.rule_name_override', + values: ['message'], + originalValue: ['message'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.group_leader.name', + values: ['fake leader'], + originalValue: ['fake leader'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.full', + values: ['Windows Server 2016'], + originalValue: ['Windows Server 2016'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.code', + values: ['memory_signature'], + originalValue: ['memory_signature'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.risk_score_mapping.field', + values: ['event.risk_score'], + originalValue: ['event.risk_score'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.description', + values: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + originalValue: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.pid', + values: ['2'], + originalValue: ['2'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.producer', + values: ['siem'], + originalValue: ['siem'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.to', + values: ['now'], + originalValue: ['now'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.interval', + values: ['5m'], + originalValue: ['5m'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.created_by', + values: ['elastic'], + originalValue: ['elastic'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.created_by', + values: ['elastic'], + originalValue: ['elastic'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.timestamp_override', + values: ['event.ingested'], + originalValue: ['event.ingested'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.ingested', + values: ['2022-09-29T19:37:00.000Z'], + originalValue: ['2022-09-29T19:37:00.000Z'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.id', + values: ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + originalValue: ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.parent.entity_id', + values: ['kj0le842x0'], + originalValue: ['kj0le842x0'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.risk_score', + values: ['47'], + originalValue: ['47'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.reason', + values: [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + originalValue: [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.name', + values: ['Windows'], + originalValue: ['Windows'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.name', + values: ['Endpoint Security'], + originalValue: ['Endpoint Security'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.name', + values: ['Host-4cfuh42w7g'], + originalValue: ['Host-4cfuh42w7g'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.status', + values: ['open'], + originalValue: ['open'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.kind', + values: ['signal'], + originalValue: ['signal'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity_mapping.value', + values: ['21', '47', '73', '99'], + originalValue: ['21', '47', '73', '99'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.tags', + values: ['Elastic', 'Endpoint Security'], + originalValue: ['Elastic', 'Endpoint Security'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.created_at', + values: ['2022-09-29T19:39:38.137Z'], + originalValue: ['2022-09-29T19:39:38.137Z'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.workflow_status', + values: ['open'], + originalValue: ['open'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.policy.applied.status', + values: ['warning'], + originalValue: ['warning'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.uuid', + values: ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + originalValue: ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.category', + values: ['malware'], + originalValue: ['malware'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.malware_classification.threshold', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.reason', + values: [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + originalValue: [ + 'malware event with process explorer.exe, on Host-4cfuh42w7g created medium alert Endpoint Security.', + ], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.pe.architecture', + values: ['x64'], + originalValue: ['x64'], + isObjectArray: false, + }, + { + category: 'data_stream', + field: 'data_stream.type', + values: ['logs'], + originalValue: ['logs'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_time', + values: ['2022-10-09T07:14:42.194Z'], + originalValue: ['2022-10-09T07:14:42.194Z'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.ancestors.id', + values: ['7L3AioMBWJvcpv7vlX2O'], + originalValue: ['7L3AioMBWJvcpv7vlX2O'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.name', + values: ['explorer.exe'], + originalValue: ['explorer.exe'], + isObjectArray: false, + }, + { + category: 'ecs', + field: 'ecs.version', + values: ['1.6.0'], + originalValue: ['1.6.0'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.severity', + values: ['medium'], + originalValue: ['medium'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.ancestors.index', + values: ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + originalValue: ['.ds-logs-endpoint.alerts-default-2022.09.29-000001'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.configuration.isolation', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'Memory_protection', + field: 'Memory_protection.feature', + values: ['signature'], + originalValue: ['signature'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.code_signature.trusted', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.code_signature.trusted', + values: ['false'], + originalValue: ['false'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.depth', + values: ['1'], + originalValue: ['1'], + isObjectArray: false, + }, + { + category: 'agent', + field: 'agent.version', + values: ['8.6.0'], + originalValue: ['8.6.0'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.risk_score_mapping.operator', + values: ['equals'], + originalValue: ['equals'], + isObjectArray: false, + }, + { + category: 'host', + field: 'host.os.family', + values: ['windows'], + originalValue: ['windows'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.from', + values: ['now-10m'], + originalValue: ['now-10m'], + isObjectArray: false, + }, + { + category: 'Memory_protection', + field: 'Memory_protection.self_injection', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.start', + values: ['2022-10-09T07:14:42.194Z'], + originalValue: ['2022-10-09T07:14:42.194Z'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.severity_mapping.severity', + values: ['low', 'medium', 'high', 'critical'], + originalValue: ['low', 'medium', 'high', 'critical'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.severity_mapping.field', + values: ['event.severity'], + originalValue: ['event.severity'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.severity_mapping.value', + values: ['21', '47', '73', '99'], + originalValue: ['21', '47', '73', '99'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.severity_mapping.operator', + values: ['equals'], + originalValue: ['equals'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.references', + values: [], + originalValue: [], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.description', + values: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + originalValue: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.language', + values: ['kuery'], + originalValue: ['kuery'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.type', + values: ['query'], + originalValue: ['query'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.rule_name_override', + values: ['message'], + originalValue: ['message'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.exceptions_list.list_id', + values: ['endpoint_list'], + originalValue: ['endpoint_list'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.exceptions_list.namespace_type', + values: ['agnostic'], + originalValue: ['agnostic'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.exceptions_list.id', + values: ['endpoint_list'], + originalValue: ['endpoint_list'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.exceptions_list.type', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.timestamp_override', + values: ['event.ingested'], + originalValue: ['event.ingested'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.from', + values: ['now-10m'], + originalValue: ['now-10m'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.severity', + values: ['medium'], + originalValue: ['medium'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.max_signals', + values: ['10000'], + originalValue: ['10000'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.risk_score', + values: ['47'], + originalValue: ['47'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.risk_score_mapping.field', + values: ['event.risk_score'], + originalValue: ['event.risk_score'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.risk_score_mapping.value', + values: [''], + originalValue: [''], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.risk_score_mapping.operator', + values: ['equals'], + originalValue: ['equals'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.author', + values: ['Elastic'], + originalValue: ['Elastic'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.query', + values: ['event.kind:alert and event.module:(endpoint and not endgame)\n'], + originalValue: ['event.kind:alert and event.module:(endpoint and not endgame)\n'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.index', + values: ['logs-endpoint.alerts-*'], + originalValue: ['logs-endpoint.alerts-*'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.version', + values: ['100'], + originalValue: ['100'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.rule_id', + values: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + originalValue: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.license', + values: ['Elastic License v2'], + originalValue: ['Elastic License v2'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.required_fields.ecs', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.required_fields.name', + values: ['event.kind', 'event.module'], + originalValue: ['event.kind', 'event.module'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.required_fields.type', + values: ['keyword'], + originalValue: ['keyword'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.immutable', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.related_integrations', + values: [], + originalValue: [], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.setup', + values: [''], + originalValue: [''], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.false_positives', + values: [], + originalValue: [], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.threat', + values: [], + originalValue: [], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.parameters.to', + values: ['now'], + originalValue: ['now'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.version', + values: ['100'], + originalValue: ['100'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.kind', + values: ['alert'], + originalValue: ['alert'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.status', + values: ['active'], + originalValue: ['active'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity_mapping.field', + values: ['event.severity', 'event.severity', 'event.severity', 'event.severity'], + originalValue: ['event.severity', 'event.severity', 'event.severity', 'event.severity'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.dataset', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.depth', + values: ['1'], + originalValue: ['1'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.immutable', + values: ['true'], + originalValue: ['true'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.group_leader.pid', + values: ['116'], + originalValue: ['116'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.sequence', + values: ['1232'], + originalValue: ['1232'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.rule_type_id', + values: ['siem.queryRule'], + originalValue: ['siem.queryRule'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.session_leader.name', + values: ['fake session'], + originalValue: ['fake session'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.name', + values: ['Endpoint Security'], + originalValue: ['Endpoint Security'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.rule_id', + values: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + originalValue: ['9a1a2dae-0b5f-4c3d-8305-a268d404c306'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.module', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.hash.sha1', + values: ['ca85243c0af6a6471bdaa560685c51eefd6dbc0d'], + originalValue: ['ca85243c0af6a6471bdaa560685c51eefd6dbc0d'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.severity_mapping.operator', + values: ['equals', 'equals', 'equals', 'equals'], + originalValue: ['equals', 'equals', 'equals', 'equals'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.malware_signature.all_names', + values: ['Windows.Trojan.FakeAgent'], + originalValue: ['Windows.Trojan.FakeAgent'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.license', + values: ['Elastic License v2'], + originalValue: ['Elastic License v2'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.kind', + values: ['alert'], + originalValue: ['alert'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.executable', + values: ['C:/fake/explorer.exe'], + originalValue: ['C:/fake/explorer.exe'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.updated_at', + values: ['2022-09-29T19:39:38.137Z'], + originalValue: ['2022-09-29T19:39:38.137Z'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.description', + values: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + originalValue: [ + 'Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Endpoint alerts.', + ], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.Ext.mapped_size', + values: ['0'], + originalValue: ['0'], + isObjectArray: false, + }, + { + category: 'data_stream', + field: 'data_stream.namespace', + values: ['default'], + originalValue: ['default'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.author', + values: ['Elastic'], + originalValue: ['Elastic'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.code_signature.subject_name', + values: ['Cybereason Inc'], + originalValue: ['Cybereason Inc'], + isObjectArray: false, + }, + { + category: 'user', + field: 'user.name', + values: ['root'], + originalValue: ['root'], + isObjectArray: false, + }, + { + category: 'source', + field: 'source.ip', + values: ['10.184.3.46'], + originalValue: ['10.184.3.46'], + isObjectArray: false, + }, + { + category: 'Endpoint', + field: 'Endpoint.policy.applied.endpoint_policy_version', + values: ['3'], + originalValue: ['3'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_event.sequence', + values: ['1232'], + originalValue: ['1232'], + isObjectArray: false, + }, + { + category: 'dll', + field: 'dll.path', + values: ['C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe'], + originalValue: ['C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.user', + values: ['SYSTEM'], + originalValue: ['SYSTEM'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.original_event.action', + values: ['start'], + originalValue: ['start'], + isObjectArray: false, + }, + { + category: 'signal', + field: 'signal.rule.to', + values: ['now'], + originalValue: ['now'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.created_at', + values: ['2022-09-29T19:39:38.137Z'], + originalValue: ['2022-09-29T19:39:38.137Z'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.Ext.malware_signature.identifier', + values: ['diagnostic-malware-signature-v1-fake'], + originalValue: ['diagnostic-malware-signature-v1-fake'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.exceptions_list.namespace_type', + values: ['agnostic'], + originalValue: ['agnostic'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.type', + values: ['info'], + originalValue: ['info'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.space_ids', + values: ['default'], + originalValue: ['default'], + isObjectArray: false, + }, + { + category: 'process', + field: 'process.entry_leader.entity_id', + values: ['b74mw1jkrm'], + originalValue: ['b74mw1jkrm'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.rule.exceptions_list.id', + values: ['endpoint_list'], + originalValue: ['endpoint_list'], + isObjectArray: false, + }, + { + category: 'event', + field: 'event.dataset', + values: ['endpoint'], + originalValue: ['endpoint'], + isObjectArray: false, + }, + { + category: 'kibana', + field: 'kibana.alert.original_time', + values: ['2022-10-09T07:14:42.194Z'], + originalValue: ['2022-10-09T07:14:42.194Z'], + isObjectArray: false, + }, + { + category: '_index', + field: '_index', + values: ['.internal.alerts-security.alerts-default-000001'], + originalValue: ['.internal.alerts-security.alerts-default-000001'], + isObjectArray: false, + }, + { + category: '_id', + field: '_id', + values: ['f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325'], + originalValue: ['f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325'], + isObjectArray: false, + }, + { + category: '_score', + field: '_score', + values: ['1'], + originalValue: ['1'], + isObjectArray: false, + }, +]; + +export const getMockAlertNestedDetailsTimelineResponse = (): Ecs => ({ + _id: 'f6aa8643ecee466753c45308ea8dc72aba0a44e1faac5f6183fd2ad6666c1325', + timestamp: '2022-09-29T19:40:26.051Z', + _index: '.internal.alerts-security.alerts-default-000001', + kibana: { + alert: { + rule: { + from: ['now-10m'], + name: ['Endpoint Security'], + to: ['now'], + uuid: ['738e91f2-402e-11ed-be15-7be3bb26d7b2'], + type: ['query'], + version: ['100'], + parameters: {}, + }, + workflow_status: ['open'], + original_time: ['2022-10-09T07:14:42.194Z'], + severity: ['medium'], + }, + }, + event: { + code: ['memory_signature'], + module: ['endpoint'], + action: ['start'], + category: ['malware'], + dataset: ['endpoint'], + id: ['7799e1d5-5dc1-4173-9d11-562496cd863b'], + kind: ['signal'], + type: ['info'], + }, + host: { + name: ['Host-4cfuh42w7g'], + os: { + family: ['windows'], + name: ['Windows'], + }, + id: ['04794e4e-59cb-4c4a-a8ee-3e6c5b65743c'], + ip: ['10.184.3.36', '10.170.218.86'], + }, + source: { + ip: ['10.184.3.46'], + }, + agent: { + type: ['endpoint'], + id: ['d08ed3f8-9852-4d0c-a5b1-b48060705369'], + }, + process: { + hash: { + md5: ['fake md5'], + sha1: ['fake sha1'], + sha256: ['fake sha256'], + }, + parent: { + pid: [1], + }, + pid: [2], + name: ['explorer.exe'], + entity_id: ['d3v4to81q9'], + executable: ['C:/fake/explorer.exe'], + entry_leader: { + entity_id: ['b74mw1jkrm'], + name: ['fake entry'], + pid: ['865'], + }, + session_leader: { + entity_id: ['b74mw1jkrm'], + name: ['fake session'], + pid: ['745'], + }, + group_leader: { + entity_id: ['b74mw1jkrm'], + name: ['fake leader'], + pid: ['116'], + }, + }, + user: { + name: ['root'], + }, +}); + +export const mockAlertDetailsFieldsResponse = getMockAlertDetailsFieldsResponse(); + +export const mockAlertDetailsTimelineResponse = getMockAlertDetailsTimelineResponse(); + +export const mockAlertNestedDetailsTimelineResponse = getMockAlertNestedDetailsTimelineResponse(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/index.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/index.ts new file mode 100644 index 000000000000..0771ffa5ccf9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/__mocks__/index.ts @@ -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 './alert_details_response'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/error_page.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/error_page.tsx new file mode 100644 index 000000000000..c050118a848d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/error_page.tsx @@ -0,0 +1,33 @@ +/* + * 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, { memo } from 'react'; +import { EuiCode, EuiEmptyPrompt } from '@elastic/eui'; +import { ERROR_PAGE_TITLE, ERROR_PAGE_BODY } from '../translations'; + +export const AlertDetailsErrorPage = memo(({ eventId }: { eventId: string }) => { + return ( + {ERROR_PAGE_TITLE}} + body={ +
+

{ERROR_PAGE_BODY}

+

+ {`_id: ${eventId}`} +

+
+ } + /> + ); +}); + +AlertDetailsErrorPage.displayName = 'AlertDetailsErrorPage'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/header.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/header.tsx new file mode 100644 index 000000000000..126ca1692692 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/header.tsx @@ -0,0 +1,39 @@ +/* + * 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 { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; +import { SecurityPageName } from '../../../../../common/constants'; +import { HeaderPage } from '../../../../common/components/header_page'; +import { BACK_TO_ALERTS_LINK } from '../translations'; + +interface AlertDetailsHeaderProps { + loading: boolean; + ruleName?: string; + timestamp?: string; +} + +export const AlertDetailsHeader = React.memo( + ({ loading, ruleName, timestamp }: AlertDetailsHeaderProps) => { + return ( + : ''} + title={ruleName} + /> + ); + } +); + +AlertDetailsHeader.displayName = 'AlertDetailsHeader'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/loading_page.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/loading_page.tsx new file mode 100644 index 000000000000..ee24b2e63687 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/components/loading_page.tsx @@ -0,0 +1,21 @@ +/* + * 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, { memo } from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; +import { LOADING_PAGE_MESSAGE } from '../translations'; + +export const AlertDetailsLoadingPage = memo(({ eventId }: { eventId: string }) => ( + } + body={

{LOADING_PAGE_MESSAGE}

} + /> +)); + +AlertDetailsLoadingPage.displayName = 'AlertDetailsLoadingPage'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.test.tsx new file mode 100644 index 000000000000..6968922896dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { Router, useParams } from 'react-router-dom'; +import { render } from '@testing-library/react'; +import { AlertDetailsPage } from '.'; +import { TestProviders } from '../../../common/mock'; +import { + mockAlertDetailsFieldsResponse, + mockAlertDetailsTimelineResponse, + mockAlertNestedDetailsTimelineResponse, +} from './__mocks__'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { useTimelineEventsDetails } from '../../../timelines/containers/details'; + +// Node modules mocks +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +(useParams as jest.Mock).mockReturnValue(mockAlertDetailsFieldsResponse._id); + +// Internal Mocks +jest.mock('../../../timelines/containers/details'); +jest.mock('../../../common/hooks/use_space_id', () => ({ + useSpaceId: jest.fn().mockReturnValue('default'), +})); + +jest.mock('../../../timelines/store/timeline', () => ({ + ...jest.requireActual('../../../timelines/store/timeline'), + timelineActions: { + createTimeline: jest.fn().mockReturnValue('new-timeline'), + }, +})); + +jest.mock('../../../common/containers/sourcerer', () => { + const mockSourcererReturn = { + browserFields: {}, + loading: true, + indexPattern: {}, + selectedPatterns: [], + missingPatterns: [], + }; + return { + useSourcererDataView: jest.fn().mockReturnValue(mockSourcererReturn), + }; +}); + +type Action = 'PUSH' | 'POP' | 'REPLACE'; +const pop: Action = 'POP'; +const getMockHistory = () => ({ + length: 1, + location: { + pathname: `/alerts/${mockAlertDetailsFieldsResponse._id}/summary`, + search: '', + state: '', + hash: '', + }, + action: pop, + push: jest.fn(), + replace: jest.fn(), + go: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + block: jest.fn(), + createHref: jest.fn(), + listen: jest.fn(), +}); + +describe('Alert Details Page', () => { + it('should render the loading page', () => { + (useTimelineEventsDetails as jest.Mock).mockReturnValue([true, null, null, null, jest.fn()]); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('alert-details-page-loading')).toBeVisible(); + }); + + it('should render the error page', () => { + (useTimelineEventsDetails as jest.Mock).mockReturnValue([false, null, null, null, jest.fn()]); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('alert-details-page-error')).toBeVisible(); + }); + + it('should render the header', () => { + (useTimelineEventsDetails as jest.Mock).mockReturnValue([ + false, + mockAlertDetailsTimelineResponse, + mockAlertDetailsFieldsResponse, + mockAlertNestedDetailsTimelineResponse, + jest.fn(), + ]); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('header-page-title')).toHaveTextContent( + mockAlertDetailsFieldsResponse.fields[ALERT_RULE_NAME][0] + ); + }); + + it('should create a timeline', () => { + (useTimelineEventsDetails as jest.Mock).mockReturnValue([ + false, + mockAlertDetailsTimelineResponse, + mockAlertDetailsFieldsResponse, + mockAlertNestedDetailsTimelineResponse, + jest.fn(), + ]); + render( + + + + + + ); + + expect(mockDispatch).toHaveBeenCalledWith('new-timeline'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.tsx new file mode 100644 index 000000000000..7894b6414d55 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/index.tsx @@ -0,0 +1,94 @@ +/* + * 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, { memo, useEffect } from 'react'; +import { Switch, useParams } from 'react-router-dom'; +import { Route } from '@kbn/kibana-react-plugin/public'; +import { ALERT_RULE_NAME, TIMESTAMP } from '@kbn/rule-data-utils'; +import { EuiSpacer } from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { timelineActions } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../common/types'; +import { useGetFieldsData } from '../../../common/hooks/use_get_fields_data'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useSpaceId } from '../../../common/hooks/use_space_id'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { getAlertDetailsTabUrl } from '../../../common/components/link_to'; +import { AlertDetailRouteType } from './types'; +import { SecuritySolutionTabNavigation } from '../../../common/components/navigation'; +import { getAlertDetailsNavTabs } from './utils/navigation'; +import { DEFAULT_ALERTS_INDEX, SecurityPageName } from '../../../../common/constants'; +import { eventID } from '../../../../common/endpoint/models/event'; +import { useTimelineEventsDetails } from '../../../timelines/containers/details'; +import { AlertDetailsLoadingPage } from './components/loading_page'; +import { AlertDetailsErrorPage } from './components/error_page'; +import { AlertDetailsHeader } from './components/header'; +import { DetailsSummaryTab } from './tabs/summary'; +import { AlertDetailsVisualizeTab } from './tabs/visualize'; + +export const AlertDetailsPage = memo(() => { + const { detailName: eventId } = useParams<{ detailName: string }>(); + const dispatch = useDispatch(); + const currentSpaceId = useSpaceId(); + const sourcererDataView = useSourcererDataView(SourcererScopeName.detections); + const spaceAlertsIndexAlias = `${DEFAULT_ALERTS_INDEX}-${currentSpaceId}`; + const [loading, detailsData, searchHit, dataAsNestedObject] = useTimelineEventsDetails({ + indexName: spaceAlertsIndexAlias, + eventId, + runtimeMappings: sourcererDataView.runtimeMappings, + skip: !eventID, + }); + const dataNotFound = !loading && !detailsData; + const hasData = !loading && detailsData; + + // Example of using useGetFieldsData. Only place it is used currently + const getFieldsData = useGetFieldsData(searchHit?.fields); + const timestamp = getFieldsData(TIMESTAMP) as string; + const ruleName = getFieldsData(ALERT_RULE_NAME) as string; + + useEffect(() => { + // TODO: move detail panel to it's own redux state + dispatch( + timelineActions.createTimeline({ + id: TimelineId.detectionsAlertDetailsPage, + columns: [], + dataViewId: null, + indexNames: [], + expandedDetail: {}, + show: false, + }) + ); + }, [dispatch]); + + return ( + <> + {loading && } + {dataNotFound && } + {hasData && ( +
+ + + + + + + + + + + +
+ )} + + + ); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/alert_render.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/alert_render.test.tsx new file mode 100644 index 000000000000..765b6180499e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/alert_render.test.tsx @@ -0,0 +1,57 @@ +/* + * 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 { get } from 'lodash/fp'; +import { render } from '@testing-library/react'; +import { AlertRenderer } from '.'; +import { TestProviders } from '../../../../../../common/mock'; +import { mockAlertNestedDetailsTimelineResponse } from '../../../__mocks__'; +import { ALERT_RENDERER_FIELDS } from '../../../../../../timelines/components/timeline/body/renderers/alert_renderer'; + +describe('AlertDetailsPage - SummaryTab - AlertRenderer', () => { + it('should render the reason renderer', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('alert-renderer-panel')).toBeVisible(); + }); + + it('should render the render the expected values', () => { + const { getByTestId } = render( + + + + ); + const alertRendererPanel = getByTestId('alert-renderer-panel'); + + ALERT_RENDERER_FIELDS.forEach((rendererField) => { + const fieldValues: string[] | null = get( + rendererField, + mockAlertNestedDetailsTimelineResponse + ); + if (fieldValues && fieldValues.length > 0) { + fieldValues.forEach((value) => { + expect(alertRendererPanel).toHaveTextContent(value); + }); + } + }); + }); + + it('should not render the reason renderer if data is not provided', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('alert-renderer-panel')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/index.tsx new file mode 100644 index 000000000000..7506c47df003 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/alert_renderer/index.tsx @@ -0,0 +1,53 @@ +/* + * 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, { useMemo } from 'react'; +import styled from 'styled-components'; +import { defaultRowRenderers } from '../../../../../../timelines/components/timeline/body/renderers'; +import { getRowRenderer } from '../../../../../../timelines/components/timeline/body/renderers/get_row_renderer'; +import { TimelineId } from '../../../../../../../common/types'; +import type { Ecs } from '../../../../../../../common/ecs'; + +export interface AlertRendererProps { + dataAsNestedObject: Ecs | null; +} + +const RendererContainer = styled.div` + overflow-x: auto; + + & .euiFlexGroup { + justify-content: flex-start; + } +`; + +export const AlertRenderer = React.memo(({ dataAsNestedObject }: AlertRendererProps) => { + const renderer = useMemo( + () => + dataAsNestedObject != null + ? getRowRenderer({ data: dataAsNestedObject, rowRenderers: defaultRowRenderers }) + : null, + [dataAsNestedObject] + ); + + return ( + <> + {renderer != null && dataAsNestedObject != null && ( +
+ + {renderer.renderRow({ + data: dataAsNestedObject, + isDraggable: false, + timelineId: TimelineId.detectionsAlertDetailsPage, + })} + +
+ )} + + ); +}); + +AlertRenderer.displayName = 'AlertRenderer'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/case_panel.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/case_panel.test.tsx new file mode 100644 index 000000000000..ab52062dbd34 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/case_panel.test.tsx @@ -0,0 +1,185 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { CasePanel } from '.'; +import { TestProviders } from '../../../../../../common/mock'; +import { + mockAlertDetailsTimelineResponse, + mockAlertNestedDetailsTimelineResponse, +} from '../../../__mocks__'; +import { + ADD_TO_EXISTING_CASE_BUTTON, + ADD_TO_NEW_CASE_BUTTON, + ERROR_LOADING_CASES, + LOADING_CASES, +} from '../translation'; +import { useGetRelatedCasesByEvent } from '../../../../../../common/containers/cases/use_get_related_cases_by_event'; +import { useGetUserCasesPermissions } from '../../../../../../common/lib/kibana'; + +jest.mock('../../../../../../common/containers/cases/use_get_related_cases_by_event'); +jest.mock('../../../../../../common/lib/kibana'); + +describe('AlertDetailsPage - SummaryTab - CasePanel', () => { + describe('No data', () => { + it('should render the loading panel', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: true, + }); + render( + + + + ); + expect(screen.getByText(LOADING_CASES)).toBeVisible(); + }); + + it('should render the error panel if an error is returned', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + error: true, + }); + render( + + + + ); + + expect(screen.getByText(ERROR_LOADING_CASES)).toBeVisible(); + }); + + it('should render the error panel if data is undefined', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + error: false, + relatedCases: undefined, + }); + render( + + + + ); + + expect(screen.getByText(ERROR_LOADING_CASES)).toBeVisible(); + }); + + describe('Partial permissions', () => { + it('should only render the add to new case button', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + relatedCases: [], + }); + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + create: true, + update: false, + }); + render( + + + + ); + + expect(screen.getByText(ADD_TO_NEW_CASE_BUTTON)).toBeVisible(); + expect(screen.queryByText(ADD_TO_EXISTING_CASE_BUTTON)).toBe(null); + }); + + it('should only render the add to existing case button', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + relatedCases: [], + }); + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + create: false, + update: true, + }); + render( + + + + ); + + expect(screen.getByText(ADD_TO_EXISTING_CASE_BUTTON)).toBeVisible(); + expect(screen.queryByText(ADD_TO_NEW_CASE_BUTTON)).toBe(null); + }); + + it('should render both add to new case and add to existing case buttons', () => { + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + relatedCases: [], + }); + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + create: true, + update: true, + }); + render( + + + + ); + + expect(screen.queryByText(ADD_TO_NEW_CASE_BUTTON)).toBeVisible(); + expect(screen.queryByText(ADD_TO_EXISTING_CASE_BUTTON)).toBeVisible(); + }); + }); + }); + describe('has related cases', () => { + const mockRelatedCase = { + title: 'test case', + id: 'test-case-id', + }; + + beforeEach(() => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + create: true, + update: true, + }); + (useGetRelatedCasesByEvent as jest.Mock).mockReturnValue({ + loading: false, + relatedCases: [mockRelatedCase], + }); + }); + + it('should show the related case', () => { + render( + + + + ); + + expect(screen.getByTestId('case-panel')).toHaveTextContent(mockRelatedCase.title); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/index.tsx new file mode 100644 index 000000000000..d22383bae35d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/case_panel/index.tsx @@ -0,0 +1,128 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; +import type { Ecs } from '@kbn/cases-plugin/common'; +import { CommentType } from '@kbn/cases-plugin/common'; +import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { useGetUserCasesPermissions, useKibana } from '../../../../../../common/lib/kibana'; +import { CaseDetailsLink } from '../../../../../../common/components/links'; +import { useGetRelatedCasesByEvent } from '../../../../../../common/containers/cases/use_get_related_cases_by_event'; +import { + ADD_TO_EXISTING_CASE_BUTTON, + ADD_TO_NEW_CASE_BUTTON, + CASE_NO_READ_PERMISSIONS, + ERROR_LOADING_CASES, + LOADING_CASES, + NO_RELATED_CASES_FOUND, +} from '../translation'; + +interface Props { + eventId: string; + dataAsNestedObject: Ecs | null; + detailsData: TimelineEventsDetailsItem[]; +} + +const CasesPanelLoading = () => ( + } + title={

{LOADING_CASES}

} + titleSize="xxs" + /> +); + +const CasesPanelError = () => <>{ERROR_LOADING_CASES}; + +export const CasesPanelNoReadPermissions = () => ; + +export const CasePanel = React.memo(({ eventId, dataAsNestedObject, detailsData }) => { + const { loading, error, relatedCases, refetchRelatedCases } = useGetRelatedCasesByEvent(eventId); + const { cases: casesUi } = useKibana().services; + const userCasesPermissions = useGetUserCasesPermissions(); + + const caseAttachments: CaseAttachmentsWithoutOwner = useMemo(() => { + return dataAsNestedObject + ? [ + { + alertId: eventId, + index: dataAsNestedObject._index ?? '', + type: CommentType.alert, + rule: casesUi.helpers.getRuleIdFromEvent({ + ecs: dataAsNestedObject, + data: detailsData, + }), + }, + ] + : []; + }, [casesUi.helpers, dataAsNestedObject, detailsData, eventId]); + + const createCaseFlyout = casesUi.hooks.getUseCasesAddToNewCaseFlyout({ + onSuccess: refetchRelatedCases, + }); + + const selectCaseModal = casesUi.hooks.getUseCasesAddToExistingCaseModal({ + onRowClick: refetchRelatedCases, + }); + + const handleAddToNewCaseClick = useCallback(() => { + createCaseFlyout.open({ attachments: caseAttachments }); + }, [createCaseFlyout, caseAttachments]); + + const handleAddToExistingCaseClick = useCallback(() => { + selectCaseModal.open({ attachments: caseAttachments }); + }, [caseAttachments, selectCaseModal]); + + if (loading) return ; + + if (error || relatedCases === undefined) return ; + + return relatedCases.length > 0 ? ( + + {relatedCases?.map(({ id, title }) => ( + + + {title} + + + ))} + + ) : ( + + {userCasesPermissions.update && ( + + + {ADD_TO_EXISTING_CASE_BUTTON} + + + )} + {/* TODO: confirm update is the only item necessary and not also create and push */} + {userCasesPermissions.create && ( + + + {ADD_TO_NEW_CASE_BUTTON} + + + )} + + } + /> + ); +}); + +CasePanel.displayName = 'CasePanel'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/get_mitre_threat_component.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/get_mitre_threat_component.ts new file mode 100644 index 000000000000..32a6e1b32df1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/get_mitre_threat_component.ts @@ -0,0 +1,123 @@ +/* + * 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 type { + Threat, + Threats, + ThreatSubtechnique, +} from '@kbn/securitysolution-io-ts-alerting-types'; +import { find } from 'lodash/fp'; +import { + ALERT_THREAT_FRAMEWORK, + ALERT_THREAT_TACTIC_ID, + ALERT_THREAT_TACTIC_NAME, + ALERT_THREAT_TACTIC_REFERENCE, + ALERT_THREAT_TECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_REFERENCE, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, + ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, + KIBANA_NAMESPACE, +} from '@kbn/rule-data-utils'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { buildThreatDescription } from '../../../../components/rules/description_step/helpers'; + +// TODO - it may make more sense to query source here for this information rather than piecing it together from the fields api +export const getMitreTitleAndDescription = (data: TimelineEventsDetailsItem[] | null) => { + const threatFrameworks = [ + ...(find({ field: ALERT_THREAT_FRAMEWORK, category: KIBANA_NAMESPACE }, data)?.values ?? []), + ]; + + const tacticIdValues = [ + ...(find({ field: ALERT_THREAT_TACTIC_ID, category: KIBANA_NAMESPACE }, data)?.values ?? []), + ]; + const tacticNameValues = [ + ...(find({ field: ALERT_THREAT_TACTIC_NAME, category: KIBANA_NAMESPACE }, data)?.values ?? []), + ]; + const tacticReferenceValues = [ + ...(find({ field: ALERT_THREAT_TACTIC_REFERENCE, category: KIBANA_NAMESPACE }, data)?.values ?? + []), + ]; + + const techniqueIdValues = [ + ...(find({ field: ALERT_THREAT_TECHNIQUE_ID, category: KIBANA_NAMESPACE }, data)?.values ?? []), + ]; + const techniqueNameValues = [ + ...(find({ field: ALERT_THREAT_TECHNIQUE_NAME, category: KIBANA_NAMESPACE }, data)?.values ?? + []), + ]; + const techniqueReferenceValues = [ + ...(find({ field: ALERT_THREAT_TECHNIQUE_REFERENCE, category: KIBANA_NAMESPACE }, data) + ?.values ?? []), + ]; + + const subTechniqueIdValues = [ + ...(find({ field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_ID, category: KIBANA_NAMESPACE }, data) + ?.values ?? []), + ]; + const subTechniqueNameValues = [ + ...(find({ field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_NAME, category: KIBANA_NAMESPACE }, data) + ?.values ?? []), + ]; + const subTechniqueReferenceValues = [ + ...(find( + { field: ALERT_THREAT_TECHNIQUE_SUBTECHNIQUE_REFERENCE, category: KIBANA_NAMESPACE }, + data + )?.values ?? []), + ]; + + const threatData: Threats = + // Use the top level framework as every threat should have a framework + threatFrameworks?.map((framework, index) => { + const threat: Threat = { + framework, + tactic: { + id: tacticIdValues[index], + name: tacticNameValues[index], + reference: tacticReferenceValues[index], + }, + technique: [], + }; + + // TODO: + // Fields api doesn't provide null entries to keep the same length of values for flattend objects + // So for the time being rather than showing incorrect data, we'll only show tactic information when the length of both line up + // We can replace this with a _source request and just pass that. + if (tacticIdValues.length === techniqueIdValues.length) { + const subtechnique: ThreatSubtechnique[] = []; + const techniqueId = techniqueIdValues[index]; + subTechniqueIdValues.forEach((subId, subIndex) => { + // TODO: see above comment. Without this matching, a subtechnique can be incorrectly matched with a higher level technique + if (subId.includes(techniqueId)) { + subtechnique.push({ + id: subTechniqueIdValues[subIndex], + name: subTechniqueNameValues[subIndex], + reference: subTechniqueReferenceValues[subIndex], + }); + } + }); + + threat.technique?.push({ + id: techniqueId, + name: techniqueNameValues[index], + reference: techniqueReferenceValues[index], + subtechnique, + }); + } + + return threat; + }) ?? []; + + // TODO: discuss moving buildThreatDescription to a shared common folder + return threatData && threatData.length > 0 + ? buildThreatDescription({ + label: threatData[0].framework, + threat: threatData, + }) + : null; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel.test.tsx new file mode 100644 index 000000000000..5ce69633c0f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel.test.tsx @@ -0,0 +1,132 @@ +/* + * 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 { render } from '@testing-library/react'; +import { find } from 'lodash/fp'; +import { TestProviders } from '../../../../../../common/mock'; +import { + mockAlertDetailsTimelineResponse, + mockAlertNestedDetailsTimelineResponse, +} from '../../../__mocks__'; +import type { HostPanelProps } from '.'; +import { HostPanel } from '.'; +import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; +import { RiskSeverity } from '../../../../../../../common/search_strategy'; +import { useRiskScore } from '../../../../../../risk_score/containers'; + +jest.mock('../../../../../../risk_score/containers'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +jest.mock('../../../../../containers/detection_engine/alerts/use_host_isolation_status', () => { + return { + useHostIsolationStatus: jest.fn().mockReturnValue({ + loading: false, + isIsolated: false, + agentStatus: 'healthy', + }), + }; +}); + +describe('AlertDetailsPage - SummaryTab - HostPanel', () => { + const defaultRiskReturnValues = { + inspect: null, + refetch: () => {}, + isModuleEnabled: true, + isLicenseValid: true, + loading: false, + }; + const HostPanelWithDefaultProps = (propOverrides: Partial) => ( + + + + ); + + beforeEach(() => { + mockUseRiskScore.mockReturnValue({ ...defaultRiskReturnValues }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render basic host fields', () => { + const { getByTestId } = render(); + const simpleHostFields = ['host.name', 'host.os.name']; + + simpleHostFields.forEach((simpleHostField) => { + expect(getByTestId('host-panel')).toHaveTextContent( + getTimelineEventData(simpleHostField, mockAlertDetailsTimelineResponse) + ); + }); + }); + + describe('Agent status', () => { + it('should show healthy', () => { + const { getByTestId } = render(); + expect(getByTestId('host-panel-agent-status')).toHaveTextContent('Healthy'); + }); + }); + + describe('host risk', () => { + it('should not show risk if the license is not valid', () => { + mockUseRiskScore.mockReturnValue({ + ...defaultRiskReturnValues, + isLicenseValid: false, + data: null, + }); + const { queryByTestId } = render(); + expect(queryByTestId('host-panel-risk')).toBe(null); + }); + + it('should render risk fields', () => { + const calculatedScoreNorm = 98.9; + const calculatedLevel = RiskSeverity.critical; + + mockUseRiskScore.mockReturnValue({ + ...defaultRiskReturnValues, + isLicenseValid: true, + data: [ + { + host: { + name: mockAlertNestedDetailsTimelineResponse.host?.name, + risk: { + calculated_score_norm: calculatedScoreNorm, + calculated_level: calculatedLevel, + }, + }, + }, + ], + }); + const { getByTestId } = render(); + + expect(getByTestId('host-panel-risk')).toHaveTextContent( + `${Math.round(calculatedScoreNorm)}` + ); + expect(getByTestId('host-panel-risk')).toHaveTextContent(calculatedLevel); + }); + }); + + describe('host ip', () => { + it('should render all the ip fields', () => { + const { getByTestId } = render(); + const ipFields = find( + { field: 'host.ip', category: 'host' }, + mockAlertDetailsTimelineResponse + )?.values as string[]; + expect(getByTestId('host-panel-ip')).toHaveTextContent(ipFields[0]); + expect(getByTestId('host-panel-ip')).toHaveTextContent('+1 More'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel_actions.tsx new file mode 100644 index 000000000000..d078785bf93f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/host_panel_actions.tsx @@ -0,0 +1,99 @@ +/* + * 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { SecurityPageName } from '../../../../../../app/types'; +import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links'; +import { getHostDetailsUrl } from '../../../../../../common/components/link_to'; + +import { OPEN_HOST_DETAILS_PAGE, SUMMARY_PANEL_ACTIONS, VIEW_HOST_SUMMARY } from '../translation'; + +export const HOST_PANEL_ACTIONS_CLASS = 'host-panel-actions-trigger'; + +export const HostPanelActions = React.memo( + ({ + className, + openHostDetailsPanel, + hostName, + }: { + className?: string; + hostName: string; + openHostDetailsPanel: (hostName: string) => void; + }) => { + const [isPopoverOpen, setPopover] = useState(false); + const { href } = useGetSecuritySolutionLinkProps()({ + deepLinkId: SecurityPageName.hosts, + path: getHostDetailsUrl(hostName), + }); + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = () => { + setPopover(false); + }; + + const handleOpenHostDetailsPanel = useCallback(() => { + openHostDetailsPanel(hostName); + closePopover(); + }, [hostName, openHostDetailsPanel]); + + const items = useMemo( + () => [ + + {VIEW_HOST_SUMMARY} + , + + {OPEN_HOST_DETAILS_PAGE} + , + ], + [handleOpenHostDetailsPanel, href] + ); + + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( +
+ + + +
+ ); + } +); + +HostPanelActions.displayName = 'HostPanelActions'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/index.tsx new file mode 100644 index 000000000000..2a04647a7b6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/host_panel/index.tsx @@ -0,0 +1,179 @@ +/* + * 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { TimelineId } from '@kbn/timelines-plugin/common'; +import React, { useCallback, useMemo } from 'react'; +import { find } from 'lodash/fp'; +import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; +import { isAlertFromEndpointEvent } from '../../../../../../common/utils/endpoint_alert_check'; +import { SummaryValueCell } from '../../../../../../common/components/event_details/table/summary_value_cell'; +import { useRiskScore } from '../../../../../../risk_score/containers'; +import { RiskScoreEntity } from '../../../../../../../common/search_strategy'; +import { getEmptyTagValue } from '../../../../../../common/components/empty_value'; +import { RiskScore } from '../../../../../../common/components/severity/common'; +import { + FirstLastSeen, + FirstLastSeenType, +} from '../../../../../../common/components/first_last_seen'; +import { DefaultFieldRenderer } from '../../../../../../timelines/components/field_renderers/field_renderers'; +import { NetworkDetailsLink } from '../../../../../../common/components/links'; +import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model'; +import { getEnrichedFieldInfo } from '../../../../../../common/components/event_details/helpers'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; +import { + AGENT_STATUS_TITLE, + HOST_NAME_TITLE, + HOST_RISK_CLASSIFICATION, + HOST_RISK_SCORE, + IP_ADDRESSES_TITLE, + LAST_SEEN_TITLE, + OPERATING_SYSTEM_TITLE, +} from '../translation'; + +export interface HostPanelProps { + data: TimelineEventsDetailsItem[]; + id: string; + selectedPatterns: SelectedDataView['selectedPatterns']; + browserFields: SelectedDataView['browserFields']; +} + +const HostPanelSection: React.FC<{ + title?: string | React.ReactElement; + grow?: FlexItemGrowSize; +}> = ({ grow, title, children }) => + children ? ( + + {title && ( + <> + +
{title}
+
+ + + )} + {children} +
+ ) : null; + +export const HostPanel = React.memo( + ({ data, id, browserFields, selectedPatterns }: HostPanelProps) => { + const hostName = getTimelineEventData('host.name', data); + const hostOs = getTimelineEventData('host.os.name', data); + + const enrichedAgentStatus = useMemo(() => { + const item = find({ field: 'agent.id', category: 'agent' }, data); + if (!data || !isAlertFromEndpointEvent({ data })) return null; + return ( + item && + getEnrichedFieldInfo({ + eventId: id, + contextId: TimelineId.detectionsAlertDetailsPage, + timelineId: TimelineId.detectionsAlertDetailsPage, + browserFields, + item, + field: { id: 'agent.id', overrideField: 'agent.status' }, + linkValueField: undefined, + }) + ); + }, [browserFields, data, id]); + + const { data: hostRisk, isLicenseValid: isRiskLicenseValid } = useRiskScore({ + riskEntity: RiskScoreEntity.host, + skip: hostName == null, + }); + + const [hostRiskScore, hostRiskLevel] = useMemo(() => { + const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined; + const hostRiskValue = hostRiskData + ? Math.round(hostRiskData.host.risk.calculated_score_norm) + : getEmptyTagValue(); + const hostRiskSeverity = hostRiskData ? ( + + ) : ( + getEmptyTagValue() + ); + + return [hostRiskValue, hostRiskSeverity]; + }, [hostRisk]); + + const hostIpFields = useMemo( + () => find({ field: 'host.ip', category: 'host' }, data)?.values ?? [], + [data] + ); + + const renderHostIp = useCallback( + (ip: string) => (ip != null ? : getEmptyTagValue()), + [] + ); + + return ( + <> + + + + + + + {hostName} + + + + {hostOs} + {enrichedAgentStatus && ( + + + + )} + + + {isRiskLicenseValid && ( + <> + + {hostRiskScore && ( + {hostRiskScore} + )} + {hostRiskLevel && ( + + {hostRiskLevel} + + )} + + + + )} + + + + + + + + + + + + + + + + ); + } +); + +HostPanel.displayName = 'HostPanel'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/index.tsx new file mode 100644 index 000000000000..792c0b46be74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/index.tsx @@ -0,0 +1,185 @@ +/* + * 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. + */ + +/* eslint-disable react/jsx-no-literals */ + +import React, { useCallback, useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { TimelineId } from '../../../../../../common/types'; +import { HoverVisibilityContainer } from '../../../../../common/components/hover_visibility_container'; +import { useDetailPanel } from '../../../../../timelines/components/side_panel/hooks/use_detail_panel'; +import { useGetUserCasesPermissions } from '../../../../../common/lib/kibana'; +import type { SelectedDataView } from '../../../../../common/store/sourcerer/model'; +import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; +import type { Ecs } from '../../../../../../common/ecs'; +import { AlertRenderer } from './alert_renderer'; +import { RulePanel } from './rule_panel'; +import { CasePanel, CasesPanelNoReadPermissions } from './case_panel'; +import { HostPanel } from './host_panel'; +import { UserPanel } from './user_panel'; +import { HostPanelActions, HOST_PANEL_ACTIONS_CLASS } from './host_panel/host_panel_actions'; +import { getTimelineEventData } from '../../utils/get_timeline_event_data'; +import { UserPanelActions, USER_PANEL_ACTIONS_CLASS } from './user_panel/user_panel_actions'; +import { RulePanelActions, RULE_PANEL_ACTIONS_CLASS } from './rule_panel/rule_panel_actions'; + +export interface DetailsSummaryTabProps { + dataAsNestedObject: Ecs | null; + detailsData: TimelineEventsDetailsItem[]; + sourcererDataView: SelectedDataView; +} + +const Column: React.FC<{ grow?: FlexItemGrowSize }> = ({ children, grow }) => ( + + + {children} + + +); + +const Row: React.FC<{ grow?: FlexItemGrowSize }> = ({ children, grow }) => ( + + + {children} + + +); + +const Panel: React.FC<{ + grow?: FlexItemGrowSize; + title: string; + actionsClassName?: string; + renderActionsPopover?: () => JSX.Element; +}> = ({ actionsClassName, children, grow = false, renderActionsPopover, title }) => ( + + + + + + +

{title}

+
+
+ {actionsClassName && renderActionsPopover ? ( + {renderActionsPopover()} + ) : null} +
+
+ + {children} +
+
+); + +export const DetailsSummaryTab = React.memo( + ({ dataAsNestedObject, detailsData, sourcererDataView }: DetailsSummaryTabProps) => { + const eventId = dataAsNestedObject?._id as string; + const hostName = useMemo(() => getTimelineEventData('host.name', detailsData), [detailsData]); + const userName = useMemo(() => getTimelineEventData('user.name', detailsData), [detailsData]); + const ruleUuid = useMemo( + () => getTimelineEventData(ALERT_RULE_UUID, detailsData), + [detailsData] + ); + const userCasesPermissions = useGetUserCasesPermissions(); + + const { DetailsPanel, openHostDetailsPanel, openUserDetailsPanel } = useDetailPanel({ + isFlyoutView: true, + sourcererScope: SourcererScopeName.detections, + timelineId: TimelineId.detectionsAlertDetailsPage, + }); + + const renderHostActions = useCallback( + () => , + [hostName, openHostDetailsPanel] + ); + + const renderUserActions = useCallback( + () => , + [openUserDetailsPanel, userName] + ); + + const renderRuleActions = useCallback( + () => , + [ruleUuid] + ); + + return ( + <> + + + + + + + + + + + + + + + + + + + + {userCasesPermissions.read ? ( + + ) : ( + + )} + + Recent Activity Panel + + + {DetailsPanel} + + ); + } +); + +DetailsSummaryTab.displayName = 'DetailsSummaryTab'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/index.tsx new file mode 100644 index 000000000000..91a3f0765053 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/index.tsx @@ -0,0 +1,146 @@ +/* + * 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { TimelineId } from '@kbn/timelines-plugin/common'; +import React, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import { find } from 'lodash/fp'; +import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; +import { + ALERT_RISK_SCORE, + ALERT_RULE_DESCRIPTION, + ALERT_RULE_NAME, + ALERT_RULE_UUID, + ALERT_SEVERITY, + KIBANA_NAMESPACE, +} from '@kbn/rule-data-utils'; +import { SeverityBadge } from '../../../../../components/rules/severity_badge'; +import { getEnrichedFieldInfo } from '../../../../../../common/components/event_details/helpers'; +import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model'; +import { FormattedFieldValue } from '../../../../../../timelines/components/timeline/body/renderers/formatted_field'; +import { + RISK_SCORE_TITLE, + RULE_DESCRIPTION_TITLE, + RULE_NAME_TITLE, + SEVERITY_TITLE, +} from '../translation'; +import { getMitreTitleAndDescription } from '../get_mitre_threat_component'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; + +export interface RulePanelProps { + data: TimelineEventsDetailsItem[]; + id: string; + browserFields: SelectedDataView['browserFields']; +} + +const threatTacticContainerStyles = css` + flex-wrap: nowrap; + & .euiFlexGroup { + flex-wrap: nowrap; + } +`; + +interface RuleSectionProps { + ['data-test-subj']?: string; + title: string; + grow?: FlexItemGrowSize; +} +const RuleSection: React.FC = ({ + grow, + title, + children, + 'data-test-subj': dataTestSubj, +}) => ( + + +
{title}
+
+ + {children} +
+); + +export const RulePanel = React.memo(({ data, id, browserFields }: RulePanelProps) => { + const ruleNameData = useMemo(() => { + const item = find({ field: ALERT_RULE_NAME, category: KIBANA_NAMESPACE }, data); + const linkValueField = find({ field: ALERT_RULE_UUID, category: KIBANA_NAMESPACE }, data); + return ( + item && + getEnrichedFieldInfo({ + eventId: id, + contextId: TimelineId.detectionsAlertDetailsPage, + timelineId: TimelineId.detectionsAlertDetailsPage, + browserFields, + item, + linkValueField, + }) + ); + }, [browserFields, data, id]); + + const threatDetails = useMemo(() => getMitreTitleAndDescription(data), [data]); + const alertRiskScore = useMemo(() => getTimelineEventData(ALERT_RISK_SCORE, data), [data]); + const alertSeverity = useMemo( + () => getTimelineEventData(ALERT_SEVERITY, data) as Severity, + [data] + ); + const alertRuleDescription = useMemo( + () => getTimelineEventData(ALERT_RULE_DESCRIPTION, data), + [data] + ); + const shouldShowThreatDetails = !!threatDetails && threatDetails?.length > 0; + return ( + <> + + + + + + + {alertRiskScore} + + + + + + + + {alertRuleDescription} + + + + + {shouldShowThreatDetails && ( + + {threatDetails[0].description} + + )} + + + + + + ); +}); + +RulePanel.displayName = 'RulePanel'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel.test.tsx new file mode 100644 index 000000000000..e646e22e287f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import { ALERT_RISK_SCORE, ALERT_RULE_DESCRIPTION, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { TestProviders } from '../../../../../../common/mock'; +import { + mockAlertDetailsTimelineResponse, + mockAlertNestedDetailsTimelineResponse, +} from '../../../__mocks__'; +import type { RulePanelProps } from '.'; +import { RulePanel } from '.'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; +import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; + +describe('AlertDetailsPage - SummaryTab - RulePanel', () => { + const RulePanelWithDefaultProps = (propOverrides: Partial) => ( + + + + ); + it('should render basic rule fields', () => { + const { getByTestId } = render(); + const simpleRuleFields = [ALERT_RISK_SCORE, ALERT_RULE_DESCRIPTION]; + + simpleRuleFields.forEach((simpleRuleField) => { + expect(getByTestId('rule-panel')).toHaveTextContent( + getTimelineEventData(simpleRuleField, mockAlertDetailsTimelineResponse) + ); + }); + }); + + it('should render the expected severity', () => { + const { getByTestId } = render(); + expect(getByTestId('rule-panel-severity')).toHaveTextContent('Medium'); + }); + + describe('Rule name link', () => { + it('should render the rule name as a link button', () => { + const { getByRole } = render(); + const ruleName = getTimelineEventData(ALERT_RULE_NAME, mockAlertDetailsTimelineResponse); + expect(getByRole('button')).toHaveTextContent(ruleName); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel_actions.tsx new file mode 100644 index 000000000000..a2eec20864a2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/rule_panel/rule_panel_actions.tsx @@ -0,0 +1,78 @@ +/* + * 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { getRuleDetailsUrl } from '../../../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../../app/types'; +import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links'; + +import { SUMMARY_PANEL_ACTIONS, OPEN_RULE_DETAILS_PAGE } from '../translation'; + +export const RULE_PANEL_ACTIONS_CLASS = 'rule-panel-actions-trigger'; + +export const RulePanelActions = React.memo( + ({ className, ruleUuid }: { className?: string; ruleUuid: string }) => { + const [isPopoverOpen, setPopover] = useState(false); + const { href } = useGetSecuritySolutionLinkProps()({ + deepLinkId: SecurityPageName.rules, + path: getRuleDetailsUrl(ruleUuid), + }); + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = () => { + setPopover(false); + }; + + const items = useMemo( + () => [ + + {OPEN_RULE_DETAILS_PAGE} + , + ], + [href] + ); + + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( +
+ + + +
+ ); + } +); + +RulePanelActions.displayName = 'RulePanelActions'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/translation.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/translation.ts new file mode 100644 index 000000000000..98fd18f1caa7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/translation.ts @@ -0,0 +1,191 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const RULE_NAME_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.rule.name', + { + defaultMessage: 'Rule name', + } +); + +export const RISK_SCORE_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.rule.riskScore', + { + defaultMessage: 'Risk score', + } +); + +export const SEVERITY_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.rule.severity', + { + defaultMessage: 'Severity', + } +); + +export const RULE_DESCRIPTION_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.rule.description', + { + defaultMessage: 'Rule description', + } +); + +export const OPEN_RULE_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.rule.action.openRuleDetailsPage', + { + defaultMessage: 'Open rule details page', + } +); + +export const NO_RELATED_CASES_FOUND = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.noCasesFound', + { + defaultMessage: 'Related cases were not found for this alert', + } +); + +export const LOADING_CASES = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.loading', + { + defaultMessage: 'Loading related cases...', + } +); + +export const ERROR_LOADING_CASES = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.error', + { + defaultMessage: 'Error loading related cases', + } +); + +export const CASE_NO_READ_PERMISSIONS = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.noRead', + { + defaultMessage: + 'You do not have the required permissions to view related cases. If you need to view cases, contact your Kibana administrator', + } +); + +export const ADD_TO_EXISTING_CASE_BUTTON = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.addToExistingCase', + { + defaultMessage: 'Add to existing case', + } +); + +export const ADD_TO_NEW_CASE_BUTTON = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.case.addToNewCase', + { + defaultMessage: 'Add to new case', + } +); + +export const HOST_NAME_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.hostName.title', + { + defaultMessage: 'Host name', + } +); + +export const OPERATING_SYSTEM_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.osName.title', + { + defaultMessage: 'Operating system', + } +); + +export const AGENT_STATUS_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.agentStatus.title', + { + defaultMessage: 'Agent status', + } +); + +export const IP_ADDRESSES_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.ipAddresses.title', + { + defaultMessage: 'IP addresses', + } +); + +export const LAST_SEEN_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.lastSeen.title', + { + defaultMessage: 'Last seen', + } +); + +export const VIEW_HOST_SUMMARY = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.action.viewHostSummary', + { + defaultMessage: 'View host summary', + } +); + +export const OPEN_HOST_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.action.openHostDetailsPage', + { + defaultMessage: 'Open host details page', + } +); + +export const HOST_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.riskScore', + { + defaultMessage: 'Host risk score', + } +); + +export const HOST_RISK_CLASSIFICATION = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.host.riskClassification', + { + defaultMessage: 'Host risk classification', + } +); + +export const USER_NAME_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.user.userName.title', + { + defaultMessage: 'User name', + } +); + +export const USER_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.user.riskScore', + { + defaultMessage: 'User risk score', + } +); + +export const USER_RISK_CLASSIFICATION = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.user.riskClassification', + { + defaultMessage: 'User risk classification', + } +); + +export const VIEW_USER_SUMMARY = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.user.action.viewUserSummary', + { + defaultMessage: 'View user summary', + } +); + +export const OPEN_USER_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.user.action.openUserDetailsPage', + { + defaultMessage: 'Open user details page', + } +); + +export const SUMMARY_PANEL_ACTIONS = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.summary.panelMoreActions', + { + defaultMessage: 'More actions', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/index.tsx new file mode 100644 index 000000000000..e870ea571bc0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/index.tsx @@ -0,0 +1,141 @@ +/* + * 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 { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import React, { useCallback, useMemo } from 'react'; +import { find } from 'lodash/fp'; +import type { FlexItemGrowSize } from '@elastic/eui/src/components/flex/flex_item'; +import { useRiskScore } from '../../../../../../risk_score/containers'; +import { RiskScoreEntity } from '../../../../../../../common/search_strategy'; +import { getEmptyTagValue } from '../../../../../../common/components/empty_value'; +import { RiskScore } from '../../../../../../common/components/severity/common'; +import { + FirstLastSeen, + FirstLastSeenType, +} from '../../../../../../common/components/first_last_seen'; +import { DefaultFieldRenderer } from '../../../../../../timelines/components/field_renderers/field_renderers'; +import { NetworkDetailsLink } from '../../../../../../common/components/links'; +import type { SelectedDataView } from '../../../../../../common/store/sourcerer/model'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; +import { + IP_ADDRESSES_TITLE, + LAST_SEEN_TITLE, + USER_NAME_TITLE, + USER_RISK_CLASSIFICATION, + USER_RISK_SCORE, +} from '../translation'; + +export interface UserPanelProps { + data: TimelineEventsDetailsItem[] | null; + selectedPatterns: SelectedDataView['selectedPatterns']; +} +const UserPanelSection: React.FC<{ + title?: string | React.ReactElement; + grow?: FlexItemGrowSize; +}> = ({ grow, title, children }) => + children ? ( + + {title && ( + <> + +
{title}
+
+ + + )} + {children} +
+ ) : null; + +export const UserPanel = React.memo(({ data, selectedPatterns }: UserPanelProps) => { + const userName = useMemo(() => getTimelineEventData('user.name', data), [data]); + + const { data: userRisk, isLicenseValid: isRiskLicenseValid } = useRiskScore({ + riskEntity: RiskScoreEntity.user, + skip: userName == null, + }); + + const [userRiskScore, userRiskLevel] = useMemo(() => { + const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined; + const userRiskValue = userRiskData + ? Math.round(userRiskData.user.risk.calculated_score_norm) + : getEmptyTagValue(); + const userRiskSeverity = userRiskData ? ( + + ) : ( + getEmptyTagValue() + ); + + return [userRiskValue, userRiskSeverity]; + }, [userRisk]); + + const sourceIpFields = useMemo( + () => find({ field: 'source.ip', category: 'source' }, data)?.values ?? [], + [data] + ); + + const renderSourceIp = useCallback( + (ip: string) => (ip != null ? : getEmptyTagValue()), + [] + ); + + return ( + <> + + + + + + + {userName} + + + {isRiskLicenseValid && ( + <> + + {userRiskScore && ( + {userRiskScore} + )} + {userRiskLevel && ( + + {userRiskLevel} + + )} + + + + )} + + + + + + + + + + + + + + + + ); +}); + +UserPanel.displayName = 'UserPanel'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel.test.tsx new file mode 100644 index 000000000000..c9e41610ffc2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { render } from '@testing-library/react'; +import { TestProviders } from '../../../../../../common/mock'; +import { + mockAlertDetailsTimelineResponse, + mockAlertNestedDetailsTimelineResponse, +} from '../../../__mocks__'; +import type { UserPanelProps } from '.'; +import { UserPanel } from '.'; +import { getTimelineEventData } from '../../../utils/get_timeline_event_data'; +import { RiskSeverity } from '../../../../../../../common/search_strategy'; +import { useRiskScore } from '../../../../../../risk_score/containers'; +import { find } from 'lodash/fp'; + +jest.mock('../../../../../../risk_score/containers'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +describe('AlertDetailsPage - SummaryTab - UserPanel', () => { + const defaultRiskReturnValues = { + inspect: null, + refetch: () => {}, + isModuleEnabled: true, + isLicenseValid: true, + loading: false, + }; + const UserPanelWithDefaultProps = (propOverrides: Partial) => ( + + + + ); + + beforeEach(() => { + mockUseRiskScore.mockReturnValue({ ...defaultRiskReturnValues }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render basic user fields', () => { + const { getByTestId } = render(); + const simpleUserFields = ['user.name']; + + simpleUserFields.forEach((simpleUserField) => { + expect(getByTestId('user-panel')).toHaveTextContent( + getTimelineEventData(simpleUserField, mockAlertDetailsTimelineResponse) + ); + }); + }); + + describe('user risk', () => { + it('should not show risk if the license is not valid', () => { + mockUseRiskScore.mockReturnValue({ + ...defaultRiskReturnValues, + isLicenseValid: false, + data: null, + }); + const { queryByTestId } = render(); + expect(queryByTestId('user-panel-risk')).toBe(null); + }); + + it('should render risk fields', () => { + const calculatedScoreNorm = 98.9; + const calculatedLevel = RiskSeverity.critical; + + mockUseRiskScore.mockReturnValue({ + ...defaultRiskReturnValues, + isLicenseValid: true, + data: [ + { + user: { + name: mockAlertNestedDetailsTimelineResponse.user?.name, + risk: { + calculated_score_norm: calculatedScoreNorm, + calculated_level: calculatedLevel, + }, + }, + }, + ], + }); + const { getByTestId } = render(); + + expect(getByTestId('user-panel-risk')).toHaveTextContent( + `${Math.round(calculatedScoreNorm)}` + ); + expect(getByTestId('user-panel-risk')).toHaveTextContent(calculatedLevel); + }); + }); + + describe('source ip', () => { + it('should render all the ip fields', () => { + const { getByTestId } = render(); + const ipFields = find( + { field: 'source.ip', category: 'source' }, + mockAlertDetailsTimelineResponse + )?.values as string[]; + expect(getByTestId('user-panel-ip')).toHaveTextContent(ipFields[0]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel_actions.tsx new file mode 100644 index 000000000000..575673a494a2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/summary/user_panel/user_panel_actions.tsx @@ -0,0 +1,99 @@ +/* + * 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 { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { getUsersDetailsUrl } from '../../../../../../common/components/link_to/redirect_to_users'; +import { SecurityPageName } from '../../../../../../app/types'; +import { useGetSecuritySolutionLinkProps } from '../../../../../../common/components/links'; + +import { OPEN_USER_DETAILS_PAGE, SUMMARY_PANEL_ACTIONS, VIEW_USER_SUMMARY } from '../translation'; + +export const USER_PANEL_ACTIONS_CLASS = 'user-panel-actions-trigger'; + +export const UserPanelActions = React.memo( + ({ + className, + openUserDetailsPanel, + userName, + }: { + className?: string; + userName: string; + openUserDetailsPanel: (userName: string) => void; + }) => { + const [isPopoverOpen, setPopover] = useState(false); + const { href } = useGetSecuritySolutionLinkProps()({ + deepLinkId: SecurityPageName.users, + path: getUsersDetailsUrl(userName), + }); + + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopover = () => { + setPopover(false); + }; + + const handleopenUserDetailsPanel = useCallback(() => { + openUserDetailsPanel(userName); + closePopover(); + }, [userName, openUserDetailsPanel]); + + const items = useMemo( + () => [ + + {VIEW_USER_SUMMARY} + , + + {OPEN_USER_DETAILS_PAGE} + , + ], + [handleopenUserDetailsPanel, href] + ); + + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( +
+ + + +
+ ); + } +); + +UserPanelActions.displayName = 'UserPanelActions'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/index.tsx new file mode 100644 index 000000000000..2324c724fedc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/index.tsx @@ -0,0 +1,228 @@ +/* + * 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, { useEffect, useRef, useState } from 'react'; +import { GraphVisualization } from '@kbn/graph-plugin/public/components/graph_visualization'; +import { createWorkspace } from '@kbn/graph-plugin/public/services/workspace'; +import type { Workspace } from '@kbn/graph-plugin/public/types'; +import { useGraphQuery } from './use-graph-query'; + +// const nodes = [ +// { +// id: '1', +// color: 'black', +// data: { +// field: 'A', +// term: '1', +// }, +// icon: { +// class: 'a', +// code: 'a', +// label: '', +// }, +// isSelected: true, +// kx: 5, +// ky: 5, +// label: '1', +// numChildren: 1, +// parent: null, +// scaledSize: 10, +// x: 5, +// y: 5, +// }, +// { +// id: '2', +// color: 'red', +// data: { +// field: 'B', +// term: '2', +// }, +// icon: { +// class: 'b', +// code: 'b', +// label: '', +// }, +// isSelected: false, +// kx: 7, +// ky: 9, +// label: '2', +// numChildren: 0, +// parent: null, +// scaledSize: 10, +// x: 7, +// y: 9, +// }, +// { +// id: '3', +// color: 'yellow', +// data: { +// field: 'C', +// term: '3', +// }, +// icon: { +// class: 'c', +// code: 'c', +// label: '', +// }, +// isSelected: false, +// kx: 12, +// ky: 2, +// label: '3', +// numChildren: 0, +// parent: null, +// scaledSize: 10, +// x: 7, +// y: 9, +// }, +// ]; +// const edges = [ +// { +// isSelected: true, +// label: '', +// topSrc: nodes[0], +// topTarget: nodes[1], +// source: nodes[0], +// target: nodes[1], +// weight: 10, +// width: 2, +// }, +// { +// isSelected: true, +// label: '', +// topSrc: nodes[1], +// topTarget: nodes[2], +// source: nodes[1], +// target: nodes[2], +// weight: 10, +// width: 2.2, +// }, +// ]; +// const workspace = { +// nodes, +// edges, +// selectNone: () => {}, +// changeHandler: () => {}, +// toggleNodeSelection: (node) => { +// return !node.isSelected; +// }, +// getAllIntersections: () => {}, +// removeEdgeFromSelection: () => {}, +// addEdgeToSelection: () => {}, +// getEdgeSelection: () => {}, +// clearEdgeSelection: () => {}, +// }; + +export const AlertDetailsVisualizeGraph = React.memo(({ id }: { id: string }) => { + const getGraph = useGraphQuery(id); + const workspaceRef = useRef(); + const [ranLayout, updateRanLayout] = useState(false); + const [renderCounter, setRenderCounter] = useState(0); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + if (!workspaceRef.current) { + workspaceRef.current = createWorkspace({ + indexName: '.alerts-security.alerts-default', + vertex_fields: [ + { + selected: false, + color: 'red', + name: 'kibana.alert.uuid', + type: 'string', + icon: { + class: 'fa-triangle-exclamation', + code: '\uf071', + label: 'Alert', + }, + aggregatable: true, + }, + { + selected: false, + color: 'blue', + name: 'host.name', + type: 'string', + icon: { + class: 'fa-laptop', + code: '\uf109', + label: 'Host', + }, + aggregatable: true, + }, + { + selected: false, + color: 'brown', + name: 'kibana.alert.rule.name', + type: 'string', + icon: { + class: 'fa-folder-open', + code: '\uf07c', + label: 'Host', + }, + aggregatable: true, + }, + { + selected: false, + color: 'gray', + name: 'user.name', + type: 'string', + icon: { + class: 'fa-user', + code: '\uf007', + label: 'User', + }, + aggregatable: true, + }, + ], + // Here we have the opportunity to look up labels for nodes... + nodeLabeller() { + // console.log(newNodes); + }, + changeHandler: () => setRenderCounter((cur) => cur + 1), + graphExploreProxy: getGraph, + exploreControls: { + useSignificance: false, + sampleSize: 2000, + timeoutMillis: 5000, + sampleDiversityField: null, + maxValuesPerDoc: 1, + minDocCount: 1, + }, + }); + } + }, [getGraph]); + + useEffect(() => { + if (workspaceRef.current && !loaded) { + workspaceRef.current.callElasticsearch({}); + setLoaded(true); + workspaceRef.current.runLayout(); + } + }, [loaded]); + + useEffect(() => { + if (loaded && workspaceRef.current && workspaceRef.current?.nodes.length > 0 && !ranLayout) { + workspaceRef.current.runLayout(); + updateRanLayout(true); + } + }, [loaded, ranLayout]); + + return workspaceRef.current && loaded ? ( +
+ {}} + onSetControl={() => {}} + onSetMergeCandidates={() => {}} + /> +
+ ) : ( + <>{'Testing'} + ); +}); + +AlertDetailsVisualizeGraph.displayName = 'AlertDetailsVisualizeGraph'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/use-graph-query.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/use-graph-query.ts new file mode 100644 index 000000000000..3ee017cf4993 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/graph/use-graph-query.ts @@ -0,0 +1,111 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useHttp } from '../../../../../../common/lib/kibana'; + +const getQuery = (alertId: string) => ({ + query: { + bool: { + should: [ + { + match_phrase: { + 'kibana.alert.uuid': alertId, + }, + }, + ], + minimum_should_match: 1, + }, + }, + controls: { + use_significance: true, + sample_size: 10000, + timeout: 5000, + }, + connections: { + vertices: [ + { + field: 'host.name', + size: 5, + min_doc_count: 1, + }, + { + field: 'kibana.alert.rule.name', + size: 5, + min_doc_count: 1, + }, + { + field: 'kibana.alert.uuid', + size: 5, + min_doc_count: 1, + }, + { + field: 'user.name', + size: 10, + min_doc_count: 1, + }, + ], + }, + vertices: [ + { + field: 'host.name', + size: 5, + min_doc_count: 1, + }, + { + field: 'kibana.alert.rule.name', + size: 5, + min_doc_count: 1, + }, + { + field: 'kibana.alert.uuid', + size: 5, + min_doc_count: 1, + }, + { + field: 'user.name', + size: 10, + min_doc_count: 1, + }, + ], +}); + +export function useGraphQuery(alertId: string) { + const http = useHttp(); + const dslQuery = getQuery(alertId); + + const mutation = useMutation((request) => { + return http.post('/api/graph/graphExplore', request); + }); + + const getWorkspace = useCallback( + (indexName, query, responseHandler) => { + const dsl = { index: '.alerts-security.alerts-default', query: dslQuery }; + const request = { body: JSON.stringify(dsl) }; + mutation.mutate(request, { + onSettled(data) { + console.log("SETTLED DATA"); + }, + onSuccess(data) { + console.log('RESPONDING: ', data); + responseHandler(data?.resp); + }, + onError(error) { + console.log({ + loading: false, + error: true, + data: undefined, + }); + }, + }); + }, + [dslQuery, mutation] + ); + + return getWorkspace; +} diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/index.tsx new file mode 100644 index 000000000000..44a6d1788312 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/tabs/visualize/index.tsx @@ -0,0 +1,117 @@ +/* + * 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, { useState } from 'react'; +import { EuiSpacer, EuiFlexGroup, EuiButtonGroup, EuiTitle } from '@elastic/eui'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { TimelineId } from '@kbn/timelines-plugin/common'; +import type { SearchHit } from '../../../../../../common/search_strategy'; +import { useTimelineDataFilters } from '../../../../../timelines/containers/use_timeline_data_filters'; +import { Resolver } from '../../../../../resolver/view'; +import { useSourcererDataView } from '../../../../../common/containers/sourcerer'; +import { JsonView } from '../../../../../common/components/event_details/json_view'; +import { EventFieldsBrowser } from '../../../../../common/components/event_details/event_fields_browser'; +import { SourcererScopeName } from '../../../../../common/store/sourcerer/model'; +import { AlertDetailsVisualizeGraph } from './graph'; + +export const AlertDetailsVisualizeTab = React.memo( + ({ + data, + searchHit, + id, + }: { + data: TimelineEventsDetailsItem[] | null; + searchHit?: SearchHit; + id: string; + }) => { + const { browserFields } = useSourcererDataView(SourcererScopeName.detections); + const { from, to, shouldUpdate, selectedPatterns } = useTimelineDataFilters( + TimelineId.detectionsPage + ); + const [visiblePage, setVisiblePage] = useState('event'); + const toggleButtons = [ + { + id: 'event', + label: 'Event data', + }, + { + id: 'analyzer', + label: 'Analyzer graph', + }, + { + id: 'json', + label: 'JSON', + }, + { + id: 'graph', + label: 'Graph', + }, + ]; + const onChange = (optionId: string) => { + setVisiblePage(optionId); + }; + + return ( + <> + {data && ( + <> + + + + + {visiblePage === 'event' && ( + <> + + +

{'All fields'}

+
+ + + + )} + {visiblePage === 'json' && ( + <> + + + + )} + {visiblePage === 'analyzer' && ( + <> + + + + )} + {visiblePage === 'graph' && } +
+ + )} + + ); + } +); + +AlertDetailsVisualizeTab.displayName = 'AlertDetailsVisualizeTab'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/translations.ts new file mode 100644 index 000000000000..e59de6b3f759 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/translations.ts @@ -0,0 +1,51 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const SUMMARY_PAGE_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.navigation.summary', + { + defaultMessage: 'Summary', + } +); + +export const VISUALIZE_PAGE_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.navigation.visualize', + { + defaultMessage: 'Visualize', + } +); + +export const BACK_TO_ALERTS_LINK = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.header.backToAlerts', + { + defaultMessage: 'Back to alerts', + } +); + +export const LOADING_PAGE_MESSAGE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.loadingPage.message', + { + defaultMessage: 'Loading details page...', + } +); + +export const ERROR_PAGE_TITLE = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.errorPage.title', + { + defaultMessage: 'Unable to load the details page', + } +); + +export const ERROR_PAGE_BODY = i18n.translate( + 'xpack.securitySolution.alerts.alertDetails.errorPage.message', + { + defaultMessage: + 'There was an error loading the details page. Please confirm the following id points to a valid document', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/types.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/types.ts new file mode 100644 index 000000000000..0ebfbe36adfd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/types.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { NavTab } from '../../../common/components/navigation/types'; + +export enum AlertDetailRouteType { + summary = 'summary', + visualize = 'visualize', +} + +export type AlertDetailNavTabs = Record<`${AlertDetailRouteType}`, NavTab>; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/breadcrumbs.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/breadcrumbs.ts new file mode 100644 index 000000000000..d9a0dafe966f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/breadcrumbs.ts @@ -0,0 +1,49 @@ +/* + * 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 type { ChromeBreadcrumb } from '@kbn/core/public'; +import type { GetSecuritySolutionUrl } from '../../../../common/components/link_to'; +import { getAlertDetailsUrl } from '../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../common/constants'; +import type { AlertDetailRouteSpyState } from '../../../../common/utils/route/types'; +import { AlertDetailRouteType } from '../types'; +import * as i18n from '../translations'; + +const TabNameMappedToI18nKey: Record = { + [AlertDetailRouteType.summary]: i18n.SUMMARY_PAGE_TITLE, + [AlertDetailRouteType.visualize]: i18n.VISUALIZE_PAGE_TITLE, +}; + +export const getTrailingBreadcrumbs = ( + params: AlertDetailRouteSpyState, + getSecuritySolutionUrl: GetSecuritySolutionUrl +): ChromeBreadcrumb[] => { + let breadcrumb: ChromeBreadcrumb[] = []; + + if (params.detailName != null) { + breadcrumb = [ + ...breadcrumb, + { + text: params.state?.ruleName ?? params.detailName, + href: getSecuritySolutionUrl({ + path: getAlertDetailsUrl(params.detailName, ''), + deepLinkId: SecurityPageName.alerts, + }), + }, + ]; + } + if (params.tabName != null) { + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[params.tabName], + href: '', + }, + ]; + } + return breadcrumb; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/get_timeline_event_data.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/get_timeline_event_data.ts new file mode 100644 index 000000000000..7d5dbc544008 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/get_timeline_event_data.ts @@ -0,0 +1,13 @@ +/* + * 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 type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy'; + +export const getTimelineEventData = (field: string, data: TimelineEventsDetailsItem[] | null) => { + const valueArray = data?.find((datum) => datum.field === field)?.values; + return valueArray && valueArray.length > 0 ? valueArray[0] : ''; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/navigation.ts b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/navigation.ts new file mode 100644 index 000000000000..652721b7d2d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/alert_details/utils/navigation.ts @@ -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 type { AlertDetailNavTabs } from '../types'; +import { ALERTS_PATH } from '../../../../../common/constants'; +import { AlertDetailRouteType } from '../types'; +import * as i18n from '../translations'; + +export const getAlertDetailsTabUrl = (alertId: string, tabName: AlertDetailRouteType) => + `${ALERTS_PATH}/${alertId}/${tabName}`; + +export const getAlertDetailsNavTabs = (alertId: string): AlertDetailNavTabs => ({ + [AlertDetailRouteType.summary]: { + id: AlertDetailRouteType.summary, + name: i18n.SUMMARY_PAGE_TITLE, + href: getAlertDetailsTabUrl(alertId, AlertDetailRouteType.summary), + disabled: false, + }, + [AlertDetailRouteType.visualize]: { + id: AlertDetailRouteType.visualize, + name: i18n.VISUALIZE_PAGE_TITLE, + href: getAlertDetailsTabUrl(alertId, AlertDetailRouteType.visualize), + disabled: false, + }, +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 288b389215e4..8fe8b12761fe 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -6,16 +6,20 @@ */ import React from 'react'; -import { Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { Route } from '@kbn/kibana-react-plugin/public'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { DetectionEnginePage } from '../detection_engine/detection_engine'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { useReadonlyHeader } from '../../../use_readonly_header'; +import { AlertDetailsPage } from '../alert_details'; +import { AlertDetailRouteType } from '../alert_details/types'; +import { getAlertDetailsTabUrl } from '../alert_details/utils/navigation'; const AlertsRoute = () => ( @@ -24,12 +28,41 @@ const AlertsRoute = () => ( ); +const AlertDetailsRoute = () => ( + + + +); + const AlertsContainerComponent: React.FC = () => { useReadonlyHeader(i18n.READ_ONLY_BADGE_TOOLTIP); - + const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); return ( + {isAlertDetailsPageEnabled && ( + <> + {/* Redirect to the summary page if only the detail name is provided */} + ( + + )} + /> + + + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index ade80404464c..3a042ee6e922 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -18,6 +18,9 @@ import { import React from 'react'; import styled from 'styled-components'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { getAlertDetailsUrl } from '../../../../common/components/link_to'; +import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import type { Ecs } from '../../../../../common/ecs'; import type { TimelineTabs } from '../../../../../common/types/timeline'; import type { BrowserFields } from '../../../../common/containers/source'; @@ -25,6 +28,7 @@ import { EventDetails } from '../../../../common/components/event_details/event_ import type { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import * as i18n from './translations'; import { PreferenceFormattedDate } from '../../../../common/components/formatted_date'; +import { SecurityPageName } from '../../../../../common/constants'; export type HandleOnEventClosed = () => void; interface Props { @@ -44,6 +48,7 @@ interface Props { } interface ExpandableEventTitleProps { + eventId: string; isAlert: boolean; loading: boolean; ruleName?: string; @@ -68,31 +73,47 @@ const StyledEuiFlexItem = styled(EuiFlexItem)` `; export const ExpandableEventTitle = React.memo( - ({ isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => ( - - - {!loading && ( - <> - -

{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}

-
- {timestamp && ( - <> - - - - )} - - - )} -
- {handleOnEventClosed && ( + ({ eventId, isAlert, loading, handleOnEventClosed, ruleName, timestamp }) => { + const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled'); + return ( + - + {!loading && ( + <> + +

{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}

+
+ {timestamp && ( + <> + + + + )} + {isAlert && eventId && isAlertDetailsPageEnabled && ( + <> + + + {i18n.OPEN_ALERT_DETAILS_PAGE} + + + + )} + + )}
- )} -
- ) + {handleOnEventClosed && ( + + + + )} +
+ ); + } ); ExpandableEventTitle.displayName = 'ExpandableEventTitle'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx index 39a4899dbc33..bc9af3fa8546 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/flyout/header.tsx @@ -12,6 +12,7 @@ import { ExpandableEventTitle } from '../expandable_event'; import { BackToAlertDetailsLink } from './back_to_alert_details_link'; interface FlyoutHeaderComponentProps { + eventId: string; isAlert: boolean; isHostIsolationPanelOpen: boolean; isolateAction: 'isolateHost' | 'unisolateHost'; @@ -22,6 +23,7 @@ interface FlyoutHeaderComponentProps { } const FlyoutHeaderContentComponent = ({ + eventId, isAlert, isHostIsolationPanelOpen, isolateAction, @@ -36,6 +38,7 @@ const FlyoutHeaderContentComponent = ({ ) : ( { { }, [ isAlert, + alertId, isHostIsolationPanelOpen, isolateAction, loading, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index ef04bffd6410..3ff6388daa15 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -80,6 +80,7 @@ const EventDetailsPanelComponent: React.FC = ({ () => isFlyoutView || isHostIsolationPanelOpen ? ( = ({ /> ) : ( = ({ /> ), [ + expandedEvent.eventId, handleOnEventClosed, isAlert, isFlyoutView, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts index 2910e04747e3..39292052cf8d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/translations.ts @@ -14,6 +14,13 @@ export const MESSAGE = i18n.translate( } ); +export const OPEN_ALERT_DETAILS_PAGE = i18n.translate( + 'xpack.securitySolution.timeline.expandableEvent.openAlertDetails', + { + defaultMessage: 'Open alert details page', + } +); + export const CLOSE = i18n.translate( 'xpack.securitySolution.timeline.expandableEvent.closeEventDetailsLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx index cdb13af5354c..05ada6ee0d0d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.test.tsx @@ -11,6 +11,7 @@ import { timelineActions } from '../../../store/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { TimelineId, TimelineTabs } from '../../../../../common/types'; +import { FlowTargetSourceDest } from '../../../../../common/search_strategy'; const mockDispatch = jest.fn(); jest.mock('../../../../common/lib/kibana'); @@ -51,71 +52,237 @@ describe('useDetailPanel', () => { (useDeepEqualSelector as jest.Mock).mockClear(); }); - test('should return openDetailsPanel fn, handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => { + test('should return open fns (event, host, network, user), handleOnDetailsPanelClosed fn, shouldShowDetailsPanel, and the DetailsPanel component', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => { return useDetailPanel(defaultProps); }); await waitForNextUpdate(); - expect(result.current.openDetailsPanel).toBeDefined(); + expect(result.current.openEventDetailsPanel).toBeDefined(); + expect(result.current.openHostDetailsPanel).toBeDefined(); + expect(result.current.openNetworkDetailsPanel).toBeDefined(); + expect(result.current.openUserDetailsPanel).toBeDefined(); expect(result.current.handleOnDetailsPanelClosed).toBeDefined(); expect(result.current.shouldShowDetailsPanel).toBe(false); expect(result.current.DetailsPanel).toBeNull(); }); }); - test('should fire redux action to open details panel', async () => { + describe('open event details', () => { const testEventId = '123'; - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => { - return useDetailPanel(defaultProps); + test('should fire redux action to open event details panel', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + result.current?.openEventDetailsPanel(testEventId); + + expect(mockDispatch).toHaveBeenCalled(); + expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); }); - await waitForNextUpdate(); + }); - result.current?.openDetailsPanel(testEventId); + test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); - expect(mockDispatch).toHaveBeenCalled(); - expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); + const mockOnClose = jest.fn(); + result.current?.openEventDetailsPanel(testEventId, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => { + // Test that the onClose ref is properly updated + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + const secondMockOnClose = jest.fn(); + result.current?.openEventDetailsPanel(testEventId, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + + result.current?.openEventDetailsPanel(testEventId, secondMockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(secondMockOnClose).toHaveBeenCalled(); + }); }); }); - test('should call provided onClose callback provided to openDetailsPanel fn', async () => { - const testEventId = '123'; - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => { - return useDetailPanel(defaultProps); + describe('open host details', () => { + const hostName = 'my-host'; + test('should fire redux action to open host details panel', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + result.current?.openHostDetailsPanel(hostName); + + expect(mockDispatch).toHaveBeenCalled(); + expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); }); - await waitForNextUpdate(); + }); + + test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + result.current?.openHostDetailsPanel(hostName, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => { + // Test that the onClose ref is properly updated + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + const secondMockOnClose = jest.fn(); + result.current?.openHostDetailsPanel(hostName, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + + result.current?.openEventDetailsPanel(hostName, secondMockOnClose); + result.current?.handleOnDetailsPanelClosed(); - const mockOnClose = jest.fn(); - result.current?.openDetailsPanel(testEventId, mockOnClose); - result.current?.handleOnDetailsPanelClosed(); + expect(secondMockOnClose).toHaveBeenCalled(); + }); + }); + }); + + describe('open network details', () => { + const ip = '1.2.3.4.5'; + const flowTarget = FlowTargetSourceDest.source; + test('should fire redux action to open host details panel', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + result.current?.openNetworkDetailsPanel(ip, flowTarget); - expect(mockOnClose).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalled(); + expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); + }); + }); + + test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + result.current?.openNetworkDetailsPanel(ip, flowTarget, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => { + // Test that the onClose ref is properly updated + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + const secondMockOnClose = jest.fn(); + result.current?.openNetworkDetailsPanel(ip, flowTarget, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + + result.current?.openNetworkDetailsPanel(ip, flowTarget, secondMockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(secondMockOnClose).toHaveBeenCalled(); + }); }); }); - test('should call the last onClose callback provided to openDetailsPanel fn', async () => { - // Test that the onClose ref is properly updated - const testEventId = '123'; - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => { - return useDetailPanel(defaultProps); + describe('open user details', () => { + const userName = 'IAmBatman'; + test('should fire redux action to open host details panel', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + result.current?.openUserDetailsPanel(userName); + + expect(mockDispatch).toHaveBeenCalled(); + expect(timelineActions.toggleDetailPanel).toHaveBeenCalled(); }); - await waitForNextUpdate(); + }); + + test('should call provided onClose callback provided to openEventDetailsPanel fn', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); + + const mockOnClose = jest.fn(); + result.current?.openUserDetailsPanel(userName, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); - const mockOnClose = jest.fn(); - const secondMockOnClose = jest.fn(); - result.current?.openDetailsPanel(testEventId, mockOnClose); - result.current?.handleOnDetailsPanelClosed(); + test('should call the last onClose callback provided to openEventDetailsPanel fn', async () => { + // Test that the onClose ref is properly updated + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => { + return useDetailPanel(defaultProps); + }); + await waitForNextUpdate(); - expect(mockOnClose).toHaveBeenCalled(); + const mockOnClose = jest.fn(); + const secondMockOnClose = jest.fn(); + result.current?.openUserDetailsPanel(userName, mockOnClose); + result.current?.handleOnDetailsPanelClosed(); - result.current?.openDetailsPanel(testEventId, secondMockOnClose); - result.current?.handleOnDetailsPanelClosed(); + expect(mockOnClose).toHaveBeenCalled(); - expect(secondMockOnClose).toHaveBeenCalled(); + result.current?.openUserDetailsPanel(userName, secondMockOnClose); + result.current?.handleOnDetailsPanelClosed(); + + expect(secondMockOnClose).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx index 0a2eaa817822..f0547ee9eaab 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/hooks/use_detail_panel.tsx @@ -8,12 +8,13 @@ import React, { useMemo, useCallback, useRef } from 'react'; import { useDispatch } from 'react-redux'; import type { EntityType } from '@kbn/timelines-plugin/common'; +import type { FlowTargetSourceDest } from '../../../../../common/search_strategy'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import type { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { activeTimeline } from '../../../containers/active_timeline_context'; -import type { TimelineTabs } from '../../../../../common/types/timeline'; -import { TimelineId } from '../../../../../common/types/timeline'; +import type { TimelineExpandedDetailType } from '../../../../../common/types/timeline'; +import { TimelineTabs, TimelineId } from '../../../../../common/types/timeline'; import { timelineDefaults } from '../../../store/timeline/defaults'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { DetailsPanel as DetailsPanelComponent } from '..'; @@ -27,7 +28,14 @@ export interface UseDetailPanelConfig { } export interface UseDetailPanelReturn { - openDetailsPanel: (eventId?: string, onClose?: () => void) => void; + openEventDetailsPanel: (eventId?: string, onClose?: () => void) => void; + openHostDetailsPanel: (hostName: string, onClose?: () => void) => void; + openNetworkDetailsPanel: ( + ip: string, + flowTarget: FlowTargetSourceDest, + onClose?: () => void + ) => void; + openUserDetailsPanel: (userName: string, onClose?: () => void) => void; handleOnDetailsPanelClosed: () => void; DetailsPanel: JSX.Element | null; shouldShowDetailsPanel: boolean; @@ -38,16 +46,18 @@ export const useDetailPanel = ({ isFlyoutView, sourcererScope, timelineId, - tabType, + tabType = TimelineTabs.query, }: UseDetailPanelConfig): UseDetailPanelReturn => { const { browserFields, selectedPatterns, runtimeMappings } = useSourcererDataView(sourcererScope); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const dispatch = useDispatch(); + const eventDetailsIndex = useMemo(() => selectedPatterns.join(','), [selectedPatterns]); const expandedDetail = useDeepEqualSelector( (state) => (getTimeline(state, timelineId) ?? timelineDefaults)?.expandedDetail ); const onPanelClose = useRef(() => {}); + const noopPanelClose = () => {}; const shouldShowDetailsPanel = useMemo(() => { if ( @@ -61,29 +71,56 @@ export const useDetailPanel = ({ return false; }, [expandedDetail, tabType]); + // We could just surface load details panel, but rather than have users be concerned + // of the config for a panel, they can just pass the base necessary values to a panel specific function const loadDetailsPanel = useCallback( - (eventId?: string) => { - if (eventId) { + (panelConfig?: TimelineExpandedDetailType) => { + if (panelConfig) { dispatch( timelineActions.toggleDetailPanel({ - panelView: 'eventDetail', + ...panelConfig, tabType, timelineId, - params: { - eventId, - indexName: selectedPatterns.join(','), - }, }) ); } }, - [dispatch, selectedPatterns, tabType, timelineId] + [dispatch, tabType, timelineId] ); - const openDetailsPanel = useCallback( + const openEventDetailsPanel = useCallback( (eventId?: string, onClose?: () => void) => { - loadDetailsPanel(eventId); - onPanelClose.current = onClose ?? (() => {}); + if (eventId) { + loadDetailsPanel({ + panelView: 'eventDetail', + params: { eventId, indexName: eventDetailsIndex }, + }); + } + onPanelClose.current = onClose ?? noopPanelClose; + }, + [loadDetailsPanel, eventDetailsIndex] + ); + + const openHostDetailsPanel = useCallback( + (hostName: string, onClose?: () => void) => { + loadDetailsPanel({ panelView: 'hostDetail', params: { hostName } }); + onPanelClose.current = onClose ?? noopPanelClose; + }, + [loadDetailsPanel] + ); + + const openNetworkDetailsPanel = useCallback( + (ip: string, flowTarget: FlowTargetSourceDest, onClose?: () => void) => { + loadDetailsPanel({ panelView: 'networkDetail', params: { ip, flowTarget } }); + onPanelClose.current = onClose ?? noopPanelClose; + }, + [loadDetailsPanel] + ); + + const openUserDetailsPanel = useCallback( + (userName: string, onClose?: () => void) => { + loadDetailsPanel({ panelView: 'userDetail', params: { userName } }); + onPanelClose.current = onClose ?? noopPanelClose; }, [loadDetailsPanel] ); @@ -128,7 +165,10 @@ export const useDetailPanel = ({ ); return { - openDetailsPanel, + openEventDetailsPanel, + openHostDetailsPanel, + openNetworkDetailsPanel, + openUserDetailsPanel, handleOnDetailsPanelClosed, shouldShowDetailsPanel, DetailsPanel, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index 7bc64b990064..83499c17bb7a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -60,6 +60,9 @@ jest.mock('../../../../../common/lib/kibana', () => { addSuccess: jest.fn(), addWarning: jest.fn(), }), + useNavigateTo: jest.fn().mockReturnValue({ + navigateTo: jest.fn(), + }), useGetUserCasesPermissions: originalKibanaLib.useGetUserCasesPermissions, }; }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx index 6d329034650f..199c79c3ac58 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.test.tsx @@ -58,6 +58,9 @@ jest.mock('../../../../../common/lib/kibana', () => { cases: mockCasesContract(), }, }), + useNavigateTo: () => ({ + navigateTo: jest.fn(), + }), useToasts: jest.fn().mockReturnValue({ addError: jest.fn(), addSuccess: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/alert_renderer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/alert_renderer/index.tsx index 28acdf9378b8..195c4141386c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/alert_renderer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/alert_renderer/index.tsx @@ -37,6 +37,21 @@ import * as i18n from './translations'; export const DEFAULT_CONTEXT_ID = 'alert-renderer'; +export const ALERT_RENDERER_FIELDS = [ + DESTINATION_IP, + DESTINATION_PORT, + EVENT_CATEGORY, + FILE_NAME, + HOST_NAME, + KIBANA_ALERT_RULE_NAME, + KIBANA_ALERT_SEVERITY, + PROCESS_NAME, + PROCESS_PARENT_NAME, + SOURCE_IP, + SOURCE_PORT, + USER_NAME, +]; + const AlertRendererFlexGroup = styled(EuiFlexGroup)` gap: ${({ theme }) => theme.eui.euiSizeXS}; `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.test.tsx index 0e5fb73a07c4..8c0643867c95 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.test.tsx @@ -71,12 +71,12 @@ jest.mock('../../../../common/lib/kibana', () => { }), }; }); -const mockDetails = () => {}; +const mockOpenDetailFn = jest.fn(); jest.mock('../../side_panel/hooks/use_detail_panel', () => { return { useDetailPanel: () => ({ - openDetailsPanel: mockDetails, + openEventDetailsPanel: mockOpenDetailFn, handleOnDetailsPanelClosed: () => {}, DetailsPanel: () =>
, shouldShowDetailsPanel: false, @@ -161,7 +161,7 @@ describe('useSessionView with active timeline and a session id and graph event i expect(kibana.services.sessionView.getSessionView).toHaveBeenCalledWith({ height: 1000, sessionEntityId: 'test', - loadAlertDetails: mockDetails, + loadAlertDetails: mockOpenDetailFn, }); }); @@ -240,7 +240,7 @@ describe('useSessionView with active timeline and a session id and graph event i ); expect(kibana.services.sessionView.getSessionView).toHaveBeenCalled(); - expect(result.current).toHaveProperty('openDetailsPanel'); + expect(result.current).toHaveProperty('openEventDetailsPanel'); expect(result.current).toHaveProperty('shouldShowDetailsPanel'); expect(result.current).toHaveProperty('SessionView'); expect(result.current).toHaveProperty('DetailsPanel'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.tsx index 941b24df76a2..6c76bb10430b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/session_tab_content/use_session_view.tsx @@ -242,7 +242,7 @@ export const useSessionView = ({ return SourcererScopeName.default; } }, [timelineId]); - const { openDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({ + const { openEventDetailsPanel, shouldShowDetailsPanel, DetailsPanel } = useDetailPanel({ isFlyoutView: timelineId !== TimelineId.active, entityType, sourcererScope, @@ -256,15 +256,15 @@ export const useSessionView = ({ return sessionViewConfig !== null ? sessionView.getSessionView({ ...sessionViewConfig, - loadAlertDetails: openDetailsPanel, + loadAlertDetails: openEventDetailsPanel, isFullScreen: fullScreen, height: heightMinusSearchBar, }) : null; - }, [fullScreen, openDetailsPanel, sessionView, sessionViewConfig, height]); + }, [fullScreen, openEventDetailsPanel, sessionView, sessionViewConfig, height]); return { - openDetailsPanel, + openEventDetailsPanel, shouldShowDetailsPanel, SessionView: sessionViewComponent, DetailsPanel, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index ddb25b0c0037..3ab58e288a83 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -16,6 +16,7 @@ import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; import { EntityType } from '@kbn/timelines-plugin/common'; import { useKibana } from '../../../common/lib/kibana'; import type { + SearchHit, TimelineEventsDetailsItem, TimelineEventsDetailsRequestOptions, TimelineEventsDetailsStrategyResponse, @@ -47,7 +48,7 @@ export const useTimelineEventsDetails = ({ }: UseTimelineEventsDetailsProps): [ boolean, EventsArgs['detailsData'], - object | undefined, + SearchHit | undefined, EventsArgs['ecs'], () => Promise ] => { @@ -65,7 +66,7 @@ export const useTimelineEventsDetails = ({ useState(null); const [ecsData, setEcsData] = useState(null); - const [rawEventData, setRawEventData] = useState(undefined); + const [rawEventData, setRawEventData] = useState(undefined); const timelineDetailsSearch = useCallback( (request: TimelineEventsDetailsRequestOptions | null) => { if (request == null || skip || isEmpty(request.eventId)) { diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 76e347d7a0b1..f8876258cd1d 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -37,6 +37,7 @@ { "path": "../encrypted_saved_objects/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../fleet/tsconfig.json" }, + { "path": "../graph/tsconfig.json" }, { "path": "../kubernetes_security/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../lists/tsconfig.json" }, diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index b58a598192eb..d0b98f7223e3 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -317,6 +317,7 @@ export enum TimelineId { hostsPageEvents = 'hosts-page-events', networkPageEvents = 'network-page-events', hostsPageSessions = 'hosts-page-sessions-v2', + detectionsAlertDetailsPage = 'detections-alert-details-page', detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', active = 'timeline-1', diff --git a/x-pack/plugins/timelines/public/store/t_grid/types.ts b/x-pack/plugins/timelines/public/store/t_grid/types.ts index 5bc0982dfad9..20afeaf71596 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/types.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/types.ts @@ -48,6 +48,7 @@ export enum TimelineId { hostsPageSessions = 'hosts-page-sessions-v2', detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', + detectionsAlertDetailsPage = 'detections-alert-details-page', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index e77ab1fe4d48..18d7577516fd 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -49,7 +49,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // See https://github.com/elastic/kibana/pull/125396 for details '--xpack.alerting.rules.minimumScheduleInterval.value=1s', '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', - `--xpack.securitySolution.enableExperimental=${JSON.stringify([])}`, + `--xpack.securitySolution.enableExperimental=${JSON.stringify([ + 'alertDetailsPageEnabled', + ])}`, `--home.disableWelcomeScreen=true`, ], },