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`] = `
}
}
>
+
+
+
+
+
+ Findings
+
+
+
+
+
+
+
+
+
+
+ Correlations
+
+
+ EuiIconMock
+
+
+
+
+
+
+
+
+
{
+ 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 {}