diff --git a/cypress/integration/4_findings.spec.js b/cypress/integration/4_findings.spec.js index 0629e111f..670423071 100644 --- a/cypress/integration/4_findings.spec.js +++ b/cypress/integration/4_findings.spec.js @@ -88,6 +88,35 @@ describe('Findings', () => { cy.get('.euiFlexItem--flexGrowZero > .euiButtonIcon').click({ force: true }); }); + it('displays finding details and create an index pattern from flyout', () => { + // filter table to show only sample_detector findings + cy.get(`input[placeholder="Search findings"]`).ospSearch('sample_detector'); + + // Click findingId to trigger Finding details flyout + cy.getTableFirstRow('[data-test-subj="finding-details-flyout-button"]').then(($el) => { + cy.get($el).click({ force: true }); + }); + + cy.get('[data-test-subj="finding-details-flyout-view-surrounding-documents"]') + .contains('View surrounding documents') + .click({ force: true }); + + cy.contains('Create index pattern to view documents'); + + cy.get( + `[data-test-subj="index_pattern_time_field_dropdown"] [data-test-subj="comboBoxSearchInput"]` + ).type('EventTime'); + + cy.get('[data-test-subj="index_pattern_form_submit_button"]') + .contains('Create index pattern') + .click({ force: true }); + + cy.contains('cypress-test-windows* has been successfully created'); + + // Close Flyout + cy.get('.euiFlexItem--flexGrowZero > .euiButtonIcon').click({ force: true }); + }); + it('allows user to view details about rules that were triggered', () => { // filter table to show only sample_detector findings cy.get(`input[placeholder="Search findings"]`).ospSearch('sample_detector'); diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index 452892870..ff8dc6529 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "2.5.0.0", "opensearchDashboardsVersion": "2.5.0", "configPath": ["opensearch_security_analytics"], - "requiredPlugins": [], + "requiredPlugins": ["data"], "server": true, "ui": true } diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index 767423ad8..2cea676f1 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -12,6 +12,7 @@ import { IndexService, RuleService, NotificationsService, + IndexPatternsService, } from '../services'; export interface BrowserServices { @@ -23,6 +24,7 @@ export interface BrowserServices { alertService: AlertsService; ruleService: RuleService; notificationsService: NotificationsService; + indexPatternsService: IndexPatternsService; } export interface RuleOptions { diff --git a/public/pages/Findings/components/CreateIndexPatternForm.tsx b/public/pages/Findings/components/CreateIndexPatternForm.tsx new file mode 100644 index 000000000..e7bf2d1bf --- /dev/null +++ b/public/pages/Findings/components/CreateIndexPatternForm.tsx @@ -0,0 +1,210 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { Formik, Form, FormikErrors } from 'formik'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButton, + EuiSpacer, + EuiComboBox, + EuiText, + EuiCallOut, +} from '@elastic/eui'; +import { IndexPatternsService } from '../../../services'; + +const ILLEGAL_CHARACTERS = [' ', '\\', '/', '?', '"', '<', '>', '|']; + +const containsIllegalCharacters = (pattern: string) => { + return ILLEGAL_CHARACTERS.some((char) => pattern.includes(char)); +}; + +export interface CreateIndexPatternFormModel { + name: string; + timeField: string; +} + +export interface CreateIndexPatternFormProps { + initialValue: { + name: string; + }; + created: (values: string) => void; + close: () => void; + indexPatternsService: IndexPatternsService; +} + +export const CreateIndexPatternForm: React.FC = ({ + initialValue, + created, + close, + indexPatternsService, +}) => { + const [timeFields, setTimeFields] = useState([]); + const [createdIndex, setCreatedIndex] = useState<{ id?: string; title: string }>(); + + const getTimeFields = useCallback( + async (name: string): Promise => { + if (!indexPatternsService) { + return []; + } + + return indexPatternsService + .getFieldsForWildcard({ + pattern: `${name}`, + metaFields: ['_source', '_id', '_type', '_index', '_score'], + params: {}, + }) + .then((res) => { + return res.filter((f) => f.type === 'date').map((f) => f.name); + }) + .catch(() => { + return []; + }); + }, + [initialValue] + ); + + useEffect(() => { + getTimeFields(initialValue.name).then((fields) => { + setTimeFields(fields); + }); + }, [initialValue.name]); + + return createdIndex ? ( + <> + +

You may now view surrounding documents within the index

+
+ + + + { + created(createdIndex?.id || ''); + }} + > + View surrounding documents + + + + + ) : ( + { + const errors: FormikErrors = {}; + + if (!values.name) { + errors.name = 'Index patter name is required'; + } + + if (!values.timeField) { + errors.timeField = 'Time field is required'; + } + + if (containsIllegalCharacters(values.name)) { + errors.name = + 'A index pattern cannot contain spaces or the characters: , /, ?, ", <, >, |'; + } + + return errors; + }} + onSubmit={async (values, { setSubmitting }) => { + try { + const newIndex = await indexPatternsService.createAndSave({ + title: values.name, + timeFieldName: values.timeField, + }); + setCreatedIndex({ id: newIndex.id, title: newIndex.title }); + } catch (e) { + console.warn(e); + } + setSubmitting(false); + }} + > + {(props) => ( +
+ + An index pattern is required to view all surrounding documents within the index. Create + an index pattern to continue. + + + + Specify index pattern name + + } + isInvalid={props.touched.name && !!props.errors?.name} + error={props.errors.name} + > + { + props.handleChange('name')(e); + const fields = await getTimeFields(e.target.value); + setTimeFields(fields); + props.setFieldValue('timeField', ''); + }} + onBlur={props.handleBlur('name')} + value={props.values.name} + /> + + + + Time filed + + } + isInvalid={props.touched.timeField && !!props.errors?.timeField} + error={props.errors.timeField} + > + ({ value: field, label: field }))} + singleSelection={{ asPlainText: true }} + onChange={(e) => { + props.handleChange('timeField')(e[0]?.value ? e[0].value : ''); + }} + onBlur={props.handleBlur('timeField')} + selectedOptions={ + props.values.timeField + ? [{ value: props.values.timeField, label: props.values.timeField }] + : [] + } + /> + + + + + + + close()}>Cancel + + + props.handleSubmit()} + > + Create index pattern + + + + + )} +
+ ); +}; diff --git a/public/pages/Findings/components/FindingDetailsFlyout.tsx b/public/pages/Findings/components/FindingDetailsFlyout.tsx index c33fd88d2..41b835154 100644 --- a/public/pages/Findings/components/FindingDetailsFlyout.tsx +++ b/public/pages/Findings/components/FindingDetailsFlyout.tsx @@ -19,6 +19,10 @@ import { EuiFormRow, EuiHorizontalRule, EuiLink, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, EuiPanel, EuiSpacer, EuiText, @@ -30,14 +34,16 @@ import { Finding, Query } from '../models/interfaces'; import { RuleViewerFlyout } from '../../Rules/components/RuleViewerFlyout/RuleViewerFlyout'; import { RuleSource } from '../../../../server/models/interfaces'; import { RuleItemInfoBase } from '../../Rules/models/types'; -import { OpenSearchService } from '../../../services'; +import { OpenSearchService, IndexPatternsService } from '../../../services'; import { RuleTableItem } from '../../Rules/utils/helpers'; +import { CreateIndexPatternForm } from './CreateIndexPatternForm'; interface FindingDetailsFlyoutProps { finding: Finding; backButton?: React.ReactNode; allRules: { [id: string]: RuleSource }; opensearchService: OpenSearchService; + indexPatternsService: IndexPatternsService; closeFlyout: () => void; } @@ -45,6 +51,7 @@ interface FindingDetailsFlyoutState { loading: boolean; ruleViewerFlyoutData: RuleTableItem | null; indexPatternId?: string; + isCreateIndexPatternModalVisible: boolean; } export default class FindingDetailsFlyout extends Component< @@ -56,6 +63,7 @@ export default class FindingDetailsFlyout extends Component< this.state = { loading: false, ruleViewerFlyoutData: null, + isCreateIndexPatternModalVisible: false, }; } @@ -215,14 +223,19 @@ export default class FindingDetailsFlyout extends Component<

Documents

- + { + if (indexPatternId) { + window.open( + `discover#/context/${indexPatternId}/${related_doc_ids[0]}`, + '_blank' + ); + } else { + this.setState({ ...this.state, isCreateIndexPatternModalVisible: true }); + } + }} > View surrounding documents @@ -266,6 +279,46 @@ export default class FindingDetailsFlyout extends Component< ); } + createIndexPatternModal() { + const { + finding: { related_doc_ids }, + } = this.props; + if (this.state.isCreateIndexPatternModalVisible) { + return ( + this.setState({ ...this.state, isCreateIndexPatternModalVisible: false })} + > + + +

Create index pattern to view documents

+
+
+ + + + this.setState({ ...this.state, isCreateIndexPatternModalVisible: false }) + } + created={(indexPatternId) => { + this.setState({ + ...this.state, + indexPatternId, + isCreateIndexPatternModalVisible: false, + }); + window.open(`discover#/context/${indexPatternId}/${related_doc_ids[0]}`, '_blank'); + }} + > + +
+ ); + } + } + render() { const { finding: { @@ -294,7 +347,7 @@ export default class FindingDetailsFlyout extends Component< ruleTableItem={this.state.ruleViewerFlyoutData} /> )} - + {this.createIndexPatternModal()} diff --git a/public/pages/Findings/components/FindingsTable/FindingsTable.tsx b/public/pages/Findings/components/FindingsTable/FindingsTable.tsx index 9b558b840..53cfd6e53 100644 --- a/public/pages/Findings/components/FindingsTable/FindingsTable.tsx +++ b/public/pages/Findings/components/FindingsTable/FindingsTable.tsx @@ -17,7 +17,7 @@ import { FieldValueSelectionFilterConfigType } from '@elastic/eui/src/components import dateMath from '@elastic/datemath'; import { capitalizeFirstLetter, formatRuleType, renderTime } from '../../../../utils/helpers'; import { DEFAULT_EMPTY_DATA } from '../../../../utils/constants'; -import { DetectorsService, OpenSearchService } from '../../../../services'; +import { DetectorsService, OpenSearchService, IndexPatternsService } from '../../../../services'; import FindingDetailsFlyout from '../FindingDetailsFlyout'; import { Finding } from '../../models/interfaces'; import CreateAlertFlyout from '../CreateAlertFlyout'; @@ -39,6 +39,7 @@ interface FindingsTableProps extends RouteComponentProps { onRefresh: () => void; onFindingsFiltered: (findings: FindingItemType[]) => void; hasNotificationsPlugin: boolean; + indexPatternsService: IndexPatternsService; } interface FindingsTableState { @@ -100,6 +101,7 @@ export default class FindingsTable extends Component ), flyoutOpen: true, diff --git a/public/pages/Findings/containers/Findings/Findings.tsx b/public/pages/Findings/containers/Findings/Findings.tsx index dfeab74b0..b3c357ea7 100644 --- a/public/pages/Findings/containers/Findings/Findings.tsx +++ b/public/pages/Findings/containers/Findings/Findings.tsx @@ -22,6 +22,7 @@ import { NotificationsService, OpenSearchService, RuleService, + IndexPatternsService, } from '../../../../services'; import { BREADCRUMBS, @@ -59,6 +60,7 @@ interface FindingsProps extends RouteComponentProps { detectorService: DetectorsService; findingsService: FindingsService; notificationsService: NotificationsService; + indexPatternsService: IndexPatternsService; opensearchService: OpenSearchService; ruleService: RuleService; notifications: NotificationsStart; diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index 933955615..734240fe5 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -54,6 +54,10 @@ const HIDDEN_NAV_ROUTES: string[] = [ ROUTES.RULES_EDIT, ROUTES.RULES_DUPLICATE, ROUTES.RULES_IMPORT, + ROUTES.EDIT_DETECTOR_DETAILS, + ROUTES.EDIT_DETECTOR_RULES, + ROUTES.EDIT_FIELD_MAPPINGS, + ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS, ]; interface MainProps extends RouteComponentProps { @@ -220,7 +224,7 @@ export default class Main extends Component { services && ( {/* Hide side navigation bar when on any HIDDEN_NAV_ROUTES pages. */} - {!HIDDEN_NAV_ROUTES.includes(pathname) && ( + {!HIDDEN_NAV_ROUTES.some((route) => pathname.match(route)) && ( @@ -239,6 +243,7 @@ export default class Main extends Component { detectorService={services.detectorsService} ruleService={services.ruleService} notificationsService={services.notificationsService} + indexPatternsService={services.indexPatternsService} notifications={core?.notifications} /> )} diff --git a/public/plugin.ts b/public/plugin.ts index e332ab12b..89c02359c 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -12,14 +12,31 @@ import { } from '../../../src/core/public'; import { PLUGIN_NAME, ROUTES } from './utils/constants'; import { SecurityAnalyticsPluginSetup, SecurityAnalyticsPluginStart } from './index'; +import { DataPublicPluginStart, DataPublicPluginSetup } from '../../../src/plugins/data/public'; + +export interface SecurityAnalyticsPluginSetupDeps { + data: DataPublicPluginSetup; +} +export interface SecurityAnalyticsPluginStartDeps { + data: DataPublicPluginStart; +} export class SecurityAnalyticsPlugin - implements Plugin { + implements + Plugin< + SecurityAnalyticsPluginSetup, + SecurityAnalyticsPluginStart, + SecurityAnalyticsPluginSetupDeps, + SecurityAnalyticsPluginStartDeps + > { constructor(private readonly initializerContext: PluginInitializerContext) { // can retrieve config from initializerContext } - public setup(core: CoreSetup): SecurityAnalyticsPluginSetup { + public setup( + core: CoreSetup, + plugins: SecurityAnalyticsPluginSetupDeps + ): SecurityAnalyticsPluginSetup { core.application.register({ id: PLUGIN_NAME, title: 'Security Analytics', @@ -31,8 +48,8 @@ export class SecurityAnalyticsPlugin }, mount: async (params: AppMountParameters) => { const { renderApp } = await import('./security_analytics_app'); - const [coreStart] = await core.getStartServices(); - return renderApp(coreStart, params, ROUTES.LANDING_PAGE); + const [coreStart, depsStart] = await core.getStartServices(); + return renderApp(coreStart, params, ROUTES.LANDING_PAGE, depsStart); }, }); return {}; diff --git a/public/security_analytics_app.tsx b/public/security_analytics_app.tsx index 73caff669..bf8628e6c 100644 --- a/public/security_analytics_app.tsx +++ b/public/security_analytics_app.tsx @@ -7,7 +7,12 @@ import { CoreStart, AppMountParameters } from 'opensearch-dashboards/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { HashRouter as Router, Route } from 'react-router-dom'; -import { AlertsService, NotificationsService, ServicesContext } from './services'; +import { + AlertsService, + NotificationsService, + ServicesContext, + IndexPatternsService, +} from './services'; import { DarkModeContext } from './components/DarkMode'; import Main from './pages/Main'; import { CoreServicesContext } from './components/core_services'; @@ -19,8 +24,14 @@ import OpenSearchService from './services/OpenSearchService'; import { BrowserServices } from './models/interfaces'; import FieldMappingService from './services/FieldMappingService'; import RuleService from './services/RuleService'; +import { SecurityAnalyticsPluginStartDeps } from './plugin'; -export function renderApp(coreStart: CoreStart, params: AppMountParameters, landingPage: string) { +export function renderApp( + coreStart: CoreStart, + params: AppMountParameters, + landingPage: string, + depsStart: SecurityAnalyticsPluginStartDeps +) { const { http, savedObjects } = coreStart; const detectorsService = new DetectorsService(http); @@ -31,6 +42,7 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters, land const alertsService = new AlertsService(http); const ruleService = new RuleService(http); const notificationsService = new NotificationsService(http); + const indexPatternsService = new IndexPatternsService(depsStart.data.indexPatterns); const services: BrowserServices = { detectorsService, @@ -41,6 +53,7 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters, land ruleService, alertService: alertsService, notificationsService, + indexPatternsService, }; const isDarkMode = coreStart.uiSettings.get('theme:darkMode') || false; diff --git a/public/services/IndexPatternsService.ts b/public/services/IndexPatternsService.ts new file mode 100644 index 000000000..309f8fd16 --- /dev/null +++ b/public/services/IndexPatternsService.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GetFieldsOptions, + IndexPatternSpec, + IndexPatternsService as CoreIndexPatternsService, +} from '../../../../src/plugins/data/common/index_patterns'; + +export default class IndexPatternsService { + constructor(private coreIndexPatternsService: CoreIndexPatternsService) {} + + async getFieldsForWildcard(options: GetFieldsOptions) { + return this.coreIndexPatternsService.getFieldsForWildcard(options); + } + + async createAndSave(spec: IndexPatternSpec, override = false, skipFetchFields = false) { + return this.coreIndexPatternsService.createAndSave(spec, override, skipFetchFields); + } +} diff --git a/public/services/index.ts b/public/services/index.ts index 4ef1fd575..9964f84a5 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -12,6 +12,7 @@ import AlertsService from './AlertsService'; import RuleService from './RuleService'; import IndexService from './IndexService'; import NotificationsService from './NotificationsService'; +import IndexPatternsService from './IndexPatternsService'; export { ServicesConsumer, @@ -24,4 +25,5 @@ export { RuleService, IndexService, NotificationsService, + IndexPatternsService, };