From f63b2ca7cffe2fbdabf2bebe63107894c05cdef8 Mon Sep 17 00:00:00 2001 From: Sai Vikhyath <191836418+vikhy-aws@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:01:49 -0800 Subject: [PATCH] Update correlations overview page and flyout (#1248) * feat: add correlations overview page with a chart and table, and new flyout for the table Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: remove log statements Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: update changes to original component Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * feat: add resources column to the table Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: maintain different state for graph in the flyout than the main graph Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: display mitre tactics as a list and add tooltip for the field Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: update as per comments on PR Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: update as per comments on PR Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: move utility function into helpers Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: make table as default view, add spinner, and alert name in flyout Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: add a different state variable to track updates to table Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: add a different state variable to track updates to table * fix: fix a bug that loading spinner is on when there is no data Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: fix a bug that loading spinner is on when there is no data Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: change graph configurations to make it force-directed Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: change graph configurations to make it force-directed Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * fix: increase correlations graph panel height Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * seperate out table-view into a different file Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> * update according to comments on PR Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> --------- Signed-off-by: vikhy-aws <191836418+vikhy-aws@users.noreply.github.com> --- .../containers/CorrelationsContainer.tsx | 270 ++++++++++++++-- .../containers/CorrelationsTable.tsx | 113 +++++++ .../containers/CorrelationsTableFlyout.tsx | 210 ++++++++++++ .../containers/CorrelationsTableView.tsx | 302 ++++++++++++++++++ public/pages/Correlations/utils/constants.tsx | 13 +- public/pages/Correlations/utils/helpers.tsx | 252 ++++++++++++++- public/store/CorrelationsStore.ts | 4 +- 7 files changed, 1127 insertions(+), 37 deletions(-) create mode 100644 public/pages/Correlations/containers/CorrelationsTable.tsx create mode 100644 public/pages/Correlations/containers/CorrelationsTableFlyout.tsx create mode 100644 public/pages/Correlations/containers/CorrelationsTableView.tsx diff --git a/public/pages/Correlations/containers/CorrelationsContainer.tsx b/public/pages/Correlations/containers/CorrelationsContainer.tsx index 87e691c34..abf74d15e 100644 --- a/public/pages/Correlations/containers/CorrelationsContainer.tsx +++ b/public/pages/Correlations/containers/CorrelationsContainer.tsx @@ -5,6 +5,7 @@ import { CorrelationFinding, CorrelationGraphData, + CorrelationRule, DataSourceProps, DateTimeFilter, FindingItemType, @@ -39,11 +40,14 @@ import { EuiBadge, EuiFilterGroup, EuiHorizontalRule, + EuiButtonGroup, + EuiFieldSearch, } from '@elastic/eui'; import { FilterItem, FilterGroup } from '../components/FilterGroup'; import { BREADCRUMBS, DEFAULT_DATE_RANGE, + DEFAULT_EMPTY_DATA, MAX_RECENTLY_USED_TIME_RANGES, ROUTES, } from '../../../utils/constants'; @@ -58,6 +62,9 @@ import { getLogTypeLabel } from '../../LogTypes/utils/helpers'; import { NotificationsStart } from 'opensearch-dashboards/public'; import { errorNotificationToast, setBreadcrumbs } from '../../../utils/helpers'; import { PageHeader } from '../../../components/PageHeader/PageHeader'; +import { debounce } from 'lodash'; +import { CorrelationsTableView } from './CorrelationsTableView'; +import { mapConnectedCorrelations } from '../utils/helpers'; interface CorrelationsProps extends RouteComponentProps< @@ -77,6 +84,18 @@ interface SpecificFindingCorrelations { correlatedFindings: CorrelationFinding[]; } +export interface CorrelationsTableData { + id: string; + startTime: number; + correlationRule: string; + alertSeverity: string[]; + logTypes: string[]; + findingsSeverity: string[]; + correlatedFindings: CorrelationFinding[]; + resources: string[]; + correlationRuleObj: CorrelationRule | null; +} + interface CorrelationsState { recentlyUsedRanges: any[]; graphData: CorrelationGraphData; @@ -84,6 +103,11 @@ interface CorrelationsState { logTypeFilterOptions: FilterItem[]; severityFilterOptions: FilterItem[]; loadingGraphData: boolean; + loadingTableData: boolean; + isGraphView: boolean; + correlationsTableData: CorrelationsTableData[]; + connectedFindings: CorrelationFinding[][]; + searchTerm: string; } export class Correlations extends React.Component { @@ -98,6 +122,11 @@ export class Correlations extends React.Component { + private onRefresh = async () => { this.updateState(); + this.fetchCorrelationsTableData(); }; onLogTypeFilterChange = (items: FilterItem[]) => { - this.setState({ logTypeFilterOptions: items }); + this.setState( + { + logTypeFilterOptions: items, + }, + () => { + if (this.state.specificFindingInfo) { + this.updateGraphDataState(this.state.specificFindingInfo); + } + } + ); }; onSeverityFilterChange = (items: FilterItem[]) => { - this.setState({ severityFilterOptions: items }); + this.setState( + { + severityFilterOptions: items, + }, + () => { + if (this.state.specificFindingInfo) { + this.updateGraphDataState(this.state.specificFindingInfo); + } + } + ); }; closeFlyout = () => { @@ -440,13 +490,30 @@ export class Correlations extends React.Component 0 || loadingData ? ( - + <> + + + + Severity: + + + {ruleSeverity.map((sev, idx) => ( + + + {sev.value} + + + ))} + + + + ) : ( { + try { + const start = datemath.parse(this.startTime); + const end = datemath.parse(this.endTime); + const startTime = start?.valueOf() || Date.now(); + const endTime = end?.valueOf() || Date.now(); + + this.setState({ loadingTableData: true }); + let allCorrelations = await DataStore.correlations.getAllCorrelationsInWindow( + startTime.toString(), + endTime.toString() + ); + + const connectedFindings = mapConnectedCorrelations(allCorrelations); + + this.setState({ connectedFindings }); + + const tableData: CorrelationsTableData[] = []; + + const allCorrelationRules = await DataStore.correlations.getCorrelationRules(); + const allCorrelatedAlerts = await DataStore.correlations.getAllCorrelationAlerts(); + + const correlationRuleMapsAlerts: { [id: string]: string[] } = {}; + + allCorrelationRules.forEach((correlationRule) => { + const correlationRuleId = correlationRule.id; + correlationRuleMapsAlerts[correlationRuleId] = []; + + allCorrelatedAlerts.correlationAlerts.forEach((correlatedAlert) => { + if (correlatedAlert.correlation_rule_id === correlationRuleId) { + correlationRuleMapsAlerts[correlationRuleId].push(correlatedAlert.severity); + } + }); + }); + + for (const findingGroup of connectedFindings) { + let correlationRule = ''; + const logTypes = new Set(); + const findingsSeverity: string[] = []; + let alertsSeverity: string[] = []; + const resources: string[] = []; + let correlationRuleObj: CorrelationRule | null = null; + + for (const finding of findingGroup) { + findingsSeverity.push(finding.detectionRule.severity); + logTypes.add(finding.logType); + } + + // Call the APIs only if correlationRule has not been found yet to avoid repeated API calls. + if (correlationRule === '') { + if (findingGroup[0] && findingGroup[0].detector && findingGroup[0].detector._source) { + const correlatedFindingsResponse = await DataStore.correlations.getCorrelatedFindings( + findingGroup[0].id, + findingGroup[0]?.detector._source?.detector_type + ); + if (correlatedFindingsResponse?.correlatedFindings[0]?.rules) { + const correlationRuleId = correlatedFindingsResponse.correlatedFindings[0].rules[0]; + correlationRuleObj = + (await DataStore.correlations.getCorrelationRule(correlationRuleId)) || null; + alertsSeverity = correlationRuleMapsAlerts[correlationRuleId]; + if (correlationRuleObj) { + correlationRule = correlationRuleObj.name; + correlationRuleObj.queries.map((query) => { + query.conditions.map((condition) => { + resources.push(condition.name + ': ' + condition.value); + }); + }); + } + } + } + } + + tableData.push({ + id: `${startTime}_${findingGroup[0]?.id}`, + startTime: startTime, + correlationRule: correlationRule, + logTypes: Array.from(logTypes), + alertSeverity: alertsSeverity, + findingsSeverity: findingsSeverity, + correlatedFindings: findingGroup, + resources: resources, + correlationRuleObj: correlationRuleObj, + }); + } + + this.setState({ + correlationsTableData: tableData, + loadingTableData: false, + }); + } catch (error) { + this.setState({ loadingTableData: false }); + errorNotificationToast( + this.props.notifications, + 'fetch and connect correlations', + error.message + ); + } + }; + + private debouncedSearch = debounce((searchTerm: string) => { + this.setState({ searchTerm }); + }, 300); + + private renderSearchBar = () => { + return ( + { + e.persist(); + const searchValue = e.target.value; + this.setState({ searchTerm: searchValue }); + this.debouncedSearch(searchValue); + }} + fullWidth={true} + isClearable={true} + compressed={true} + aria-label="Search correlations" + /> + ); + }; + render() { const findingCardsData = this.state.specificFindingInfo; const datePicker = ( @@ -529,6 +718,7 @@ export class Correlations extends React.Component + {findingCardsData.correlatedFindings.map((finding, index) => { return ( <> @@ -539,7 +729,7 @@ export class Correlations extends React.Component + {this.renderSearchBar()} - - - - - Severity: - + this.setState({ isGraphView: id === 'graph' })} + buttonSize="s" + /> - {ruleSeverity.map((sev, idx) => ( - - - {sev.value} - - - ))} - - {this.renderCorrelationsGraph(this.state.loadingGraphData)} + {this.state.isGraphView ? ( + this.renderCorrelationsGraph(this.state.loadingGraphData) + ) : ( + <> + + + )} diff --git a/public/pages/Correlations/containers/CorrelationsTable.tsx b/public/pages/Correlations/containers/CorrelationsTable.tsx new file mode 100644 index 000000000..b8bcbff6b --- /dev/null +++ b/public/pages/Correlations/containers/CorrelationsTable.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiBasicTableColumn, + EuiToolTip, + EuiSmallButtonIcon, + EuiInMemoryTable, +} from '@elastic/eui'; +import { CorrelationsTableData } from './CorrelationsContainer'; +import { displayBadges, displaySeveritiesBadges, displayResourcesBadges } from '../utils/helpers'; +import { DEFAULT_EMPTY_DATA } from '../../../utils/constants'; + +interface CorrelationsTableProps { + correlationsTableData: CorrelationsTableData[]; + onViewDetails: (row: CorrelationsTableData) => void; +} + +export const CorrelationsTable: React.FC = ({ + correlationsTableData, + onViewDetails, +}) => { + const alertSeverityMap: { [key: string]: string } = { + '1': 'critical', + '2': 'high', + '3': 'medium', + '4': 'low', + '5': 'informational', + }; + + const columns: EuiBasicTableColumn[] = [ + { + field: 'startTime', + name: 'Start Time', + sortable: true, + dataType: 'date', + render: (startTime: number) => { + return new Date(startTime).toLocaleString(); + }, + }, + { + field: 'correlationRule', + name: 'Correlation Rule', + sortable: true, + render: (name: string) => name || DEFAULT_EMPTY_DATA, + }, + { + field: 'logTypes', + name: 'Log Types', + sortable: true, + render: (logTypes: string[]) => displayBadges(logTypes), + }, + { + field: 'alertSeverity', + name: 'Alert Severity', + sortable: true, + render: (alertsSeverity: string[]) => + displaySeveritiesBadges(alertsSeverity.map((severity) => alertSeverityMap[severity])), + }, + { + field: 'findingsSeverity', + name: 'Findings Severity', + sortable: true, + render: (findingsSeverity: string[]) => displaySeveritiesBadges(findingsSeverity), + }, + { + field: 'resources', + name: 'Resources', + sortable: true, + render: (resources: string[]) => displayResourcesBadges(resources), + }, + { + field: 'actions', + name: 'Actions', + render: (_, correlationTableRow: CorrelationsTableData) => { + return ( + + onViewDetails(correlationTableRow)} + /> + + ); + }, + }, + ]; + + const getRowProps = (item: any) => { + return { + 'data-test-subj': `row-${item.id}`, + key: item.id, + className: 'euiTableRow', + }; + }; + + return ( + + ); +}; diff --git a/public/pages/Correlations/containers/CorrelationsTableFlyout.tsx b/public/pages/Correlations/containers/CorrelationsTableFlyout.tsx new file mode 100644 index 000000000..709ceb656 --- /dev/null +++ b/public/pages/Correlations/containers/CorrelationsTableFlyout.tsx @@ -0,0 +1,210 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTextColor, + EuiPanel, + EuiBasicTableColumn, + EuiInMemoryTable, + EuiBadge, + EuiLink, +} from '@elastic/eui'; +import { displayBadges } from '../utils/helpers'; +import { DEFAULT_EMPTY_DATA } from '../../../utils/constants'; +import { capitalizeFirstLetter } from '../../../utils/helpers'; +import { CorrelationGraph } from '../components/CorrelationGraph'; +import { getSeverityColor, graphRenderOptions } from '../utils/constants'; +import { CorrelationFinding, CorrelationGraphData } from '../../../../types'; +import { CorrelationsTableData } from './CorrelationsContainer'; + +interface FlyoutTableData { + timestamp: string; + mitreTactic: string[]; + detectionRule: string; + severity: string; +} + +interface CorrelationsTableFlyoutProps { + isFlyoutOpen: boolean; + selectedTableRow: CorrelationsTableData | null; + flyoutGraphData: CorrelationGraphData; + loadingTableData: boolean; + onClose: () => void; + setNetwork: (network: any) => void; +} + +export const CorrelationsTableFlyout: React.FC = ({ + isFlyoutOpen, + selectedTableRow, + flyoutGraphData, + loadingTableData, + onClose, + setNetwork, +}) => { + if (!isFlyoutOpen || !selectedTableRow) { + return null; + } + + const findingsTableColumns: EuiBasicTableColumn[] = [ + { + field: 'timestamp', + name: 'Time', + render: (timestamp: string) => new Date(timestamp).toLocaleString(), + sortable: true, + }, + { + field: 'mitreTactic', + name: 'Mitre Tactic', + sortable: true, + render: (mitreTactic: string[]) => displayBadges(mitreTactic), + }, + { + field: 'detectionRule', + name: 'Detection Rule', + sortable: true, + }, + { + field: 'severity', + name: 'Severity', + render: (ruleSeverity: string) => { + const severity = capitalizeFirstLetter(ruleSeverity) || DEFAULT_EMPTY_DATA; + const { background, text } = getSeverityColor(severity); + + return ( + + {severity} + + ); + }, + }, + ]; + + const flyoutTableData: FlyoutTableData[] = selectedTableRow.correlatedFindings.map( + (correlatedFinding: CorrelationFinding) => ({ + timestamp: correlatedFinding.timestamp, + mitreTactic: + correlatedFinding.detectionRule.tags?.map((mitreTactic) => mitreTactic.value) || [], + detectionRule: correlatedFinding.detectionRule.name, + severity: correlatedFinding.detectionRule.severity, + }) + ); + + return ( + + + +

Correlation Details

+
+ + + + +

+ Time +
+ {new Date(selectedTableRow.startTime).toLocaleString()} +

+
+
+ + +

+ Alert +
+ {selectedTableRow.correlationRuleObj?.trigger?.name || DEFAULT_EMPTY_DATA} +

+
+
+ + +

+ Correlation Rule +
+ {selectedTableRow.correlationRule} +

+
+
+
+
+ + + + +

Correlated Findings

+
+ {selectedTableRow.correlatedFindings && ( + + )} +
+ + +

Findings

+
+ + + + +

Observed MITRE Attack Tactics

+
+ + + {Array.from( + new Set( + selectedTableRow.correlatedFindings.flatMap( + (finding: CorrelationFinding) => + finding.detectionRule.tags?.map((tag) => tag.value) || [] + ) + ) + ).map((tactic, i) => { + const link = `https://attack.mitre.org/techniques/${tactic + .split('.') + .slice(1) + .join('/') + .toUpperCase()}`; + + return ( + + + + {tactic} + + + + ); + })} + +
+
+
+
+ ); +}; diff --git a/public/pages/Correlations/containers/CorrelationsTableView.tsx b/public/pages/Correlations/containers/CorrelationsTableView.tsx new file mode 100644 index 000000000..d05251713 --- /dev/null +++ b/public/pages/Correlations/containers/CorrelationsTableView.tsx @@ -0,0 +1,302 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiLoadingChart, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSpacer, + EuiEmptyPrompt, + EuiSmallButton, + EuiText, +} from '@elastic/eui'; +import { ChartContainer } from '../../../components/Charts/ChartContainer'; +import { DEFAULT_DATE_RANGE, ROUTES } from '../../../utils/constants'; +import { renderVisualization } from '../../../utils/helpers'; +import { getChartTimeUnit, getDomainRange } from '../../Overview/utils/helpers'; +import { getCorrelatedFindingsVisualizationSpec } from '../utils/helpers'; +import { CorrelationsTableData } from './CorrelationsContainer'; +import { CorrelationsTable } from './CorrelationsTable'; +import { CorrelationFinding, CorrelationGraphData } from '../../../../types'; +import { FilterItem } from '../components/FilterGroup'; +import { CorrelationsTableFlyout } from './CorrelationsTableFlyout'; +import { Network } from 'react-graph-vis'; +import { emptyGraphData } from '../utils/constants'; + +interface CorrelationsTableViewProps { + dateTimeFilter?: { + startTime: string; + endTime: string; + }; + correlationsTableData: CorrelationsTableData[]; + connectedFindings: CorrelationFinding[][]; + loadingTableData: boolean; + logTypeFilterOptions: FilterItem[]; + severityFilterOptions: FilterItem[]; + searchTerm: string; + addNode: (nodes: any[], finding: CorrelationFinding) => void; + addEdge: (edges: any[], f1: CorrelationFinding, f2: CorrelationFinding) => void; + onNodeClick: (params: any) => void; + setNetwork: (network: Network) => void; + createNodeTooltip: ({ detectionRule, timestamp, logType }: CorrelationFinding) => Element | null; +} + +interface CorrelationsTableViewState { + isFlyoutOpen: boolean; + selectedTableRow: CorrelationsTableData | null; + flyoutGraphData: CorrelationGraphData; +} + +export class CorrelationsTableView extends React.Component< + CorrelationsTableViewProps, + CorrelationsTableViewState +> { + static defaultProps = { + dateTimeFilter: { + startTime: DEFAULT_DATE_RANGE.start, + endTime: DEFAULT_DATE_RANGE.end, + }, + }; + + constructor(props: CorrelationsTableViewProps) { + super(props); + this.state = { + isFlyoutOpen: false, + selectedTableRow: null, + flyoutGraphData: { ...emptyGraphData }, + }; + } + + private getCorrelationPairs = (correlatedFindings: any[]) => { + const pairs: [any, any][] = []; + for (let i = 0; i < correlatedFindings.length; i++) { + for (let j = i + 1; j < correlatedFindings.length; j++) { + pairs.push([correlatedFindings[i], correlatedFindings[j]]); + } + } + return pairs; + }; + + private prepareGraphData = (correlationPairs: CorrelationFinding[][] | [any, any][]) => { + const createdEdges = new Set(); + const createdNodes = new Set(); + const graphData: CorrelationGraphData = { + graph: { + nodes: [], + edges: [], + }, + events: { + click: this.props.onNodeClick, + }, + }; + + correlationPairs.forEach((correlation: CorrelationFinding[]) => { + const possibleCombination1 = `${correlation[0].id}:${correlation[1].id}`; + const possibleCombination2 = `${correlation[1].id}:${correlation[0].id}`; + + if (createdEdges.has(possibleCombination1) || createdEdges.has(possibleCombination2)) { + return; + } + + if (!createdNodes.has(correlation[0].id)) { + this.props.addNode(graphData.graph.nodes, correlation[0]); + createdNodes.add(correlation[0].id); + } + if (!createdNodes.has(correlation[1].id)) { + this.props.addNode(graphData.graph.nodes, correlation[1]); + createdNodes.add(correlation[1].id); + } + this.props.addEdge(graphData.graph.edges, correlation[0], correlation[1]); + createdEdges.add(possibleCombination1); + }); + + return graphData; + }; + + private openTableFlyout = (correlationTableRow: CorrelationsTableData) => { + let newGraphData: CorrelationGraphData = { + graph: { + nodes: [], + edges: [], + }, + events: { + click: this.props.onNodeClick, + }, + }; + if (correlationTableRow.correlatedFindings) { + const correlationPairs = this.getCorrelationPairs(correlationTableRow.correlatedFindings); + newGraphData = this.prepareGraphData(correlationPairs); + } + this.setState({ + isFlyoutOpen: true, + selectedTableRow: correlationTableRow, + flyoutGraphData: newGraphData, + }); + }; + + private renderCorrelationsTable = (loadingData: boolean) => { + if (loadingData) { + return ( +
+ +
+ ); + } + const filteredTableData = this.getFilteredTableData(this.props.correlationsTableData); + return ( + <> + + + ); + }; + + private renderCorrelatedFindingsChart = () => { + renderVisualization( + this.generateVisualizationSpec(this.props.connectedFindings), + 'correlated-findings-view' + ); + return this.props.connectedFindings.length > 0 ? ( + <> + + + + + + +

Correlated Findings

+
+
+
+
+ + + +
+
+ + + ) : ( + +

No correlations found

+ + } + body={ + +

There are no correlated findings in the system.

+
+ } + actions={[ + + Create correlation rule + , + ]} + /> + ); + }; + + private getFilteredTableData = (tableData: CorrelationsTableData[]): CorrelationsTableData[] => { + const { logTypeFilterOptions, severityFilterOptions, searchTerm } = this.props; + const alertSeverityMap: { [key: string]: string } = { + '1': 'critical', + '2': 'high', + '3': 'medium', + '4': 'low', + '5': 'informational', + }; + + const selectedLogTypes = logTypeFilterOptions + .filter((item) => item.checked === 'on' && item.visible) + .map((item) => item.id); + + const selectedSeverities = severityFilterOptions + .filter((item) => item.checked === 'on' && item.visible) + .map((item) => item.id.toLowerCase()); + + return tableData.filter((row) => { + const logTypeMatch = row.logTypes.some((logType) => selectedLogTypes.includes(logType)); + + const severityMatch = row.alertSeverity.some((severity) => + selectedSeverities.includes(alertSeverityMap[severity]) + ); + + const searchLower = searchTerm.toLowerCase(); + const searchMatch = + searchTerm === '' || + row.correlationRule?.toLowerCase().includes(searchLower) || + row.logTypes.some((type) => type.toLowerCase().includes(searchLower)) || + row.alertSeverity.some((severity) => + alertSeverityMap[severity].toLowerCase().includes(searchLower) + ) || + row.findingsSeverity.some((severity) => severity.toLowerCase().includes(searchLower)) || + row.resources.some((resource) => resource.toLowerCase().includes(searchLower)); + return logTypeMatch && severityMatch && searchMatch; + }); + }; + + private generateVisualizationSpec = (connectedFindings: CorrelationFinding[][]) => { + const visData = connectedFindings.map((correlatedFindings) => { + return { + title: 'Correlated Findings', + correlatedFinding: correlatedFindings.length, + time: correlatedFindings[0].timestamp, + }; + }); + + const { dateTimeFilter } = this.props; + const chartTimeUnits = getChartTimeUnit( + dateTimeFilter?.startTime || DEFAULT_DATE_RANGE.start, + dateTimeFilter?.endTime || DEFAULT_DATE_RANGE.end + ); + + return getCorrelatedFindingsVisualizationSpec(visData, { + timeUnit: chartTimeUnits.timeUnit, + dateFormat: chartTimeUnits.dateFormat, + domain: getDomainRange( + [ + dateTimeFilter?.startTime || DEFAULT_DATE_RANGE.start, + dateTimeFilter?.endTime || DEFAULT_DATE_RANGE.end, + ], + chartTimeUnits.timeUnit.unit + ), + }); + }; + + private closeTableFlyout = () => { + this.setState({ + isFlyoutOpen: false, + selectedTableRow: null, + flyoutGraphData: { + graph: { nodes: [], edges: [] }, + events: { click: this.props.onNodeClick }, + }, + }); + }; + + render() { + return ( + <> + {this.renderCorrelatedFindingsChart()} + {this.renderCorrelationsTable(this.props.loadingTableData)} + {this.state.isFlyoutOpen && ( + + )} + + ); + } +} diff --git a/public/pages/Correlations/utils/constants.tsx b/public/pages/Correlations/utils/constants.tsx index 4bd4b2544..744d4f6a3 100644 --- a/public/pages/Correlations/utils/constants.tsx +++ b/public/pages/Correlations/utils/constants.tsx @@ -33,16 +33,25 @@ export const graphRenderOptions = { height: '800px', width: '100%', physics: { + enabled: true, + barnesHut: { + gravitationalConstant: -7000, + centralGravity: 0.5, + springConstant: 0.01, + springLength: 125, + damping: 0.1, + }, stabilization: { + enabled: true, fit: true, - iterations: 1000, + iterations: 1500, }, }, interaction: { zoomView: true, zoomSpeed: 0.2, dragView: true, - dragNodes: false, + dragNodes: true, multiselect: true, tooltipDelay: 50, hover: true, diff --git a/public/pages/Correlations/utils/helpers.tsx b/public/pages/Correlations/utils/helpers.tsx index 32b543da3..279e7dcb0 100644 --- a/public/pages/Correlations/utils/helpers.tsx +++ b/public/pages/Correlations/utils/helpers.tsx @@ -4,10 +4,36 @@ */ import React from 'react'; -import { EuiBasicTableColumn, EuiBadge, EuiToolTip, EuiSmallButtonIcon, EuiLink } from '@elastic/eui'; -import { CorrelationRule, CorrelationRuleQuery, CorrelationRuleTableItem } from '../../../../types'; +import { + EuiBasicTableColumn, + EuiBadge, + EuiToolTip, + EuiSmallButtonIcon, + EuiLink, + EuiFlexItem, + EuiFlexGroup, +} from '@elastic/eui'; +import { + CorrelationFinding, + CorrelationRule, + CorrelationRuleQuery, + CorrelationRuleTableItem, +} from '../../../../types'; import { Search } from '@opensearch-project/oui/src/eui_components/basic_table'; import { formatRuleType, getLogTypeFilterOptions } from '../../../utils/helpers'; +import { DEFAULT_EMPTY_DATA } from '../../../utils/constants'; +import { getSeverityColor, getSeverityLabel } from './constants'; +import { + addInteractiveLegends, + DateOpts, + defaultDateFormat, + defaultScaleDomain, + defaultTimeUnit, + getTimeTooltip, + getVisualizationSpec, + getXAxis, + getYAxis, +} from '../../Overview/utils/helpers'; export const getCorrelationRulesTableColumns = ( onRuleNameClick: (rule: CorrelationRule) => void, @@ -70,6 +96,141 @@ export const getCorrelationRulesTableColumns = ( ]; }; +export const displayBadges = (inputList: string[]) => { + if (!inputList || inputList.length === 0) return DEFAULT_EMPTY_DATA; + const MAX_DISPLAY = 2; + const remainingCount = inputList.length > MAX_DISPLAY ? inputList.length - MAX_DISPLAY : 0; + const displayedInputList = inputList.slice(0, MAX_DISPLAY).map((input) => { + const label = input; + return {label}; + }); + const tooltipContent = ( + <> + {inputList.slice(MAX_DISPLAY).map((input) => { + const label = input; + return ( + + {label} + + ); + })} + + ); + return ( + + {displayedInputList} + {remainingCount > 0 && ( + + + {`+${remainingCount} more`} + + + )} + + ); +}; + +export const displaySeveritiesBadges = (severities: string[]) => { + if (!severities || severities.length === 0) return DEFAULT_EMPTY_DATA; + const MAX_DISPLAY = 2; + const remainingCount = severities.length > MAX_DISPLAY ? severities.length - MAX_DISPLAY : 0; + const displayedSeverities = severities.slice(0, MAX_DISPLAY).map((severity) => { + const label = getSeverityLabel(severity); + const { background, text } = getSeverityColor(label); + return ( + + {label} + + ); + }); + + const tooltipContent = ( + + {severities.slice(MAX_DISPLAY).map((severity) => { + const label = getSeverityLabel(severity); + const { background, text } = getSeverityColor(label); + return ( + + + {label} + + + ); + })} + + ); + + return ( + + {displayedSeverities} + {remainingCount > 0 && ( + + + {`+${remainingCount} more`} + + + )} + + ); +}; + +export const displayResourcesBadges = (resources: string[]) => { + if (!resources || resources.length === 0) return DEFAULT_EMPTY_DATA; + const MAX_DISPLAY = 2; + const remainingCount = resources.length > MAX_DISPLAY ? resources.length - MAX_DISPLAY : 0; + const displayedresources = resources.slice(0, MAX_DISPLAY).map((resources) => { + const label = resources; + return {label}; + }); + const tooltipContent = ( + <> + {resources.slice(MAX_DISPLAY).map((resources) => { + const label = resources; + return ( + + + {label} + + + ); + })} + + ); + return ( + + {displayedresources} + {remainingCount > 0 && ( + + + {`+${remainingCount} more`} + + + )} + + ); +}; + export const getCorrelationRulesTableSearchConfig = (): Search => { return { box: { @@ -87,3 +248,90 @@ export const getCorrelationRulesTableSearchConfig = (): Search => { ], }; }; + +export const getCorrelatedFindingsVisualizationSpec = ( + visualizationData: any[], + dateOpts: DateOpts = { + timeUnit: defaultTimeUnit, + dateFormat: defaultDateFormat, + domain: defaultScaleDomain, + } +) => { + return getVisualizationSpec('Correlated Findings data overview', visualizationData, [ + addInteractiveLegends({ + mark: { + type: 'bar', + clip: true, + }, + encoding: { + tooltip: [getYAxis('correlatedFinding', 'Correlated Findings'), getTimeTooltip(dateOpts)], + x: getXAxis(dateOpts), + y: getYAxis('correlatedFinding', 'Count'), + color: { + field: 'title', + legend: { + title: 'Legend', + }, + }, + }, + }), + ]); +}; + +export const mapConnectedCorrelations = ( + correlations: { + finding1: CorrelationFinding; + finding2: CorrelationFinding; + }[] +) => { + const connectionsMap = new Map>(); + const findingsMap = new Map(); + + correlations.forEach((correlation) => { + const { finding1, finding2 } = correlation; + + findingsMap.set(finding1.id, finding1); + findingsMap.set(finding2.id, finding2); + + if (!connectionsMap.has(finding1.id)) { + connectionsMap.set(finding1.id, new Set()); + } + connectionsMap.get(finding1.id)!.add(finding2.id); + + if (!connectionsMap.has(finding2.id)) { + connectionsMap.set(finding2.id, new Set()); + } + connectionsMap.get(finding2.id)!.add(finding1.id); + }); + + const visited = new Set(); + const connectedGroups: CorrelationFinding[][] = []; + + function depthFirstSearch(findingId: string, currentGroup: CorrelationFinding[]) { + // Get all the connected correlated findings for the given finding + visited.add(findingId); + const finding = findingsMap.get(findingId); + if (finding) { + currentGroup.push(finding); + } + + const connections = connectionsMap.get(findingId) || new Set(); + connections.forEach((connectedId) => { + if (!visited.has(connectedId)) { + depthFirstSearch(connectedId, currentGroup); + } + }); + } + + connectionsMap.forEach((_, findingId) => { + if (!visited.has(findingId)) { + const currentGroup: CorrelationFinding[] = []; + depthFirstSearch(findingId, currentGroup); + if (currentGroup.length > 0) { + connectedGroups.push(currentGroup); + } + } + }); + + return connectedGroups; +}; diff --git a/public/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts index 28b36931a..d533d9b85 100644 --- a/public/store/CorrelationsStore.ts +++ b/public/store/CorrelationsStore.ts @@ -150,7 +150,7 @@ export class CorrelationsStore implements ICorrelationsStore { name: hit._source.name, time_window: hit._source.time_window || 300000, queries, - trigger: hit._source?.trigger + trigger: hit._source?.trigger, }; } @@ -176,7 +176,7 @@ export class CorrelationsStore implements ICorrelationsStore { name: hit._source.name, time_window: hit._source.time_window || 300000, queries, - trigger: hit._source?.trigger + trigger: hit._source?.trigger, }; }); }