diff --git a/cypress/integration/3_alerts.spec.js b/cypress/integration/3_alerts.spec.js index de17c6bb4..dc40577d6 100644 --- a/cypress/integration/3_alerts.spec.js +++ b/cypress/integration/3_alerts.spec.js @@ -63,6 +63,8 @@ describe('Alerts', () => { // Refresh the table cy.get('[data-test-subj="superDatePickerApplyTimeButton"]').click({ force: true }); + cy.wait(10000); + // Confirm there are alerts created cy.get('tbody > tr').filter(`:contains(${alertName})`).should('have.length', docCount); }); @@ -141,6 +143,8 @@ describe('Alerts', () => { cy.get('[aria-label="View details"]').click({ force: true }); }); + cy.wait(10000); + cy.get('[data-test-subj="alert-details-flyout"]').within(() => { // Wait for findings table to finish loading cy.wait(3000); @@ -274,6 +278,8 @@ describe('Alerts', () => { // Press the "Acknowledge" button cy.get('[data-test-subj="acknowledge-button"]').click({ force: true }); + cy.wait(10000) + // Wait for acknowledge API to finish executing cy.contains('Acknowledged'); @@ -309,8 +315,9 @@ describe('Alerts', () => { cy.contains('Active').click({ force: true }); }); + cy.wait(10000); cy.get('tbody > tr').filter(`:contains(${alertName})`).should('have.length', 3); - + cy.get('tbody > tr') // Click the "Acknowledge" icon button in the first row .first() @@ -318,16 +325,18 @@ describe('Alerts', () => { cy.get('[aria-label="Acknowledge"]').click({ force: true }); }); + cy.wait(10000); cy.get('tbody > tr').filter(`:contains(${alertName})`).should('have.length', 2); // Filter the table to show only "Acknowledged" alerts - cy.get('[data-text="Status"]'); + cy.get('[data-text="Status"]').click({ force: true }); cy.get('[class="euiFilterSelect__items"]').within(() => { cy.contains('Active').click({ force: true }); cy.contains('Acknowledged').click({ force: true }); }); - // Confirm there are now 3 "Acknowledged" alerts + cy.wait(10000); + // Confirm there are now 2 "Acknowledged" alerts cy.get('tbody > tr').filter(`:contains(${alertName})`).should('have.length', 2); }); @@ -352,6 +361,8 @@ describe('Alerts', () => { // Click the "Acknowledge" button on the flyout cy.get('[data-test-subj="alert-details-flyout-acknowledge-button"]').click({ force: true }); + cy.wait(5000); + // Confirm the alert is now "Acknowledged" cy.get('[data-test-subj="text-details-group-content-alert-status"]').contains('Active'); diff --git a/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx new file mode 100644 index 000000000..087c8ed2e --- /dev/null +++ b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx @@ -0,0 +1,274 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBadge, + 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 { parseAlertSeverityToOption } from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers'; + import { NotificationsStart } from 'opensearch-dashboards/public'; + import { DataStore } from '../../../../store/DataStore'; + import { CorrelationAlertTableItem, Finding, Query } from '../../../../../types'; + + export interface CorrelationAlertFlyoutProps { + alertItem: CorrelationAlertTableItem; + notifications: NotificationsStart; + onClose: () => void; + onAcknowledge: (selectedItems: CorrelationAlertTableItem[]) => 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) => detectionType || DEFAULT_EMPTY_DATA, + }, + { + field: 'queries', + name: 'Log type', + sortable: true, + dataType: 'string', + render: (queries: Query[], item: any) => { + const key = item.id; + const tag = queries[0]?.tags[1]; + return ( + + {tag ? formatRuleType(tag) : ''} + + ); + }, + }, + ]; + } + + + 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..3f11b7d94 100644 --- a/public/pages/Alerts/containers/Alerts/Alerts.tsx +++ b/public/pages/Alerts/containers/Alerts/Alerts.tsx @@ -18,6 +18,9 @@ import { EuiToolTip, EuiEmptyPrompt, EuiTableSelectionType, + EuiTabs, + EuiTab, + EuiIcon, } from '@elastic/eui'; import { FieldValueSelectionFilterConfigType } from '@elastic/eui/src/components/search_bar/filters/field_value_selection_filter'; import dateMath from '@elastic/datemath'; @@ -41,7 +44,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 +60,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, CorrelationAlertTableItem, DataSourceProps, DateTimeFilter, Detector } from '../../../../../types'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import { DataStore } from '../../../../store/DataStore'; @@ -65,6 +69,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 +81,21 @@ export interface AlertsState { groupBy: string; recentlyUsedRanges: DurationRange[]; selectedItems: AlertItem[]; + correlatedItems: CorrelationAlertTableItem[]; alerts: AlertItem[]; + correlationAlerts: CorrelationAlertTableItem[]; flyoutData?: { alertItem: AlertItem }; + flyoutCorrelationData?: { alertItem: CorrelationAlertTableItem }; alertsFiltered: boolean; + filteredCorrelationAlerts: CorrelationAlertTableItem[]; filteredAlerts: AlertItem[]; detectors: { [key: string]: Detector }; loading: boolean; timeUnit: TimeUnit; dateFormat: string; widgetEmptyMessage: React.ReactNode | undefined; + widgetEmptyCorrelationMessage: React.ReactNode | undefined; + tab: 'detector findings' | 'correlations'; // Union type for tab } const groupByOptions = [ @@ -111,13 +122,18 @@ 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, + widgetEmptyCorrelationMessage: undefined, + tab: 'detector findings' }; } @@ -134,12 +150,23 @@ export class Alerts extends Component { 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) { + this.onRefresh(); + } if (this.props.dataSource !== prevProps.dataSource) { this.onRefresh(); } else if (alertsChanged) { this.filterAlerts(); + } else if (correlationAlertsChanged) { + this.filterCorrelationAlerts(); } else if (this.state.groupBy !== prevState.groupBy) { - renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); + this.renderVisAsPerTab(); } } @@ -173,6 +200,55 @@ 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, + widgetEmptyCorrelationMessage: filteredCorrelationAlerts.length ? undefined : ( + + No alerts.Adjust the time range to see more + results. +

+ } + /> + ), + }); + renderVisualization(this.generateCorrelationVisualizationSpec(filteredCorrelationAlerts), 'alerts-view'); + }; + + private renderVisAsPerTab() { + if (this.state.tab === "detector findings") { + renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); + } else { + renderVisualization(this.generateCorrelationVisualizationSpec(this.state.filteredCorrelationAlerts), 'alerts-view'); + } + } + + private getAlertsAsPerTab() { + if (this.state.tab === "detector findings") { + this.abortPendingGetAlerts(); + const abortController = new AbortController(); + this.abortControllers.push(abortController); + this.getAlerts(abortController.signal); + } else { + this.getCorrelationAlerts(); + } + } + getColumns(): EuiBasicTableColumn[] { return [ { @@ -252,10 +328,100 @@ 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) => triggerName || DEFAULT_EMPTY_DATA, + }, + { + field: 'correlation_rule_name', + name: 'Correlation Rule Name', + sortable: true, + dataType: 'string', + render: (correlationRulename: string, alertItem: CorrelationAlertTableItem) => ( + this.setCorrelationFlyout(alertItem)}>{correlationRulename} + ), + }, + { + field: 'correlation_rule_categories', + name: 'Log Types', + sortable: false, + dataType: 'string', + render: (correlationRuleCategories: string[]) => correlationRuleCategories.join(', ') || 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: CorrelationAlertTableItem) => { + const disableAcknowledge = alertItem.state !== ALERT_STATE.ACTIVE; + return ( + + this.onAcknowledgeCorrelationAlert([alertItem])} + /> + + ); + }, + }, + { + render: (alertItem: CorrelationAlertTableItem) => ( + + this.setCorrelationFlyout(alertItem)} + /> + + ), + }, + ], + }, + ]; + } + setFlyout(alertItem?: AlertItem): void { this.setState({ flyoutData: alertItem ? { alertItem } : undefined }); } + setCorrelationFlyout(alertItem?: CorrelationAlertTableItem): void { + this.setState({ flyoutCorrelationData: alertItem ? { alertItem } : undefined }); + } + generateVisualizationSpec(alerts: AlertItem[]) { const visData = alerts.map((alert) => { const time = new Date(alert.start_time); @@ -286,6 +452,36 @@ export class Alerts extends Component { }); } + generateCorrelationVisualizationSpec(alerts: CorrelationAlertTableItem[]) { + 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 +502,34 @@ export class Alerts extends Component { this.abortPendingGetAlerts(); } + async getCorrelationAlerts() { + this.setState({ loading: true, correlationAlerts: [] }); + const { correlationService, notifications } = this.props; + try { + const correlationRes = await correlationService.getCorrelationAlerts(); + if (correlationRes.ok) { + const alerts = correlationRes.response.correlationAlerts; + // Fetch correlation queries for each alert + const enrichedAlerts = await Promise.all(alerts.map(async (alert) => { + const correlation = await DataStore.correlations.getCorrelationRule(alert.correlation_rule_id); + const correlationQueries = correlation?.queries || []; + const correlationRuleCategories = correlationQueries.map((query) => query.logType); + return { + ...alert, + correlation_rule_categories: correlationRuleCategories, + }; + })); + this.setState({ correlationAlerts: enrichedAlerts }); + } 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 +554,7 @@ export class Alerts extends Component { abort, duration, (alerts) => { - this.setState({ alerts: [...this.state.alerts, ...alerts]}) + this.setState({ alerts: [...this.state.alerts, ...alerts] }) } ); } @@ -358,6 +582,19 @@ export class Alerts extends Component { ); } + createAcknowledgeControlForCorrelations() { + const { correlatedItems } = this.state; + return ( + this.onAcknowledgeCorrelationAlert(correlatedItems)} + data-test-subj={'acknowledge-button'} + > + Acknowledge + + ); + } + onTimeChange = ({ start, end }: { start: string; end: string }) => { let { recentlyUsedRanges } = this.state; recentlyUsedRanges = recentlyUsedRanges.filter( @@ -387,20 +624,24 @@ export class Alerts extends Component { } onRefresh = async () => { - this.abortPendingGetAlerts(); - const abortController = new AbortController(); - this.abortControllers.push(abortController); - this.getAlerts(abortController.signal); - renderVisualization(this.generateVisualizationSpec(this.state.filteredAlerts), 'alerts-view'); + this.getAlertsAsPerTab(); + this.renderVisAsPerTab(); }; onSelectionChange = (selectedItems: AlertItem[]) => { this.setState({ selectedItems }); }; + onCorrelationSelectionChange = (correlatedItems: CorrelationAlertTableItem[]) => { + this.setState({ correlatedItems }); + }; + onFlyoutClose = () => { this.setState({ flyoutData: undefined }); }; + onCorrelationFlyoutClose = () => { + this.setState({ flyoutCorrelationData: undefined }); + }; onAcknowledge = async (selectedItems: AlertItem[] = []) => { const { alertService, notifications } = this.props; @@ -433,16 +674,47 @@ export class Alerts extends Component { this.onRefresh(); }; + onAcknowledgeCorrelationAlert = async (selectedItems: CorrelationAlertTableItem[] = []) => { + 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 alertIds: string[] = []; + selectedItems.forEach((item) => { + alertIds.push(item.id); + }); + + 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, widgetEmptyMessage, + widgetEmptyCorrelationMessage, } = this.state; const { @@ -453,6 +725,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 +734,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 +770,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: CorrelationAlertTableItem) => item.state === ALERT_STATE.ACTIVE, + selectableMessage: (selectable) => (selectable ? '' : DISABLE_ACKNOWLEDGED_ALERT_HELP_TEXT), + }; + const sorting: any = { sort: { field: 'start_time', @@ -514,6 +831,14 @@ export class Alerts extends Component { indexPatternService={this.props.indexPatternService} /> )} + {flyoutCorrelationData && ( + + )} @@ -563,21 +888,56 @@ export class Alerts extends Component { - - `${item.id}`} - isSelectable={true} - pagination - search={search} - sorting={sorting} - selection={selection} - loading={loading} - message={widgetEmptyMessage} - /> + + + this.setState({ tab: 'detector findings' })} isSelected={this.state.tab === 'detector findings'}> + Findings + + this.setState({ tab: 'correlations' })} isSelected={this.state.tab === 'correlations'}> + +
+ Correlations + +
+
+
+
+ {this.state.tab === 'detector 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={widgetEmptyCorrelationMessage} + /> + )}
+
); diff --git a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap index f05f80fc1..a8526e78b 100644 --- a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap +++ b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap @@ -1126,6 +1126,88 @@ exports[` spec renders the component 1`] = ` } } > + +
+ + + + + + +
+
{ + return ( + <> + +

+ The alerts on correlations feature is experimental and should not be used in a production environment. Any generated alerts and created indexes in will be impacted if the feature is deactivated. For more information see  + + Security Analytics documentation + + . To leave feedback, visit  + + forum.opensearch.org + +

+
+ + + ); +}; \ No newline at end of file diff --git a/public/pages/Correlations/containers/CreateCorrelationRule.tsx b/public/pages/Correlations/containers/CreateCorrelationRule.tsx index c45aca062..17e5f1556 100644 --- a/public/pages/Correlations/containers/CreateCorrelationRule.tsx +++ b/public/pages/Correlations/containers/CreateCorrelationRule.tsx @@ -30,6 +30,9 @@ import { EuiFieldNumber, EuiCheckableCard, htmlIdGenerator, + EuiComboBoxOptionOption, + EuiSwitch, + EuiTextArea, } from '@elastic/eui'; import { ruleTypes } from '../../Rules/utils/constants'; import { @@ -39,13 +42,20 @@ import { CorrelationRuleQuery, DataSourceProps, } from '../../../../types'; -import { BREADCRUMBS, ROUTES } from '../../../utils/constants'; +import { BREADCRUMBS, NOTIFICATIONS_HREF, OS_NOTIFICATION_PLUGIN, ROUTES } from '../../../utils/constants'; import { CoreServicesContext } from '../../../components/core_services'; import { RouteComponentProps, useParams } from 'react-router-dom'; import { validateName } from '../../../utils/validation'; -import { FieldMappingService, IndexService } from '../../../services'; -import { errorNotificationToast, getDataSources, getLogTypeOptions } from '../../../utils/helpers'; +import { FieldMappingService, IndexService, OpenSearchService, NotificationsService } from '../../../services'; +import { errorNotificationToast, getDataSources, getLogTypeOptions, getPlugins } from '../../../utils/helpers'; +import { severityOptions } from '../../../pages/Alerts/utils/constants'; import _ from 'lodash'; +import { NotificationChannelOption, NotificationChannelTypeOptions } from '../../CreateDetector/components/ConfigureAlerts/models/interfaces'; +import { getEmptyAlertCondition, getNotificationChannels, parseAlertSeverityToOption, parseNotificationChannelsToOptions } from '../../CreateDetector/components/ConfigureAlerts/utils/helpers'; +import { NotificationsCallOut } from '../../../../public/components/NotificationsCallOut'; +import { ExperimentalBanner } from '../components/ExperimentalBanner'; +import { ALERT_SEVERITY_OPTIONS } from '../../CreateDetector/components/ConfigureAlerts/utils/constants'; +import uuid from 'uuid'; export interface CreateCorrelationRuleProps extends DataSourceProps { indexService: IndexService; @@ -56,6 +66,8 @@ export interface CreateCorrelationRuleProps extends DataSourceProps { { rule: CorrelationRuleModel; isReadOnly: boolean } >['history']; notifications: NotificationsStart | null; + notificationsService: NotificationsService + opensearchService: OpenSearchService } export interface CorrelationOption { @@ -87,6 +99,11 @@ const unitOptions: EuiSelectOption[] = [ { value: 'HOURS', text: 'Hours' }, ]; +const ruleSeverityComboBoxOptions = severityOptions.map(option => ({ + label: option.text, + value: option.value, +})); + export const CreateCorrelationRule: React.FC = ( props: CreateCorrelationRuleProps ) => { @@ -100,11 +117,17 @@ export const CreateCorrelationRule: React.FC = ( ...correlationRuleStateDefaultValue, }); const [action, setAction] = useState('Create'); + const [hasNotificationPlugin, setHasNotificationPlugin] = useState(false); + const [loadingNotifications, setLoadingNotifications] = useState(true); + const [notificationChannels, setNotificationChannels] = useState([]); const [logTypeOptions, setLogTypeOptions] = useState([]); const [period, setPeriod] = useState({ interval: 1, unit: 'MINUTES' }); const [dataFilterEnabled, setDataFilterEnabled] = useState(false); const [groupByEnabled, setGroupByEnabled] = useState(false); + const [showForm, setShowForm] = useState(false); + const [showNotificationDetails, setShowNotificationDetails] = useState(false); const resetForm = useRef(false); + const [selectedNotificationChannelOption, setSelectedNotificationChannelOption] = useState([]); const validateCorrelationRule = useCallback( (rule: CorrelationRuleModel) => { @@ -173,6 +196,18 @@ export const CreateCorrelationRule: React.FC = ( ); useEffect(() => { + const setInitalNotificationValues = async () => { + const plugins = await getPlugins(props.opensearchService); + if (plugins) { + setHasNotificationPlugin(plugins.includes(OS_NOTIFICATION_PLUGIN)); + } + }; + const setNotificationChannelValues = async () => { + const channels = await getNotificationChannels(props.notificationsService); + const parsedChannels = parseNotificationChannelsToOptions(channels); + setNotificationChannels(parsedChannels); + setLoadingNotifications(false); + } if (props.history.location.state?.rule) { setAction('Edit'); setInitialValues(props.history.location.state?.rule); @@ -187,17 +222,33 @@ export const CreateCorrelationRule: React.FC = ( setAction('Edit'); setInitialRuleValues(); } - const setupLogTypeOptions = async () => { const options = await getLogTypeOptions(); setLogTypeOptions(options); }; setupLogTypeOptions(); + setInitalNotificationValues(); + setNotificationChannelValues(); resetForm.current = true; }, [props.dataSource]); useEffect(() => { + const alertCondition = initialValues.trigger; + 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) { + notificationChannels.forEach((typeOption) => { + const matchingChannel = typeOption.options.find((option) => option.value === channelId); + if (matchingChannel) selectedNotificationChannelOption.push(matchingChannel); + }); + } + setSelectedNotificationChannelOption(selectedNotificationChannelOption); + } setPeriod(parseTime(initialValues.time_window)); setGroupByEnabled(initialValues.queries.some((q) => !!q.field)); setDataFilterEnabled(initialValues.queries.some((q) => q.conditions.length > 0)); @@ -205,9 +256,92 @@ export const CreateCorrelationRule: React.FC = ( initialValues.queries.forEach(({ index }) => { updateLogFieldsForIndex(index); }); - }, [initialValues]); + }, [initialValues, notificationChannels]); + + const onMessageSubjectChange = (subject: string) => { + const newActions = initialValues?.trigger?.actions; + if (newActions) { + newActions[0].name = subject; + newActions[0].subject_template.source = subject; + setInitialValues(prevState => ({ + ...prevState, + trigger: { + ...prevState.trigger!, + newActions, + }, + })); + } + }; + + const onMessageBodyChange = (message: string) => { + const newActions = [...(initialValues?.trigger?.actions ?? [])]; + newActions[0].message_template.source = message; + setInitialValues(prevState => ({ + ...prevState, + trigger: { + ...prevState.trigger!, + newActions, + }, + })); + }; + + const prepareMessage = (updateMessage: boolean = false, onMount: boolean = false) => { + const alertCondition = initialValues?.trigger; + if (alertCondition) { + const lineBreak = '\n'; + const lineBreakAndTab = '\n\t'; + + const alertConditionName = `Triggered alert condition: ${alertCondition.name}`; + const alertConditionSeverity = `Severity: ${parseAlertSeverityToOption(alertCondition.severity)?.label || alertCondition.severity + }`; + const correlationRuleName = `Correlation Rule name: ${initialValues.name}`; + const defaultSubject = [alertConditionName, alertConditionSeverity, correlationRuleName].join(' - '); + + if (alertCondition.actions) { + if (updateMessage || !alertCondition.actions[0]?.subject_template.source) + onMessageSubjectChange(defaultSubject); + + if (updateMessage || !alertCondition.actions[0]?.message_template.source) { + const selectedNames = alertCondition.ids; + const corrRuleQueries = `Detector data sources:${lineBreakAndTab}${initialValues.queries.join( + `,${lineBreakAndTab}` + )}`; + const ruleNames = `Rule Names:${lineBreakAndTab}${selectedNames?.join(`,${lineBreakAndTab}`) ?? ''}`; + const ruleSeverities = `Rule Severities:${lineBreakAndTab}${alertCondition.sev_levels.join( + `,${lineBreakAndTab}` + )}`; + + const alertConditionSelections = []; + if (selectedNames?.length) { + alertConditionSelections.push(ruleNames); + alertConditionSelections.push(lineBreak); + } + if (alertCondition.sev_levels.length) { + alertConditionSelections.push(ruleSeverities); + alertConditionSelections.push(lineBreak); + } + + const alertConditionDetails = [ + alertConditionName, + alertConditionSeverity, + correlationRuleName, + corrRuleQueries, + ]; + let defaultMessageBody = alertConditionDetails.join(lineBreak); + if (alertConditionSelections.length) + defaultMessageBody = + defaultMessageBody + lineBreak + lineBreak + alertConditionSelections.join(lineBreak); + onMessageBodyChange(defaultMessageBody); + } + } + + } + }; + const submit = async (values: CorrelationRuleModel) => { + const randomTriggerId = uuid(); + const randomActionId = uuid(); let error; if ((error = validateCorrelationRule(values))) { errorNotificationToast(props.notifications, action, 'rule', error); @@ -226,6 +360,23 @@ export const CreateCorrelationRule: React.FC = ( }); } + // Modify or set default values for trigger if present + if (values.trigger) { + // Set default values for ids + values.trigger.id = randomTriggerId; + values.trigger.ids?.push(randomTriggerId); + if (!values.trigger.severity) { + values.trigger.severity = ALERT_SEVERITY_OPTIONS.HIGHEST.value; + } + // Set default values for actions if present + if (values.trigger.actions) { + values.trigger.actions.forEach((action) => { + action.id = randomActionId; + action.name = randomActionId; + }); + } + } + if (action === 'Edit') { await correlationStore.updateCorrelationRule(values as CorrelationRule); } else { @@ -242,7 +393,7 @@ export const CreateCorrelationRule: React.FC = ( if (dataSourcesRes.ok) { setIndices(dataSourcesRes.dataSources); } - } catch (error: any) {} + } catch (error: any) { } }, [props.indexService, props.notifications]); useEffect(() => { @@ -452,15 +603,15 @@ export const CreateCorrelationRule: React.FC = ( selectedOptions={ query.logType ? [ - { - value: query.logType, - label: - ruleTypes.find( - (logType) => - logType.value.toLowerCase() === query.logType.toLowerCase() - )?.label || query.logType, - }, - ] + { + value: query.logType, + label: + ruleTypes.find( + (logType) => + logType.value.toLowerCase() === query.logType.toLowerCase() + )?.label || query.logType, + }, + ] : [] } isClearable={true} @@ -690,7 +841,6 @@ export const CreateCorrelationRule: React.FC = ( initialValues={initialValues} validate={(values) => { const errors: FormikErrors = {}; - if (!values.name) { errors.name = 'Rule name is required'; } else { @@ -698,7 +848,6 @@ export const CreateCorrelationRule: React.FC = ( errors.name = 'Invalid rule name.'; } } - if ( Number.isNaN(values.time_window) || values.time_window > 86400000 || @@ -727,7 +876,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(); @@ -827,30 +976,247 @@ export const CreateCorrelationRule: React.FC = ( > {createForm(queries, touched, errors, props)} - {action === 'Create' || action === 'Edit' ? ( - <> - - - - Cancel - + + + + +

Alert Trigger

+
+ + +

Get an alert on the correlation between the findings.

+
+ + + { - props.handleSubmit(); - }} - fill={true} + onClick={() => setShowForm(!showForm)} > - {action === 'Edit' ? 'Update' : 'Create '} correlation rule + Add Alert Trigger - - ) : null} + + {showForm && ( + <> + + + + +

Trigger Name

+ + } + > + { + const triggerName = e.target.value || 'Trigger 1'; + props.setFieldValue('trigger.name', triggerName) + }} + value={trigger?.name} + required={true} + data-test-subj="alert-condition-name" + /> +
+
+ + +

Alert Severity

+ + } + > + { + const selectedSeverity = e[0]?.value || ''; + props.setFieldValue('trigger.severity', selectedSeverity); // Update using setFieldValue + }} + selectedOptions={ + trigger?.severity ? [parseAlertSeverityToOption(trigger?.severity)] : [ALERT_SEVERITY_OPTIONS.HIGHEST] + } + isClearable={true} + data-test-subj="alert-severity-combo-box" + /> +
+
+ + _

}> + setShowForm(false)} + /> +
+
+
+ + + { setShowNotificationDetails(e.target.checked) }} + /> + + + {showNotificationDetails && ( + <> + + + +

Notification channel

+ + } + > + + channelType.options.map(option => ({ + label: option.label, + value: option.value, + })) + )} + onChange={(selectedOptions) => { + const newDestinationId = selectedOptions.length > 0 ? selectedOptions[0].value || '' : ''; + props.setFieldValue('trigger.actions[0].destination_id', newDestinationId); + }} + selectedOptions={ + trigger?.actions && trigger.actions.length > 0 && trigger.actions[0]?.destination_id + ? [notificationChannels.flatMap(channelType => + channelType.options.filter(option => option.value === trigger.actions[0]?.destination_id) + )[0]] + : [] + } + singleSelection={{ asPlainText: true }} + isDisabled={!hasNotificationPlugin} + /> +
+
+ + + Manage channels + + +
+ + {!hasNotificationPlugin && ( + <> + + + + )} + + + + +

Notification message

+ + } + paddingSize={'l'} + initialIsOpen={false} + > + + + +

Message subject

+ + } + fullWidth={true} + > + { + const subjectBody = e.target.value || ''; + props.setFieldValue('trigger.actions[0].subject_template.source', subjectBody) + }} + value={trigger?.actions?.[0]?.subject_template?.source ?? ''} + required={true} + fullWidth={true} + /> +
+
+ + + +

Message body

+ + } + fullWidth={true} + > + { + const messsageBody = e.target.value || ''; + props.setFieldValue('trigger.actions[0].message_template.source', messsageBody) + }} + value={trigger?.actions?.[0]?.message_template?.source ?? ''} + required={true} + fullWidth={true} + /> +
+
+
+
+ + + + )} + + )} +
+ { + action === 'Create' || action === 'Edit' ? ( + <> + + + + Cancel + + + { + props.handleSubmit(); + }} + fill={true} + > + {action === 'Edit' ? 'Update' : 'Create '} correlation rule + + + + + ) : null + } ); }} - + ); }; diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index f66b71414..80eeb49c5 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -58,6 +58,7 @@ import { DataSourceMenuWrapper } from '../../components/MDS/DataSourceMenuWrappe import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types'; import { DataSourceContext, DataSourceContextConsumer } from '../../services/DataSourceContext'; import { dataSourceInfo } from '../../services/utils/constants'; +import { getPlugins } from '../../utils/helpers'; enum Navigation { SecurityAnalytics = 'Security Analytics', @@ -389,7 +390,6 @@ export default class Main extends Component { {(saContext: SecurityAnalyticsContextType | null) => { const services = saContext?.services; const metrics = saContext?.metrics!; - return ( @@ -574,6 +574,7 @@ export default class Main extends Component { notifications={core?.notifications} opensearchService={services.opensearchService} indexPatternService={services.indexPatternsService} + correlationService={services.correlationsService} dataSource={selectedDataSource} /> )} @@ -649,6 +650,8 @@ export default class Main extends Component { fieldMappingService={services?.fieldMappingService} notifications={core?.notifications} dataSource={selectedDataSource} + notificationsService={services?.notificationsService} + opensearchService={services?.opensearchService} /> )} /> @@ -661,6 +664,8 @@ export default class Main extends Component { fieldMappingService={services?.fieldMappingService} notifications={core?.notifications} dataSource={selectedDataSource} + notificationsService={services?.notificationsService} + opensearchService={services?.opensearchService} /> )} /> diff --git a/public/services/CorrelationService.ts b/public/services/CorrelationService.ts index c5ce896c9..e0664b3a5 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({alertIds: 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/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts index d142d0dd6..9a482d2a5 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'; @@ -78,8 +80,8 @@ export class CorrelationsStore implements ICorrelationsStore { return correlationInput; }), + trigger: correlationRule.trigger }); - if (!response.ok) { errorNotificationToast(this.notifications, 'create', 'correlation rule', response.error); return false; @@ -112,8 +114,8 @@ export class CorrelationsStore implements ICorrelationsStore { return correlationInput; }), + trigger: correlationRule.trigger, }); - if (!response.ok) { errorNotificationToast(this.notifications, 'update', 'correlation rule', response.error); return false; @@ -196,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(); @@ -211,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 = { @@ -269,7 +271,7 @@ export class CorrelationsStore implements ICorrelationsStore { name: rule._source.title, severity: rule._source.level, tags: rule._source.tags, - } + } : { name: DEFAULT_EMPTY_DATA, severity: DEFAULT_EMPTY_DATA }, }; }); @@ -294,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({ @@ -325,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/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..2d3b56ef5 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,24 @@ 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 CorrelationAlertTableItem extends CorrelationAlertItem{ + correlation_rule_categories: string[]; +} + + export interface AlertResponse extends AlertItem { version: number; schema_version: number; @@ -97,6 +125,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; diff --git a/types/Correlations.ts b/types/Correlations.ts index 1cf3cc222..0e35c25ef 100644 --- a/types/Correlations.ts +++ b/types/Correlations.ts @@ -8,6 +8,7 @@ import { FilterItem } from '../public/pages/Correlations/components/FilterGroup' import { Query } from '@opensearch-project/oui/src/eui_components/search_bar/search_bar'; import { RuleInfo } from './Rule'; import { DetectorHit } from './Detector'; +import { TriggerAction } from './Alert'; export enum CorrelationsLevel { AllFindings = 'AllFindings', @@ -53,6 +54,7 @@ export interface CorrelationRuleModel { name: string; time_window: number; // Time in milliseconds queries: CorrelationRuleQuery[]; + trigger: CorrelationRuleTrigger | undefined; } export interface CorrelationRule extends CorrelationRuleModel { @@ -74,6 +76,7 @@ export interface CorrelationRuleSource { name: string; time_window: number; correlate: CorrelationRuleSourceQueries[]; + trigger?: CorrelationRuleTrigger | undefined; } export interface CorrelationRuleHit { @@ -118,6 +121,20 @@ export interface CreateCorrelationRuleResponse { _version: number; } +export interface CorrelationRuleTrigger { + // Trigger fields + name: string; + id?: string; + + // Trigger fields based on Rules + sev_levels: string[]; + ids: string[]; + + // Alert related fields + actions?: TriggerAction[]; + severity: string; +} + export interface UpdateCorrelationRuleResponse extends CreateCorrelationRuleResponse {} export interface DeleteCorrelationRuleResponse {}