diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2e2dffa05c9fb..18c029ce2300e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -312,3 +312,5 @@ export const showAllOthersBucket: string[] = [ export const ELASTIC_NAME = 'estc'; export const TRANSFORM_STATS_URL = `/api/transform/transforms/${metadataTransformPattern}-*/_stats`; + +export const RISKY_HOSTS_INDEX = 'ml_host_risk_score_latest'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 51211705db573..148390324c13f 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -20,6 +20,7 @@ export const allowedExperimentalValues = Object.freeze({ excludePoliciesInFilterEnabled: false, uebaEnabled: false, disableIsolationUIPendingStatuses: false, + riskyHostsEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts index bae99649c2e01..8e65666e921fa 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts @@ -11,6 +11,7 @@ export * from './common'; export * from './details'; export * from './first_last_seen'; export * from './kpi'; +export * from './risky_hosts'; export * from './overview'; export * from './uncommon_processes'; @@ -22,5 +23,6 @@ export enum HostsQueries { hosts = 'hosts', hostsEntities = 'hostsEntities', overview = 'overviewHost', + riskyHosts = 'riskyHosts', uncommonProcesses = 'uncommonProcesses', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risky_hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risky_hosts/index.ts new file mode 100644 index 0000000000000..f6290e5321a3c --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risky_hosts/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Inspect, Maybe, RequestBasicOptions } from '../../..'; +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +export type HostsRiskyHostsRequestOptions = RequestBasicOptions; + +export interface HostsRiskyHostsStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 208579ffacabe..47a96d8a5fe69 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -28,6 +28,8 @@ import { HostsKpiUniqueIpsStrategyResponse, HostsKpiUniqueIpsRequestOptions, HostFirstLastSeenRequestOptions, + HostsRiskyHostsStrategyResponse, + HostsRiskyHostsRequestOptions, } from './hosts'; import { NetworkQueries, @@ -124,6 +126,8 @@ export type StrategyResponseType = T extends HostsQ ? HostDetailsStrategyResponse : T extends UebaQueries.riskScore ? RiskScoreStrategyResponse + : T extends HostsQueries.riskyHosts + ? HostsRiskyHostsStrategyResponse : T extends UebaQueries.hostRules ? HostRulesStrategyResponse : T extends UebaQueries.userRules @@ -178,6 +182,8 @@ export type StrategyResponseType = T extends HostsQ export type StrategyRequestType = T extends HostsQueries.hosts ? HostsRequestOptions + : T extends HostsQueries.riskyHosts + ? HostsRiskyHostsRequestOptions : T extends HostsQueries.details ? HostDetailsRequestOptions : T extends HostsQueries.overview diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts new file mode 100644 index 0000000000000..df57f7cc8d050 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON, + OVERVIEW_RISKY_HOSTS_LINKS, + OVERVIEW_RISKY_HOSTS_LINKS_ERROR_INNER_PANEL, + OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL, + OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT, + OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON, +} from '../../screens/overview'; + +import { loginAndWaitForPage } from '../../tasks/login'; +import { OVERVIEW_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; + +describe('Risky Hosts Link Panel', () => { + before(() => { + cleanKibana(); + }); + + it('renders disabled panel view as expected', () => { + loginAndWaitForPage(OVERVIEW_URL); + cy.get(`${OVERVIEW_RISKY_HOSTS_LINKS} ${OVERVIEW_RISKY_HOSTS_LINKS_ERROR_INNER_PANEL}`).should( + 'exist' + ); + cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); + cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 hosts'); + cy.get(`${OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON}`).should('exist'); + cy.get(`${OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON}`) + .should('have.attr', 'href') + .and('match', /host-risk-score.md/); + }); + + describe('enabled module', () => { + before(() => { + esArchiverLoad('risky_hosts'); + }); + + after(() => { + esArchiverUnload('risky_hosts'); + }); + + it('renders disabled dashboard module as expected when there are no hosts in the selected time period', () => { + loginAndWaitForPage( + `${OVERVIEW_URL}?sourcerer=(timerange:(from:%272021-07-08T04:00:00.000Z%27,kind:absolute,to:%272021-07-09T03:59:59.999Z%27))` + ); + cy.get( + `${OVERVIEW_RISKY_HOSTS_LINKS} ${OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL}` + ).should('exist'); + cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); + cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 hosts'); + }); + + it('renders dashboard module as expected when there are hosts in the selected time period', () => { + loginAndWaitForPage(OVERVIEW_URL); + cy.get( + `${OVERVIEW_RISKY_HOSTS_LINKS} ${OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL}` + ).should('not.exist'); + cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); + cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 1 host'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index b505bc3b01848..b617b6261d297 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -155,3 +155,14 @@ export const OVERVIEW_CTI_LINKS_INFO_INNER_PANEL = '[data-test-subj="cti-inner-p export const OVERVIEW_CTI_VIEW_DASHBOARD_BUTTON = '[data-test-subj="cti-view-dashboard-button"]'; export const OVERVIEW_CTI_TOTAL_EVENT_COUNT = `${OVERVIEW_CTI_LINKS} [data-test-subj="header-panel-subtitle"]`; export const OVERVIEW_CTI_ENABLE_MODULE_BUTTON = '[data-test-subj="cti-enable-module-button"]'; + +export const OVERVIEW_RISKY_HOSTS_LINKS = '[data-test-subj="risky-hosts-dashboard-links"]'; +export const OVERVIEW_RISKY_HOSTS_LINKS_ERROR_INNER_PANEL = + '[data-test-subj="risky-hosts-inner-panel-danger"]'; +export const OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL = + '[data-test-subj="risky-hosts-inner-panel-warning"]'; +export const OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON = + '[data-test-subj="risky-hosts-view-dashboard-button"]'; +export const OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT = `${OVERVIEW_RISKY_HOSTS_LINKS} [data-test-subj="header-panel-subtitle"]`; +export const OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON = + '[data-test-subj="risky-hosts-enable-module-button"]'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 9e1fd3a769eee..768137c9d731d 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -32,6 +32,7 @@ import { defaultControlColumn } from '../../../timelines/components/timeline/bod import { EventsViewer } from './events_viewer'; import * as i18n from './translations'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; + const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; const leadingControlColumns: ControlColumnProps[] = [ { diff --git a/x-pack/plugins/security_solution/public/common/utils/exceptions/index.ts b/x-pack/plugins/security_solution/public/common/utils/exceptions/index.ts new file mode 100644 index 0000000000000..bc4b93112963f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/exceptions/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const isIndexNotFoundError = (error: unknown): boolean => + ( + error as { + attributes?: { caused_by?: { type?: string } }; + } + ).attributes?.caused_by?.type === 'index_not_found_exception'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.test.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.test.tsx new file mode 100644 index 0000000000000..fc68807f5eb83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { DisabledLinkPanel } from './disabled_link_panel'; +import { ThreatIntelPanelView as TestView } from '../overview_cti_links/threat_intel_panel_view'; + +jest.mock('../../../common/lib/kibana'); + +describe('DisabledLinkPanel', () => { + const defaultProps = { + bodyCopy: 'body', + buttonCopy: 'button', + dataTestSubjPrefix: '_test_prefix_', + docLink: '/doclink', + LinkPanelViewComponent: TestView, + listItems: [], + titleCopy: 'title', + }; + + it('renders expected children', () => { + render( + + + + ); + + expect(screen.getByTestId('_test_prefix_-inner-panel-danger')).toBeInTheDocument(); + expect(screen.getByTestId('_test_prefix_-enable-module-button')).toHaveTextContent( + defaultProps.buttonCopy + ); + expect(screen.getByRole('link')).toHaveAttribute('href', defaultProps.docLink); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.tsx new file mode 100644 index 0000000000000..67d6d5608fe39 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/disabled_link_panel.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiButton } from '@elastic/eui'; + +import { InnerLinkPanel } from './inner_link_panel'; +import { LinkPanelListItem, LinkPanelViewProps } from './types'; + +interface DisabledLinkPanelProps { + bodyCopy: string; + buttonCopy: string; + dataTestSubjPrefix: string; + docLink: string; + LinkPanelViewComponent: React.ComponentType; + listItems: LinkPanelListItem[]; + titleCopy: string; +} + +const DisabledLinkPanelComponent: React.FC = ({ + bodyCopy, + buttonCopy, + dataTestSubjPrefix, + docLink, + LinkPanelViewComponent, + listItems, + titleCopy, +}) => ( + + {buttonCopy} + + } + color="warning" + dataTestSubj={`${dataTestSubjPrefix}-inner-panel-danger`} + title={titleCopy} + /> + } + /> +); + +export const DisabledLinkPanel = memo(DisabledLinkPanelComponent); +DisabledLinkPanel.displayName = 'DisabledLinkPanel'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/helpers.ts b/x-pack/plugins/security_solution/public/overview/components/link_panel/helpers.ts new file mode 100644 index 0000000000000..45d26d9269f6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/helpers.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LinkPanelListItem } from '.'; + +export const isLinkPanelListItem = ( + item: LinkPanelListItem | Partial +): item is LinkPanelListItem => + typeof item.title === 'string' && typeof item.path === 'string' && typeof item.count === 'number'; + +export interface EventCounts { + [key: string]: number; +} diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/index.ts b/x-pack/plugins/security_solution/public/overview/components/link_panel/index.ts new file mode 100644 index 0000000000000..64c8d6a38efe7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { InnerLinkPanel } from './inner_link_panel'; +export { isLinkPanelListItem } from './helpers'; +export { LinkPanel } from './link_panel'; +export { LinkPanelListItem } from './types'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.test.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.test.tsx new file mode 100644 index 0000000000000..819f3d285b1ad --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EuiButton } from '@elastic/eui'; +import { TestProviders } from '../../../common/mock'; +import { InnerLinkPanel } from './inner_link_panel'; + +describe('InnerLinkPanel', () => { + const defaultProps = { + body: 'test_body', + button: , + dataTestSubj: 'custom_test_subj', + title: 'test_title', + }; + + it('renders expected children', () => { + render( + + + + ); + + expect(screen.getByTestId('custom_test_subj')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByTestId('inner-link-panel-title')).toHaveTextContent(defaultProps.title); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_inner_panel.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_inner_panel.tsx rename to x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.tsx index dbdd9ed5526a8..07ecce00a1c57 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_inner_panel.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/inner_link_panel.tsx @@ -9,20 +9,10 @@ import React from 'react'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSplitPanel, EuiText } from '@elastic/eui'; -const PanelContainer = styled(EuiSplitPanel.Inner)` - margin-bottom: ${({ theme }) => theme.eui.paddingSizes.m}; -`; - const ButtonContainer = styled(EuiFlexGroup)` padding: ${({ theme }) => theme.eui.paddingSizes.s}; `; -const Title = styled(EuiText)<{ textcolor: 'primary' | 'warning' }>` - color: ${({ theme, textcolor }) => - textcolor === 'primary' ? theme.eui.euiColorPrimary : theme.eui.euiColorWarningText}; - margin-bottom: ${({ theme }) => theme.eui.paddingSizes.m}; -`; - const Icon = styled(EuiIcon)` padding: 0; margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -30,38 +20,49 @@ const Icon = styled(EuiIcon)` transform: scale(${({ color }) => (color === 'primary' ? 1.4 : 1)}); `; -export const CtiInnerPanel = ({ - color, - title, +const PanelContainer = styled(EuiSplitPanel.Inner)` + margin-bottom: ${({ theme }) => theme.eui.paddingSizes.m}; +`; + +const Title = styled(EuiText)<{ textcolor: 'primary' | 'warning' }>` + color: ${({ theme, textcolor }) => + textcolor === 'primary' ? theme.eui.euiColorPrimary : theme.eui.euiColorWarningText}; + margin-bottom: ${({ theme }) => theme.eui.paddingSizes.m}; +`; + +export const InnerLinkPanel = ({ body, button, + color, dataTestSubj, + title, }: { - color: 'primary' | 'warning'; - title: string; body: string; button?: JSX.Element; + color: 'primary' | 'warning'; dataTestSubj: string; -}) => { - const iconType = color === 'primary' ? 'iInCircle' : 'help'; - return ( - - - - - - - {title} - - - {body} - - {button && ( - - {button} - - )} - - - ); -}; + title: string; +}) => ( + + + + + + + + {title} + + + + {body} + + {button && ( + + {button} + + )} + + +); + +InnerLinkPanel.displayName = 'InnerLinkPanel'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/link.test.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/link.test.tsx new file mode 100644 index 0000000000000..3353e31616467 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/link.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Link } from './link'; + +describe('Link', () => { + it('renders tag when there is a path', () => { + render(); + + expect(screen.getByRole('link')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/test-path'); + expect(screen.getByRole('link')).toHaveTextContent('test_copy'); + }); + + it('does not render tag when there is no path', () => { + render(); + + expect(screen.getByText('test_copy')).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/link.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/link.tsx new file mode 100644 index 0000000000000..e6a3efbc1aed4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/link.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiLink, EuiText } from '@elastic/eui'; + +export const Link: React.FC<{ path?: string; copy: string }> = ({ path, copy }) => + path ? ( + + {copy} + + ) : ( + + {copy} + + ); + +Link.displayName = 'Link'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.test.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.test.tsx new file mode 100644 index 0000000000000..70eb8aa0d9129 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EuiButton, EuiPanel } from '@elastic/eui'; +import { TestProviders } from '../../../common/mock'; +import { LinkPanel } from './link_panel'; + +describe('LinkPanel', () => { + const defaultProps = { + button: , + columns: [ + { name: 'title', field: 'title', sortable: true }, + { + name: 'count', + field: 'count', + }, + ], + dataTestSubj: '_custom_test_subj_', + infoPanel:
, + listItems: [ + { title: 'a', count: 2, path: '' }, + { title: 'b', count: 1, path: '/test' }, + ], + panelTitle: 'test-panel-title', + splitPanel: , + subtitle: , + }; + + it('renders expected children', () => { + render( + + + + ); + + expect(screen.getByTestId('_custom_test_subj_')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByTestId('_test_button_')).toBeInTheDocument(); + expect(screen.getByTestId('_split_panel_')).toBeInTheDocument(); + expect(screen.getByTestId('_subtitle_')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.tsx b/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.tsx new file mode 100644 index 0000000000000..4cc2d62d88791 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/link_panel.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { chunk } from 'lodash'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTableFieldDataColumnType, + EuiBasicTable, + CriteriaWithPagination, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { InspectButtonContainer } from '../../../common/components/inspect'; +import { HeaderSection } from '../../../common/components/header_section'; +import { LinkPanelListItem } from './types'; + +// @ts-ignore-next-line +const StyledTable = styled(EuiBasicTable)` + [data-test-subj='panel-link'], + [data-test-subj='panel-no-link'] { + opacity: 0; + } + tr:hover { + [data-test-subj='panel-link'], + [data-test-subj='panel-no-link'] { + opacity: 1; + } + } +`; + +const PAGE_SIZE = 5; + +const sortAndChunkItems = ( + listItems: LinkPanelListItem[], + sortField: string | number, + sortDirection: 'asc' | 'desc' +) => { + const sortedItems = [...listItems].sort((a, b) => { + const aSortValue = a[sortField]; + const bSortValue = b[sortField]; + if (typeof aSortValue !== 'undefined' && typeof bSortValue !== 'undefined') { + if (aSortValue === bSortValue) { + return a.title > b.title ? 1 : a.title < b.title ? -1 : 0; + } + return aSortValue > bSortValue ? 1 : aSortValue < bSortValue ? -1 : 0; + } + return 0; + }); + if (sortDirection === 'desc') { + sortedItems.reverse(); + } + return chunk(sortedItems, PAGE_SIZE); +}; + +const LinkPanelComponent = ({ + button, + columns, + dataTestSubj, + defaultSortField, + defaultSortOrder, + infoPanel, + inspectQueryId, + listItems, + panelTitle, + splitPanel, + subtitle, +}: { + button: React.ReactNode; + columns: Array>; + dataTestSubj: string; + defaultSortField?: string; + defaultSortOrder?: 'asc' | 'desc'; + infoPanel?: React.ReactNode; + inspectQueryId?: string; + listItems: LinkPanelListItem[]; + panelTitle: string; + splitPanel: React.ReactNode; + subtitle: React.ReactNode; +}) => { + const [pageIndex, setPageIndex] = useState(0); + const [sortField, setSortField] = useState(defaultSortField ?? 'title'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(defaultSortOrder ?? 'asc'); + + const onTableChange = ({ page, sort }: CriteriaWithPagination) => { + const { index } = page; + setPageIndex(index); + if (sort) { + const { field, direction } = sort; + setSortField(field); + setSortDirection(direction); + } + }; + + const chunkedItems = useMemo( + () => sortAndChunkItems(listItems, sortField, sortDirection), + [listItems, sortDirection, sortField] + ); + + const pagination = useMemo( + () => ({ + hidePerPageOptions: true, + pageIndex, + pageSize: PAGE_SIZE, + totalItemCount: listItems.length, + }), + [pageIndex, listItems.length] + ); + + const sorting = useMemo( + () => ({ + sort: { + direction: sortDirection, + field: sortField, + }, + }), + [sortField, sortDirection] + ); + + return ( + <> + + + + + + + <>{button} + + {splitPanel} + {infoPanel} + + + + + + + ); +}; + +export const LinkPanel = React.memo(LinkPanelComponent); + +LinkPanel.displayName = 'LinkPanel'; diff --git a/x-pack/plugins/security_solution/public/overview/components/link_panel/types.ts b/x-pack/plugins/security_solution/public/overview/components/link_panel/types.ts new file mode 100644 index 0000000000000..f6c0fb6f3837f --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/link_panel/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface LinkPanelListItem { + [key: string]: string | number | undefined; + copy?: string; + count: number; + path: string; + title: string; + hostPath?: string; +} + +export interface LinkPanelViewProps { + buttonHref?: string; + isInspectEnabled?: boolean; + isPluginDisabled?: boolean; + listItems: LinkPanelListItem[]; + splitPanel?: JSX.Element; + totalCount?: number; +} diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.test.tsx index a9f48d2103bd4..1ad7975572f46 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { cloneDeep } from 'lodash/fp'; -import { mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n/react'; import { CtiDisabledModule } from './cti_disabled_module'; import { ThemeProvider } from 'styled-components'; @@ -35,7 +35,7 @@ describe('CtiDisabledModule', () => { }); it('renders splitPanel with "danger" variant', () => { - const wrapper = mount( + render( @@ -45,10 +45,7 @@ describe('CtiDisabledModule', () => { ); - expect( - wrapper - .find('[data-test-subj="cti-dashboard-links"] [data-test-subj="cti-inner-panel-danger"]') - .hostNodes().length - ).toEqual(1); + expect(screen.getByTestId('cti-dashboard-links')).toBeInTheDocument(); + expect(screen.getByTestId('cti-inner-panel-danger')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.tsx index 38c352c43b0d4..2697e4a571ad8 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_disabled_module.tsx @@ -5,51 +5,30 @@ * 2.0. */ -import React, { useMemo } from 'react'; -import { EuiButton } from '@elastic/eui'; -import { ThreatIntelPanelView } from './threat_intel_panel_view'; +import React from 'react'; import { EMPTY_LIST_ITEMS } from '../../containers/overview_cti_links/helpers'; import { useKibana } from '../../../common/lib/kibana'; -import { CtiInnerPanel } from './cti_inner_panel'; import * as i18n from './translations'; +import { DisabledLinkPanel } from '../link_panel/disabled_link_panel'; +import { ThreatIntelPanelView } from './threat_intel_panel_view'; export const CtiDisabledModuleComponent = () => { const threatIntelDocLink = `${ useKibana().services.docLinks.links.filebeat.base }/filebeat-module-threatintel.html`; - const danger = useMemo( - () => ( - - {i18n.DANGER_BUTTON} - - } - dataTestSubj="cti-inner-panel-danger" - /> - ), - [threatIntelDocLink] - ); - return ( - ); }; -CtiDisabledModuleComponent.displayName = 'CtiDisabledModule'; - export const CtiDisabledModule = React.memo(CtiDisabledModuleComponent); +CtiDisabledModule.displayName = 'CtiDisabledModule'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx index a30e752cc3afc..310b03959746e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { cloneDeep } from 'lodash/fp'; -import { mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n/react'; import { CtiEnabledModule } from './cti_enabled_module'; import { ThemeProvider } from 'styled-components'; @@ -50,7 +50,7 @@ describe('CtiEnabledModule', () => { }); it('renders CtiWithEvents when there are events', () => { - const wrapper = mount( + render( @@ -60,12 +60,12 @@ describe('CtiEnabledModule', () => { ); - expect(wrapper.exists('[data-test-subj="cti-with-events"]')).toBe(true); + expect(screen.getByTestId('cti-with-events')).toBeInTheDocument(); }); it('renders CtiWithNoEvents when there are no events', () => { useCTIEventCountsMock.mockReturnValueOnce({ totalCount: 0 }); - const wrapper = mount( + render( @@ -75,12 +75,12 @@ describe('CtiEnabledModule', () => { ); - expect(wrapper.exists('[data-test-subj="cti-with-no-events"]')).toBe(true); + expect(screen.getByTestId('cti-with-no-events')).toBeInTheDocument(); }); it('renders null while event counts are loading', () => { useCTIEventCountsMock.mockReturnValueOnce({ totalCount: -1 }); - const wrapper = mount( + const { container } = render( @@ -90,6 +90,6 @@ describe('CtiEnabledModule', () => { ); - expect(wrapper.html()).toEqual(''); + expect(container.firstChild).toBeNull(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx index 304e8b99135d3..5a40c79d6e5ec 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx @@ -21,20 +21,24 @@ export const CtiEnabledModuleComponent: React.FC = (props case -1: return null; case 0: - return ; + return ( +
+ +
+ ); default: return ( - +
+ +
); } }; -CtiEnabledModuleComponent.displayName = 'CtiEnabledModule'; - export const CtiEnabledModule = React.memo(CtiEnabledModuleComponent); +CtiEnabledModule.displayName = 'CtiEnabledModule'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.test.tsx index 7ecaccad19d64..926f6175c3129 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { cloneDeep } from 'lodash/fp'; -import { mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { I18nProvider } from '@kbn/i18n/react'; import { CtiNoEvents } from './cti_no_events'; import { ThemeProvider } from 'styled-components'; @@ -40,7 +40,7 @@ describe('CtiNoEvents', () => { }); it('renders warning inner panel', () => { - const wrapper = mount( + render( @@ -50,15 +50,12 @@ describe('CtiNoEvents', () => { ); - expect( - wrapper - .find('[data-test-subj="cti-dashboard-links"] [data-test-subj="cti-inner-panel-warning"]') - .hostNodes().length - ).toEqual(1); + expect(screen.getByTestId('cti-dashboard-links')).toBeInTheDocument(); + expect(screen.getByTestId('cti-inner-panel-warning')).toBeInTheDocument(); }); it('renders event counts as 0', () => { - const wrapper = mount( + render( @@ -68,8 +65,6 @@ describe('CtiNoEvents', () => { ); - expect(wrapper.find('[data-test-subj="cti-total-event-count"]').text()).toEqual( - 'Showing: 0 indicators' - ); + expect(screen.getByText('Showing: 0 indicators')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx index 525235142ace1..fa7ac50c08765 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_no_events.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { useCtiDashboardLinks } from '../../containers/overview_cti_links'; import { ThreatIntelPanelView } from './threat_intel_panel_view'; -import { CtiInnerPanel } from './cti_inner_panel'; +import { InnerLinkPanel } from '../link_panel'; import * as i18n from './translations'; import { emptyEventCountsByDataset } from '../../containers/overview_cti_links/helpers'; const warning = ( - { - const { buttonHref, listItems, isDashboardPluginDisabled } = useCtiDashboardLinks( + const { buttonHref, listItems, isPluginDisabled } = useCtiDashboardLinks( emptyEventCountsByDataset, to, from @@ -33,12 +33,10 @@ export const CtiNoEventsComponent = ({ to, from }: { to: string; from: string }) buttonHref={buttonHref} listItems={listItems} splitPanel={warning} - totalEventCount={0} - isDashboardPluginDisabled={isDashboardPluginDisabled} + isPluginDisabled={isPluginDisabled} /> ); }; -CtiNoEventsComponent.displayName = 'CtiNoEvents'; - export const CtiNoEvents = React.memo(CtiNoEventsComponent); +CtiNoEvents.displayName = 'CtiNoEvents'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx index b2f7c7d761d2c..f78451e205b1e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_with_events.tsx @@ -21,7 +21,7 @@ export const CtiWithEventsComponent = ({ to: string; totalCount: number; }) => { - const { buttonHref, isDashboardPluginDisabled, listItems } = useCtiDashboardLinks( + const { buttonHref, isPluginDisabled, listItems } = useCtiDashboardLinks( eventCountsByDataset, to, from @@ -30,9 +30,9 @@ export const CtiWithEventsComponent = ({ return ( ); }; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx index 0c50bbf145b17..5348c12fb6c8e 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx @@ -21,15 +21,22 @@ export type ThreatIntelLinkPanelProps = Pick< const ThreatIntelLinkPanelComponent: React.FC = (props) => { switch (props.isThreatIntelModuleEnabled) { case true: - return ; + return ( +
+ +
+ ); case false: - return ; + return ( +
+ +
+ ); case undefined: default: return null; } }; -ThreatIntelLinkPanelComponent.displayName = 'ThreatIntelDashboardLinksComponent'; - export const ThreatIntelLinkPanel = React.memo(ThreatIntelLinkPanelComponent); +ThreatIntelLinkPanel.displayName = 'ThreatIntelDashboardLinksComponent'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/mock.ts b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/mock.ts index 5da69d8c1af3a..1d02acaf65f48 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/mock.ts +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/mock.ts @@ -31,7 +31,7 @@ export const mockCtiEventCountsResponse = { }; export const mockCtiLinksResponse = { - isDashboardPluginDisabled: false, + isPluginDisabled: false, buttonHref: '/button', listItems: [ { title: 'abuseurl', count: 1, path: '/dashboard_path_abuseurl' }, @@ -45,7 +45,7 @@ export const mockCtiLinksResponse = { }; export const mockEmptyCtiLinksResponse = { - isDashboardPluginDisabled: false, + isPluginDisabled: false, buttonHref: '/button', listItems: [ { title: 'abuseurl', count: 0, path: '/dashboard_path_abuseurl' }, @@ -72,7 +72,7 @@ export const mockCtiWithEventsProps = { export const mockThreatIntelPanelViewProps = { buttonHref: '/button_href', - isDashboardPluginDisabled: false, + isPluginDisabled: false, listItems: mockCtiLinksResponse.listItems, splitPanel: undefined, totalEventCount: 1337, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.test.tsx deleted file mode 100644 index ffd0c8e69e76d..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Provider } from 'react-redux'; -import { cloneDeep } from 'lodash/fp'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { ThreatIntelPanelView } from './threat_intel_panel_view'; -import { ThemeProvider } from 'styled-components'; -import { createStore, State } from '../../../common/store'; -import { - createSecuritySolutionStorageMock, - kibanaObservable, - mockGlobalState, - SUB_PLUGINS_REDUCER, -} from '../../../common/mock'; -import { mockTheme, mockThreatIntelPanelViewProps } from './mock'; - -jest.mock('../../../common/lib/kibana'); - -describe('ThreatIntelPanelView', () => { - const state: State = mockGlobalState; - - const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - - beforeEach(() => { - const myState = cloneDeep(state); - store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); - }); - - it('renders enabled button when there is a button href', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('button').props().disabled).toEqual(false); - }); - - it('renders disabled button when there is no button href', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('button').at(1).props().disabled).toEqual(true); - }); - - it('renders info panel if dashboard plugin is disabled', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('[data-test-subj="cti-inner-panel-info"]').hostNodes().length).toEqual(1); - }); - - it('does not render info panel if dashboard plugin is disabled', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('[data-test-subj="cti-inner-panel-info"]').length).toEqual(0); - }); - - it('renders split panel if split panel is passed in as a prop', () => { - const wrapper = mount( - - - - , - }} - /> - - - - ); - - expect(wrapper.find('[data-test-subj="mock-split-panel"]').length).toEqual(1); - }); - - it('renders list items with links', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('li a').at(0).props().href).toEqual( - mockThreatIntelPanelViewProps.listItems[0].path - ); - }); - - it('renders total event count', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.find('[data-test-subj="cti-total-event-count"]').text()).toEqual( - `Showing: ${mockThreatIntelPanelViewProps.totalEventCount} indicators` - ); - }); - - it('renders inspect button by default', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.exists('[data-test-subj="inspect-icon-button"]')).toBe(true); - }); - - it('does not render inspect button if isInspectEnabled is false', () => { - const wrapper = mount( - - - - - - - - ); - - expect(wrapper.exists('[data-test-subj="inspect-icon-button"]')).toBe(false); - }); -}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx index 6bd7bef20fcbe..ba4851600c4b4 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx @@ -6,196 +6,102 @@ */ import React, { useMemo } from 'react'; -import styled from 'styled-components'; -import { - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiLink, - EuiPanel, - EuiSpacer, - EuiText, -} from '@elastic/eui'; +import { EuiButton, EuiTableFieldDataColumnType } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { InspectButtonContainer } from '../../../common/components/inspect'; -import { HeaderSection } from '../../../common/components/header_section'; -import { ID as CTIEventCountQueryId } from '../../containers/overview_cti_links/use_cti_event_counts'; -import { CtiListItem } from '../../containers/overview_cti_links/helpers'; + import { useKibana } from '../../../common/lib/kibana'; -import { CtiInnerPanel } from './cti_inner_panel'; import * as i18n from './translations'; +import { LinkPanel, InnerLinkPanel, LinkPanelListItem } from '../link_panel'; +import { LinkPanelViewProps } from '../link_panel/types'; import { shortenCountIntoString } from '../../../common/utils/shorten_count_into_string'; +import { Link } from '../link_panel/link'; +import { ID as CTIEventCountQueryId } from '../../containers/overview_cti_links/use_cti_event_counts'; +import { LINK_COPY } from '../overview_risky_host_links/translations'; -const DashboardLink = styled.li` - margin: 0 ${({ theme }) => theme.eui.paddingSizes.s} 0 ${({ theme }) => theme.eui.paddingSizes.m}; -`; - -const DashboardLinkItems = styled(EuiFlexGroup)` - width: 100%; -`; - -const Title = styled(EuiFlexItem)` - min-width: 110px; -`; - -const List = styled.ul` - margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; -`; - -const DashboardRightSideElement = styled(EuiFlexItem)` - align-items: flex-end; -`; - -const RightSideLink = styled(EuiLink)` - text-align: right; - min-width: 180px; -`; - -interface ThreatIntelPanelViewProps { - buttonHref?: string; - isDashboardPluginDisabled?: boolean; - isInspectEnabled?: boolean; - listItems: CtiListItem[]; - splitPanel?: JSX.Element; - totalEventCount: number; -} - -const linkCopy = ( - -); - -const panelTitle = ( - -); +const columns: Array> = [ + { name: 'Name', field: 'title', sortable: true, truncateText: true, width: '100%' }, + { + name: 'Indicator', + field: 'count', + render: shortenCountIntoString, + sortable: true, + truncateText: true, + width: '20%', + align: 'right', + }, + { + name: '', + field: 'path', + truncateText: true, + width: '80px', + // eslint-disable-next-line react/display-name + render: (path: string) => , + }, +]; -export const ThreatIntelPanelView: React.FC = ({ +export const ThreatIntelPanelView: React.FC = ({ buttonHref = '', - isDashboardPluginDisabled, + isPluginDisabled, isInspectEnabled = true, listItems, splitPanel, - totalEventCount, + totalCount = 0, }) => { - const subtitle = useMemo( - () => ( - - ), - [totalEventCount] - ); - - const button = useMemo( - () => ( - - - - ), - [buttonHref] - ); - const threatIntelDashboardDocLink = `${ useKibana().services.docLinks.links.filebeat.base }/load-kibana-dashboards.html`; - const infoPanel = useMemo( - () => - isDashboardPluginDisabled ? ( - - {i18n.INFO_BUTTON} - - } - /> - ) : null, - [isDashboardPluginDisabled, threatIntelDashboardDocLink] - ); return ( - <> - - - - - - - <>{button} - - {splitPanel} - {infoPanel} - - - {listItems.map(({ title, path, count }) => ( - - - - {title} - - - {shortenCountIntoString(count)} - - - {path ? ( - - {linkCopy} - - ) : ( - - {linkCopy} - - )} - - - - - ))} - - - - - - - - + ( + + {i18n.VIEW_DASHBOARD} + + ), + [buttonHref] + ), + columns, + dataTestSubj: 'cti-dashboard-links', + infoPanel: useMemo( + () => + isPluginDisabled ? ( + + {i18n.INFO_BUTTON} + + } + /> + ) : null, + [isPluginDisabled, threatIntelDashboardDocLink] + ), + inspectQueryId: isInspectEnabled ? CTIEventCountQueryId : undefined, + listItems, + panelTitle: i18n.PANEL_TITLE, + splitPanel, + subtitle: useMemo( + () => ( + + ), + [totalCount] + ), + }} + /> ); }; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts index 91abd48eb2b7e..4a64462b27ad5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts @@ -64,3 +64,11 @@ export const DANGER_BUTTON = i18n.translate( defaultMessage: 'Enable Module', } ); + +export const PANEL_TITLE = i18n.translate('xpack.securitySolution.overview.ctiDashboardTitle', { + defaultMessage: 'Threat Intelligence', +}); + +export const VIEW_DASHBOARD = i18n.translate('xpack.securitySolution.overview.ctiViewDasboard', { + defaultMessage: 'View dashboard', +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx new file mode 100644 index 0000000000000..0fd7184e0c55a --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { cloneDeep } from 'lodash/fp'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ThemeProvider } from 'styled-components'; +import { useRiskyHostLinks } from '../../containers/overview_risky_host_links/use_risky_host_links'; +import { mockTheme } from '../overview_cti_links/mock'; +import { RiskyHostLinks } from '.'; +import { createStore, State } from '../../../common/store'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, +} from '../../../common/mock'; +import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; +import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; + +jest.mock('../../../common/lib/kibana'); + +jest.mock('../../containers/overview_risky_host_links/use_risky_host_links'); +const useRiskyHostLinksMock = useRiskyHostLinks as jest.Mock; + +jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); +const useRiskyHostsDashboardButtonHrefMock = useRiskyHostsDashboardButtonHref as jest.Mock; +useRiskyHostsDashboardButtonHrefMock.mockReturnValue({ buttonHref: '/test' }); + +jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'); +const useRiskyHostsDashboardLinksMock = useRiskyHostsDashboardLinks as jest.Mock; +useRiskyHostsDashboardLinksMock.mockReturnValue({ + listItemsWithLinks: [{ title: 'a', count: 1, path: '/test' }], +}); + +describe('RiskyHostLinks', () => { + const state: State = mockGlobalState; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + beforeEach(() => { + const myState = cloneDeep(state); + store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + }); + + it('renders enabled module view if module is enabled', () => { + useRiskyHostLinksMock.mockReturnValueOnce({ + loading: false, + isModuleEnabled: true, + listItems: [], + }); + + render( + + + + + + + + ); + + expect(screen.queryByTestId('risky-hosts-enable-module-button')).not.toBeInTheDocument(); + }); + + it('renders disabled module view if module is disabled', () => { + useRiskyHostLinksMock.mockReturnValueOnce({ + loading: false, + isModuleEnabled: false, + listItems: [], + }); + + render( + + + + + + + + ); + + expect(screen.getByTestId('risky-hosts-enable-module-button')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx new file mode 100644 index 0000000000000..895037170c447 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; +import { useRiskyHostLinks } from '../../containers/overview_risky_host_links/use_risky_host_links'; +import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; +import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module'; +export type RiskyHostLinksProps = Pick; + +const RiskyHostLinksComponent: React.FC = (props) => { + const { listItems, isModuleEnabled } = useRiskyHostLinks(props); + + switch (isModuleEnabled) { + case true: + return ; + case false: + return ; + case undefined: + default: + return null; + } +}; + +export const RiskyHostLinks = React.memo(RiskyHostLinksComponent); +RiskyHostLinks.displayName = 'RiskyHostLinks'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/navigate_to_host.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/navigate_to_host.tsx new file mode 100644 index 0000000000000..4680aedc0ba60 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/navigate_to_host.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import { APP_ID, SecurityPageName } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; + +export const NavigateToHost: React.FC<{ name: string }> = ({ name }): JSX.Element => { + const { navigateToApp } = useKibana().services.application; + const { filterManager } = useKibana().services.data.query; + + const goToHostPage = useCallback( + (e) => { + e.preventDefault(); + filterManager.addFilters([ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { match_phrase: { 'host.name': name } }, + }, + ]); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.hosts, + }); + }, + [filterManager, name, navigateToApp] + ); + return ( + + {name} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.test.tsx new file mode 100644 index 0000000000000..9aa1421287220 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { cloneDeep } from 'lodash/fp'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ThemeProvider } from 'styled-components'; +import { createStore, State } from '../../../common/store'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, +} from '../../../common/mock'; +import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module'; +import { mockTheme } from '../overview_cti_links/mock'; + +jest.mock('../../../common/lib/kibana'); + +describe('RiskyHostsModule', () => { + const state: State = mockGlobalState; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + beforeEach(() => { + const myState = cloneDeep(state); + store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + }); + + it('renders expected children', () => { + render( + + + + + + + + ); + + expect(screen.getByTestId('risky-hosts-dashboard-links')).toBeInTheDocument(); + expect(screen.getByTestId('risky-hosts-view-dashboard-button')).toBeInTheDocument(); + expect(screen.getByTestId('risky-hosts-enable-module-button')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.tsx new file mode 100644 index 0000000000000..7d8436bd9dd25 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_disabled_module.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import * as i18n from './translations'; +import { DisabledLinkPanel } from '../link_panel/disabled_link_panel'; +import { RiskyHostsPanelView } from './risky_hosts_panel_view'; +import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; + +const RISKY_HOSTS_DOC_LINK = + 'https://www.github.com/elastic/detection-rules/blob/main/docs/experimental-machine-learning/host-risk-score.md'; + +export const RiskyHostsDisabledModuleComponent = () => ( + +); + +export const RiskyHostsDisabledModule = React.memo(RiskyHostsDisabledModuleComponent); +RiskyHostsEnabledModule.displayName = 'RiskyHostsDisabledModule'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx new file mode 100644 index 0000000000000..f751abdfb3ab8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { cloneDeep } from 'lodash/fp'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ThemeProvider } from 'styled-components'; +import { createStore, State } from '../../../common/store'; +import { + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + SUB_PLUGINS_REDUCER, +} from '../../../common/mock'; +import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; +import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; +import { mockTheme } from '../overview_cti_links/mock'; +import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; + +jest.mock('../../../common/lib/kibana'); + +jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); +const useRiskyHostsDashboardButtonHrefMock = useRiskyHostsDashboardButtonHref as jest.Mock; +useRiskyHostsDashboardButtonHrefMock.mockReturnValue({ buttonHref: '/test' }); + +jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'); +const useRiskyHostsDashboardLinksMock = useRiskyHostsDashboardLinks as jest.Mock; +useRiskyHostsDashboardLinksMock.mockReturnValue({ + listItemsWithLinks: [{ title: 'a', count: 1, path: '/test' }], +}); + +describe('RiskyHostsEnabledModule', () => { + const state: State = mockGlobalState; + + const { storage } = createSecuritySolutionStorageMock(); + let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + + beforeEach(() => { + const myState = cloneDeep(state); + store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + }); + + it('renders expected children', () => { + render( + + + + + + + + ); + expect(screen.getByTestId('risky-hosts-dashboard-links')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx new file mode 100644 index 0000000000000..f26e0c7fb4338 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { RiskyHostsPanelView } from './risky_hosts_panel_view'; +import { LinkPanelListItem } from '../link_panel'; +import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; +import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; + +const RiskyHostsEnabledModuleComponent: React.FC<{ + from: string; + listItems: LinkPanelListItem[]; + to: string; +}> = ({ listItems, to, from }) => { + const { buttonHref } = useRiskyHostsDashboardButtonHref(to, from); + const { listItemsWithLinks } = useRiskyHostsDashboardLinks(to, from, listItems); + + return ( + + ); +}; + +export const RiskyHostsEnabledModule = React.memo(RiskyHostsEnabledModuleComponent); +RiskyHostsEnabledModule.displayName = 'RiskyHostsEnabledModule'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx new file mode 100644 index 0000000000000..e227e66a7d4f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; + +import { EuiButton, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { InnerLinkPanel, LinkPanel, LinkPanelListItem } from '../link_panel'; +import { LinkPanelViewProps } from '../link_panel/types'; +import { Link } from '../link_panel/link'; +import * as i18n from './translations'; +import { VIEW_DASHBOARD } from '../overview_cti_links/translations'; +import { QUERY_ID as RiskyHostsQueryId } from '../../containers/overview_risky_host_links/use_risky_host_links'; +import { NavigateToHost } from './navigate_to_host'; + +const columns: Array> = [ + { + name: 'Host Name', + field: 'title', + sortable: true, + truncateText: true, + width: '55%', + render: (name) => () as JSX.Element, + }, + { + align: 'right', + field: 'count', + name: 'Risk Score', + sortable: true, + truncateText: true, + width: '15%', + }, + { + field: 'copy', + name: 'Current Risk', + sortable: true, + truncateText: true, + width: '15%', + }, + { + field: 'path', + name: '', + render: (path: string) => () as JSX.Element, + truncateText: true, + width: '80px', + }, +]; + +const warningPanel = ( + +); + +export const RiskyHostsPanelView: React.FC = ({ + buttonHref = '', + isInspectEnabled, + listItems, + splitPanel, + totalCount = 0, +}) => { + const splitPanelElement = + typeof splitPanel === 'undefined' + ? listItems.length === 0 + ? warningPanel + : undefined + : splitPanel; + return ( + ( + + {VIEW_DASHBOARD} + + ), + [buttonHref] + ), + columns, + dataTestSubj: 'risky-hosts-dashboard-links', + defaultSortField: 'count', + defaultSortOrder: 'desc', + inspectQueryId: isInspectEnabled ? RiskyHostsQueryId : undefined, + listItems, + panelTitle: i18n.PANEL_TITLE, + splitPanel: splitPanelElement, + subtitle: useMemo( + () => ( + + ), + [totalCount] + ), + }} + /> + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/translations.ts b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/translations.ts new file mode 100644 index 0000000000000..c175196857bbc --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/translations.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const WARNING_TITLE = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardWarningPanelTitle', + { + defaultMessage: 'No host risk score data available to display', + } +); + +export const WARNING_BODY = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardWarningPanelBody', + { + defaultMessage: `We haven't detected any host risk score data from the hosts in your environment for the selected time range.`, + } +); + +export const DANGER_TITLE = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardDangerPanelTitle', + { + defaultMessage: 'No host risk score data to display', + } +); + +export const DANGER_BODY = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardEnableThreatIntel', + { + defaultMessage: + 'Please enable the host risk score module in order to view the list of risky hosts.', + } +); + +export const DANGER_BUTTON = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardDangerPanelButton', + { + defaultMessage: 'Enable Risk Score', + } +); + +export const LINK_COPY = i18n.translate('xpack.securitySolution.overview.riskyHostsSource', { + defaultMessage: 'Source', +}); + +export const PANEL_TITLE = i18n.translate( + 'xpack.securitySolution.overview.riskyHostsDashboardTitle', + { + defaultMessage: 'Current host risk scores', + } +); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/helpers.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/helpers.ts index 6f7953e78731a..9ac61cc9487ee 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/helpers.ts +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/helpers.ts @@ -7,17 +7,12 @@ import { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; import { CTI_DATASET_KEY_MAP } from '../../../../common/cti/constants'; +import { LinkPanelListItem } from '../../components/link_panel'; +import { EventCounts } from '../../components/link_panel/helpers'; -export interface CtiListItem { - path: string; - title: CtiDatasetTitle; - count: number; -} +export const ctiTitles = Object.keys(CTI_DATASET_KEY_MAP) as string[]; -export type CtiDatasetTitle = keyof typeof CTI_DATASET_KEY_MAP; -export const ctiTitles = Object.keys(CTI_DATASET_KEY_MAP) as CtiDatasetTitle[]; - -export const EMPTY_LIST_ITEMS: CtiListItem[] = ctiTitles.map((title) => ({ +export const EMPTY_LIST_ITEMS: LinkPanelListItem[] = ctiTitles.map((title) => ({ title, count: 0, path: '', @@ -30,23 +25,16 @@ export const TAG_REQUEST_BODY = { searchFields: ['name'], }; -export interface EventCounts { - [key: string]: number; -} - export const DASHBOARD_SO_TITLE_PREFIX = '[Filebeat Threat Intel] '; export const OVERVIEW_DASHBOARD_LINK_TITLE = 'Overview'; -export const getListItemsWithoutLinks = (eventCounts: EventCounts): CtiListItem[] => { +export const getCtiListItemsWithoutLinks = (eventCounts: EventCounts): LinkPanelListItem[] => { return EMPTY_LIST_ITEMS.map((item) => ({ ...item, count: eventCounts[CTI_DATASET_KEY_MAP[item.title]] ?? 0, })); }; -export const isCtiListItem = (item: CtiListItem | Partial): item is CtiListItem => - typeof item.title === 'string' && typeof item.path === 'string' && typeof item.count === 'number'; - export const isOverviewItem = (item: { path?: string; title?: string }) => item.title === OVERVIEW_DASHBOARD_LINK_TITLE; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/index.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/index.tsx index 8839aff7dc33d..a546d20e49583 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/index.tsx @@ -8,13 +8,13 @@ import { useState, useEffect, useCallback } from 'react'; import { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; import { useKibana } from '../../../common/lib/kibana'; import { - CtiListItem, TAG_REQUEST_BODY, createLinkFromDashboardSO, - getListItemsWithoutLinks, - isCtiListItem, + getCtiListItemsWithoutLinks, isOverviewItem, + EMPTY_LIST_ITEMS, } from './helpers'; +import { LinkPanelListItem, isLinkPanelListItem } from '../../components/link_panel'; export const useCtiDashboardLinks = ( eventCountsByDataset: { [key: string]: number }, @@ -25,15 +25,15 @@ export const useCtiDashboardLinks = ( const savedObjectsClient = useKibana().services.savedObjects.client; const [buttonHref, setButtonHref] = useState(); - const [listItems, setListItems] = useState([]); + const [listItems, setListItems] = useState(EMPTY_LIST_ITEMS); - const [isDashboardPluginDisabled, setIsDashboardPluginDisabled] = useState(false); + const [isPluginDisabled, setIsDashboardPluginDisabled] = useState(false); const handleDisabledPlugin = useCallback(() => { - if (!isDashboardPluginDisabled) { + if (!isPluginDisabled) { setIsDashboardPluginDisabled(true); } - setListItems(getListItemsWithoutLinks(eventCountsByDataset)); - }, [setIsDashboardPluginDisabled, setListItems, eventCountsByDataset, isDashboardPluginDisabled]); + setListItems(getCtiListItemsWithoutLinks(eventCountsByDataset)); + }, [setIsDashboardPluginDisabled, setListItems, eventCountsByDataset, isPluginDisabled]); const handleTagsReceived = useCallback( (TagsSO?) => { @@ -75,7 +75,7 @@ export const useCtiDashboardLinks = ( ) ); const items = DashboardsSO.savedObjects - ?.reduce((acc: CtiListItem[], dashboardSO, i) => { + ?.reduce((acc: LinkPanelListItem[], dashboardSO, i) => { const item = createLinkFromDashboardSO( dashboardSO, eventCountsByDataset, @@ -83,7 +83,7 @@ export const useCtiDashboardLinks = ( ); if (isOverviewItem(item)) { setButtonHref(item.path); - } else if (isCtiListItem(item)) { + } else if (isLinkPanelListItem(item)) { acc.push(item); } return acc; @@ -102,14 +102,14 @@ export const useCtiDashboardLinks = ( from, handleDisabledPlugin, handleTagsReceived, - isDashboardPluginDisabled, + isPluginDisabled, savedObjectsClient, to, ]); return { buttonHref, - isDashboardPluginDisabled, + isPluginDisabled, listItems, }; }; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_host_links.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_host_links.ts new file mode 100644 index 0000000000000..7df091cbbd463 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_host_links.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +import { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { useRiskyHostsComplete } from './use_risky_hosts'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useKibana } from '../../../common/lib/kibana'; +import { inputsActions } from '../../../common/store/actions'; +import { LinkPanelListItem } from '../../components/link_panel'; +import { RISKY_HOSTS_INDEX } from '../../../../common/constants'; +import { isIndexNotFoundError } from '../../../common/utils/exceptions'; + +export const QUERY_ID = 'risky_hosts'; +const noop = () => {}; + +export interface RiskyHost { + host: { + name: string; + }; + risk_score: number; + risk: string; +} + +const isRecord = (item: unknown): item is Record => + typeof item === 'object' && !!item; + +const isRiskyHostHit = (item: unknown): item is RiskyHost => + isRecord(item) && + isRecord(item.host) && + typeof item.host.name === 'string' && + typeof item.risk_score === 'number' && + typeof item.risk === 'string'; + +const getListItemsFromHits = (items: RiskyHost[]): LinkPanelListItem[] => { + return items.map(({ host, risk_score: count, risk: copy }) => ({ + title: host.name, + count, + copy, + path: '', + })); +}; + +export const useRiskyHostLinks = ({ to, from }: { to: string; from: string }) => { + const [isModuleEnabled, setIsModuleEnabled] = useState(undefined); + + const { addError } = useAppToasts(); + const { data } = useKibana().services; + + const dispatch = useDispatch(); + + const { error, loading, result, start } = useRiskyHostsComplete(); + + const deleteQuery = useCallback(() => { + dispatch(inputsActions.deleteOneQuery({ inputId: 'global', id: QUERY_ID })); + }, [dispatch]); + + useEffect(() => { + if (!loading && result) { + setIsModuleEnabled(true); + dispatch( + inputsActions.setQuery({ + inputId: 'global', + id: QUERY_ID, + inspect: { + dsl: result.inspect?.dsl ?? [], + response: [JSON.stringify(result.rawResponse, null, 2)], + }, + loading, + refetch: noop, + }) + ); + } + return deleteQuery; + }, [deleteQuery, dispatch, loading, result, setIsModuleEnabled]); + + useEffect(() => { + if (error) { + if (isIndexNotFoundError(error)) { + setIsModuleEnabled(false); + } else { + addError(error, { + title: i18n.translate('xpack.securitySolution.overview.riskyHostsError', { + defaultMessage: 'Error Fetching Risky Hosts', + }), + }); + setIsModuleEnabled(true); + } + } + }, [addError, error, setIsModuleEnabled]); + + useEffect(() => { + start({ + data, + timerange: { to, from, interval: '' }, + defaultIndex: [RISKY_HOSTS_INDEX], + filterQuery: '', + }); + }, [start, data, to, from]); + + return { + listItems: isRiskyHostHit(result?.rawResponse?.hits?.hits?.[0]?._source) + ? getListItemsFromHits( + result?.rawResponse?.hits?.hits?.map((hit) => hit._source) as RiskyHost[] + ) + : [], + isModuleEnabled, + loading, + }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts.ts new file mode 100644 index 0000000000000..baf7606e8e238 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { useObservable, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; +import { + DataPublicPluginStart, + isCompleteResponse, + isErrorResponse, +} from '../../../../../../../src/plugins/data/public'; +import { + HostsQueries, + HostsRiskyHostsRequestOptions, + HostsRiskyHostsStrategyResponse, +} from '../../../../common'; + +type GetRiskyHostsProps = HostsRiskyHostsRequestOptions & { + data: DataPublicPluginStart; + signal: AbortSignal; +}; + +export const getRiskyHosts = ({ + data, + defaultIndex, + filterQuery, + timerange, + signal, +}: GetRiskyHostsProps): Observable => + data.search.search( + { + defaultIndex, + factoryQueryType: HostsQueries.riskyHosts, + filterQuery, + timerange, + }, + { + strategy: 'securitySolutionSearchStrategy', + abortSignal: signal, + } + ); + +export const getRiskyHostsComplete = ( + props: GetRiskyHostsProps +): Observable => { + return getRiskyHosts(props).pipe( + filter((response) => { + return isErrorResponse(response) || isCompleteResponse(response); + }) + ); +}; + +const getRiskyHostsWithOptionalSignal = withOptionalSignal(getRiskyHostsComplete); + +export const useRiskyHostsComplete = () => useObservable(getRiskyHostsWithOptionalSignal); diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts new file mode 100644 index 0000000000000..555ae7544180b --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useState, useEffect } from 'react'; +import { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; +import { useKibana } from '../../../common/lib/kibana'; + +const DASHBOARD_REQUEST_BODY_SEARCH = '"Current Risk Score for Hosts"'; +export const DASHBOARD_REQUEST_BODY = { + type: 'dashboard', + search: DASHBOARD_REQUEST_BODY_SEARCH, + fields: ['title'], +}; + +export const useRiskyHostsDashboardButtonHref = (to: string, from: string) => { + const createDashboardUrl = useKibana().services.dashboard?.dashboardUrlGenerator?.createUrl; + const savedObjectsClient = useKibana().services.savedObjects.client; + + const [buttonHref, setButtonHref] = useState(); + + useEffect(() => { + if (createDashboardUrl && savedObjectsClient) { + savedObjectsClient.find(DASHBOARD_REQUEST_BODY).then( + async (DashboardsSO?: { + savedObjects?: Array<{ + attributes?: SavedObjectAttributes; + id?: string; + }>; + }) => { + if (DashboardsSO?.savedObjects?.length) { + const dashboardUrl = await createDashboardUrl({ + dashboardId: DashboardsSO.savedObjects[0].id, + timeRange: { + to, + from, + }, + }); + setButtonHref(dashboardUrl); + } + } + ); + } + }, [createDashboardUrl, from, savedObjectsClient, to]); + + return { + buttonHref, + }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_id.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_id.ts new file mode 100644 index 0000000000000..c01e65fa20e81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_id.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; +import { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; +import { useKibana } from '../../../common/lib/kibana'; + +const DASHBOARD_REQUEST_BODY_SEARCH = '"Drilldown of Host Risk Score"'; +export const DASHBOARD_REQUEST_BODY = { + type: 'dashboard', + search: DASHBOARD_REQUEST_BODY_SEARCH, + fields: ['title'], +}; + +export const useRiskyHostsDashboardId = () => { + const savedObjectsClient = useKibana().services.savedObjects.client; + const [dashboardId, setDashboardId] = useState(); + + useEffect(() => { + if (savedObjectsClient) { + savedObjectsClient.find(DASHBOARD_REQUEST_BODY).then( + async (DashboardsSO?: { + savedObjects?: Array<{ + attributes?: SavedObjectAttributes; + id?: string; + }>; + }) => { + if (DashboardsSO?.savedObjects?.length) { + setDashboardId(DashboardsSO.savedObjects[0].id); + } + } + ); + } + }, [savedObjectsClient]); + + return dashboardId; +}; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_links.tsx b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_links.tsx new file mode 100644 index 0000000000000..ad592f016badb --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_links.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useState, useEffect } from 'react'; +import { useKibana } from '../../../common/lib/kibana'; +import { LinkPanelListItem } from '../../components/link_panel'; +import { useRiskyHostsDashboardId } from './use_risky_hosts_dashboard_id'; + +export const useRiskyHostsDashboardLinks = ( + to: string, + from: string, + listItems: LinkPanelListItem[] +) => { + const createDashboardUrl = useKibana().services.dashboard?.dashboardUrlGenerator?.createUrl; + const dashboardId = useRiskyHostsDashboardId(); + const [listItemsWithLinks, setListItemsWithLinks] = useState([]); + + useEffect(() => { + let cancelled = false; + const createLinks = async () => { + if (createDashboardUrl && dashboardId) { + const dashboardUrls = await Promise.all( + listItems.map((listItem) => + createDashboardUrl({ + dashboardId, + timeRange: { + to, + from, + }, + filters: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { match_phrase: { 'host.name': listItem.title } }, + }, + ], + }) + ) + ); + if (!cancelled) { + setListItemsWithLinks( + listItems.map((item, i) => ({ + ...item, + path: dashboardUrls[i] as unknown as string, + })) + ); + } + } else { + setListItemsWithLinks(listItems); + } + }; + createLinks(); + return () => { + cancelled = true; + }; + }, [createDashboardUrl, dashboardId, from, listItems, to]); + + return { listItemsWithLinks }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index d40f43c81aead..aadedda5d6233 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -31,6 +31,8 @@ import { } from '../components/overview_cti_links/mock'; import { useCtiDashboardLinks } from '../containers/overview_cti_links'; import { EndpointPrivileges } from '../../common/components/user_privileges/use_endpoint_privileges'; +import { useRiskyHostLinks } from '../containers/overview_risky_host_links/use_risky_host_links'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/containers/source'); @@ -72,7 +74,6 @@ jest.mock('../../common/containers/local_storage/use_messages_storage'); jest.mock('../containers/overview_cti_links'); jest.mock('../containers/overview_cti_links/use_cti_event_counts'); -jest.mock('../containers/overview_cti_links'); const useCtiDashboardLinksMock = useCtiDashboardLinks as jest.Mock; useCtiDashboardLinksMock.mockReturnValue(mockCtiLinksResponse); @@ -85,6 +86,18 @@ jest.mock('../containers/overview_cti_links/use_is_threat_intel_module_enabled') const useIsThreatIntelModuleEnabledMock = useIsThreatIntelModuleEnabled as jest.Mock; useIsThreatIntelModuleEnabledMock.mockReturnValue(true); +jest.mock('../containers/overview_risky_host_links/use_risky_host_links'); +const useRiskyHostLinksMock = useRiskyHostLinks as jest.Mock; +useRiskyHostLinksMock.mockReturnValue({ + loading: false, + isModuleEnabled: false, + listItems: [], +}); + +jest.mock('../../common/hooks/use_experimental_features'); +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; +useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + const endpointNoticeMessage = (hasMessageValue: boolean) => { return { hasMessage: () => hasMessageValue, diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index ffafe211960d5..93c0bac5a88d7 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -34,7 +34,9 @@ import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useIsThreatIntelModuleEnabled } from '../containers/overview_cti_links/use_is_threat_intel_module_enabled'; import { useUserPrivileges } from '../../common/components/user_privileges'; +import { RiskyHostLinks } from '../components/overview_risky_host_links'; import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; @@ -76,6 +78,9 @@ const OverviewComponent = () => { } = useUserPrivileges(); const { hasIndexRead, hasKibanaREAD } = useAlertsPrivileges(); const isThreatIntelModuleEnabled = useIsThreatIntelModuleEnabled(); + + const riskyHostsEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + return ( <> {indicesExist ? ( @@ -146,13 +151,27 @@ const OverviewComponent = () => { /> - + + + + + + {riskyHostsEnabled && ( + + )} + + diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts index fbe1ac6413bef..b807806e18091 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts @@ -10,6 +10,7 @@ import { HostsQueries, HostsKpiQueries } from '../../../../../common/search_stra import { allHosts } from './all'; import { hostDetails } from './details'; import { hostOverview } from './overview'; +import { riskyHosts } from './risky_hosts'; import { firstOrLastSeenHost } from './last_first_seen'; import { uncommonProcesses } from './uncommon_processes'; import { authentications, authenticationsEntities } from './authentications'; @@ -26,6 +27,7 @@ jest.mock('./authentications'); jest.mock('./kpi/authentications'); jest.mock('./kpi/hosts'); jest.mock('./kpi/unique_ips'); +jest.mock('./risky_hosts'); describe('hostsFactory', () => { test('should include correct apis', () => { @@ -37,6 +39,7 @@ describe('hostsFactory', () => { [HostsQueries.uncommonProcesses]: uncommonProcesses, [HostsQueries.authentications]: authentications, [HostsQueries.authenticationsEntities]: authenticationsEntities, + [HostsQueries.riskyHosts]: riskyHosts, [HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications, [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts index cd95a38ec3092..d067dacfc5290 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts @@ -21,6 +21,7 @@ import { authentications, authenticationsEntities } from './authentications'; import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/authentications'; import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts'; import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips'; +import { riskyHosts } from './risky_hosts'; export const hostsFactory: Record< HostsQueries | HostsKpiQueries, @@ -34,6 +35,7 @@ export const hostsFactory: Record< [HostsQueries.uncommonProcesses]: uncommonProcesses, [HostsQueries.authentications]: authentications, [HostsQueries.authenticationsEntities]: authenticationsEntities, + [HostsQueries.riskyHosts]: riskyHosts, [HostsKpiQueries.kpiAuthentications]: hostsKpiAuthentications, [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/index.ts new file mode 100644 index 0000000000000..0b2fd1c00c3df --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SecuritySolutionFactory } from '../../types'; +import { HostsQueries } from '../../../../../../common'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { buildRiskyHostsQuery } from './query.risky_hosts.dsl'; +import { + HostsRiskyHostsRequestOptions, + HostsRiskyHostsStrategyResponse, +} from '../../../../../../common/search_strategy/security_solution/hosts/risky_hosts'; + +export const riskyHosts: SecuritySolutionFactory = { + buildDsl: (options: HostsRiskyHostsRequestOptions) => buildRiskyHostsQuery(options), + parse: async ( + options: HostsRiskyHostsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const inspect = { + dsl: [inspectStringifyObject(buildRiskyHostsQuery(options))], + }; + + return { + ...response, + inspect, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/query.risky_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/query.risky_hosts.dsl.ts new file mode 100644 index 0000000000000..79b6a91ff403c --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risky_hosts/query.risky_hosts.dsl.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HostsRiskyHostsRequestOptions } from '../../../../../../common/search_strategy/security_solution/hosts/risky_hosts'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildRiskyHostsQuery = ({ + filterQuery, + timerange: { from, to }, + defaultIndex, +}: HostsRiskyHostsRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + index: defaultIndex, + allowNoIndices: false, + ignoreUnavailable: true, + track_total_hits: false, + body: { + query: { + bool: { + filter, + }, + }, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1679760f98891..656cba9984832 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22993,7 +22993,6 @@ "xpack.securitySolution.overview.ctiDashboardWarningPanelBody": "選択した時間範囲からデータが検出されませんでした。別の時間範囲の検索を試してください。", "xpack.securitySolution.overview.ctiDashboardWarningPanelTitle": "表示する脅威インテリジェンスデータがありません", "xpack.securitySolution.overview.ctiViewDasboard": "ダッシュボードを表示", - "xpack.securitySolution.overview.ctiViewSourceDasboard": "ソースダッシュボードを表示", "xpack.securitySolution.overview.endgameDnsTitle": "DNS", "xpack.securitySolution.overview.endgameFileTitle": "ファイル", "xpack.securitySolution.overview.endgameImageLoadTitle": "画像読み込み", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d998a4cf261e7..ccbe51f773201 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23363,12 +23363,11 @@ "xpack.securitySolution.overview.ctiDashboardInfoPanelBody": "按照此指南启用您的仪表板,以便可以在可视化中查看您的源。", "xpack.securitySolution.overview.ctiDashboardInfoPanelButton": "如何加载 Kibana 仪表板", "xpack.securitySolution.overview.ctiDashboardInfoPanelTitle": "启用 Kibana 仪表板以查看源", - "xpack.securitySolution.overview.ctiDashboardSubtitle": "正在显示:{totalEventCount} 个{totalEventCount, plural, other {指标}}", + "xpack.securitySolution.overview.ctiDashboardSubtitle": "正在显示:{totalCount} 个{totalCount, plural, other {指标}}", "xpack.securitySolution.overview.ctiDashboardTitle": "威胁情报", "xpack.securitySolution.overview.ctiDashboardWarningPanelBody": "我们尚未从选定时间范围检测到任何数据,请尝试搜索其他时间范围。", "xpack.securitySolution.overview.ctiDashboardWarningPanelTitle": "没有可显示的威胁情报数据", "xpack.securitySolution.overview.ctiViewDasboard": "查看仪表板", - "xpack.securitySolution.overview.ctiViewSourceDasboard": "查看源仪表板", "xpack.securitySolution.overview.endgameDnsTitle": "DNS", "xpack.securitySolution.overview.endgameFileTitle": "文件", "xpack.securitySolution.overview.endgameImageLoadTitle": "映像加载", diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index d22ff564beb2c..c1c22d1ea1d8f 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -40,6 +40,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // retrieve rules from the filesystem but not from fleet for Cypress tests '--xpack.securitySolution.prebuiltRulesFromFileSystem=true', '--xpack.securitySolution.prebuiltRulesFromSavedObjects=false', + '--xpack.securitySolution.enableExperimental=["riskyHostsEnabled"]', `--home.disableWelcomeScreen=true`, ], }, diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json new file mode 100644 index 0000000000000..7327f0fc76897 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json @@ -0,0 +1,23 @@ +{ + "type":"doc", + "value":{ + "id":"a4cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f", + "index":"ml_host_risk_score_latest", + "source":{ + "@timestamp":"2021-03-10T14:51:05.766Z", + "risk_score":21, + "host":{ + "name":"ip-10-10-10-121" + }, + "rules":{ + "Unusual Linux Username":{ + "average_risk":21, + "rule_count":2, + "rule_risk":42 + } + }, + "ingest_timestamp":"2021-03-09T18:02:08.319296053Z", + "risk":"Low" + } + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json new file mode 100644 index 0000000000000..211c50f6baee2 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json @@ -0,0 +1,58 @@ +{ + "type": "index", + "value": { + "index": "ml_host_risk_score_latest", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "ingest_timestamp": { + "type": "date" + }, + "risk": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "risk_score": { + "type": "long" + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": "ml_host_risk_score_latest", + "rollover_alias": "ml_host_risk_score_latest" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "max_docvalue_fields_search": "200", + "number_of_replicas": "1", + "number_of_shards": "1", + "refresh_interval": "5s" + } + } + } +}