diff --git a/cypress/support/helpers.js b/cypress/support/helpers.js index 2033d9967..ca572887e 100644 --- a/cypress/support/helpers.js +++ b/cypress/support/helpers.js @@ -190,7 +190,12 @@ Cypress.Commands.add( cy.get($tr).within(($tr) => { data.map((rowData) => { rowData.forEach((tdData) => { - tdData && cy.get($tr).find('td').contains(`${tdData}`); + if (typeof tdData === 'string') { + tdData && cy.get($tr).find('td').contains(`${tdData}`); + } else { + // if rule is an object then use path + tdData && cy.get($tr).find('td').contains(`${tdData.path}`); + } }); }); }); diff --git a/public/app.scss b/public/app.scss index 2cff1f2d7..27e53780a 100644 --- a/public/app.scss +++ b/public/app.scss @@ -15,7 +15,8 @@ $euiTextColor: $euiColorDarkestShade !default; @import "./pages/Overview/components/Widgets/WidgetContainer.scss"; @import "./pages/Main/components/Callout.scss"; @import "./pages/Detectors/components/ReviewFieldMappings/ReviewFieldMappings.scss"; -@import "./pages/Correlations//Correlations.scss"; +@import "./pages/Correlations/Correlations.scss"; +@import "./pages/Findings/components/CorrelationsTable/CorrelationsTable.scss"; .selected-radio-panel { background-color: tintOrShade($euiColorPrimary, 90%, 70%); diff --git a/public/pages/Correlations/containers/CorrelationsContainer.tsx b/public/pages/Correlations/containers/CorrelationsContainer.tsx index 081973f42..554de6f9d 100644 --- a/public/pages/Correlations/containers/CorrelationsContainer.tsx +++ b/public/pages/Correlations/containers/CorrelationsContainer.tsx @@ -391,17 +391,20 @@ export class Correlations extends React.Component {findingCardsData.correlatedFindings.map((finding, index) => { return ( - + <> + + + ); })} diff --git a/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.scss b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.scss new file mode 100644 index 000000000..ed98fdddb --- /dev/null +++ b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.scss @@ -0,0 +1,6 @@ +.correlations-table-details-row { + .correlations-table-details-row-value { + font-weight: 600; + color: $euiColorDarkestShade; + } +} diff --git a/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx new file mode 100644 index 000000000..b5bb8bf80 --- /dev/null +++ b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx @@ -0,0 +1,178 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { CorrelationFinding } from '../../../../../types'; +import { ruleTypes } from '../../../Rules/utils/constants'; +import { DEFAULT_EMPTY_DATA, ROUTES } from '../../../../utils/constants'; +import { getSeverityBadge } from '../../../Rules/utils/helpers'; +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiPanel, + EuiInMemoryTable, + EuiBasicTableColumn, +} from '@elastic/eui'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { FindingItemType } from '../../containers/Findings/Findings'; +import { RouteComponentProps } from 'react-router-dom'; + +export interface CorrelationsTableProps { + finding: FindingItemType; + correlatedFindings: CorrelationFinding[]; + history: RouteComponentProps['history']; + isLoading: boolean; +} + +export const CorrelationsTable: React.FC = ({ + correlatedFindings, + finding, + history, + isLoading, +}) => { + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ + [key: string]: JSX.Element; + }>({}); + + const toggleCorrelationDetails = (item: any) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = ( + + + + Finding ID + + + + {item.id} + + + + + + + Threat detector + + + + {item.detectorName} + + + + + + + Detection rule + + + + {item.detectionRule?.name || '-'} + + + + + ); + } + + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + const columns: EuiBasicTableColumn[] = [ + { + field: 'timestamp', + name: 'Time', + sortable: true, + }, + { + name: 'Correlated rule', + truncateText: true, + render: (item: CorrelationFinding) => item?.correlationRule.name || DEFAULT_EMPTY_DATA, + }, + { + field: 'logType', + name: 'Log type', + sortable: true, + render: (category: string) => + // TODO: This formatting may need some refactoring depending on the response payload + ruleTypes.find((ruleType) => ruleType.value === category)?.label || DEFAULT_EMPTY_DATA, + }, + { + name: 'Rule severity', + truncateText: true, + align: 'center', + render: (item: CorrelationFinding) => getSeverityBadge(item.detectionRule.severity), + }, + { + field: 'correlationScore', + name: 'Score', + sortable: true, + }, + { + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (item: any) => ( + toggleCorrelationDetails(item)} + aria-label={itemIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'} + iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'} + /> + ), + }, + ]; + + const goToCorrelationsPage = () => { + history.push({ + pathname: `${ROUTES.CORRELATIONS}`, + state: { + finding: finding, + correlatedFindings: correlatedFindings, + }, + }); + }; + + return ( + <> + + + +

Correlated findings

+
+
+ + goToCorrelationsPage()} + disabled={correlatedFindings.length === 0} + > + View correlations graph + + +
+ + + + + + + ); +}; diff --git a/public/pages/Findings/components/FindingDetailsFlyout.tsx b/public/pages/Findings/components/FindingDetailsFlyout.tsx index e636c63a0..c3237abb9 100644 --- a/public/pages/Findings/components/FindingDetailsFlyout.tsx +++ b/public/pages/Findings/components/FindingDetailsFlyout.tsx @@ -30,8 +30,6 @@ import { EuiIcon, EuiTabs, EuiTab, - EuiInMemoryTable, - EuiBasicTableColumn, } from '@elastic/eui'; import { capitalizeFirstLetter, renderTime } from '../../../utils/helpers'; import { DEFAULT_EMPTY_DATA, ROUTES } from '../../../utils/constants'; @@ -39,14 +37,14 @@ import { Query } from '../models/interfaces'; import { RuleViewerFlyout } from '../../Rules/components/RuleViewerFlyout/RuleViewerFlyout'; import { RuleSource } from '../../../../server/models/interfaces'; import { OpenSearchService, IndexPatternsService, CorrelationService } from '../../../services'; -import { getSeverityBadge, RuleTableItem } from '../../Rules/utils/helpers'; +import { RuleTableItem } from '../../Rules/utils/helpers'; import { CreateIndexPatternForm } from './CreateIndexPatternForm'; import { FindingItemType } from '../containers/Findings/Findings'; import { CorrelationFinding, RuleItemInfoBase } from '../../../../types'; import { FindingFlyoutTabId, FindingFlyoutTabs } from '../utils/constants'; import { DataStore } from '../../../store/DataStore'; import { RouteComponentProps } from 'react-router-dom'; -import { ruleTypes } from '../../Rules/utils/constants'; +import { CorrelationsTable } from './CorrelationsTable/CorrelationsTable'; interface FindingDetailsFlyoutProps extends RouteComponentProps { finding: FindingItemType; @@ -96,24 +94,33 @@ export default class FindingDetailsFlyout extends Component< allFindings = await DataStore.findings.getAllFindings(); } - DataStore.correlations - .getCorrelatedFindings(id, detector._source?.detector_type) - .then((findings) => { - if (findings?.correlatedFindings.length) { - let correlatedFindings: any[] = []; - findings.correlatedFindings.map((finding) => { - allFindings.map((item) => { - if (finding.id === item.id) { - correlatedFindings.push(finding); - } + DataStore.correlations.getCorrelationRules().then((correlationRules) => { + DataStore.correlations + .getCorrelatedFindings(id, detector._source?.detector_type) + .then((findings) => { + if (findings?.correlatedFindings.length) { + let correlatedFindings: any[] = []; + findings.correlatedFindings.map((finding: CorrelationFinding) => { + allFindings.map((item: FindingItemType) => { + if (finding.id === item.id) { + correlatedFindings.push({ + ...finding, + correlationRule: correlationRules.find( + (rule) => finding.rules?.indexOf(rule.id) !== -1 + ), + }); + } + }); }); + this.setState({ correlatedFindings }); + } + }) + .finally(() => { + this.setState({ + areCorrelationsLoading: false, }); - this.setState({ correlatedFindings }); - } - }) - .finally(() => { - this.setState({ areCorrelationsLoading: false }); - }); + }); + }); }; componentDidMount(): void { @@ -383,98 +390,23 @@ export default class FindingDetailsFlyout extends Component< } } - private getTabContent( - tabId: FindingFlyoutTabId, - isDocumentLoading = false, - areCorrelationsLoading = false - ) { + private getTabContent(tabId: FindingFlyoutTabId, isDocumentLoading = false) { switch (tabId) { case FindingFlyoutTabId.CORRELATIONS: - return this.createCorrelationsTable(areCorrelationsLoading); + return ( + + ); case FindingFlyoutTabId.DETAILS: default: return this.createFindingDetails(isDocumentLoading); } } - private goToCorrelationsPage = () => { - const { correlatedFindings } = this.state; - const { finding } = this.props; - - this.props.history.push({ - pathname: `${ROUTES.CORRELATIONS}`, - state: { - finding: finding, - correlatedFindings: correlatedFindings, - }, - }); - }; - - private createCorrelationsTable(areCorrelationsLoading: boolean) { - const columns: EuiBasicTableColumn[] = [ - { - field: 'timestamp', - name: 'Time', - sortable: true, - }, - { - field: 'id', - name: 'Correlated finding id', - }, - { - field: 'logType', - name: 'Log type', - sortable: true, - render: (category: string) => - // TODO: This formatting may need some refactoring depending on the response payload - ruleTypes.find((ruleType) => ruleType.value === category)?.label || DEFAULT_EMPTY_DATA, - }, - { - name: 'Rule severity', - truncateText: true, - align: 'center', - render: (item: CorrelationFinding) => getSeverityBadge(item.detectionRule.severity), - }, - { - field: 'correlationScore', - name: 'Score', - sortable: true, - }, - ]; - - return ( - <> - - - -

Correlated findings

-
-
- - this.goToCorrelationsPage()} - disabled={this.state.correlatedFindings.length === 0} - > - View correlations graph - - -
- - - - - - - ); - } - private createFindingDetails(isDocumentLoading: boolean) { const { finding: { queries }, @@ -505,7 +437,7 @@ export default class FindingDetailsFlyout extends Component< timestamp, }, } = this.props; - const { isDocumentLoading, areCorrelationsLoading } = this.state; + const { isDocumentLoading } = this.state; return ( - {tab.name} + {tab.id === 'Correlations' ? ( + <> + {tab.name} ( + {this.state.areCorrelationsLoading + ? DEFAULT_EMPTY_DATA + : this.state.correlatedFindings.length} + ) + + ) : ( + tab.name + )} ); })} diff --git a/public/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts index e735dd709..1a2bb5d9e 100644 --- a/public/store/CorrelationsStore.ts +++ b/public/store/CorrelationsStore.ts @@ -16,6 +16,10 @@ import { NotificationsStart } from 'opensearch-dashboards/public'; import { errorNotificationToast } from '../utils/helpers'; import { DEFAULT_EMPTY_DATA } from '../utils/constants'; +export interface ICorrelationsCache { + [key: string]: CorrelationRule[]; +} + export class CorrelationsStore implements ICorrelationsStore { /** * Correlation rules service instance @@ -34,6 +38,21 @@ export class CorrelationsStore implements ICorrelationsStore { */ readonly notifications: NotificationsStart; + /** + * Keeps rule's data cached + * + * @property {ICorrelationsCache} cache + */ + private cache: ICorrelationsCache = {}; + + /** + * Invalidates all rules data + */ + private invalidateCache = () => { + this.cache = {}; + return this; + }; + constructor( service: CorrelationService, detectorsService: DetectorsService, @@ -48,7 +67,7 @@ export class CorrelationsStore implements ICorrelationsStore { } public async createCorrelationRule(correlationRule: CorrelationRule): Promise { - const response = await this.service.createCorrelationRule({ + const response = await this.invalidateCache().service.createCorrelationRule({ name: correlationRule.name, correlate: correlationRule.queries?.map((query) => ({ index: query.index, @@ -69,10 +88,16 @@ export class CorrelationsStore implements ICorrelationsStore { } public async getCorrelationRules(index?: string): Promise { + const cacheKey: string = `getCorrelationRules:${JSON.stringify(arguments)}`; + + if (this.cache[cacheKey]) { + return this.cache[cacheKey]; + } + const response = await this.service.getCorrelationRules(index); if (response?.ok) { - return response.response.hits.hits.map((hit) => { + return (this.cache[cacheKey] = response.response.hits.hits.map((hit) => { const queries: CorrelationRuleQuery[] = hit._source.correlate.map((queryData) => { return { index: queryData.index, @@ -86,14 +111,14 @@ export class CorrelationsStore implements ICorrelationsStore { name: hit._source.name, queries, }; - }); + })); } return []; } public async deleteCorrelationRule(ruleId: string): Promise { - const response = await this.service.deleteCorrelationRule(ruleId); + const response = await this.invalidateCache().service.deleteCorrelationRule(ruleId); if (!response.ok) { errorNotificationToast(this.notifications, 'delete', 'correlation rule', response.error); @@ -154,6 +179,7 @@ export class CorrelationsStore implements ICorrelationsStore { findings[f.id] = { id: f.id, logType: detector._source.detector_type, + detectorName: detector._source.name, timestamp: new Date(f.timestamp).toLocaleString(), detectionRule: rule ? { diff --git a/public/store/DataStore.ts b/public/store/DataStore.ts index c9f55094c..4f085d1e7 100644 --- a/public/store/DataStore.ts +++ b/public/store/DataStore.ts @@ -7,15 +7,14 @@ import { RulesStore } from './RulesStore'; import { BrowserServices } from '../models/interfaces'; import { NotificationsStart } from 'opensearch-dashboards/public'; import { DetectorsStore } from './DetectorsStore'; -import { ICorrelationsStore } from '../../types'; import { CorrelationsStore } from './CorrelationsStore'; -import { FindingsStore, IFindingsStore } from './FindingsStore'; +import { FindingsStore } from './FindingsStore'; export class DataStore { public static rules: RulesStore; public static detectors: DetectorsStore; - public static correlations: ICorrelationsStore; - public static findings: IFindingsStore; + public static correlations: CorrelationsStore; + public static findings: FindingsStore; public static init = (services: BrowserServices, notifications: NotificationsStart) => { const rulesStore = new RulesStore(services.ruleService, notifications); diff --git a/types/Correlations.ts b/types/Correlations.ts index 290638459..4db43c44e 100644 --- a/types/Correlations.ts +++ b/types/Correlations.ts @@ -26,9 +26,12 @@ export interface CorrelationGraphData { export type CorrelationFinding = { id: string; correlationScore?: number; + correlationRule?: CorrelationFindingHit; logType: string; timestamp: string; detectionRule: { name: string; severity: string }; + detectorName?: string; + rules?: string[]; }; export interface CorrelationRuleQuery { @@ -43,15 +46,15 @@ export interface CorrelationFieldCondition { condition: 'AND' | 'OR'; } -export interface CorrelationRule extends CorrelationRuleModel { - id: string; -} - export interface CorrelationRuleModel { name: string; queries: CorrelationRuleQuery[]; } +export interface CorrelationRule extends CorrelationRuleModel { + id: string; +} + export interface CorrelationRuleSourceQueries { index: string; query: string;