diff --git a/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx new file mode 100644 index 000000000..7818c3cd7 --- /dev/null +++ b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx @@ -0,0 +1,274 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLink, + EuiSpacer, + EuiTitle, + } from '@elastic/eui'; + import { RuleSource } from '../../../../../server/models/interfaces'; + import React from 'react'; + import { ContentPanel } from '../../../../components/ContentPanel'; + import { ALERT_STATE, DEFAULT_EMPTY_DATA, ROUTES } from '../../../../utils/constants'; + import { + capitalizeFirstLetter, + createTextDetailsGroup, + errorNotificationToast, + formatRuleType, + renderTime, + } from '../../../../utils/helpers'; + import { IndexPatternsService, OpenSearchService, DetectorsService } from '../../../../services'; + import { parseAlertSeverityToOption } from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers'; + import { NotificationsStart } from 'opensearch-dashboards/public'; + import { DataStore } from '../../../../store/DataStore'; + import { CorrelationAlertItem, Detector, Finding } from '../../../../../types'; + + export interface CorrelationAlertFlyoutProps { + alertItem: CorrelationAlertItem; + notifications: NotificationsStart; + detectorService: DetectorsService; + opensearchService: OpenSearchService; + indexPatternService: IndexPatternsService; + onClose: () => void; + onAcknowledge: (selectedItems: CorrelationAlertItem[]) => void; + } + + export interface CorrelationAlertFlyoutState { + acknowledged: boolean; + findingItems: Finding[]; + loading: boolean; + rules: { [key: string]: RuleSource }; + } + + export class CorrelationAlertFlyout extends React.Component { + constructor(props: CorrelationAlertFlyoutProps) { + super(props); + + this.state = { + acknowledged: props.alertItem.state === ALERT_STATE.ACKNOWLEDGED, + findingItems: [], + loading: false, + rules: {}, + }; + } + + async componentDidMount() { + this.getFindings(); + } + + getFindings = async () => { + this.setState({ loading: true }); + const { notifications } = this.props; + try { + const findingIds = this.props.alertItem.correlated_finding_ids; + const relatedFindings = await DataStore.findings.getFindingsByIds( + findingIds + ); + this.setState({ findingItems: relatedFindings }); + } catch (e: any) { + errorNotificationToast(notifications, 'retrieve', 'findings', e); + } + await this.getRules(); + this.setState({ loading: false }); + }; + + getRules = async () => { + const { notifications } = this.props; + try { + const { findingItems } = this.state; + const ruleIds: string[] = []; + + // Extract ruleIds in order from findingItems + findingItems.forEach((finding) => { + finding.queries.forEach((query) => { + ruleIds.push(query.id); + }); + }); + + if (ruleIds.length > 0) { + // Fetch rules based on ruleIds + const rules = await DataStore.rules.getAllRules({ _id: ruleIds }); + + // Prepare allRules object with rules mapped by _id + const allRules: { [id: string]: RuleSource } = {}; + rules.forEach((hit) => { + allRules[hit._id] = hit._source; + }); + + // Update state with allRules + this.setState({ rules: allRules }); + } + } catch (e: any) { + // Handle errors if any + errorNotificationToast(notifications, 'retrieve', 'rules', e); + } + }; + + createFindingTableColumns(): EuiBasicTableColumn[] { + const { rules } = this.state; + + const backButton = ( + DataStore.findings.closeFlyout()} + display="base" + size="s" + data-test-subj={'finding-details-flyout-back-button'} + /> + ); + + return [ + { + field: 'timestamp', + name: 'Time', + sortable: true, + dataType: 'date', + render: renderTime, + }, + { + field: 'id', + name: 'Finding ID', + sortable: true, + dataType: 'string', + render: (id: string, finding: any) => ( + { + const ruleId = finding.queries[0]?.id; // Assuming you retrieve rule ID from finding + const rule: RuleSource | undefined = rules[ruleId]; + + DataStore.findings.openFlyout( + { + ...finding, + detector: { _id: finding.detector_id as string, _index: '' }, + ruleName: rule?.title || '', + ruleSeverity: rule?.level === 'critical' ? rule.level : finding['ruleSeverity'] || rule?.level, + }, + [...this.state.findingItems, finding], + true, + backButton + ); + }} + data-test-subj={'finding-details-flyout-button'} + > + {id.length > 7 ? `${id.slice(0, 7)}...` : id} + + ), + }, + { + field: 'detectionType', + name: 'Detection type', + render: (detectionType: string, finding: any) => detectionType || DEFAULT_EMPTY_DATA, + }, + { + field: 'queries', + name: 'Log type', + sortable: true, + dataType: 'string', + render: (finding: any) => { + const ruleId = finding.queries[0]?.id; // Retrieve rule ID from the first query of the finding + const rule: RuleSource | undefined = rules[ruleId]; + + return formatRuleType(rule?.category || ''); // Pass category from rule as string, default to empty string if rule is undefined + }, + }, + ]; + } + + + render() { + const { onClose, alertItem, onAcknowledge } = this.props; + const { trigger_name, state, severity, start_time, end_time } = alertItem; + const { acknowledged, findingItems, loading } = this.state; + + return ( + + + + + +

Alert details

+
+
+ + + + { + this.setState({ acknowledged: true }); + onAcknowledge([alertItem]); + }} + data-test-subj={'alert-details-flyout-acknowledge-button'} + > + Acknowledge + + + + + + + +
+
+ + {createTextDetailsGroup([ + { label: 'Alert trigger name', content: trigger_name }, + { label: 'Alert status', content: capitalizeFirstLetter(state) }, + { + label: 'Alert severity', + content: parseAlertSeverityToOption(severity)?.label || DEFAULT_EMPTY_DATA, + }, + ])} + {createTextDetailsGroup([ + { label: 'Start time', content: renderTime(start_time) }, + { label: 'Last updated time', content: renderTime(end_time) }, + { + label: 'Correlation rule', + content: alertItem.correlation_rule_name, + url: `#${ROUTES.CORRELATION_RULE_EDIT}/${alertItem.correlation_rule_id}`, + target: '_blank', + }, + ])} + + + + + + columns={this.createFindingTableColumns()} + items={findingItems} + loading={loading} + /> + + +
+ ); + } + } + \ No newline at end of file diff --git a/public/pages/Alerts/containers/Alerts/Alerts.tsx b/public/pages/Alerts/containers/Alerts/Alerts.tsx index 511579816..47f7f4e56 100644 --- a/public/pages/Alerts/containers/Alerts/Alerts.tsx +++ b/public/pages/Alerts/containers/Alerts/Alerts.tsx @@ -18,6 +18,8 @@ import { EuiToolTip, EuiEmptyPrompt, EuiTableSelectionType, + EuiTabs, + EuiTab, } from '@elastic/eui'; import { FieldValueSelectionFilterConfigType } from '@elastic/eui/src/components/search_bar/filters/field_value_selection_filter'; import dateMath from '@elastic/datemath'; @@ -41,7 +43,8 @@ import { CoreServicesContext } from '../../../../components/core_services'; import AlertsService from '../../../../services/AlertsService'; import DetectorService from '../../../../services/DetectorService'; import { AlertFlyout } from '../../components/AlertFlyout/AlertFlyout'; -import { FindingsService, IndexPatternsService, OpenSearchService } from '../../../../services'; +import { CorrelationAlertFlyout } from '../../components/CorrelationAlertFlyout/CorrelationAlertFlyout'; +import { CorrelationService, FindingsService, IndexPatternsService, OpenSearchService } from '../../../../services'; import { parseAlertSeverityToOption } from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers'; import { DISABLE_ACKNOWLEDGED_ALERT_HELP_TEXT } from '../../utils/constants'; import { @@ -56,7 +59,7 @@ import { import { NotificationsStart } from 'opensearch-dashboards/public'; import { match, RouteComponentProps, withRouter } from 'react-router-dom'; import { ChartContainer } from '../../../../components/Charts/ChartContainer'; -import { AlertItem, DataSourceProps, DateTimeFilter, Detector } from '../../../../../types'; +import { AlertItem, CorrelationAlertItem, DataSourceProps, DateTimeFilter, Detector } from '../../../../../types'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import { DataStore } from '../../../../store/DataStore'; @@ -65,6 +68,7 @@ export interface AlertsProps extends RouteComponentProps, DataSourceProps { detectorService: DetectorService; findingService: FindingsService; opensearchService: OpenSearchService; + correlationService: CorrelationService notifications: NotificationsStart; indexPatternService: IndexPatternsService; match: match<{ detectorId: string }>; @@ -76,15 +80,20 @@ export interface AlertsState { groupBy: string; recentlyUsedRanges: DurationRange[]; selectedItems: AlertItem[]; + correlatedItems: CorrelationAlertItem[]; alerts: AlertItem[]; + correlationAlerts: CorrelationAlertItem[]; flyoutData?: { alertItem: AlertItem }; + flyoutCorrelationData?: { alertItem: CorrelationAlertItem }; alertsFiltered: boolean; + filteredCorrelationAlerts: CorrelationAlertItem[]; filteredAlerts: AlertItem[]; detectors: { [key: string]: Detector }; loading: boolean; timeUnit: TimeUnit; dateFormat: string; widgetEmptyMessage: React.ReactNode | undefined; + tab: string; } const groupByOptions = [ @@ -96,6 +105,7 @@ export class Alerts extends Component { static contextType = CoreServicesContext; private abortControllers: AbortController[] = []; + constructor(props: AlertsProps) { super(props); @@ -111,13 +121,17 @@ export class Alerts extends Component { groupBy: 'status', recentlyUsedRanges: [DEFAULT_DATE_RANGE], selectedItems: [], + correlatedItems: [], alerts: [], + correlationAlerts: [], alertsFiltered: false, filteredAlerts: [], + filteredCorrelationAlerts:[], detectors: {}, timeUnit: timeUnits.timeUnit, dateFormat: timeUnits.dateFormat, widgetEmptyMessage: undefined, + tab: 'findings' }; } @@ -133,12 +147,28 @@ export class Alerts extends Component { prevProps.dateTimeFilter?.endTime !== dateTimeFilter.endTime || prevState.alerts !== this.state.alerts || prevState.alerts.length !== this.state.alerts.length; - + + const correlationAlertsChanged = + prevProps.dateTimeFilter?.startTime !== dateTimeFilter.startTime || + prevProps.dateTimeFilter?.endTime !== dateTimeFilter.endTime || + prevState.correlationAlerts !== this.state.correlationAlerts || + prevState.correlationAlerts.length !== this.state.correlationAlerts.length; + + if (prevState.tab != this.state.tab) { + if (this.state.tab == "findings") { + renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); + } else { + renderVisualization(this.generateCorrelationVisualizationSpec(this.state.filteredCorrelationAlerts), 'alerts-view'); + } + } if (this.props.dataSource !== prevProps.dataSource) { this.onRefresh(); } else if (alertsChanged) { this.filterAlerts(); - } else if (this.state.groupBy !== prevState.groupBy) { + } else if (correlationAlertsChanged) { + this.filterCorrelationAlerts(); + } + else if (this.state.groupBy !== prevState.groupBy) { renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); } } @@ -173,6 +203,36 @@ export class Alerts extends Component { renderVisualization(this.generateVisualizationSpec(filteredAlerts), 'alerts-view'); }; + filterCorrelationAlerts = () => { + const { correlationAlerts } = this.state; + const { + dateTimeFilter = { + startTime: DEFAULT_DATE_RANGE.start, + endTime: DEFAULT_DATE_RANGE.end, + }, + } = this.props; + const startMoment = dateMath.parse(dateTimeFilter.startTime); + const endMoment = dateMath.parse(dateTimeFilter.endTime); + const filteredCorrelationAlerts = correlationAlerts.filter((correlationAlert) => + moment(correlationAlert.end_time).isBetween(moment(startMoment), moment(endMoment)) + ); + this.setState({ + alertsFiltered: true, + filteredCorrelationAlerts: filteredCorrelationAlerts, + widgetEmptyMessage: filteredCorrelationAlerts.length ? undefined : ( + + No alerts.Adjust the time range to see more + results. +

+ } + /> + ), + }); + renderVisualization(this.generateCorrelationVisualizationSpec(filteredCorrelationAlerts), 'alerts-view'); + }; + getColumns(): EuiBasicTableColumn[] { return [ { @@ -252,10 +312,93 @@ export class Alerts extends Component { ]; } + getCorrelationColumns(): EuiBasicTableColumn[] { + return [ + { + field: 'start_time', + name: 'Start time', + sortable: true, + dataType: 'date', + render: renderTime, + }, + { + field: 'trigger_name', + name: 'Alert trigger name', + sortable: false, + dataType: 'string', + render: (triggerName: string, alertItem: CorrelationAlertItem) => ( + this.setCorrelationFlyout(alertItem)}>{triggerName} + ), + }, + { + field: 'correlation_rule_name', + name: 'Correlation Rule Name', + sortable: true, + dataType: 'string', + render: (correlationRulename: string) => correlationRulename || DEFAULT_EMPTY_DATA, + }, + { + field: 'state', + name: 'Status', + sortable: true, + dataType: 'string', + render: (status: string) => (status ? capitalizeFirstLetter(status) : DEFAULT_EMPTY_DATA), + }, + { + field: 'severity', + name: 'Alert severity', + sortable: true, + dataType: 'string', + render: (severity: string) => + parseAlertSeverityToOption(severity)?.label || DEFAULT_EMPTY_DATA, + }, + { + name: 'Actions', + sortable: false, + actions: [ + { + render: (alertItem: CorrelationAlertItem) => { + const disableAcknowledge = alertItem.state !== ALERT_STATE.ACTIVE; + return ( + + this.onAcknowledgeCorrelationAlert([alertItem])} + /> + + ); + }, + }, + { + render: (alertItem: CorrelationAlertItem) => ( + + this.setCorrelationFlyout(alertItem)} + /> + + ), + }, + ], + }, + ]; + } + setFlyout(alertItem?: AlertItem): void { this.setState({ flyoutData: alertItem ? { alertItem } : undefined }); } + setCorrelationFlyout(alertItem?: CorrelationAlertItem): void { + this.setState({ flyoutCorrelationData: alertItem ? { alertItem } : undefined }); + } + generateVisualizationSpec(alerts: AlertItem[]) { const visData = alerts.map((alert) => { const time = new Date(alert.start_time); @@ -286,6 +429,36 @@ export class Alerts extends Component { }); } + generateCorrelationVisualizationSpec(alerts: CorrelationAlertItem[]) { + const visData = alerts.map((alert) => { + const time = new Date(alert.start_time); + time.setMilliseconds(0); + time.setSeconds(0); + + return { + alert: 1, + time, + status: alert.state, + severity: parseAlertSeverityToOption(alert.severity)?.label || alert.severity, + }; + }); + const { + dateTimeFilter = { + startTime: DEFAULT_DATE_RANGE.start, + endTime: DEFAULT_DATE_RANGE.end, + }, + } = this.props; + const chartTimeUnits = getChartTimeUnit(dateTimeFilter.startTime, dateTimeFilter.endTime); + return getAlertsVisualizationSpec(visData, this.state.groupBy, { + timeUnit: chartTimeUnits.timeUnit, + dateFormat: chartTimeUnits.dateFormat, + domain: getDomainRange( + [dateTimeFilter.startTime, dateTimeFilter.endTime], + chartTimeUnits.timeUnit.unit + ), + }); + } + createGroupByControl(): React.ReactNode { return createSelectComponent( groupByOptions, @@ -306,6 +479,24 @@ export class Alerts extends Component { this.abortPendingGetAlerts(); } + async getCorrelationAlerts() { + this.setState({ loading: true, correlationAlerts: [] }); + const { correlationService, notifications, dateTimeFilter } = this.props; + try { + const correlationRes = await correlationService.getCorrelationAlerts(); + const duration = getDuration(dateTimeFilter); + if (correlationRes.ok) { + this.setState({ correlationAlerts: correlationRes.response.correlationAlerts }); + } else { + errorNotificationToast(notifications, 'retrieve', 'correlations', correlationRes.error); + } + } catch (e: any) { + errorNotificationToast(notifications, 'retrieve', 'correlationAlerts', e); + } + this.filterCorrelationAlerts(); + this.setState({ loading: false }); + } + async getAlerts(abort: AbortSignal) { this.setState({ loading: true, alerts: [] }); const { detectorService, notifications, dateTimeFilter } = this.props; @@ -330,7 +521,7 @@ export class Alerts extends Component { abort, duration, (alerts) => { - this.setState({ alerts: [...this.state.alerts, ...alerts]}) + this.setState({ alerts: [...this.state.alerts, ...alerts] }) } ); } @@ -391,16 +582,25 @@ export class Alerts extends Component { const abortController = new AbortController(); this.abortControllers.push(abortController); this.getAlerts(abortController.signal); + this.getCorrelationAlerts(); renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); + renderVisualization(this.generateCorrelationVisualizationSpec(this.state.filteredCorrelationAlerts), 'alerts-view'); }; onSelectionChange = (selectedItems: AlertItem[]) => { this.setState({ selectedItems }); }; + onCorrelationSelectionChange = (correlatedItems: CorrelationAlertItem[]) => { + this.setState({ correlatedItems }); + }; + onFlyoutClose = () => { this.setState({ flyoutData: undefined }); }; + onCorrelationFlyoutClose = () => { + this.setState({ flyoutCorrelationData: undefined }); + }; onAcknowledge = async (selectedItems: AlertItem[] = []) => { const { alertService, notifications } = this.props; @@ -433,12 +633,46 @@ export class Alerts extends Component { this.onRefresh(); }; + onAcknowledgeCorrelationAlert = async (selectedItems: CorrelationAlertItem[] = []) => { + const { correlationService, notifications } = this.props; + let successCount = 0; + try { + // Separating the selected items by detector ID, and adding all selected alert IDs to an array for that detector ID. + const correlations: { [key: string]: string[] } = {}; + selectedItems.forEach((item) => { + if (!correlations[item.correlation_rule_id]) correlations[item.correlation_rule_id] = [item.id]; + else correlations[item.correlation_rule_id].push(item.id); + }); + + for (let corrId of Object.keys(correlations)) { + const alertIds = correlations[corrId]; + if (alertIds.length > 0) { + const response = await correlationService.acknowledgeCorrelationAlerts(alertIds); + if (response.ok) { + successCount += alertIds.length; + } else { + errorNotificationToast(notifications, 'acknowledge', 'alerts', response.error); + } + } + } + } catch (e: any) { + errorNotificationToast(notifications, 'acknowledge', 'alerts', e); + } + if (successCount) + successNotificationToast(notifications, 'acknowledged', `${successCount} alerts`); + this.setState({ selectedItems: [] }); + this.onRefresh(); + }; + render() { const { alerts, alertsFiltered, detectors, filteredAlerts, + filteredCorrelationAlerts, + correlationAlerts, + flyoutCorrelationData, flyoutData, loading, recentlyUsedRanges, @@ -453,6 +687,8 @@ export class Alerts extends Component { } = this.props; const severities = new Set(); const statuses = new Set(); + const corrSeverities = new Set(); + const corrStatuses = new Set(); filteredAlerts.forEach((alert) => { if (alert) { severities.add(alert.severity); @@ -460,6 +696,13 @@ export class Alerts extends Component { } }); + filteredCorrelationAlerts.forEach((alert) => { + if (alert) { + corrSeverities.add(alert.severity); + corrStatuses.add(alert.state); + } + }); + const search = { box: { placeholder: 'Search alerts', @@ -489,12 +732,48 @@ export class Alerts extends Component { ], }; + const correlationSearch = { + box: { + placeholder: 'Search alerts', + schema: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'severity', + name: 'Alert severity', + options: Array.from(corrSeverities).map((severity) => ({ + value: severity, + name: parseAlertSeverityToOption(severity)?.label || severity, + })), + multiSelect: 'or', + } as FieldValueSelectionFilterConfigType, + { + type: 'field_value_selection', + field: 'state', + name: 'Status', + options: Array.from(corrStatuses).map((status) => ({ + value: status, + name: capitalizeFirstLetter(status) || status, + })), + multiSelect: 'or', + } as FieldValueSelectionFilterConfigType, + ], + }; + + const selection: EuiTableSelectionType = { onSelectionChange: this.onSelectionChange, selectable: (item: AlertItem) => item.state === ALERT_STATE.ACTIVE, selectableMessage: (selectable) => (selectable ? '' : DISABLE_ACKNOWLEDGED_ALERT_HELP_TEXT), }; + const correlationSelection: EuiTableSelectionType = { + onSelectionChange: this.onCorrelationSelectionChange, + selectable: (item: CorrelationAlertItem) => item.state === ALERT_STATE.ACTIVE, + selectableMessage: (selectable) => (selectable ? '' : DISABLE_ACKNOWLEDGED_ALERT_HELP_TEXT), + }; + const sorting: any = { sort: { field: 'start_time', @@ -514,6 +793,15 @@ export class Alerts extends Component { indexPatternService={this.props.indexPatternService} /> )} + {flyoutCorrelationData && ( + + )} @@ -564,20 +852,46 @@ export class Alerts extends Component { - `${item.id}`} - isSelectable={true} - pagination - search={search} - sorting={sorting} - selection={selection} - loading={loading} - message={widgetEmptyMessage} + + this.setState({ tab: 'findings' })} isSelected={this.state.tab === 'findings'}> + Findings + + this.setState({ tab: 'correlations' })} isSelected={this.state.tab === 'correlations'}> + Correlations + + + {this.state.tab === 'findings' && ( + // Content for the "Findings" tab + `${item.id}`} + isSelectable={true} + pagination + search={search} + sorting={sorting} + selection={selection} + loading={loading} + message={widgetEmptyMessage} + /> + )} + {this.state.tab === 'correlations' && ( + `${item.id}`} + isSelectable={true} + pagination + search={correlationSearch} + sorting={sorting} + selection={correlationSelection} + loading={loading} + message={widgetEmptyMessage} /> + )} + ); diff --git a/public/pages/Correlations/containers/CreateCorrelationRule.tsx b/public/pages/Correlations/containers/CreateCorrelationRule.tsx index e428eeeaf..225c0e1ca 100644 --- a/public/pages/Correlations/containers/CreateCorrelationRule.tsx +++ b/public/pages/Correlations/containers/CreateCorrelationRule.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { ChangeEvent, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { Form, Formik, FormikErrors, FormikTouched } from 'formik'; import { ContentPanel } from '../../../components/ContentPanel'; import { DataStore } from '../../../store/DataStore'; @@ -55,6 +55,7 @@ import { getEmptyAlertCondition, getNotificationChannels, parseAlertSeverityToOp import { NotificationsCallOut } from '../../../../public/components/NotificationsCallOut'; import { BrowserServices } from '../../../../public/models/interfaces'; import { ExperimentalBanner } from '../components/ExperimentalBanner'; +import { ALERT_SEVERITY_OPTIONS } from '../components/ConfigureAlerts/utils/constants'; export interface CreateCorrelationRuleProps extends DataSourceProps { indexService: IndexService; @@ -127,26 +128,22 @@ export const CreateCorrelationRule: React.FC = ( const resetForm = useRef(false); const [selectedNotificationChannelOption, setSelectedNotificationChannelOption] = useState([]); - const onRuleSeverityChange = (selectedOptions: Array>) => { - const sev_levels: string[] = selectedOptions - .map(option => option.value) - .filter((value): value is string => value !== undefined); - - setInitialValues(prevState => ({ - ...prevState, + const onAlertSeverityChange = (severity: string) => { + setInitialValues({ + ...initialValues, trigger: { - ...prevState.trigger!, - sev_levels, + ...initialValues.trigger!, + name: severity, }, - })); + }); }; - const onNameChange = (e: ChangeEvent) => { + const onNameChange = (triggerName: string) => { setInitialValues({ ...initialValues, trigger: { ...initialValues.trigger!, - name: e.target.value, + name: triggerName, }, }); }; @@ -831,7 +828,7 @@ export const CreateCorrelationRule: React.FC = ( if (alertCondition && alertCondition.actions) { if (alertCondition.actions.length == 0) alertCondition.actions = getEmptyAlertCondition().actions; - + const channelId = alertCondition?.actions[0].destination_id; const selectedNotificationChannelOption: NotificationChannelOption[] = []; if (channelId) { @@ -867,7 +864,6 @@ export const CreateCorrelationRule: React.FC = ( initialValues={initialValues} validate={(values) => { const errors: FormikErrors = {}; - if (!values.name) { errors.name = 'Rule name is required'; } else { @@ -904,7 +900,7 @@ export const CreateCorrelationRule: React.FC = ( }} enableReinitialize={true} > - {({ values: { name, queries, time_window }, touched, errors, ...props }) => { + {({ values: { name, queries, time_window, trigger }, touched, errors, ...props }) => { if (resetForm.current) { resetForm.current = false; props.resetForm(); @@ -1042,8 +1038,12 @@ export const CreateCorrelationRule: React.FC = ( > { + const triggerName = e.target.value || ''; + props.handleChange('trigger?.name')(triggerName); + onNameChange(triggerName); + }} data-test-subj="alert-condition-name" /> @@ -1059,10 +1059,18 @@ export const CreateCorrelationRule: React.FC = ( - (initialValues?.trigger?.sev_levels ?? []).includes(option.value || '') - )} + singleSelection={{ asPlainText: true }} + selectedOptions={ + trigger?.severity ? [parseAlertSeverityToOption(trigger?.severity)] : [ALERT_SEVERITY_OPTIONS.HIGHEST] + } + onChange={(e) => { + const selectedSeverity = e[0]?.value || ''; + props.handleChange(`trigger?.severity`)(selectedSeverity); + if (selectedSeverity !== '') { + onAlertSeverityChange(selectedSeverity); + } + }} + isClearable={true} data-test-subj="alert-severity-combo-box" /> @@ -1154,7 +1162,7 @@ export const CreateCorrelationRule: React.FC = ( > onMessageSubjectChange(e.target.value)} required={true} fullWidth={true} @@ -1173,7 +1181,7 @@ export const CreateCorrelationRule: React.FC = ( > onMessageBodyChange(e.target.value)} required={true} fullWidth={true} diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index 2202669d4..d35dca77e 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -574,6 +574,7 @@ export default class Main extends Component { notifications={core?.notifications} opensearchService={services.opensearchService} indexPatternService={services.indexPatternsService} + correlationService={services.correlationsService} dataSource={selectedDataSource} /> )} diff --git a/public/services/CorrelationService.ts b/public/services/CorrelationService.ts index c5ce896c9..c4b9a127f 100644 --- a/public/services/CorrelationService.ts +++ b/public/services/CorrelationService.ts @@ -14,12 +14,38 @@ import { SearchCorrelationRulesResponse, ICorrelationsService, UpdateCorrelationRuleResponse, + GetCorrelationAlertsResponse, + AckCorrelationAlertsResponse } from '../../types'; import { dataSourceInfo } from './utils/constants'; export default class CorrelationService implements ICorrelationsService { constructor(private httpClient: HttpSetup) {} + acknowledgeCorrelationAlerts = async ( + body: any + ): Promise> => { + const url = `..${API.ACK_CORRELATION_ALERTS}`; + + return (await this.httpClient.post(url, { + body: JSON.stringify(body), + query: { + dataSourceId: dataSourceInfo.activeDataSource.id, + }, + })) as ServerResponse; + }; + + getCorrelationAlerts = async ( + ): Promise> => { + const url = `..${API.GET_CORRELATION_ALERTS}`; + + return (await this.httpClient.get(url, { + query: { + dataSourceId: dataSourceInfo.activeDataSource.id, + }, + })) as ServerResponse; + }; + getCorrelatedFindings = async ( finding: string, detector_type: string, diff --git a/public/services/FindingsService.ts b/public/services/FindingsService.ts index 4a5a890e4..93eb5dbec 100644 --- a/public/services/FindingsService.ts +++ b/public/services/FindingsService.ts @@ -20,7 +20,7 @@ export default class FindingsService { getFindingsParams: GetFindingsParams ): Promise> => { const findingIds = getFindingsParams.findingIds - ? JSON.stringify(getFindingsParams.findingIds) + ? getFindingsParams.findingIds.join(',') : undefined; const query = { sortOrder: 'desc', diff --git a/public/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts index f8f74b095..587dc566f 100644 --- a/public/store/CorrelationsStore.ts +++ b/public/store/CorrelationsStore.ts @@ -4,11 +4,13 @@ */ import { + AckCorrelationAlertsResponse, CorrelationFieldCondition, CorrelationFinding, CorrelationRule, CorrelationRuleQuery, DetectorHit, + GetCorrelationAlertsResponse, ICorrelationsStore, IRulesStore, } from '../../types'; @@ -89,7 +91,6 @@ export class CorrelationsStore implements ICorrelationsStore { } public async updateCorrelationRule(correlationRule: CorrelationRule): Promise { - console.log("Correlation rule is ", correlationRule); const response = await this.service.updateCorrelationRule(correlationRule.id, { name: correlationRule.name, time_window: correlationRule.time_window, @@ -115,7 +116,6 @@ export class CorrelationsStore implements ICorrelationsStore { }), trigger: correlationRule.trigger, }); - console.log("Resposne is ", response); if (!response.ok) { errorNotificationToast(this.notifications, 'update', 'correlation rule', response.error); return false; @@ -198,9 +198,9 @@ export class CorrelationsStore implements ICorrelationsStore { start_time, end_time ); - + const result: { finding1: CorrelationFinding; finding2: CorrelationFinding }[] = []; - + if (allCorrelationsRes.ok) { const firstTenGrandCorrelations = allCorrelationsRes.response.findings.slice(0, 10000); const allFindingIdsSet = new Set(); @@ -213,7 +213,7 @@ export class CorrelationsStore implements ICorrelationsStore { let allFindings: { [id: string]: CorrelationFinding } = {}; const maxFindingsFetchedInSingleCall = 10000; - for (let i = 0; i < allFindingIds.length; i+= maxFindingsFetchedInSingleCall) { + for (let i = 0; i < allFindingIds.length; i += maxFindingsFetchedInSingleCall) { const findingIds = allFindingIds.slice(i, i + maxFindingsFetchedInSingleCall); const findings = await this.fetchAllFindings(findingIds); allFindings = { @@ -268,10 +268,10 @@ export class CorrelationsStore implements ICorrelationsStore { timestamp: new Date(f.timestamp).toLocaleString(), detectionRule: rule ? { - name: rule._source.title, - severity: rule._source.level, - tags: rule._source.tags, - } + name: rule._source.title, + severity: rule._source.level, + tags: rule._source.tags, + } : { name: DEFAULT_EMPTY_DATA, severity: DEFAULT_EMPTY_DATA }, }; }); @@ -296,7 +296,7 @@ export class CorrelationsStore implements ICorrelationsStore { if (response?.ok) { const correlatedFindings: CorrelationFinding[] = []; const allFindingIds = response.response.findings.map(f => f.finding); - const allFindings = await this.fetchAllFindings(allFindingIds); + const allFindings = await this.fetchAllFindings(allFindingIds); response.response.findings.forEach((f) => { if (allFindings[f.finding]) { correlatedFindings.push({ @@ -327,6 +327,33 @@ export class CorrelationsStore implements ICorrelationsStore { }; } + public async getAllCorrelationAlerts( + ): Promise { + const response = await this.service.getCorrelationAlerts(); + if (response?.ok) { + return { + correlationAlerts: response.response.correlationAlerts, + total_alerts: response.response.total_alerts, + }; + } else { + throw new Error('Failed to fetch correlated alerts'); + } + } + + public async acknowledgeCorrelationAlerts( + alertIds: string[] + ): Promise { + const response = await this.service.acknowledgeCorrelationAlerts(alertIds); + if (response?.ok) { + return { + acknowledged: response.response.acknowledged, + failed: response.response.failed, + }; + } else { + throw new Error('Failed to acknowledge correlated alerts'); + } + } + private parseRuleQueryString(queryString: string): CorrelationFieldCondition[] { const queries: CorrelationFieldCondition[] = []; if (!queryString) { diff --git a/server/clusters/addCorrelationMethods.ts b/server/clusters/addCorrelationMethods.ts index 698e8a482..f5fba333d 100644 --- a/server/clusters/addCorrelationMethods.ts +++ b/server/clusters/addCorrelationMethods.ts @@ -89,4 +89,20 @@ export function addCorrelationMethods(securityAnalytics: any, createAction: any) needBody: false, method: 'GET', }); + + securityAnalytics[METHOD_NAMES.GET_CORRELATION_ALERTS] = createAction({ + url: { + fmt: `${API.GET_CORRELATION_ALERTS}`, + }, + needBody: false, + method: 'GET', + }); + + securityAnalytics[METHOD_NAMES.ACK_CORRELATION_ALERTS] = createAction({ + url: { + fmt: `${API.ACK_CORRELATION_ALERTS}`, + }, + needBody: true, + method: 'POST', + }); } diff --git a/server/models/interfaces/index.ts b/server/models/interfaces/index.ts index a8407d059..f108d5623 100644 --- a/server/models/interfaces/index.ts +++ b/server/models/interfaces/index.ts @@ -39,6 +39,8 @@ export interface SecurityAnalyticsApi { readonly CORRELATIONS: string; readonly LOGTYPE_BASE: string; readonly METRICS: string; + readonly GET_CORRELATION_ALERTS: string; + readonly ACK_CORRELATION_ALERTS: string; } export interface NodeServices { diff --git a/server/routes/CorrelationRoutes.ts b/server/routes/CorrelationRoutes.ts index cce4b6314..739ea4a4b 100644 --- a/server/routes/CorrelationRoutes.ts +++ b/server/routes/CorrelationRoutes.ts @@ -87,4 +87,25 @@ export function setupCorrelationRoutes(services: NodeServices, router: IRouter) }, correlationService.deleteCorrelationRule ); + + router.get( + { + path: `${API.GET_CORRELATION_ALERTS}`, + validate: { + query: createQueryValidationSchema(), + }, + }, + correlationService.getAllCorrelationAlerts + ); + + router.post( + { + path: `${API.ACK_CORRELATION_ALERTS}`, + validate: { + body: schema.any(), + query: createQueryValidationSchema(), + }, + }, + correlationService.acknowledgeCorrelationAlerts + ); } diff --git a/server/routes/FindingsRoutes.ts b/server/routes/FindingsRoutes.ts index 6302b1a26..6a9b68757 100644 --- a/server/routes/FindingsRoutes.ts +++ b/server/routes/FindingsRoutes.ts @@ -25,7 +25,7 @@ export function setupFindingsRoutes(services: NodeServices, router: IRouter) { detectionType: schema.maybe(schema.string()), severity: schema.maybe(schema.string()), searchString: schema.maybe(schema.string()), - findingIds: schema.maybe(schema.arrayOf(schema.string())), + findingIds: schema.string(), startTime: schema.maybe(schema.number()), endTime: schema.maybe(schema.number()) }), diff --git a/server/services/CorrelationService.ts b/server/services/CorrelationService.ts index 06f7d504d..b3b6cd776 100644 --- a/server/services/CorrelationService.ts +++ b/server/services/CorrelationService.ts @@ -21,6 +21,67 @@ import { import { MDSEnabledClientService } from './MDSEnabledClientService'; export default class CorrelationService extends MDSEnabledClientService { + + acknowledgeCorrelationAlerts = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ) => { + try { + const params: any = { body: request.body }; + const client = this.getClient(request, context); + const ackCorrelationAlertsResp = await client( + CLIENT_CORRELATION_METHODS.ACK_CORRELATION_ALERTS, + params + ); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: ackCorrelationAlertsResp, + }, + }); + } catch (error: any) { + console.error('Security Analytics - CorrelationService - ackCorrelationAlertsResp:', error); + return response.custom({ + statusCode: 200, + body: { + ok: false, + error: error.message, + }, + }); + } + }; + + getAllCorrelationAlerts = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ) => { + try { + const client = this.getClient(request, context); + const getCorrelationAlertsResp = await client( + CLIENT_CORRELATION_METHODS.GET_CORRELATION_ALERTS, + ); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: getCorrelationAlertsResp, + }, + }); + } catch (error: any) { + console.error('Security Analytics - CorrelationService - getCorrelationAlerts:', error); + return response.custom({ + statusCode: 200, + body: { + ok: false, + error: error.message, + }, + }); + } + }; + createCorrelationRule = async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, diff --git a/server/utils/constants.ts b/server/utils/constants.ts index c1e115d2f..bca032d47 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -36,6 +36,8 @@ export const API: SecurityAnalyticsApi = { CORRELATIONS: `${BASE_API_PATH}/correlations`, LOGTYPE_BASE: `${BASE_API_PATH}/logtype`, METRICS: `/api/security_analytics/stats`, + GET_CORRELATION_ALERTS: `${BASE_API_PATH}/correlationAlerts`, + ACK_CORRELATION_ALERTS: `${BASE_API_PATH}/_acknowledge/correlationAlerts`, }; /** @@ -66,6 +68,8 @@ export const METHOD_NAMES = { DELETE_CORRELATION_RULE: 'deleteCorrelationRule', GET_CORRELATED_FINDINGS: 'getCorrelatedFindings', GET_ALL_CORRELATIONS: 'getAllCorrelations', + GET_CORRELATION_ALERTS: 'getAllCorrelationAlerts', + ACK_CORRELATION_ALERTS: 'acknowledgeCorrelationAlerts', // Finding methods GET_FINDINGS: 'getFindings', @@ -120,6 +124,8 @@ export const CLIENT_CORRELATION_METHODS = { DELETE_CORRELATION_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_CORRELATION_RULE}`, GET_CORRELATED_FINDINGS: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_CORRELATED_FINDINGS}`, GET_ALL_CORRELATIONS: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_ALL_CORRELATIONS}`, + GET_CORRELATION_ALERTS: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_CORRELATION_ALERTS}`, + ACK_CORRELATION_ALERTS: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.ACK_CORRELATION_ALERTS}` }; export const CLIENT_FIELD_MAPPINGS_METHODS = { diff --git a/types/Alert.ts b/types/Alert.ts index b85634f54..090a0c0da 100644 --- a/types/Alert.ts +++ b/types/Alert.ts @@ -70,6 +70,16 @@ export interface GetAlertsResponse { detectorType: string; } +export interface GetCorrelationAlertsResponse { + correlationAlerts: CorrelationAlertResponse[]; + total_alerts: number; +} + +export interface AckCorrelationAlertsResponse { + acknowledged: CorrelationAlertResponse[]; + failed: CorrelationAlertResponse[]; +} + export interface AlertItem { id: string; start_time: string; @@ -82,6 +92,19 @@ export interface AlertItem { acknowledged_time: string | null; } +export interface CorrelationAlertItem { + id: string; + start_time: string; + trigger_name: string; + correlation_rule_id: string; + correlation_rule_name: string; + state: string; + severity: string; + correlated_finding_ids: string[]; + end_time: string; + acknowledged_time: string | null; +} + export interface AlertResponse extends AlertItem { version: number; schema_version: number; @@ -97,6 +120,20 @@ export interface AlertResponse extends AlertItem { end_time: string | null; } +export interface CorrelationAlertResponse extends CorrelationAlertItem { + version: number; + schema_version: number; + trigger_id: string; + related_doc_ids: string[]; + error_message: string | null; + alert_history: string[]; + action_execution_results: { + action_id: string; + last_execution_time: number; + throttled_count: number; + }[]; +} + export interface AcknowledgeAlertsParams { body: { alerts: string[] }; detector_id: string;