From 38ae314a18617185a7936d188f0e1a172a78c7fb Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 9 Dec 2020 12:53:14 -0800 Subject: [PATCH 01/11] [CI] Set correct script execute permissions (#85475) Signed-off-by: Tyler Smalley --- test/scripts/checks/jest_configs.sh | 0 test/scripts/checks/mocha_coverage.sh | 0 test/scripts/checks/plugins_with_circular_deps.sh | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 test/scripts/checks/jest_configs.sh mode change 100644 => 100755 test/scripts/checks/mocha_coverage.sh mode change 100644 => 100755 test/scripts/checks/plugins_with_circular_deps.sh diff --git a/test/scripts/checks/jest_configs.sh b/test/scripts/checks/jest_configs.sh old mode 100644 new mode 100755 diff --git a/test/scripts/checks/mocha_coverage.sh b/test/scripts/checks/mocha_coverage.sh old mode 100644 new mode 100755 diff --git a/test/scripts/checks/plugins_with_circular_deps.sh b/test/scripts/checks/plugins_with_circular_deps.sh old mode 100644 new mode 100755 From ac06cb87cd15dea5cccd1f1b320026fa7a95c95b Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 9 Dec 2020 22:50:55 +0100 Subject: [PATCH 02/11] =?UTF-8?q?chore:=20=F0=9F=A4=96=20remove=20extraPub?= =?UTF-8?q?licDirs=20(#85454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/ui_actions/kibana.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 337c5ddf0fd5c..ca979aa021026 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -3,9 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "extraPublicDirs": [ - "public/tests/test_samples" - ], "requiredBundles": [ "kibanaUtils", "kibanaReact" From 5bb47d48b0294c4be5afb07c39307cc04308b7e1 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 9 Dec 2020 15:07:22 -0700 Subject: [PATCH 03/11] [Security Solutions][Detection Engine] Fixes one liner access control with find_rules REST API ## Summary Fixes one liner access control where during the project rename, one got named to `access` instead of `access:securitySolution` --- .../lib/detection_engine/routes/rules/find_rules_route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts index a68e534a2d4ea..b2074ad20b674 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -28,7 +28,7 @@ export const findRulesRoute = (router: IRouter) => { ), }, options: { - tags: ['access'], + tags: ['access:securitySolution'], }, }, async (context, request, response) => { From 6c52ac84c682d3a3c50075b696b42103e9595245 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 9 Dec 2020 17:50:33 -0500 Subject: [PATCH 04/11] [FLEET] New Integration Policy Details page for use in Integrations section (#85355) * new UI route to show Edit Package Policy page * Package policy List items point to new Integration Policy details page * Refactor to use common service to generate pkgKey * add breadcrumb for edit policy under integrations --- .../tutorial_module_notice.tsx | 3 +- .../fleet/constants/page_paths.ts | 4 + .../fleet/hooks/use_breadcrumbs.tsx | 14 ++ .../components/layout.tsx | 6 +- .../create_package_policy_page/index.tsx | 3 +- .../step_define_package_policy.tsx | 5 +- .../step_select_package.tsx | 5 +- .../create_package_policy_page/types.ts | 2 +- .../edit_package_policy_page/index.tsx | 103 +++++++-- .../epm/components/package_list_grid.tsx | 3 +- .../applications/fleet/sections/epm/index.tsx | 4 + .../epm/screens/detail/index.test.tsx | 205 +++++++++++++++++- .../sections/epm/screens/detail/index.tsx | 3 +- .../screens/detail/package_policies_panel.tsx | 4 +- .../sections/epm/screens/policy/index.tsx | 17 ++ .../services/pkg_key_from_package_info.ts | 11 + 16 files changed, 352 insertions(+), 40 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx index 3430a4eb5b258..6cd701da61e26 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/home_integration/tutorial_module_notice.tsx @@ -8,6 +8,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; import { TutorialModuleNoticeComponent } from 'src/plugins/home/public'; import { useGetPackages, useLink, useCapabilities } from '../../hooks'; +import { pkgKeyFromPackageInfo } from '../../services/pkg_key_from_package_info'; const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName }) => { const { getHref } = useLink(); @@ -41,7 +42,7 @@ const TutorialModuleNotice: TutorialModuleNoticeComponent = memo(({ moduleName } availableAsIntegrationLink: ( '/integrations/installed', integration_details: ({ pkgkey, panel }) => `/integrations/detail/${pkgkey}${panel ? `/${panel}` : ''}`, + integration_policy_edit: ({ packagePolicyId }) => + `/integrations/edit-integration/${packagePolicyId}`, policies: () => '/policies', policies_list: () => '/policies', policy_details: ({ policyId, tabId }) => `/policies/${policyId}${tabId ? `/${tabId}` : ''}`, diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx index 40654645ecd3f..4feff29896459 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_breadcrumbs.tsx @@ -73,6 +73,20 @@ const breadcrumbGetters: { }, { text: pkgTitle }, ], + integration_policy_edit: ({ pkgTitle, pkgkey, policyName }) => [ + BASE_BREADCRUMB, + { + href: pagePathGetters.integrations(), + text: i18n.translate('xpack.fleet.breadcrumbs.integrationPageTitle', { + defaultMessage: 'Integration', + }), + }, + { + href: pagePathGetters.integration_details({ pkgkey, panel: 'policies' }), + text: pkgTitle, + }, + { text: policyName }, + ], policies: () => [ BASE_BREADCRUMB, { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx index 9188f0069b8bf..cac133acd4d2d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/layout.tsx @@ -38,7 +38,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ 'data-test-subj': dataTestSubj, }) => { const pageTitle = useMemo(() => { - if ((from === 'package' || from === 'edit') && packageInfo) { + if ((from === 'package' || from === 'package-edit' || from === 'edit') && packageInfo) { return ( @@ -76,7 +76,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ ); } - return from === 'edit' ? ( + return from === 'edit' || from === 'package-edit' ? (

{ - return from === 'edit' ? ( + return from === 'edit' || from === 'package-edit' ? ( { ? packageInfo && ( ) : agentPolicy && ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index f6533a06cea27..b7de9d0afe8f5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -20,6 +20,7 @@ import { AgentPolicy, PackageInfo, PackagePolicy, NewPackagePolicy } from '../.. import { packageToPackagePolicyInputs } from '../../../services'; import { Loading } from '../../../components'; import { PackagePolicyValidationResults } from './services'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; export const StepDefinePackagePolicy: React.FunctionComponent<{ agentPolicy: AgentPolicy; @@ -34,8 +35,8 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Update package policy's package and agent policy info useEffect(() => { const pkg = packagePolicy.package; - const currentPkgKey = pkg ? `${pkg.name}-${pkg.version}` : ''; - const pkgKey = `${packageInfo.name}-${packageInfo.version}`; + const currentPkgKey = pkg ? pkgKeyFromPackageInfo(pkg) : ''; + const pkgKey = pkgKeyFromPackageInfo(packageInfo); // If package has changed, create shell package policy with input&stream values based on package info if (currentPkgKey !== pkgKey) { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx index 8c646323c312c..3bcafaecbf8d9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_select_package.tsx @@ -16,6 +16,7 @@ import { sendGetPackageInfoByKey, } from '../../../hooks'; import { PackageIcon } from '../../../components/package_icon'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; export const StepSelectPackage: React.FunctionComponent<{ agentPolicyId: string; @@ -32,7 +33,7 @@ export const StepSelectPackage: React.FunctionComponent<{ }) => { // Selected package state const [selectedPkgKey, setSelectedPkgKey] = useState( - packageInfo ? `${packageInfo.name}-${packageInfo.version}` : undefined + packageInfo ? pkgKeyFromPackageInfo(packageInfo) : undefined ); const [selectedPkgError, setSelectedPkgError] = useState(); @@ -92,7 +93,7 @@ export const StepSelectPackage: React.FunctionComponent<{ updatePackageInfo(undefined); } }; - if (!packageInfo || selectedPkgKey !== `${packageInfo.name}-${packageInfo.version}`) { + if (!packageInfo || selectedPkgKey !== pkgKeyFromPackageInfo(packageInfo)) { fetchPackageInfo(); } }, [selectedPkgKey, packageInfo, updatePackageInfo, setIsLoadingSecondStep]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts index c6e16c2cb4d97..7eb5d95c1ab05 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/types.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export type CreatePackagePolicyFrom = 'package' | 'policy' | 'edit'; +export type CreatePackagePolicyFrom = 'package' | 'package-edit' | 'policy' | 'edit'; export type PackagePolicyFormState = 'VALID' | 'INVALID' | 'CONFIRM' | 'LOADING' | 'SUBMITTED'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 56950d155f782..26f99bd88a923 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; import { useRouteMatch, useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -45,15 +45,24 @@ import { useUIExtension } from '../../../hooks/use_ui_extension'; import { ExtensionWrapper } from '../../../components/extension_wrapper'; import { GetOnePackagePolicyResponse } from '../../../../../../common/types/rest_spec'; import { PackagePolicyEditExtensionComponentProps } from '../../../types'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; -export const EditPackagePolicyPage: React.FunctionComponent = () => { +export const EditPackagePolicyPage = memo(() => { + const { + params: { packagePolicyId }, + } = useRouteMatch<{ policyId: string; packagePolicyId: string }>(); + + return ; +}); + +export const EditPackagePolicyForm = memo<{ + packagePolicyId: string; + from?: CreatePackagePolicyFrom; +}>(({ packagePolicyId, from = 'edit' }) => { const { notifications } = useStartServices(); const { agents: { enabled: isFleetEnabled }, } = useConfig(); - const { - params: { policyId, packagePolicyId }, - } = useRouteMatch<{ policyId: string; packagePolicyId: string }>(); const history = useHistory(); const { getHref, getPath } = useLink(); @@ -76,16 +85,31 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { GetOnePackagePolicyResponse['item'] >(); + const policyId = agentPolicy?.id ?? ''; + // Retrieve agent policy, package, and package policy info useEffect(() => { const getData = async () => { setIsLoadingData(true); setLoadingError(undefined); try { - const [{ data: agentPolicyData }, { data: packagePolicyData }] = await Promise.all([ - sendGetOneAgentPolicy(policyId), - sendGetOnePackagePolicy(packagePolicyId), - ]); + const { + data: packagePolicyData, + error: packagePolicyError, + } = await sendGetOnePackagePolicy(packagePolicyId); + + if (packagePolicyError) { + throw packagePolicyError; + } + + const { data: agentPolicyData, error: agentPolicyError } = await sendGetOneAgentPolicy( + packagePolicyData!.item.policy_id + ); + + if (agentPolicyError) { + throw agentPolicyError; + } + if (agentPolicyData?.item) { setAgentPolicy(agentPolicyData.item); } @@ -123,7 +147,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { setPackagePolicy(newPackagePolicy); if (packagePolicyData.item.package) { const { data: packageData } = await sendGetPackageInfoByKey( - `${packagePolicyData.item.package.name}-${packagePolicyData.item.package.version}` + pkgKeyFromPackageInfo(packagePolicyData.item.package) ); if (packageData?.response) { setPackageInfo(packageData.response); @@ -150,7 +174,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { } }; - if (isFleetEnabled) { + if (isFleetEnabled && policyId) { getAgentCount(); } }, [policyId, isFleetEnabled]); @@ -214,8 +238,32 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { [updatePackagePolicy] ); - // Cancel url - const cancelUrl = getHref('policy_details', { policyId }); + // Cancel url + Success redirect Path: + // if `from === 'edit'` then it links back to Policy Details + // if `from === 'package-edit'` then it links back to the Integration Policy List + const cancelUrl = useMemo((): string => { + if (packageInfo && policyId) { + return from === 'package-edit' + ? getHref('integration_details', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + panel: 'policies', + }) + : getHref('policy_details', { policyId }); + } + return '/'; + }, [from, getHref, packageInfo, policyId]); + + const successRedirectPath = useMemo(() => { + if (packageInfo && policyId) { + return from === 'package-edit' + ? getPath('integration_details', { + pkgkey: pkgKeyFromPackageInfo(packageInfo!), + panel: 'policies', + }) + : getPath('policy_details', { policyId }); + } + return '/'; + }, [from, getPath, packageInfo, policyId]); // Save package policy const [formState, setFormState] = useState('INVALID'); @@ -237,7 +285,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { } const { error } = await savePackagePolicy(); if (!error) { - history.push(getPath('policy_details', { policyId })); + history.push(successRedirectPath); notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.editPackagePolicy.updatedNotificationTitle', { defaultMessage: `Successfully updated '{packagePolicyName}'`, @@ -287,7 +335,7 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { }; const layoutProps = { - from: 'edit' as CreatePackagePolicyFrom, + from, cancelUrl, agentPolicy, packageInfo, @@ -363,13 +411,21 @@ export const EditPackagePolicyPage: React.FunctionComponent = () => { error={ loadingError || i18n.translate('xpack.fleet.editPackagePolicy.errorLoadingDataMessage', { - defaultMessage: 'There was an error loading this intergration information', + defaultMessage: 'There was an error loading this integration information', }) } /> ) : ( <> - + {from === 'package' || from === 'package-edit' ? ( + + ) : ( + + )} {formState === 'CONFIRM' && ( { )} ); -}; +}); -const Breadcrumb: React.FunctionComponent<{ policyName: string; policyId: string }> = ({ +const PoliciesBreadcrumb: React.FunctionComponent<{ policyName: string; policyId: string }> = ({ policyName, policyId, }) => { useBreadcrumbs('edit_integration', { policyName, policyId }); return null; }; + +const IntegrationsBreadcrumb = memo<{ + pkgTitle: string; + policyName: string; + pkgkey: string; +}>(({ pkgTitle, policyName, pkgkey }) => { + useBreadcrumbs('integration_policy_edit', { policyName, pkgTitle, pkgkey }); + return null; +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx index b96fda2c23af1..42e4a6051d725 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/components/package_list_grid.tsx @@ -21,6 +21,7 @@ import { Loading } from '../../../components'; import { PackageList } from '../../../types'; import { useLocalSearch, searchIdField } from '../hooks'; import { PackageCard } from './package_card'; +import { pkgKeyFromPackageInfo } from '../../../services/pkg_key_from_package_info'; interface ListProps { isLoading?: boolean; @@ -118,7 +119,7 @@ function GridColumn({ list }: GridColumnProps) { {list.length ? ( list.map((item) => ( - + )) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx index 8884d1f9d7a75..733aa9dfcf8aa 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/index.tsx @@ -11,6 +11,7 @@ import { useBreadcrumbs } from '../../hooks'; import { CreatePackagePolicyPage } from '../agent_policy/create_package_policy_page'; import { EPMHomePage } from './screens/home'; import { Detail } from './screens/detail'; +import { Policy } from './screens/policy'; export const EPMApp: React.FunctionComponent = () => { useBreadcrumbs('integrations'); @@ -20,6 +21,9 @@ export const EPMApp: React.FunctionComponent = () => { + + + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx index 7ed14a27c32cf..3d43725f2dc71 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.test.tsx @@ -10,17 +10,26 @@ import React, { lazy, memo } from 'react'; import { PAGE_ROUTING_PATHS, pagePathGetters } from '../../../../constants'; import { Route } from 'react-router-dom'; import { + GetAgentPoliciesResponse, GetFleetStatusResponse, GetInfoResponse, + GetPackagePoliciesResponse, } from '../../../../../../../common/types/rest_spec'; import { DetailViewPanelName, KibanaAssetType } from '../../../../../../../common/types/models'; -import { epmRouteService, fleetSetupRouteService } from '../../../../../../../common/services'; -import { act } from '@testing-library/react'; +import { + agentPolicyRouteService, + epmRouteService, + fleetSetupRouteService, + packagePolicyRouteService, +} from '../../../../../../../common/services'; +import { act, cleanup } from '@testing-library/react'; describe('when on integration detail', () => { - const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey: 'nginx-0.3.7' }); + const pkgkey = 'nginx-0.3.7'; + const detailPageUrlPath = pagePathGetters.integration_details({ pkgkey }); let testRenderer: TestRenderer; let renderResult: ReturnType; + let mockedApi: MockedApi; const render = () => (renderResult = testRenderer.render( @@ -30,10 +39,15 @@ describe('when on integration detail', () => { beforeEach(() => { testRenderer = createTestRendererMock(); - mockApiCalls(testRenderer.startServices.http); + mockedApi = mockApiCalls(testRenderer.startServices.http); testRenderer.history.push(detailPageUrlPath); }); + afterEach(() => { + cleanup(); + window.location.hash = '#/'; + }); + describe('and a custom UI extension is NOT registered', () => { beforeEach(() => render()); @@ -137,9 +151,48 @@ describe('when on integration detail', () => { }); }); }); + + describe('and on the Policies Tab', () => { + const policiesTabURLPath = pagePathGetters.integration_details({ pkgkey, panel: 'policies' }); + beforeEach(() => { + testRenderer.history.push(policiesTabURLPath); + render(); + }); + + it('should display policies list', () => { + const table = renderResult.getByTestId('integrationPolicyTable'); + expect(table).not.toBeNull(); + }); + + it('should link to integration policy detail when an integration policy is clicked', async () => { + await mockedApi.waitForApi(); + const firstPolicy = renderResult.getByTestId('integrationNameLink') as HTMLAnchorElement; + expect(firstPolicy.href).toEqual( + 'http://localhost/mock/app/fleet#/integrations/edit-integration/e8a37031-2907-44f6-89d2-98bd493f60dc' + ); + }); + }); }); -const mockApiCalls = (http: MockedFleetStartServices['http']) => { +interface MockedApi { + /** Will return a promise that resolves when triggered APIs are complete */ + waitForApi: () => Promise; +} + +const mockApiCalls = (http: MockedFleetStartServices['http']): MockedApi => { + let inflightApiCalls = 0; + const apiDoneListeners: Array<() => void> = []; + const markApiCallAsHandled = async () => { + inflightApiCalls++; + await new Promise((r) => setTimeout(r, 1)); + inflightApiCalls--; + + // If no more pending API calls, then notify listeners + if (inflightApiCalls === 0 && apiDoneListeners.length > 0) { + apiDoneListeners.splice(0).forEach((listener) => listener()); + } + }; + // @ts-ignore const epmPackageResponse: GetInfoResponse = { response: { @@ -369,7 +422,7 @@ const mockApiCalls = (http: MockedFleetStartServices['http']) => { owner: { github: 'elastic/integrations-services' }, latestVersion: '0.3.7', removable: true, - status: 'not_installed', + status: 'installed', }, } as GetInfoResponse; @@ -388,24 +441,162 @@ On Windows, the module was tested with Nginx installed from the Chocolatey repos const agentsSetupResponse: GetFleetStatusResponse = { isReady: true, missing_requirements: [] }; + const packagePoliciesResponse: GetPackagePoliciesResponse = { + items: [ + { + id: 'e8a37031-2907-44f6-89d2-98bd493f60dc', + version: 'WzgzMiwxXQ==', + name: 'nginx-1', + description: '', + namespace: 'default', + policy_id: '521c1b70-3976-11eb-ad1c-3baa423084d9', + enabled: true, + output_id: '', + inputs: [ + { + type: 'logfile', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.access' }, + vars: { paths: { value: ['/var/log/nginx/access.log*'], type: 'text' } }, + id: 'logfile-nginx.access-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + paths: ['/var/log/nginx/access.log*'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + }, + }, + { + enabled: true, + data_stream: { type: 'logs', dataset: 'nginx.error' }, + vars: { paths: { value: ['/var/log/nginx/error.log*'], type: 'text' } }, + id: 'logfile-nginx.error-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + paths: ['/var/log/nginx/error.log*'], + exclude_files: ['.gz$'], + multiline: { + pattern: '^\\d{4}\\/\\d{2}\\/\\d{2} ', + negate: true, + match: 'after', + }, + processors: [{ add_locale: null }], + }, + }, + { + enabled: false, + data_stream: { type: 'logs', dataset: 'nginx.ingress_controller' }, + vars: { paths: { value: ['/var/log/nginx/ingress.log*'], type: 'text' } }, + id: 'logfile-nginx.ingress_controller-e8a37031-2907-44f6-89d2-98bd493f60dc', + }, + ], + }, + { + type: 'nginx/metrics', + enabled: true, + streams: [ + { + enabled: true, + data_stream: { type: 'metrics', dataset: 'nginx.stubstatus' }, + vars: { + period: { value: '10s', type: 'text' }, + server_status_path: { value: '/nginx_status', type: 'text' }, + }, + id: 'nginx/metrics-nginx.stubstatus-e8a37031-2907-44f6-89d2-98bd493f60dc', + compiled_stream: { + metricsets: ['stubstatus'], + hosts: ['http://127.0.0.1:80'], + period: '10s', + server_status_path: '/nginx_status', + }, + }, + ], + vars: { hosts: { value: ['http://127.0.0.1:80'], type: 'text' } }, + }, + ], + package: { name: 'nginx', title: 'Nginx', version: '0.3.7' }, + revision: 1, + created_at: '2020-12-09T13:46:31.013Z', + created_by: 'elastic', + updated_at: '2020-12-09T13:46:31.013Z', + updated_by: 'elastic', + }, + ], + total: 1, + page: 1, + perPage: 20, + }; + + const agentPoliciesResponse: GetAgentPoliciesResponse = { + items: [ + { + id: '521c1b70-3976-11eb-ad1c-3baa423084d9', + name: 'Default', + namespace: 'default', + description: 'Default agent policy created by Kibana', + status: 'active', + package_policies: [ + '4d09bd78-b0ad-4238-9fa3-d87d3c887c73', + '2babac18-eb8e-4ce4-b53b-4b7c5f507019', + 'e8a37031-2907-44f6-89d2-98bd493f60dc', + ], + is_default: true, + monitoring_enabled: ['logs', 'metrics'], + revision: 6, + updated_at: '2020-12-09T13:46:31.840Z', + updated_by: 'elastic', + agents: 0, + }, + ], + total: 1, + page: 1, + perPage: 100, + }; + http.get.mockImplementation(async (path) => { if (typeof path === 'string') { if (path === epmRouteService.getInfoPath(`nginx-0.3.7`)) { + markApiCallAsHandled(); return epmPackageResponse; } if (path === epmRouteService.getFilePath('/package/nginx/0.3.7/docs/README.md')) { + markApiCallAsHandled(); return packageReadMe; } if (path === fleetSetupRouteService.getFleetSetupPath()) { + markApiCallAsHandled(); return agentsSetupResponse; } + if (path === packagePolicyRouteService.getListPath()) { + markApiCallAsHandled(); + return packagePoliciesResponse; + } + + if (path === agentPolicyRouteService.getListPath()) { + markApiCallAsHandled(); + return agentPoliciesResponse; + } + const err = new Error(`API [GET ${path}] is not MOCKED!`); // eslint-disable-next-line no-console - console.log(err); + console.error(err); throw err; } }); + + return { + waitForApi() { + return new Promise((resolve) => { + if (inflightApiCalls > 0) { + apiDoneListeners.push(resolve); + } else { + resolve(); + } + }); + }, + }; }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx index 467276f0d0b8c..c70a11db004a6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/index.tsx @@ -43,6 +43,7 @@ import { Content } from './content'; import './index.scss'; import { useUIExtension } from '../../../../hooks/use_ui_extension'; import { PLUGIN_ID } from '../../../../../../../common/constants'; +import { pkgKeyFromPackageInfo } from '../../../../services/pkg_key_from_package_info'; export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; @@ -315,7 +316,7 @@ export function Detail() { isSelected: panelId === panel, 'data-test-subj': `tab-${panelId}`, href: getHref('integration_details', { - pkgkey: `${packageInfo?.name}-${packageInfo?.version}`, + pkgkey: pkgKeyFromPackageInfo(packageInfo || {}), panel: panelId, }), }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx index 8609b08c9a774..4061b86f1f740 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/package_policies_panel.tsx @@ -36,8 +36,8 @@ const IntegrationDetailsLink = memo<{ return ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx new file mode 100644 index 0000000000000..fcd4821996efe --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/policy/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { EditPackagePolicyForm } from '../../../agent_policy/edit_package_policy_page'; + +export const Policy = memo(() => { + const { + params: { packagePolicyId }, + } = useRouteMatch<{ packagePolicyId: string }>(); + + return ; +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts b/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.ts new file mode 100644 index 0000000000000..0e38abe6f5160 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/services/pkg_key_from_package_info.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const pkgKeyFromPackageInfo = ( + packageInfo: T +): string => { + return `${packageInfo.name}-${packageInfo.version}`; +}; From 3b9c2e4e9cf9470dd1c0163e97223646eeed153a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Dec 2020 18:08:49 -0500 Subject: [PATCH 05/11] Deprecate disabling the security plugin (#85159) Co-authored-by: Aleh Zasypkin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/config_deprecations.test.ts | 186 ++++++++++++++++++ .../security/server/config_deprecations.ts | 67 +++++++ x-pack/plugins/security/server/index.ts | 49 +---- 3 files changed, 255 insertions(+), 47 deletions(-) create mode 100644 x-pack/plugins/security/server/config_deprecations.test.ts create mode 100644 x-pack/plugins/security/server/config_deprecations.ts diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts new file mode 100644 index 0000000000000..28cd4166b2683 --- /dev/null +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { configDeprecationFactory, applyDeprecations } from '@kbn/config'; +import { securityConfigDeprecationProvider } from './config_deprecations'; +import { cloneDeep } from 'lodash'; + +const applyConfigDeprecations = (settings: Record = {}) => { + const deprecations = securityConfigDeprecationProvider(configDeprecationFactory); + const deprecationMessages: string[] = []; + const migrated = applyDeprecations( + settings, + deprecations.map((deprecation) => ({ + deprecation, + path: 'xpack.security', + })), + (msg) => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; +}; + +describe('Config Deprecations', () => { + it('does not report deprecations for default configuration', () => { + const defaultConfig = { xpack: { security: {} } }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(defaultConfig)); + expect(migrated).toEqual(defaultConfig); + expect(messages).toHaveLength(0); + }); + + it('renames sessionTimeout to session.idleTimeout', () => { + const config = { + xpack: { + security: { + sessionTimeout: 123, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.sessionTimeout).not.toBeDefined(); + expect(migrated.xpack.security.session.idleTimeout).toEqual(123); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.sessionTimeout\\" is deprecated and has been replaced by \\"xpack.security.session.idleTimeout\\"", + ] + `); + }); + + it(`warns that 'authorization.legacyFallback.enabled' is unused`, () => { + const config = { + xpack: { + security: { + authorization: { + legacyFallback: { + enabled: true, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "xpack.security.authorization.legacyFallback.enabled is deprecated and is no longer used", + ] + `); + }); + + it(`warns that 'authc.saml.maxRedirectURLSize is unused`, () => { + const config = { + xpack: { + security: { + authc: { + saml: { + maxRedirectURLSize: 123, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "xpack.security.authc.saml.maxRedirectURLSize is deprecated and is no longer used", + ] + `); + }); + + it(`warns that 'xpack.security.authc.providers.saml..maxRedirectURLSize' is unused`, () => { + const config = { + xpack: { + security: { + authc: { + providers: { + saml: { + saml1: { + maxRedirectURLSize: 123, + }, + }, + }, + }, + }, + }, + }; + const { messages } = applyConfigDeprecations(cloneDeep(config)); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\`xpack.security.authc.providers.saml..maxRedirectURLSize\` is deprecated and is no longer used", + ] + `); + }); + + it(`warns when 'xpack.security.authc.providers' is an array of strings`, () => { + const config = { + xpack: { + security: { + authc: { + providers: ['basic', 'saml'], + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.", + ] + `); + }); + + it(`warns when both the basic and token providers are enabled`, () => { + const config = { + xpack: { + security: { + authc: { + providers: ['basic', 'token'], + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Defining \`xpack.security.authc.providers\` as an array of provider types is deprecated. Use extended \`object\` format instead.", + "Enabling both \`basic\` and \`token\` authentication providers in \`xpack.security.authc.providers\` is deprecated. Login page will only use \`token\` provider.", + ] + `); + }); + + it('warns when the security plugin is disabled', () => { + const config = { + xpack: { + security: { + enabled: false, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Disabling the security plugin (\`xpack.security.enabled\`) will not be supported in the next major version (8.0). To turn off security features, disable them in Elasticsearch instead.", + ] + `); + }); + + it('does not warn when the security plugin is enabled', () => { + const config = { + xpack: { + security: { + enabled: true, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated).toEqual(config); + expect(messages).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts new file mode 100644 index 0000000000000..3c5d82b93acba --- /dev/null +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { ConfigDeprecationProvider } from 'src/core/server'; + +export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ + rename, + unused, +}) => [ + rename('sessionTimeout', 'session.idleTimeout'), + unused('authorization.legacyFallback.enabled'), + unused('authc.saml.maxRedirectURLSize'), + // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. + (settings, fromPath, log) => { + if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { + log( + 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + const hasProviderType = (providerType: string) => { + const providers = settings?.xpack?.security?.authc?.providers; + if (Array.isArray(providers)) { + return providers.includes(providerType); + } + + return Object.values(providers?.[providerType] || {}).some( + (provider) => (provider as { enabled: boolean | undefined })?.enabled !== false + ); + }; + + if (hasProviderType('basic') && hasProviderType('token')) { + log( + 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' + ); + } + return settings; + }, + (settings, fromPath, log) => { + const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< + string, + any + >; + if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { + log( + '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + if (settings?.xpack?.security?.enabled === false) { + log( + 'Disabling the security plugin (`xpack.security.enabled`) will not be supported in the next major version (8.0). ' + + 'To turn off security features, disable them in Elasticsearch instead.' + ); + } + return settings; + }, +]; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 85f49bf3f931a..5d51b88d82939 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -12,6 +12,7 @@ import type { PluginInitializerContext, } from '../../../../src/core/server'; import { ConfigSchema } from './config'; +import { securityConfigDeprecationProvider } from './config_deprecations'; import { Plugin, SecurityPluginSetup, @@ -40,53 +41,7 @@ export type { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema, - deprecations: ({ rename, unused }) => [ - rename('sessionTimeout', 'session.idleTimeout'), - unused('authorization.legacyFallback.enabled'), - unused('authc.saml.maxRedirectURLSize'), - // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. - (settings, fromPath, log) => { - if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { - log( - 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' - ); - } - - return settings; - }, - (settings, fromPath, log) => { - const hasProviderType = (providerType: string) => { - const providers = settings?.xpack?.security?.authc?.providers; - if (Array.isArray(providers)) { - return providers.includes(providerType); - } - - return Object.values(providers?.[providerType] || {}).some( - (provider) => (provider as { enabled: boolean | undefined })?.enabled !== false - ); - }; - - if (hasProviderType('basic') && hasProviderType('token')) { - log( - 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' - ); - } - return settings; - }, - (settings, fromPath, log) => { - const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< - string, - any - >; - if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { - log( - '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' - ); - } - - return settings; - }, - ], + deprecations: securityConfigDeprecationProvider, exposeToBrowser: { loginAssistanceMessage: true, }, From 6dfdbe2e836eb3728f16d51b6bcace55700061d6 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 9 Dec 2020 18:11:53 -0500 Subject: [PATCH 06/11] Introduce external url service (#81234) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...lugin-core-public.httpsetup.externalurl.md | 11 + .../kibana-plugin-core-public.httpsetup.md | 1 + .../kibana-plugin-core-public.iexternalurl.md | 20 + ...in-core-public.iexternalurl.validateurl.md | 26 + ...in-core-public.iexternalurlpolicy.allow.md | 13 + ...gin-core-public.iexternalurlpolicy.host.md | 24 + ...a-plugin-core-public.iexternalurlpolicy.md | 22 + ...core-public.iexternalurlpolicy.protocol.md | 24 + .../core/public/kibana-plugin-core-public.md | 2 + ...a-plugin-core-server.iexternalurlconfig.md | 20 + ...n-core-server.iexternalurlconfig.policy.md | 13 + ...in-core-server.iexternalurlpolicy.allow.md | 13 + ...gin-core-server.iexternalurlpolicy.host.md | 24 + ...a-plugin-core-server.iexternalurlpolicy.md | 22 + ...core-server.iexternalurlpolicy.protocol.md | 24 + .../core/server/kibana-plugin-core-server.md | 2 + .../public/http/external_url_service.test.ts | 494 ++++++++++++++++++ src/core/public/http/external_url_service.ts | 111 ++++ src/core/public/http/http_service.mock.ts | 3 + src/core/public/http/http_service.ts | 2 + src/core/public/http/types.ts | 19 + src/core/public/index.ts | 3 +- .../injected_metadata_service.mock.ts | 2 + .../injected_metadata_service.ts | 11 + src/core/public/public.api.md | 14 + src/core/server/external_url/config.test.ts | 160 ++++++ src/core/server/external_url/config.ts | 61 +++ .../external_url/external_url_config.ts | 101 ++++ src/core/server/external_url/index.ts | 21 + .../http/cookie_session_storage.test.ts | 57 +- src/core/server/http/http_config.test.ts | 3 +- src/core/server/http/http_config.ts | 9 +- src/core/server/http/http_service.mock.ts | 2 + src/core/server/http/http_service.test.ts | 2 + src/core/server/http/http_service.ts | 10 +- src/core/server/http/http_tools.test.ts | 2 + .../lifecycle_handlers.test.ts | 51 +- src/core/server/http/test_utils.ts | 55 +- src/core/server/http/types.ts | 2 + src/core/server/index.ts | 1 + src/core/server/legacy/legacy_service.ts | 6 +- .../rendering_service.test.ts.snap | 35 ++ .../server/rendering/rendering_service.tsx | 1 + src/core/server/rendering/types.ts | 2 + src/core/server/server.api.md | 12 + src/core/server/server.ts | 2 + src/core/server/types.ts | 1 + src/core/server/utils/crypto/index.ts | 1 + src/core/server/utils/crypto/sha256.test.ts | 39 ++ src/core/server/utils/crypto/sha256.ts | 33 ++ .../dashboard_empty_screen.test.tsx.snap | 9 + .../__snapshots__/flyout.test.tsx.snap | 3 + ...telemetry_management_section.test.tsx.snap | 3 + .../api_keys/api_keys_management_app.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 6 +- .../roles/roles_management_app.test.tsx | 8 +- .../users/users_management_app.test.tsx | 6 +- 57 files changed, 1544 insertions(+), 82 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md create mode 100644 src/core/public/http/external_url_service.test.ts create mode 100644 src/core/public/http/external_url_service.ts create mode 100644 src/core/server/external_url/config.test.ts create mode 100644 src/core/server/external_url/config.ts create mode 100644 src/core/server/external_url/external_url_config.ts create mode 100644 src/core/server/external_url/index.ts create mode 100644 src/core/server/utils/crypto/sha256.test.ts create mode 100644 src/core/server/utils/crypto/sha256.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md new file mode 100644 index 0000000000000..b26c9d371e496 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.externalurl.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [HttpSetup](./kibana-plugin-core-public.httpsetup.md) > [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) + +## HttpSetup.externalUrl property + +Signature: + +```typescript +externalUrl: IExternalUrl; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md index bb43e9f588a72..b8a99cbb62353 100644 --- a/docs/development/core/public/kibana-plugin-core-public.httpsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.httpsetup.md @@ -18,6 +18,7 @@ export interface HttpSetup | [anonymousPaths](./kibana-plugin-core-public.httpsetup.anonymouspaths.md) | IAnonymousPaths | APIs for denoting certain paths for not requiring authentication | | [basePath](./kibana-plugin-core-public.httpsetup.basepath.md) | IBasePath | APIs for manipulating the basePath on URL segments. See [IBasePath](./kibana-plugin-core-public.ibasepath.md) | | [delete](./kibana-plugin-core-public.httpsetup.delete.md) | HttpHandler | Makes an HTTP request with the DELETE method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | +| [externalUrl](./kibana-plugin-core-public.httpsetup.externalurl.md) | IExternalUrl | | | [fetch](./kibana-plugin-core-public.httpsetup.fetch.md) | HttpHandler | Makes an HTTP request. Defaults to a GET request unless overriden. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [get](./kibana-plugin-core-public.httpsetup.get.md) | HttpHandler | Makes an HTTP request with the GET method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | | [head](./kibana-plugin-core-public.httpsetup.head.md) | HttpHandler | Makes an HTTP request with the HEAD method. See [HttpHandler](./kibana-plugin-core-public.httphandler.md) for options. | diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md new file mode 100644 index 0000000000000..5a598281c7be7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) + +## IExternalUrl interface + +APIs for working with external URLs. + +Signature: + +```typescript +export interface IExternalUrl +``` + +## Methods + +| Method | Description | +| --- | --- | +| [validateUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.validateurl.md) | Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.If the URL is valid, then a URL will be returned. Otherwise, this will return null. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md new file mode 100644 index 0000000000000..466d7cfebf547 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.validateurl.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) > [validateUrl](./kibana-plugin-core-public.iexternalurl.validateurl.md) + +## IExternalUrl.validateUrl() method + +Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml. + +If the URL is valid, then a URL will be returned. Otherwise, this will return null. + +Signature: + +```typescript +validateUrl(relativeOrAbsoluteUrl: string): URL | null; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| relativeOrAbsoluteUrl | string | | + +Returns: + +`URL | null` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..ec7129a43b99a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates if this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..5551d52cc1226 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md new file mode 100644 index 0000000000000..a87dc69d79e23 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-public.iexternalurlpolicy.allow.md) | boolean | Indicates if this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-public.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..67b9b439a54f6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-public.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 5f656b9ca510d..a3df5d30137df 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -73,6 +73,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IAnonymousPaths](./kibana-plugin-core-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | | [IBasePath](./kibana-plugin-core-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | | [IContextContainer](./kibana-plugin-core-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) | APIs for working with external URLs. | +| [IExternalUrlPolicy](./kibana-plugin-core-public.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IHttpFetchError](./kibana-plugin-core-public.ihttpfetcherror.md) | | | [IHttpInterceptController](./kibana-plugin-core-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-core-public.httpinterceptor.md). | | [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md new file mode 100644 index 0000000000000..8df4db4aa9b5e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) + +## IExternalUrlConfig interface + +External Url configuration for use in Kibana. + +Signature: + +```typescript +export interface IExternalUrlConfig +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) | IExternalUrlPolicy[] | A set of policies describing which external urls are allowed. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md new file mode 100644 index 0000000000000..b5b6f07038076 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlconfig.policy.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) > [policy](./kibana-plugin-core-server.iexternalurlconfig.policy.md) + +## IExternalUrlConfig.policy property + +A set of policies describing which external urls are allowed. + +Signature: + +```typescript +readonly policy: IExternalUrlPolicy[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md new file mode 100644 index 0000000000000..e0c140409dcf0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.allow.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) + +## IExternalUrlPolicy.allow property + +Indicates of this policy allows or denies access to the described destination. + +Signature: + +```typescript +allow: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md new file mode 100644 index 0000000000000..e65de074f1578 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.host.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) + +## IExternalUrlPolicy.host property + +Optional host describing the external destination. May be combined with `protocol`. Required if `protocol` is not defined. + +Signature: + +```typescript +host?: string; +``` + +## Example + + +```ts +// allows access to all of google.com, using any protocol. +allow: true, +host: 'google.com' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md new file mode 100644 index 0000000000000..8e3658a10ed81 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) + +## IExternalUrlPolicy interface + +A policy describing whether access to an external destination is allowed. + +Signature: + +```typescript +export interface IExternalUrlPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allow](./kibana-plugin-core-server.iexternalurlpolicy.allow.md) | boolean | Indicates of this policy allows or denies access to the described destination. | +| [host](./kibana-plugin-core-server.iexternalurlpolicy.host.md) | string | Optional host describing the external destination. May be combined with protocol. Required if protocol is not defined. | +| [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) | string | Optional protocol describing the external destination. May be combined with host. Required if host is not defined. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md new file mode 100644 index 0000000000000..00c5d05eb0cc4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.iexternalurlpolicy.protocol.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) > [protocol](./kibana-plugin-core-server.iexternalurlpolicy.protocol.md) + +## IExternalUrlPolicy.protocol property + +Optional protocol describing the external destination. May be combined with `host`. Required if `host` is not defined. + +Signature: + +```typescript +protocol?: string; +``` + +## Example + + +```ts +// allows access to all destinations over the `https` protocol. +allow: true, +protocol: 'https' + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 1a4209ff87c5b..269db90c4db9b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -94,6 +94,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [IContextContainer](./kibana-plugin-core-server.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [ICspConfig](./kibana-plugin-core-server.icspconfig.md) | CSP configuration for use in Kibana. | | [ICustomClusterClient](./kibana-plugin-core-server.icustomclusterclient.md) | See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md) | +| [IExternalUrlConfig](./kibana-plugin-core-server.iexternalurlconfig.md) | External Url configuration for use in Kibana. | +| [IExternalUrlPolicy](./kibana-plugin-core-server.iexternalurlpolicy.md) | A policy describing whether access to an external destination is allowed. | | [IKibanaResponse](./kibana-plugin-core-server.ikibanaresponse.md) | A response data object, expected to returned as a result of [RequestHandler](./kibana-plugin-core-server.requesthandler.md) execution | | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | A tiny abstraction for TCP socket. | | [ImageValidation](./kibana-plugin-core-server.imagevalidation.md) | | diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts new file mode 100644 index 0000000000000..af34dba5e6216 --- /dev/null +++ b/src/core/public/http/external_url_service.test.ts @@ -0,0 +1,494 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExternalUrlConfig } from 'src/core/server/types'; + +import { injectedMetadataServiceMock } from '../mocks'; +import { Sha256 } from '../utils'; + +import { ExternalUrlService } from './external_url_service'; + +const setupService = ({ + location, + serverBasePath, + policy, +}: { + location: URL; + serverBasePath: string; + policy: ExternalUrlConfig['policy']; +}) => { + const hashedPolicies = policy.map((entry) => { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; + return { + ...entry, + host: hostToHash ? new Sha256().update(hostToHash, 'utf8').digest('hex') : undefined, + }; + }); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getExternalUrlConfig.mockReturnValue({ policy: hashedPolicies }); + injectedMetadata.getServerBasePath.mockReturnValue(serverBasePath); + + const service = new ExternalUrlService(); + return { + setup: service.setup({ + injectedMetadata, + location, + }), + }; +}; + +const internalRequestScenarios = [ + { + description: 'without any policies', + allowExternal: false, + policy: [], + }, + { + description: 'with an unrestricted policy', + allowExternal: true, + policy: [ + { + allow: true, + }, + ], + }, + { + description: 'with a fully restricted policy', + allowExternal: false, + policy: [ + { + allow: false, + }, + ], + }, +]; + +describe('External Url Service', () => { + describe('#validateUrl', () => { + describe('internal requests with a server base path', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + internalRequestScenarios.forEach(({ description, policy, allowExternal }) => { + describe(description, () => { + it('allows relative URLs that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana that start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + + if (allowExternal) { + it('allows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + + it('allows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/path?foo=bar`); + }); + + it('allows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${serverRoot}/some/path?foo=bar`); + }); + } else { + it('disallows absolute URLs to Kibana that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${serverRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that attempt to bypass the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/../../path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('disallows relative URLs that do not start with the server base path', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + } + }); + }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('does allow relative URLs that include a host if allowed by policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual( + `https://www.google.com${serverBasePath}${urlCandidate}` + ); + }); + }); + }); + + describe('internal requests without a server base path', () => { + const serverBasePath = ''; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + internalRequestScenarios.forEach(({ description, policy }) => { + describe(description, () => { + it('allows relative URLs', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`${serverBasePath}${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}${urlCandidate}`); + }); + + it('allows absolute URLs to Kibana', () => { + const { setup } = setupService({ location, serverBasePath, policy }); + const urlCandidate = `${kibanaRoot}/some/path?foo=bar`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`${kibanaRoot}/some/path?foo=bar`); + }); + }); + }); + + describe('handles protocol resolution bypass', () => { + it('does not allow relative URLs that include a host', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeNull(); + }); + + it('allows relative URLs that include a host in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `/some/path?foo=bar`; + const result = setup.validateUrl(`//www.google.com${urlCandidate}`); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(`https://www.google.com${urlCandidate}`); + }); + }); + }); + + describe('external requests', () => { + const serverBasePath = '/base-path'; + const serverRoot = `https://my-kibana.example.com:5601`; + const kibanaRoot = `${serverRoot}${serverBasePath}`; + const location = new URL(`${kibanaRoot}/app/management?q=1&bar=false#some-hash`); + + it('does not allow external urls by default', () => { + const { setup } = setupService({ location, serverBasePath, policy: [] }); + const urlCandidate = `http://www.google.com`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('does not allow external urls with a fully restricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: false, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with an unrestricted policy', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a partially matching host and protocol in the allow list', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with a partially matching host and protocol in the allow list when the URL includes the root domain', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com./foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv4 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '192.168.10.12', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://192.168.10.12/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with an IPv6 address', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: '[2001:db8:85a3:8d3:1319:8a2e:370:7348]', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://[2001:db8:85a3:8d3:1319:8a2e:370:7348]/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls that specify a locally addressable host', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'some-host-name', + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://some-host-name/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('disallows external urls with a matching host and unmatched protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + protocol: 'https', + }, + ], + }); + const urlCandidate = `http://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + + it('allows external urls with a matching host and any protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `ftp://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('allows external urls with any host and matching protocol', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeInstanceOf(URL); + expect(result?.toString()).toEqual(urlCandidate); + }); + + it('disallows external urls that match multiple rules, one of which denies the request', () => { + const { setup } = setupService({ + location, + serverBasePath, + policy: [ + { + allow: true, + protocol: 'https', + }, + { + allow: false, + host: 'www.google.com', + }, + ], + }); + const urlCandidate = `https://www.google.com/foo?bar=baz`; + const result = setup.validateUrl(urlCandidate); + + expect(result).toBeNull(); + }); + }); + }); +}); diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts new file mode 100644 index 0000000000000..e975451a7fdaa --- /dev/null +++ b/src/core/public/http/external_url_service.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IExternalUrlPolicy } from 'src/core/server/types'; + +import { CoreService } from 'src/core/types'; +import { IExternalUrl } from './types'; +import { InjectedMetadataSetup } from '../injected_metadata'; +import { Sha256 } from '../utils'; + +interface SetupDeps { + location: Pick; + injectedMetadata: InjectedMetadataSetup; +} + +function* getHostHashes(actualHost: string) { + yield new Sha256().update(actualHost, 'utf8').digest('hex'); + let host = actualHost.substr(actualHost.indexOf('.') + 1); + while (host) { + yield new Sha256().update(host, 'utf8').digest('hex'); + if (host.indexOf('.') === -1) { + break; + } + host = host.substr(host.indexOf('.') + 1); + } +} + +const isHostMatch = (actualHost: string, ruleHostHash: string) => { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + !actualHost.includes('[') && !actualHost.endsWith('.') ? `${actualHost}.` : actualHost; + for (const hash of getHostHashes(hostToHash)) { + if (hash === ruleHostHash) { + return true; + } + } + return false; +}; + +const isProtocolMatch = (actualProtocol: string, ruleProtocol: string) => { + return normalizeProtocol(actualProtocol) === normalizeProtocol(ruleProtocol); +}; + +function normalizeProtocol(protocol: string) { + return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase(); +} + +const createExternalUrlValidation = ( + rules: IExternalUrlPolicy[], + location: Pick, + serverBasePath: string +) => { + const base = new URL(location.origin + serverBasePath); + return function validateExternalUrl(next: string) { + const url = new URL(next, base); + + const isInternalURL = + url.origin === base.origin && + (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`)); + + if (isInternalURL) { + return url; + } + + let allowed: null | boolean = null; + rules.forEach((rule) => { + const hostMatch = rule.host ? isHostMatch(url.hostname || '', rule.host) : true; + + const protocolMatch = rule.protocol ? isProtocolMatch(url.protocol, rule.protocol) : true; + + const isRuleMatch = hostMatch && protocolMatch; + + if (isRuleMatch && allowed !== false) { + allowed = rule.allow; + } + }); + + return allowed === true ? url : null; + }; +}; + +export class ExternalUrlService implements CoreService { + setup({ injectedMetadata, location }: SetupDeps): IExternalUrl { + const serverBasePath = injectedMetadata.getServerBasePath(); + const { policy } = injectedMetadata.getExternalUrlConfig(); + + return { + validateUrl: createExternalUrlValidation(policy, location, serverBasePath), + }; + } + + start() {} + + stop() {} +} diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 68533159765fb..025336487c855 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -41,6 +41,9 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ register: jest.fn(), isAnonymous: jest.fn(), }, + externalUrl: { + validateUrl: jest.fn(), + }, addLoadingCountSource: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), intercept: jest.fn(), diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 2eaaefe285755..a65eb5f76e1ac 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -25,6 +25,7 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { ExternalUrlService } from './external_url_service'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -51,6 +52,7 @@ export class HttpService implements CoreService { this.service = { basePath, anonymousPaths: this.anonymousPaths.setup({ basePath }), + externalUrl: new ExternalUrlService().setup({ injectedMetadata, location: window.location }), intercept: fetchService.intercept.bind(fetchService), fetch: fetchService.fetch.bind(fetchService), delete: fetchService.delete.bind(fetchService), diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 7285d1a4288dc..5910aa0fc3238 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -33,6 +33,8 @@ export interface HttpSetup { */ anonymousPaths: IAnonymousPaths; + externalUrl: IExternalUrl; + /** * Adds a new {@link HttpInterceptor} to the global HTTP client. * @param interceptor a {@link HttpInterceptor} @@ -112,6 +114,23 @@ export interface IBasePath { */ readonly publicBaseUrl?: string; } +/** + * APIs for working with external URLs. + * + * @public + */ +export interface IExternalUrl { + /** + * Determines if the provided URL is a valid location to send users. + * Validation is based on the configured allow list in kibana.yml. + * + * If the URL is valid, then a URL will be returned. + * Otherwise, this will return null. + * + * @param relativeOrAbsoluteUrl + */ + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} /** * APIs for denoting paths as not requiring authentication diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 557529fc94dc4..8e240bfe91d48 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,7 +77,7 @@ import { HandlerParameters, } from './context'; -export { PackageInfo, EnvironmentMode } from '../server/types'; +export { PackageInfo, EnvironmentMode, IExternalUrlPolicy } from '../server/types'; export { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export { @@ -164,6 +164,7 @@ export { HttpHandler, IBasePath, IAnonymousPaths, + IExternalUrl, IHttpInterceptController, IHttpFetchError, IHttpResponseInterceptorOverrides, diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 96282caa62c0a..ec05edcbbf25c 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -27,6 +27,7 @@ const createSetupContractMock = () => { getKibanaVersion: jest.fn(), getKibanaBranch: jest.fn(), getCspConfig: jest.fn(), + getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), getLegacyMetadata: jest.fn(), getPlugins: jest.fn(), @@ -35,6 +36,7 @@ const createSetupContractMock = () => { getKibanaBuildNumber: jest.fn(), }; setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); + setupContract.getExternalUrlConfig.mockReturnValue({ policy: [] }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); setupContract.getAnonymousStatusPage.mockReturnValue(false); setupContract.getLegacyMetadata.mockReturnValue({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 283710980e3ce..51025e24140da 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -22,6 +22,7 @@ import { deepFreeze } from '@kbn/std'; import { DiscoveredPlugin, PluginName } from '../../server'; import { EnvironmentMode, + IExternalUrlPolicy, PackageInfo, UiSettingsParams, UserProvidedValues, @@ -49,6 +50,9 @@ export interface InjectedMetadataParams { csp: { warnLegacyBrowsers: boolean; }; + externalUrl: { + policy: IExternalUrlPolicy[]; + }; vars: { [key: string]: unknown; }; @@ -112,6 +116,10 @@ export class InjectedMetadataService { return this.state.csp; }, + getExternalUrlConfig: () => { + return this.state.externalUrl; + }, + getPlugins: () => { return this.state.uiPlugins; }, @@ -154,6 +162,9 @@ export interface InjectedMetadataSetup { getCspConfig: () => { warnLegacyBrowsers: boolean; }; + getExternalUrlConfig: () => { + policy: IExternalUrlPolicy[]; + }; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 0b1d3f8263a23..65912e0954261 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -727,6 +727,8 @@ export interface HttpSetup { anonymousPaths: IAnonymousPaths; basePath: IBasePath; delete: HttpHandler; + // (undocumented) + externalUrl: IExternalUrl; fetch: HttpHandler; get: HttpHandler; getLoadingCount$(): Observable; @@ -777,6 +779,18 @@ export interface IContextContainer> { // @public export type IContextProvider, TContextName extends keyof HandlerContextType> = (context: PartialExceptFor, 'core'>, ...rest: HandlerParameters) => Promise[TContextName]> | HandlerContextType[TContextName]; +// @public +export interface IExternalUrl { + validateUrl(relativeOrAbsoluteUrl: string): URL | null; +} + +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public (undocumented) export interface IHttpFetchError extends Error { // (undocumented) diff --git a/src/core/server/external_url/config.test.ts b/src/core/server/external_url/config.test.ts new file mode 100644 index 0000000000000..eeaf3751904d4 --- /dev/null +++ b/src/core/server/external_url/config.test.ts @@ -0,0 +1,160 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { config } from './config'; + +describe('externalUrl config', () => { + it('provides a default policy allowing all external urls', () => { + expect(config.schema.validate({})).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); + }); + + it('allows an empty policy', () => { + expect(config.schema.validate({ policy: [] })).toMatchInlineSnapshot(` + Object { + "policy": Array [], + } + `); + }); + + it('allows a policy with just a protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "protocol": "http", + }, + ], + } + `); + }); + + it('allows a policy with just a host', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + }, + ], + } + `); + }); + + it('allows a policy with both host and protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + protocol: 'http', + host: 'www.google.com', + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + "host": "www.google.com", + "protocol": "http", + }, + ], + } + `); + }); + + it('allows a policy without a host or protocol', () => { + expect( + config.schema.validate({ + policy: [ + { + allow: true, + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + } + `); + }); + + describe('protocols', () => { + ['http', 'https', 'ftp', 'ftps', 'custom-protocol+123.bar'].forEach((protocol) => { + it(`allows a protocol of "${protocol}"`, () => { + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }); + }); + }); + + ['1http', '', 'custom-protocol()', 'https://'].forEach((protocol) => { + it(`disallows a protocol of "${protocol}"`, () => { + expect(() => + config.schema.validate({ + policy: [ + { + allow: true, + protocol, + }, + ], + }) + ).toThrowError(); + }); + }); + }); +}); diff --git a/src/core/server/external_url/config.ts b/src/core/server/external_url/config.ts new file mode 100644 index 0000000000000..4a26365a0c93d --- /dev/null +++ b/src/core/server/external_url/config.ts @@ -0,0 +1,61 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; +import { IExternalUrlPolicy } from '.'; + +/** + * @internal + */ +export type ExternalUrlConfigType = TypeOf; + +const allowSchema = schema.boolean(); + +const hostSchema = schema.string(); + +const protocolSchema = schema.string({ + validate: (value) => { + // tools.ietf.org/html/rfc3986#section-3.1 + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + const schemaRegex = /^[a-zA-Z][a-zA-Z0-9\+\-\.]*$/; + if (!schemaRegex.test(value)) + throw new Error( + 'Protocol must begin with a letter, and can only contain letters, numbers, and the following characters: `+ - .`' + ); + }, +}); + +const policySchema = schema.object({ + allow: allowSchema, + protocol: schema.maybe(protocolSchema), + host: schema.maybe(hostSchema), +}); + +export const config = { + path: 'externalUrl', + schema: schema.object({ + policy: schema.arrayOf(policySchema, { + defaultValue: [ + { + allow: true, + }, + ], + }), + }), +}; diff --git a/src/core/server/external_url/external_url_config.ts b/src/core/server/external_url/external_url_config.ts new file mode 100644 index 0000000000000..065a9cd1d2609 --- /dev/null +++ b/src/core/server/external_url/external_url_config.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from '../utils'; +import { config } from './config'; + +const DEFAULT_CONFIG = Object.freeze(config.schema.validate({})); + +/** + * External Url configuration for use in Kibana. + * @public + */ +export interface IExternalUrlConfig { + /** + * A set of policies describing which external urls are allowed. + */ + readonly policy: IExternalUrlPolicy[]; +} + +/** + * A policy describing whether access to an external destination is allowed. + * @public + */ +export interface IExternalUrlPolicy { + /** + * Indicates if this policy allows or denies access to the described destination. + */ + allow: boolean; + + /** + * Optional host describing the external destination. + * May be combined with `protocol`. + * + * @example + * ```ts + * // allows access to all of google.com, using any protocol. + * allow: true, + * host: 'google.com' + * ``` + */ + host?: string; + + /** + * Optional protocol describing the external destination. + * May be combined with `host`. + * + * @example + * ```ts + * // allows access to all destinations over the `https` protocol. + * allow: true, + * protocol: 'https' + * ``` + */ + protocol?: string; +} + +/** + * External Url configuration for use in Kibana. + * @public + */ +export class ExternalUrlConfig implements IExternalUrlConfig { + static readonly DEFAULT = new ExternalUrlConfig(DEFAULT_CONFIG); + + public readonly policy: IExternalUrlPolicy[]; + /** + * Returns the default External Url configuration when passed with no config + * @internal + */ + constructor(rawConfig: IExternalUrlConfig) { + this.policy = rawConfig.policy.map((entry) => { + if (entry.host) { + // If the host contains a `[`, then it's likely an IPv6 address. Otherwise, append a `.` if it doesn't already contain one + const hostToHash = + entry.host && !entry.host.includes('[') && !entry.host.endsWith('.') + ? `${entry.host}.` + : entry.host; + return { + ...entry, + host: createSHA256Hash(hostToHash), + }; + } + return entry; + }); + } +} diff --git a/src/core/server/external_url/index.ts b/src/core/server/external_url/index.ts new file mode 100644 index 0000000000000..dfc8e753fa644 --- /dev/null +++ b/src/core/server/external_url/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ExternalUrlConfig, IExternalUrlConfig, IExternalUrlPolicy } from './external_url_config'; +export { ExternalUrlConfigType, config } from './config'; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 7ac7e4b9712d0..0e7b55b7d35ab 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -46,29 +46,40 @@ const setupDeps = { context: contextSetup, }; -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['http://1.2.3.4'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - healthCheck: { - delay: 2000, - }, - ssl: { - verificationMode: 'none', - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['http://1.2.3.4'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + healthCheck: { + delay: 2000, + }, + ssl: { + verificationMode: 'none', + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); beforeEach(() => { logger = loggingSystemMock.create(); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 6538c1ae973b7..c82e7c3796e4b 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -20,6 +20,7 @@ import uuid from 'uuid'; import { config, HttpConfig } from './http_config'; import { CspConfig } from '../csp'; +import { ExternalUrlConfig } from '../external_url'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; @@ -344,7 +345,7 @@ describe('HttpConfig', () => { }, }, }); - const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT); + const httpConfig = new HttpConfig(rawConfig, CspConfig.DEFAULT, ExternalUrlConfig.DEFAULT); expect(httpConfig.customResponseHeaders).toEqual({ string: 'string', diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 9a425fa645503..d26f077723ce3 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -22,6 +22,7 @@ import { hostname } from 'os'; import url from 'url'; import { CspConfigType, CspConfig, ICspConfig } from '../csp'; +import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url'; import { SslConfig, sslSchema } from './ssl_config'; const validBasePathRegex = /^\/.*[^\/]$/; @@ -156,13 +157,18 @@ export class HttpConfig { public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; + public externalUrl: IExternalUrlConfig; public xsrf: { disableProtection: boolean; allowlist: string[] }; public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] }; /** * @internal */ - constructor(rawHttpConfig: HttpConfigType, rawCspConfig: CspConfigType) { + constructor( + rawHttpConfig: HttpConfigType, + rawCspConfig: CspConfigType, + rawExternalUrlConfig: ExternalUrlConfig + ) { this.autoListen = rawHttpConfig.autoListen; this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; @@ -186,6 +192,7 @@ export class HttpConfig { this.ssl = new SslConfig(rawHttpConfig.ssl || {}); this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); + this.externalUrl = rawExternalUrlConfig; this.xsrf = rawHttpConfig.xsrf; this.requestId = rawHttpConfig.requestId; } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 552f41d912417..d19bee27dd4cf 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -37,6 +37,7 @@ import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { configMock } from '../config/mocks'; +import { ExternalUrlConfig } from '../external_url'; type BasePathMocked = jest.Mocked; type AuthMocked = jest.Mocked; @@ -105,6 +106,7 @@ const createInternalSetupContractMock = () => { registerStaticDir: jest.fn(), basePath: createBasePathMock(), csp: CspConfig.DEFAULT, + externalUrl: ExternalUrlConfig.DEFAULT, auth: createAuthMock(), getAuthHeaders: jest.fn(), getServerInfo: jest.fn(), diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 3d55322461288..9075cb293667a 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -30,6 +30,7 @@ import { ConfigService, Env } from '../config'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { contextServiceMock } from '../context/context_service.mock'; import { config as cspConfig } from '../csp'; +import { config as externalUrlConfig } from '../external_url'; const logger = loggingSystemMock.create(); const env = Env.createDefault(REPO_ROOT, getEnvOptions()); @@ -48,6 +49,7 @@ const createConfigService = (value: Partial = {}) => { ); configService.setSchema(config.path, config.schema); configService.setSchema(cspConfig.path, cspConfig.schema); + configService.setSchema(externalUrlConfig.path, externalUrlConfig.schema); return configService; }; const contextSetup = contextServiceMock.createSetupContract(); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 171a20160d26d..ae2e82d8b2241 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -44,6 +44,11 @@ import { import { RequestHandlerContext } from '../../server'; import { registerCoreHandlers } from './lifecycle_handlers'; +import { + ExternalUrlConfigType, + config as externalUrlConfig, + ExternalUrlConfig, +} from '../external_url'; interface SetupDeps { context: ContextSetup; @@ -73,7 +78,8 @@ export class HttpService this.config$ = combineLatest([ configService.atPath(httpConfig.path), configService.atPath(cspConfig.path), - ]).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(externalUrlConfig.path), + ]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); this.httpServer = new HttpServer(logger, 'Kibana'); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -103,6 +109,8 @@ export class HttpService this.internalSetup = { ...serverContract, + externalUrl: new ExternalUrlConfig(config.externalUrl), + createRouter: (path: string, pluginId: PluginOpaqueId = this.coreContext.coreId) => { const enhanceHandler = this.requestHandlerContext!.createHandler.bind(null, pluginId); const router = new Router(path, this.log, enhanceHandler); diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 1423e27b914a3..a409a7485a0ef 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -136,6 +136,7 @@ describe('getServerOptions', () => { certificate: 'some-certificate-path', }, }), + {} as any, {} as any ); @@ -165,6 +166,7 @@ describe('getServerOptions', () => { clientAuthentication: 'required', }, }), + {} as any, {} as any ); diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index 7df35b04c66cf..ba7f55caeba22 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -50,26 +50,37 @@ describe('core lifecycle handlers', () => { beforeEach(async () => { const configService = configServiceMock.create(); - configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - name: kibanaName, - customResponseHeaders: { - 'some-header': 'some-value', - }, - xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - } as any) - ); + configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + name: kibanaName, + customResponseHeaders: { + 'some-header': 'some-value', + }, + xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] }, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); + }); server = createHttpServer({ configService }); const serverSetup = await server.setup(setupDeps); diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index cdcbe513e1224..0a5cee5505ef1 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -32,28 +32,39 @@ const env = Env.createDefault(REPO_ROOT, getEnvOptions()); const logger = loggingSystemMock.create(); const configService = configServiceMock.create(); -configService.atPath.mockReturnValue( - new BehaviorSubject({ - hosts: ['localhost'], - maxPayload: new ByteSizeValue(1024), - autoListen: true, - ssl: { - enabled: false, - }, - compression: { enabled: true }, - xsrf: { - disableProtection: true, - allowlist: [], - }, - customResponseHeaders: {}, - requestId: { - allowFromAnyIp: true, - ipAllowlist: [], - }, - keepaliveTimeout: 120_000, - socketTimeout: 120_000, - } as any) -); +configService.atPath.mockImplementation((path) => { + if (path === 'server') { + return new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + xsrf: { + disableProtection: true, + allowlist: [], + }, + customResponseHeaders: {}, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + keepaliveTimeout: 120_000, + socketTimeout: 120_000, + } as any); + } + if (path === 'externalUrl') { + return new BehaviorSubject({ + policy: [], + } as any); + } + if (path === 'csp') { + return new BehaviorSubject({} as any); + } + throw new Error(`Unexpected config path: ${path}`); +}); const defaultContext: CoreContext = { coreId, diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index afd7b0174d158..558fa20e0fd6b 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -30,6 +30,7 @@ import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IBasePath } from './base_path_service'; +import { ExternalUrlConfig } from '../external_url'; import { PluginOpaqueId, RequestHandlerContext } from '..'; /** @@ -280,6 +281,7 @@ export interface InternalHttpServiceSetup extends Omit { auth: HttpServerSetup['auth']; server: HttpServerSetup['server']; + externalUrl: ExternalUrlConfig; createRouter: (path: string, plugin?: PluginOpaqueId) => IRouter; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 6abe067f24c8c..0f2761b67437d 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -136,6 +136,7 @@ export { DeleteDocumentResponse, } from './elasticsearch'; export * from './elasticsearch/legacy/api_types'; +export { IExternalUrlConfig, IExternalUrlPolicy } from './external_url'; export { AuthenticationHandler, AuthHeaders, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 6da5d54869801..669286ccb2318 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -32,6 +32,7 @@ import { DevConfig, DevConfigType, config as devConfig } from '../dev'; import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } from '../http'; import { Logger } from '../logging'; import { LegacyServiceSetupDeps, LegacyServiceStartDeps, LegacyConfig, LegacyVars } from './types'; +import { ExternalUrlConfigType, config as externalUrlConfig } from '../external_url'; import { CoreSetup, CoreStart } from '..'; interface LegacyKbnServer { @@ -84,8 +85,9 @@ export class LegacyService implements CoreService { .pipe(map((rawConfig) => new DevConfig(rawConfig))); this.httpConfig$ = combineLatest( configService.atPath(httpConfig.path), - configService.atPath(cspConfig.path) - ).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath(cspConfig.path), + configService.atPath(externalUrlConfig.path) + ).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl))); } public async setupLegacyConfig() { diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index a3f6b27f135be..f6b39ea24262b 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -23,6 +23,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -67,6 +74,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -111,6 +125,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, @@ -159,6 +180,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/translations/en.json", }, @@ -203,6 +231,13 @@ Object { "version": Any, }, }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, "i18n": Object { "translationsUrl": "/mock-server-basepath/translations/en.json", }, diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 4bbb2bd4811cb..b7c57f1c31e40 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -79,6 +79,7 @@ export class RenderingService { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, }, csp: { warnLegacyBrowsers: http.csp.warnLegacyBrowsers }, + externalUrl: http.externalUrl, vars: vars ?? {}, uiPlugins: await Promise.all( [...uiPlugins.public].map(async ([id, plugin]) => ({ diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 1954fc1c79e55..1b73b2be46835 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -25,6 +25,7 @@ import { InternalHttpServiceSetup, KibanaRequest, LegacyRequest } from '../http' import { UiPlugins, DiscoveredPlugin } from '../plugins'; import { IUiSettingsClient, UserProvidedValues } from '../ui_settings'; import type { InternalStatusServiceSetup } from '../status'; +import { IExternalUrlPolicy } from '../external_url'; /** @internal */ export interface RenderingMetadata { @@ -50,6 +51,7 @@ export interface RenderingMetadata { translationsUrl: string; }; csp: Pick; + externalUrl: { policy: IExternalUrlPolicy[] }; vars: Record; uiPlugins: Array<{ id: string; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b65ba329cec1e..81b794092e075 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -934,6 +934,18 @@ export interface ICustomClusterClient extends IClusterClient { close: () => Promise; } +// @public +export interface IExternalUrlConfig { + readonly policy: IExternalUrlPolicy[]; +} + +// @public +export interface IExternalUrlPolicy { + allow: boolean; + host?: string; + protocol?: string; +} + // @public export interface IKibanaResponse { // (undocumented) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 0b3249ad58750..75530e557de04 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -53,6 +53,7 @@ import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { config as externalUrlConfig } from './external_url'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -314,6 +315,7 @@ export class Server { pathConfig, cspConfig, elasticsearchConfig, + externalUrlConfig, loggingConfig, httpConfig, pluginsConfig, diff --git a/src/core/server/types.ts b/src/core/server/types.ts index f8d2f635671fa..48b3a9058605c 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,3 +23,4 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export type { EnvironmentMode, PackageInfo } from '@kbn/config'; +export type { ExternalUrlConfig, IExternalUrlPolicy } from './external_url'; diff --git a/src/core/server/utils/crypto/index.ts b/src/core/server/utils/crypto/index.ts index 9a36682cc4ecb..aa9728e0462d6 100644 --- a/src/core/server/utils/crypto/index.ts +++ b/src/core/server/utils/crypto/index.ts @@ -18,3 +18,4 @@ */ export { Pkcs12ReadResult, readPkcs12Keystore, readPkcs12Truststore } from './pkcs12'; +export { createSHA256Hash } from './sha256'; diff --git a/src/core/server/utils/crypto/sha256.test.ts b/src/core/server/utils/crypto/sha256.test.ts new file mode 100644 index 0000000000000..ddb8ffee36da6 --- /dev/null +++ b/src/core/server/utils/crypto/sha256.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createSHA256Hash } from './sha256'; + +describe('createSHA256Hash', () => { + it('creates a hex-encoded hash by default', () => { + expect(createSHA256Hash('foo')).toMatchInlineSnapshot( + `"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"` + ); + }); + + it('allows the output encoding to be changed', () => { + expect(createSHA256Hash('foo', 'base64')).toMatchInlineSnapshot( + `"LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564="` + ); + }); + + it('accepts a buffer as input', () => { + const data = Buffer.from('foo', 'utf8'); + expect(createSHA256Hash(data)).toEqual(createSHA256Hash('foo')); + }); +}); diff --git a/src/core/server/utils/crypto/sha256.ts b/src/core/server/utils/crypto/sha256.ts new file mode 100644 index 0000000000000..de9eee2efad5a --- /dev/null +++ b/src/core/server/utils/crypto/sha256.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import crypto, { HexBase64Latin1Encoding } from 'crypto'; + +export const createSHA256Hash = ( + input: string | Buffer, + outputEncoding: HexBase64Latin1Encoding = 'hex' +) => { + let data: Buffer; + if (typeof input === 'string') { + data = Buffer.from(input, 'utf8'); + } else { + data = input; + } + return crypto.createHash('sha256').update(data).digest(outputEncoding); +}; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index b64b485f65615..68d8a6a42eb5d 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -18,6 +18,9 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -382,6 +385,9 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], @@ -754,6 +760,9 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index d06fd0df98a8c..a48965cf7f41c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -180,6 +180,9 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index cad06255ffe98..896b1671328a9 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -321,6 +321,9 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "serverBasePath": "", }, "delete": [MockFunction], + "externalUrl": Object { + "validateUrl": [MockFunction], + }, "fetch": [MockFunction], "get": [MockFunction], "getLoadingCount$": [MockFunction], diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 30c5f8a361b42..ca370271b4360 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -43,7 +43,7 @@ describe('apiKeysManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: '/', text: 'API Keys' }]); expect(container).toMatchInlineSnapshot(`
- Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}}} + Page: {"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"apiKeysAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}}}
`); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index 5479bc36d1ed5..4ce49501a3ed1 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -54,7 +54,7 @@ describe('roleMappingsManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Role Mappings' }]); expect(container).toMatchInlineSnapshot(`
- Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Role Mappings Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -73,7 +73,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Mapping Edit Page: {"roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -94,7 +94,7 @@ describe('roleMappingsManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}} + Role Mapping Edit Page: {"name":"role@mapping","roleMappingsAPI":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@mapping","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 8bcf58428c08d..5e25cf8581f6e 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -71,7 +71,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }]); expect(container).toMatchInlineSnapshot(`
- Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Roles Page: {"notifications":{"toasts":{}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -87,7 +87,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + Role Edit Page: {"action":"edit","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -108,7 +108,7 @@ describe('rolesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}} + Role Edit Page: {"action":"edit","roleName":"role@name","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/role@name","search":"","hash":""}}}
`); @@ -126,7 +126,7 @@ describe('rolesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Roles' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}} + Role Edit Page: {"action":"clone","roleName":"someRoleName","rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"indicesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"privilegesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}},"notifications":{"toasts":{}},"fatalErrors":{},"license":{"features$":{"_isScalar":false}},"docLinks":{"esDocBasePath":"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/"},"uiCapabilities":{"catalogue":{},"management":{},"navLinks":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/clone/someRoleName","search":"","hash":""}}}
`); diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index c9e448d90d925..f0a594469bd16 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -60,7 +60,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }]); expect(container).toMatchInlineSnapshot(`
- Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}} + Users Page: {"notifications":{"toasts":{}},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/","search":"","hash":""}}}
`); @@ -76,7 +76,7 @@ describe('usersManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `/`, text: 'Users' }, { text: 'Create' }]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/edit","search":"","hash":""}}}
`); @@ -97,7 +97,7 @@ describe('usersManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}}},"notifications":{"toasts":{}},"username":"foo@bar.com","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/foo@bar.com","search":"","hash":""}}} + User Edit Page: {"authc":{},"userAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"rolesAPIClient":{"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{},"externalUrl":{}}},"notifications":{"toasts":{}},"username":"foo@bar.com","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/foo@bar.com","search":"","hash":""}}}
`); From 21d85b2cc0f76bf0dbc6e9709a27051f67df5d7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Dec 2020 18:21:06 -0600 Subject: [PATCH 07/11] Update dependency @elastic/charts to v24.4.0 (#85452) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3687e6d590ce7..1295217b4bcbe 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "@cypress/webpack-preprocessor": "^5.4.11", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.3.0", + "@elastic/charts": "24.4.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", diff --git a/yarn.lock b/yarn.lock index 34abc2cd39593..789ff171ef9c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1381,10 +1381,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@24.3.0": - version "24.3.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.3.0.tgz#5bb62143c2f941becbbbf91aafde849034b6330f" - integrity sha512-CmyekVOdy242m9pYf2yBNA6d54b8cohmNeoWghtNkM2wHT8Ut856zPV7mRhAMgNG61I7/pNCEnCD0OOpZPr4Xw== +"@elastic/charts@24.4.0": + version "24.4.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-24.4.0.tgz#217f55540f48a8f59c49250781d99c36110b2544" + integrity sha512-8dxDEs0g1mV4MjPgIArAmdDQDKjH8EitCLh8/Rouv8kkxvdXnL86VkSHpUbZNK9zPAecArwHBSkyCBZNmbqT2A== dependencies: "@popperjs/core" "^2.4.0" chroma-js "^2.1.0" From d8a9407a8dd1837ab433eea13a98fe5fe6c7a1d0 Mon Sep 17 00:00:00 2001 From: Andrew Wilkins Date: Thu, 10 Dec 2020 09:34:40 +0800 Subject: [PATCH 08/11] [APM] enable 'sanitize_field_names' for Go (#85373) https://github.com/elastic/apm-agent-go/pull/856 added central config support for 'sanitize_field_names' to the Go agent, so we can now enable it in the UI too. --- .../agent_configuration/setting_definitions/general_settings.ts | 2 +- .../agent_configuration/setting_definitions/index.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index e5961ac6cf6ef..e978b6d55251b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -235,7 +235,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'Sometimes it is necessary to sanitize, i.e., remove, sensitive data sent to Elastic APM. This config accepts a list of wildcard patterns of field names which should be sanitized. These apply to HTTP headers (including cookies) and `application/x-www-form-urlencoded` data (POST form fields). The query string and the captured request body (such as `application/json` data) will not get sanitized.', } ), - includeAgents: ['java', 'python'], + includeAgents: ['java', 'python', 'go'], }, // Ignore transactions based on URLs diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index dc5ce6cef97bc..abe353ab8f3a3 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -46,6 +46,7 @@ describe('filterByAgent', () => { 'capture_body', 'capture_headers', 'recording', + 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', 'transaction_max_spans', From c9b5ec730381b9270191e1d708c1dbc5c7f6b6ce Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 9 Dec 2020 21:07:31 -0500 Subject: [PATCH 09/11] [Fleet] Update agent listing for better status reporting (#84798) --- .../fleet/common/services/agent_status.ts | 10 +- .../fleet/common/types/models/agent.ts | 2 + .../fleet/common/types/rest_spec/agent.ts | 1 + .../fleet/components/search_bar.tsx | 206 +++------ .../fleet/hooks/use_request/agents.ts | 21 +- .../components/search_and_filter_bar.tsx | 212 +++++++++ .../components/status_badges.tsx | 52 +++ .../agent_list_page/components/status_bar.tsx | 45 ++ .../components/table_header.tsx | 68 +++ .../sections/agents/agent_list_page/index.tsx | 412 ++++++++---------- .../agents/components/agent_health.tsx | 4 +- .../agents/components/list_layout.tsx | 115 +---- .../sections/agents/services/agent_status.tsx | 71 +++ .../public/applications/fleet/types/index.ts | 1 + .../fleet/server/routes/agent/handlers.ts | 3 +- .../fleet/server/services/agents/status.ts | 27 +- .../fleet/server/types/rest_spec/agent.ts | 1 + .../translations/translations/ja-JP.json | 8 - .../translations/translations/zh-CN.json | 8 - 19 files changed, 752 insertions(+), 515 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx diff --git a/x-pack/plugins/fleet/common/services/agent_status.ts b/x-pack/plugins/fleet/common/services/agent_status.ts index cd990d70c3612..30b52bcb28748 100644 --- a/x-pack/plugins/fleet/common/services/agent_status.ts +++ b/x-pack/plugins/fleet/common/services/agent_status.ts @@ -62,6 +62,14 @@ export function buildKueryForOfflineAgents() { }s AND not (${buildKueryForErrorAgents()})`; } -export function buildKueryForUpdatingAgents() { +export function buildKueryForUpgradingAgents() { return `${AGENT_SAVED_OBJECT_TYPE}.upgrade_started_at:*`; } + +export function buildKueryForUpdatingAgents() { + return `(${buildKueryForUpgradingAgents()}) or (${buildKueryForEnrollingAgents()}) or (${buildKueryForUnenrollingAgents()})`; +} + +export function buildKueryForInactiveAgents() { + return `${AGENT_SAVED_OBJECT_TYPE}.active:false`; +} diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 872b389d248a3..59fab14f90e6e 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -22,6 +22,8 @@ export type AgentStatus = | 'updating' | 'degraded'; +export type SimplifiedAgentStatus = 'healthy' | 'unhealthy' | 'updating' | 'offline' | 'inactive'; + export type AgentActionType = | 'POLICY_CHANGE' | 'UNENROLL' diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index da7d126c4ecd3..236fc586bf528 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -206,6 +206,7 @@ export interface UpdateAgentRequest { export interface GetAgentStatusRequest { query: { + kuery?: string; policyId?: string; }; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx index 9ebc8ea9380a9..fbc36f9c8ba23 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx @@ -4,33 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; -import { IFieldType } from 'src/plugins/data/public'; -// @ts-ignore -import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useDebounce, useStartServices } from '../hooks'; +import React, { useState, useEffect, useMemo } from 'react'; +import { + QueryStringInput, + IFieldType, + esKuery, +} from '../../../../../../../src/plugins/data/public'; +import { useStartServices } from '../hooks'; import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants'; -const DEBOUNCE_SEARCH_MS = 150; const HIDDEN_FIELDS = [`${AGENT_SAVED_OBJECT_TYPE}.actions`]; -interface Suggestion { - label: string; - description: string; - value: string; - type: { - color: string; - iconType: string; - }; - start: number; - end: number; -} - interface Props { value: string; fieldPrefix: string; - onChange: (newValue: string) => void; + onChange: (newValue: string, submit?: boolean) => void; placeholder?: string; } @@ -40,135 +28,73 @@ export const SearchBar: React.FunctionComponent = ({ onChange, placeholder, }) => { - const { suggestions } = useSuggestions(fieldPrefix, value); - - // TODO fix type when correctly typed in EUI - const onAutocompleteClick = (suggestion: any) => { - onChange( - [value.slice(0, suggestion.start), suggestion.value, value.slice(suggestion.end, -1)].join('') - ); - }; - // TODO fix type when correctly typed in EUI - const onChangeSearch = (e: any) => { - onChange(e.value); - }; - - return ( - { - return { - ...suggestion, - // For type - onClick: () => {}, - descriptionDisplay: 'wrap', - labelWidth: '40', - }; - })} - /> - ); -}; - -export function transformSuggestionType(type: string): { iconType: string; color: string } { - switch (type) { - case 'field': - return { iconType: 'kqlField', color: 'tint4' }; - case 'value': - return { iconType: 'kqlValue', color: 'tint0' }; - case 'conjunction': - return { iconType: 'kqlSelector', color: 'tint3' }; - case 'operator': - return { iconType: 'kqlOperand', color: 'tint1' }; - default: - return { iconType: 'kqlOther', color: 'tint1' }; - } -} - -function useSuggestions(fieldPrefix: string, search: string) { const { data } = useStartServices(); + const [indexPatternFields, setIndexPatternFields] = useState(); - const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH_MS); - const [suggestions, setSuggestions] = useState([]); + const isQueryValid = useMemo(() => { + if (!value || value === '') { + return true; + } - const fetchSuggestions = async () => { try { - const res = (await data.indexPatterns.getFieldsForWildcard({ - pattern: INDEX_NAME, - })) as IFieldType[]; - if (!data || !data.autocomplete) { - throw new Error('Missing data plugin'); - } - const query = debouncedSearch || ''; - // @ts-ignore - const esSuggestions = ( - await data.autocomplete.getQuerySuggestions({ - language: 'kuery', - indexPatterns: [ - { - title: INDEX_NAME, - fields: res, - }, - ], - boolFilter: [], - query, - selectionStart: query.length, - selectionEnd: query.length, - }) - ) - .filter((suggestion) => { - if (suggestion.type === 'conjunction') { - return true; - } - if (suggestion.type === 'value') { - return true; - } - if (suggestion.type === 'operator') { - return true; - } + esKuery.fromKueryExpression(value); + return true; + } catch (e) { + return false; + } + }, [value]); - if (fieldPrefix && suggestion.text.startsWith(fieldPrefix)) { + useEffect(() => { + const fetchFields = async () => { + try { + const _fields: IFieldType[] = await data.indexPatterns.getFieldsForWildcard({ + pattern: INDEX_NAME, + }); + const fields = (_fields || []).filter((field) => { + if (fieldPrefix && field.name.startsWith(fieldPrefix)) { for (const hiddenField of HIDDEN_FIELDS) { - if (suggestion.text.startsWith(hiddenField)) { + if (field.name.startsWith(hiddenField)) { return false; } } return true; } + }); + setIndexPatternFields(fields); + } catch (err) { + setIndexPatternFields(undefined); + } + }; + fetchFields(); + }, [data.indexPatterns, fieldPrefix]); - return false; - }) - .map((suggestion: any) => ({ - label: suggestion.text, - description: suggestion.description || '', - type: transformSuggestionType(suggestion.type), - start: suggestion.start, - end: suggestion.end, - value: suggestion.text, - })); - - setSuggestions(esSuggestions); - } catch (err) { - setSuggestions([]); - } - }; - - useEffect(() => { - fetchSuggestions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearch]); - - return { - suggestions, - }; -} + return ( + { + onChange(newQuery.query as string); + }} + onSubmit={(newQuery) => { + onChange(newQuery.query as string, true); + }} + /> + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts index 7bbf621c57894..b6a3ecfde78d6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/hooks/use_request/agents.ts @@ -62,13 +62,10 @@ export function useGetAgents(query: GetAgentsRequest['query'], options?: Request }); } -export function sendGetAgentStatus( - query: GetAgentStatusRequest['query'], - options?: RequestOptions -) { - return sendRequest({ +export function sendGetAgents(query: GetAgentsRequest['query'], options?: RequestOptions) { + return sendRequest({ method: 'get', - path: agentRouteService.getStatusPath(), + path: agentRouteService.getListPath(), query, ...options, }); @@ -83,6 +80,18 @@ export function useGetAgentStatus(query: GetAgentStatusRequest['query'], options }); } +export function sendGetAgentStatus( + query: GetAgentStatusRequest['query'], + options?: RequestOptions +) { + return sendRequest({ + method: 'get', + path: agentRouteService.getStatusPath(), + query, + ...options, + }); +} + export function sendPutAgentReassign( agentId: string, body: PutAgentReassignRequest['body'], diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx new file mode 100644 index 0000000000000..baea6d364e586 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentPolicy } from '../../../../types'; +import { SearchBar } from '../../../../components'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../../../constants'; + +const statusFilters = [ + { + status: 'healthy', + label: i18n.translate('xpack.fleet.agentList.statusHealthyFilterText', { + defaultMessage: 'Healthy', + }), + }, + { + status: 'unhealthy', + label: i18n.translate('xpack.fleet.agentList.statusUnhealthyFilterText', { + defaultMessage: 'Unhealthy', + }), + }, + { + status: 'updating', + label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { + defaultMessage: 'Updating', + }), + }, + { + status: 'offline', + label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { + defaultMessage: 'Offline', + }), + }, + { + status: 'inactive', + label: i18n.translate('xpack.fleet.agentList.statusInactiveFilterText', { + defaultMessage: 'Inactive', + }), + }, +]; + +export const SearchAndFilterBar: React.FunctionComponent<{ + agentPolicies: AgentPolicy[]; + draftKuery: string; + onDraftKueryChange: (kuery: string) => void; + onSubmitSearch: (kuery: string) => void; + selectedAgentPolicies: string[]; + onSelectedAgentPoliciesChange: (selectedPolicies: string[]) => void; + selectedStatus: string[]; + onSelectedStatusChange: (selectedStatus: string[]) => void; + showUpgradeable: boolean; + onShowUpgradeableChange: (showUpgradeable: boolean) => void; +}> = ({ + agentPolicies, + draftKuery, + onDraftKueryChange, + onSubmitSearch, + selectedAgentPolicies, + onSelectedAgentPoliciesChange, + selectedStatus, + onSelectedStatusChange, + showUpgradeable, + onShowUpgradeableChange, +}) => { + // Policies state for filtering + const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); + + // Status for filtering + const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); + + // Add a agent policy id to current search + const addAgentPolicyFilter = (policyId: string) => { + onSelectedAgentPoliciesChange([...selectedAgentPolicies, policyId]); + }; + + // Remove a agent policy id from current search + const removeAgentPolicyFilter = (policyId: string) => { + onSelectedAgentPoliciesChange( + selectedAgentPolicies.filter((agentPolicy) => agentPolicy !== policyId) + ); + }; + + return ( + <> + {/* Search and filter bar */} + + + + + { + onDraftKueryChange(newSearch); + if (submit) { + onSubmitSearch(newSearch); + } + }} + fieldPrefix={AGENT_SAVED_OBJECT_TYPE} + /> + + + + setIsStatutsFilterOpen(!isStatusFilterOpen)} + isSelected={isStatusFilterOpen} + hasActiveFilters={selectedStatus.length > 0} + numActiveFilters={selectedStatus.length} + disabled={agentPolicies.length === 0} + > + + + } + isOpen={isStatusFilterOpen} + closePopover={() => setIsStatutsFilterOpen(false)} + panelPaddingSize="none" + > +
+ {statusFilters.map(({ label, status }, idx) => ( + { + if (selectedStatus.includes(status)) { + onSelectedStatusChange([...selectedStatus.filter((s) => s !== status)]); + } else { + onSelectedStatusChange([...selectedStatus, status]); + } + }} + > + {label} + + ))} +
+
+ setIsAgentPoliciesFilterOpen(!isAgentPoliciesFilterOpen)} + isSelected={isAgentPoliciesFilterOpen} + hasActiveFilters={selectedAgentPolicies.length > 0} + numActiveFilters={selectedAgentPolicies.length} + numFilters={agentPolicies.length} + disabled={agentPolicies.length === 0} + > + + + } + isOpen={isAgentPoliciesFilterOpen} + closePopover={() => setIsAgentPoliciesFilterOpen(false)} + panelPaddingSize="none" + > +
+ {agentPolicies.map((agentPolicy, index) => ( + { + if (selectedAgentPolicies.includes(agentPolicy.id)) { + removeAgentPolicyFilter(agentPolicy.id); + } else { + addAgentPolicyFilter(agentPolicy.id); + } + }} + > + {agentPolicy.name} + + ))} +
+
+ { + onShowUpgradeableChange(!showUpgradeable); + }} + > + + +
+
+
+
+
+ + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.tsx new file mode 100644 index 0000000000000..250b021c77c15 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_badges.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiHealth, EuiNotificationBadge, EuiFlexItem } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { + AGENT_STATUSES, + getColorForAgentStatus, + getLabelForAgentStatus, +} from '../../services/agent_status'; +import { SimplifiedAgentStatus } from '../../../../types'; + +export const AgentStatusBadges: React.FC<{ + showInactive?: boolean; + agentStatus: { [k in SimplifiedAgentStatus]: number }; +}> = memo(({ agentStatus, showInactive }) => { + const agentStatuses = useMemo(() => { + return AGENT_STATUSES.filter((status) => (showInactive ? true : status !== 'inactive')); + }, [showInactive]); + + return ( + + {agentStatuses.map((status) => ( + + + + ))} + + ); +}); + +const AgentStatusBadge: React.FC<{ status: SimplifiedAgentStatus; count: number }> = memo( + ({ status, count }) => { + return ( + <> + + + {getLabelForAgentStatus(status)} + + + {count} + + + + + + ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx new file mode 100644 index 0000000000000..b2fa2eacbd5f2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/status_bar.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { EuiColorPaletteDisplay } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { AGENT_STATUSES, getColorForAgentStatus } from '../../services/agent_status'; +import { SimplifiedAgentStatus } from '../../../../types'; + +const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)` + &.ingest-agent-status-bar { + border: none; + border-radius: 0; + &:after { + border: none; + } + } +`; + +export const AgentStatusBar: React.FC<{ + agentStatus: { [k in SimplifiedAgentStatus]: number }; +}> = ({ agentStatus }) => { + const palette = useMemo(() => { + return AGENT_STATUSES.reduce((acc, status) => { + const previousStop = acc.length > 0 ? acc[acc.length - 1].stop : 0; + acc.push({ + stop: previousStop + (agentStatus[status] || 0), + color: getColorForAgentStatus(status), + }); + return acc; + }, [] as Array<{ stop: number; color: string }>); + }, [agentStatus]); + return ( + <> + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx new file mode 100644 index 0000000000000..80ab76ffde4a0 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/table_header.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { Agent, SimplifiedAgentStatus } from '../../../../types'; + +import { AgentStatusBar } from './status_bar'; +import { AgentBulkActions } from './bulk_actions'; +import {} from '@elastic/eui'; +import { AgentStatusBadges } from './status_badges'; + +export type SelectionMode = 'manual' | 'query'; + +export const AgentTableHeader: React.FunctionComponent<{ + agentStatus?: { [k in SimplifiedAgentStatus]: number }; + showInactive: boolean; + totalAgents: number; + totalInactiveAgents: number; + selectableAgents: number; + selectionMode: SelectionMode; + setSelectionMode: (mode: SelectionMode) => void; + currentQuery: string; + selectedAgents: Agent[]; + setSelectedAgents: (agents: Agent[]) => void; + refreshAgents: () => void; +}> = ({ + agentStatus, + totalAgents, + totalInactiveAgents, + selectableAgents, + selectionMode, + setSelectionMode, + currentQuery, + selectedAgents, + setSelectedAgents, + refreshAgents, + showInactive, +}) => { + return ( + <> + + + + + + {agentStatus && ( + + )} + + + + {agentStatus && } + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 1d08a1f791976..2067a2bd91c58 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -3,41 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useMemo, useCallback, useRef } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { EuiBasicTable, EuiButton, EuiEmptyPrompt, - EuiFilterButton, - EuiFilterGroup, - EuiFilterSelectItem, EuiFlexGroup, EuiFlexItem, EuiLink, - EuiPopover, EuiSpacer, EuiText, EuiContextMenuItem, EuiIcon, EuiPortal, - EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { AgentEnrollmentFlyout } from '../components'; -import { Agent, AgentPolicy } from '../../../types'; +import { Agent, AgentPolicy, SimplifiedAgentStatus } from '../../../types'; import { usePagination, useCapabilities, useGetAgentPolicies, - useGetAgents, + sendGetAgents, + sendGetAgentStatus, useUrlParams, useLink, useBreadcrumbs, useLicense, useKibanaVersion, + useStartServices, } from '../../../hooks'; -import { SearchBar, ContextMenuActions } from '../../../components'; +import { ContextMenuActions } from '../../../components'; import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { @@ -46,37 +43,11 @@ import { AgentUnenrollAgentModal, AgentUpgradeAgentModal, } from '../components'; -import { AgentBulkActions, SelectionMode } from './components/bulk_actions'; - -const REFRESH_INTERVAL_MS = 5000; - -const statusFilters = [ - { - status: 'online', - label: i18n.translate('xpack.fleet.agentList.statusOnlineFilterText', { - defaultMessage: 'Online', - }), - }, - { - status: 'offline', - label: i18n.translate('xpack.fleet.agentList.statusOfflineFilterText', { - defaultMessage: 'Offline', - }), - }, - , - { - status: 'error', - label: i18n.translate('xpack.fleet.agentList.statusErrorFilterText', { - defaultMessage: 'Error', - }), - }, - { - status: 'updating', - label: i18n.translate('xpack.fleet.agentList.statusUpdatingFilterText', { - defaultMessage: 'Updating', - }), - }, -] as Array<{ label: string; status: string }>; +import { AgentTableHeader } from './components/table_header'; +import { SelectionMode } from './components/bulk_actions'; +import { SearchAndFilterBar } from './components/search_and_filter_bar'; + +const REFRESH_INTERVAL_MS = 10000; const RowActions = React.memo<{ agent: Agent; @@ -160,6 +131,7 @@ function safeMetadata(val: any) { } export const AgentListPage: React.FunctionComponent<{}> = () => { + const { notifications } = useStartServices(); useBreadcrumbs('fleet_agent_list'); const { getHref } = useLink(); const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; @@ -168,50 +140,43 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const kibanaVersion = useKibanaVersion(); // Agent data states - const [showInactive, setShowInactive] = useState(false); const [showUpgradeable, setShowUpgradeable] = useState(false); // Table and search states + const [draftKuery, setDraftKuery] = useState(defaultKuery); const [search, setSearch] = useState(defaultKuery); const [selectionMode, setSelectionMode] = useState('manual'); const [selectedAgents, setSelectedAgents] = useState([]); const tableRef = useRef>(null); const { pagination, pageSizeOptions, setPagination } = usePagination(); + const onSubmitSearch = useCallback( + (newKuery: string) => { + setSearch(newKuery); + setPagination({ + ...pagination, + currentPage: 1, + }); + }, + [setSearch, pagination, setPagination] + ); + // Policies state for filtering - const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); const [selectedAgentPolicies, setSelectedAgentPolicies] = useState([]); // Status for filtering - const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); const [selectedStatus, setSelectedStatus] = useState([]); const isUsingFilter = - search.trim() || - selectedAgentPolicies.length || - selectedStatus.length || - showInactive || - showUpgradeable; + search.trim() || selectedAgentPolicies.length || selectedStatus.length || showUpgradeable; const clearFilters = useCallback(() => { + setDraftKuery(''); setSearch(''); setSelectedAgentPolicies([]); setSelectedStatus([]); - setShowInactive(false); setShowUpgradeable(false); - }, [setSearch, setSelectedAgentPolicies, setSelectedStatus, setShowInactive, setShowUpgradeable]); - - // Add a agent policy id to current search - const addAgentPolicyFilter = (policyId: string) => { - setSelectedAgentPolicies([...selectedAgentPolicies, policyId]); - }; - - // Remove a agent policy id from current search - const removeAgentPolicyFilter = (policyId: string) => { - setSelectedAgentPolicies( - selectedAgentPolicies.filter((agentPolicy) => agentPolicy !== policyId) - ); - }; + }, [setSearch, setDraftKuery, setSelectedAgentPolicies, setSelectedStatus, setShowUpgradeable]); // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); @@ -221,65 +186,140 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const [agentToUnenroll, setAgentToUnenroll] = useState(undefined); const [agentToUpgrade, setAgentToUpgrade] = useState(undefined); - let kuery = search.trim(); - if (selectedAgentPolicies.length) { - if (kuery) { - kuery = `(${kuery}) and`; + // Kuery + const kuery = useMemo(() => { + let kueryBuilder = search.trim(); + if (selectedAgentPolicies.length) { + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and`; + } + kueryBuilder = `${kueryBuilder} ${AGENT_SAVED_OBJECT_TYPE}.policy_id : (${selectedAgentPolicies + .map((agentPolicy) => `"${agentPolicy}"`) + .join(' or ')})`; } - kuery = `${kuery} ${AGENT_SAVED_OBJECT_TYPE}.policy_id : (${selectedAgentPolicies - .map((agentPolicy) => `"${agentPolicy}"`) - .join(' or ')})`; - } - if (selectedStatus.length) { - const kueryStatus = selectedStatus - .map((status) => { - switch (status) { - case 'online': - return AgentStatusKueryHelper.buildKueryForOnlineAgents(); - case 'offline': - return AgentStatusKueryHelper.buildKueryForOfflineAgents(); - case 'updating': - return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); - case 'error': - return AgentStatusKueryHelper.buildKueryForErrorAgents(); - } + if (selectedStatus.length) { + const kueryStatus = selectedStatus + .map((status) => { + switch (status) { + case 'healthy': + return AgentStatusKueryHelper.buildKueryForOnlineAgents(); + case 'unhealthy': + return AgentStatusKueryHelper.buildKueryForErrorAgents(); + case 'offline': + return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'updating': + return AgentStatusKueryHelper.buildKueryForUpdatingAgents(); + case 'inactive': + return AgentStatusKueryHelper.buildKueryForInactiveAgents(); + } - return ''; - }) - .join(' or '); + return undefined; + }) + .filter((statusKuery) => statusKuery !== undefined) + .join(' or '); - if (kuery) { - kuery = `(${kuery}) and ${kueryStatus}`; - } else { - kuery = kueryStatus; + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and ${kueryStatus}`; + } else { + kueryBuilder = kueryStatus; + } } - } - const agentsRequest = useGetAgents( - { - page: pagination.currentPage, - perPage: pagination.pageSize, - kuery: kuery && kuery !== '' ? kuery : undefined, - showInactive, - showUpgradeable, - }, - { - pollIntervalMs: REFRESH_INTERVAL_MS, + return kueryBuilder; + }, [selectedStatus, selectedAgentPolicies, search]); + + const showInactive = useMemo(() => { + return selectedStatus.includes('inactive'); + }, [selectedStatus]); + + const [agents, setAgents] = useState([]); + const [agentsStatus, setAgentsStatus] = useState< + { [key in SimplifiedAgentStatus]: number } | undefined + >(); + const [isLoading, setIsLoading] = useState(false); + const [totalAgents, setTotalAgents] = useState(0); + const [totalInactiveAgents, setTotalInactiveAgents] = useState(0); + + // Request to fetch agents and agent status + const currentRequestRef = useRef(0); + const fetchData = useCallback(() => { + async function fetchDataAsync() { + currentRequestRef.current++; + const currentRequest = currentRequestRef.current; + + try { + setIsLoading(true); + const [agentsRequest, agentsStatusRequest] = await Promise.all([ + sendGetAgents({ + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: kuery && kuery !== '' ? kuery : undefined, + showInactive, + showUpgradeable, + }), + sendGetAgentStatus({ + kuery: kuery && kuery !== '' ? kuery : undefined, + }), + ]); + // Return if a newer request as been triggered + if (currentRequestRef.current !== currentRequest) { + return; + } + if (agentsRequest.error) { + throw agentsRequest.error; + } + if (!agentsRequest.data) { + throw new Error('Invalid GET /agents response'); + } + if (agentsStatusRequest.error) { + throw agentsStatusRequest.error; + } + if (!agentsStatusRequest.data) { + throw new Error('Invalid GET /agents-status response'); + } + + setAgentsStatus({ + healthy: agentsStatusRequest.data.results.online, + unhealthy: agentsStatusRequest.data.results.error, + offline: agentsStatusRequest.data.results.offline, + updating: agentsStatusRequest.data.results.other, + inactive: agentsRequest.data.totalInactive, + }); + + setAgents(agentsRequest.data.list); + setTotalAgents(agentsRequest.data.total); + setTotalInactiveAgents(agentsRequest.data.totalInactive); + } catch (error) { + notifications.toasts.addError(error, { + title: i18n.translate('xpack.fleet.agentList.errorFetchingDataTitle', { + defaultMessage: 'Error fetching agents', + }), + }); + } + setIsLoading(false); } - ); + fetchDataAsync(); + }, [pagination, kuery, showInactive, showUpgradeable, notifications.toasts]); - const agents = agentsRequest.data ? agentsRequest.data.list : []; - const totalAgents = agentsRequest.data ? agentsRequest.data.total : 0; - const totalInactiveAgents = agentsRequest.data ? agentsRequest.data.totalInactive : 0; - const { isLoading } = agentsRequest; + // Send request to get agent list and status + useEffect(() => { + fetchData(); + const interval = setInterval(() => { + fetchData(); + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(interval); + }, [fetchData]); const agentPoliciesRequest = useGetAgentPolicies({ page: 1, perPage: 1000, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const agentPolicies = agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []; + const agentPolicies = useMemo( + () => (agentPoliciesRequest.data ? agentPoliciesRequest.data.items : []), + [agentPoliciesRequest] + ); const agentPoliciesIndexedById = useMemo(() => { return agentPolicies.reduce((acc, agentPolicy) => { acc[agentPolicy.id] = agentPolicy; @@ -287,7 +327,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return acc; }, {} as { [k: string]: AgentPolicy }); }, [agentPolicies]); - const { isLoading: isAgentPoliciesLoading } = agentPoliciesRequest; const columns = [ { @@ -405,7 +444,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { return ( agentsRequest.resendRequest()} + refresh={() => fetchData()} onReassignClick={() => setAgentToReassign(agent)} onUnenrollClick={() => setAgentToUnenroll(agent)} onUpgradeClick={() => setAgentToUpgrade(agent)} @@ -452,7 +491,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agents={[agentToReassign]} onClose={() => { setAgentToReassign(undefined); - agentsRequest.resendRequest(); + fetchData(); }} /> @@ -464,7 +503,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agentCount={1} onClose={() => { setAgentToUnenroll(undefined); - agentsRequest.resendRequest(); + fetchData(); }} useForceUnenroll={agentToUnenroll.status === 'unenrolling'} /> @@ -478,7 +517,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { agentCount={1} onClose={() => { setAgentToUpgrade(undefined); - agentsRequest.resendRequest(); + fetchData(); }} version={kibanaVersion} /> @@ -486,134 +525,26 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { )} {/* Search and filter bar */} - - - - - { - setPagination({ - ...pagination, - currentPage: 1, - }); - setSearch(newSearch); - }} - fieldPrefix={AGENT_SAVED_OBJECT_TYPE} - /> - - - - setIsStatutsFilterOpen(!isStatusFilterOpen)} - isSelected={isStatusFilterOpen} - hasActiveFilters={selectedStatus.length > 0} - numActiveFilters={selectedStatus.length} - disabled={isAgentPoliciesLoading} - > - - - } - isOpen={isStatusFilterOpen} - closePopover={() => setIsStatutsFilterOpen(false)} - panelPaddingSize="none" - > -
- {statusFilters.map(({ label, status }, idx) => ( - { - if (selectedStatus.includes(status)) { - setSelectedStatus([...selectedStatus.filter((s) => s !== status)]); - } else { - setSelectedStatus([...selectedStatus, status]); - } - }} - > - {label} - - ))} -
-
- setIsAgentPoliciesFilterOpen(!isAgentPoliciesFilterOpen)} - isSelected={isAgentPoliciesFilterOpen} - hasActiveFilters={selectedAgentPolicies.length > 0} - numActiveFilters={selectedAgentPolicies.length} - numFilters={agentPolicies.length} - disabled={isAgentPoliciesLoading} - > - - - } - isOpen={isAgentPoliciesFilterOpen} - closePopover={() => setIsAgentPoliciesFilterOpen(false)} - panelPaddingSize="none" - > -
- {agentPolicies.map((agentPolicy, index) => ( - { - if (selectedAgentPolicies.includes(agentPolicy.id)) { - removeAgentPolicyFilter(agentPolicy.id); - } else { - addAgentPolicyFilter(agentPolicy.id); - } - }} - > - {agentPolicy.name} - - ))} -
-
- { - setShowUpgradeable(!showUpgradeable); - }} - > - - - setShowInactive(!showInactive)} - > - - -
-
-
-
-
+ - {/* Agent total and bulk actions */} - agent.active).length || 0} selectionMode={selectionMode} setSelectionMode={setSelectionMode} @@ -625,10 +556,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { setSelectionMode('manual'); } }} - refreshAgents={() => agentsRequest.resendRequest()} + refreshAgents={() => fetchData()} /> - - + {/* Agent list table */} @@ -638,7 +568,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { loading={isLoading} hasActions={true} noItemsMessage={ - isLoading && agentsRequest.isInitialRequest ? ( + isLoading && currentRequestRef.current === 1 ? ( - + ), Unhealthy: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx index bf0163fe904e6..dfa093ca8bf80 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/list_layout.tsx @@ -5,122 +5,31 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import styled from 'styled-components'; -import { - EuiHealth, - EuiText, - EuiFlexGroup, - EuiFlexItem, - EuiStat, - EuiI18nNumber, - EuiButton, -} from '@elastic/eui'; +import { EuiText, EuiFlexGroup, EuiFlexItem, EuiButton, EuiPortal } from '@elastic/eui'; import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; import { useRouteMatch } from 'react-router-dom'; import { PAGE_ROUTING_PATHS } from '../../../constants'; import { WithHeaderLayout } from '../../../layouts'; import { useCapabilities, useLink, useGetAgentPolicies } from '../../../hooks'; -import { useGetAgentStatus } from '../../agent_policy/details_page/hooks'; import { AgentEnrollmentFlyout } from '../components'; -import { DonutChart } from './donut_chart'; - -const REFRESH_INTERVAL_MS = 5000; - -const Divider = styled.div` - width: 0; - height: 100%; - border-left: ${(props) => props.theme.eui.euiBorderThin}; - height: 45px; -`; export const ListLayout: React.FunctionComponent<{}> = ({ children }) => { const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; - const agentStatusRequest = useGetAgentStatus(undefined, { - pollIntervalMs: REFRESH_INTERVAL_MS, - }); - const agentStatus = agentStatusRequest.data?.results; // Agent enrollment flyout state const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = React.useState(false); - const headerRightColumn = ( - - - } - description={i18n.translate('xpack.fleet.agentListStatus.totalLabel', { - defaultMessage: 'Agents', - })} - /> - - - - + const headerRightColumn = hasWriteCapabilites ? ( + - - - - } - description={i18n.translate('xpack.fleet.agentListStatus.onlineLabel', { - defaultMessage: 'Online', - })} - /> + setIsEnrollmentFlyoutOpen(true)}> + + - - } - description={i18n.translate('xpack.fleet.agentListStatus.offlineLabel', { - defaultMessage: 'Offline', - })} - /> - - - } - description={i18n.translate('xpack.fleet.agentListStatus.errorLabel', { - defaultMessage: 'Error', - })} - /> - - {hasWriteCapabilites && ( - <> - - - - - setIsEnrollmentFlyoutOpen(true)}> - - - - - )} - ); + ) : undefined; const headerLeftColumn = ( @@ -177,10 +86,12 @@ export const ListLayout: React.FunctionComponent<{}> = ({ children }) => { } > {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} {children} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx new file mode 100644 index 0000000000000..5e7b42798c294 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/services/agent_status.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { euiPaletteColorBlindBehindText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SimplifiedAgentStatus } from '../../../types'; + +const visColors = euiPaletteColorBlindBehindText(); +const colorToHexMap = { + // TODO - replace with variable once https://github.com/elastic/eui/issues/2731 is closed + default: '#d3dae6', + primary: visColors[1], + secondary: visColors[0], + accent: visColors[2], + warning: visColors[5], + danger: visColors[9], +}; + +export const AGENT_STATUSES: SimplifiedAgentStatus[] = [ + 'healthy', + 'unhealthy', + 'updating', + 'offline', + 'inactive', +]; + +export function getColorForAgentStatus(agentStatus: SimplifiedAgentStatus): string { + switch (agentStatus) { + case 'healthy': + return colorToHexMap.secondary; + case 'offline': + case 'inactive': + return colorToHexMap.default; + case 'unhealthy': + return colorToHexMap.warning; + case 'updating': + return colorToHexMap.primary; + default: + throw new Error(`Insuported Agent status ${agentStatus}`); + } +} + +export function getLabelForAgentStatus(agentStatus: SimplifiedAgentStatus): string { + switch (agentStatus) { + case 'healthy': + return i18n.translate('xpack.fleet.agentStatus.healthyLabel', { + defaultMessage: 'Healthy', + }); + case 'offline': + return i18n.translate('xpack.fleet.agentStatus.offlineLabel', { + defaultMessage: 'Offline', + }); + case 'inactive': + return i18n.translate('xpack.fleet.agentStatus.inactiveLabel', { + defaultMessage: 'Inactive', + }); + case 'unhealthy': + return i18n.translate('xpack.fleet.agentStatus.unhealthyLabel', { + defaultMessage: 'Unhealthy', + }); + case 'updating': + return i18n.translate('xpack.fleet.agentStatus.updatingLabel', { + defaultMessage: 'Updating', + }); + default: + throw new Error(`Insuported Agent status ${agentStatus}`); + } +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts index dd80c1ad77b85..dadacf6006085 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/types/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/types/index.ts @@ -12,6 +12,7 @@ export { AgentPolicy, NewAgentPolicy, AgentEvent, + SimplifiedAgentStatus, EnrollmentAPIKey, PackagePolicy, NewPackagePolicy, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index eff7d3c3c5cf3..a867196f9762f 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -330,7 +330,8 @@ export const getAgentStatusForAgentPolicyHandler: RequestHandler< // TODO change path const results = await AgentService.getAgentStatusForAgentPolicy( soClient, - request.query.policyId + request.query.policyId, + request.query.kuery ); const body: GetAgentStatusResponse = { results }; diff --git a/x-pack/plugins/fleet/server/services/agents/status.ts b/x-pack/plugins/fleet/server/services/agents/status.ts index 35033cbe86ea5..0dfa6db7df9be 100644 --- a/x-pack/plugins/fleet/server/services/agents/status.ts +++ b/x-pack/plugins/fleet/server/services/agents/status.ts @@ -23,7 +23,8 @@ export const getAgentStatus = AgentStatusKueryHelper.getAgentStatus; export async function getAgentStatusForAgentPolicy( soClient: SavedObjectsClientContract, - agentPolicyId?: string + agentPolicyId?: string, + filterKuery?: string ) { const [all, online, error, offline] = await Promise.all( [ @@ -36,15 +37,29 @@ export async function getAgentStatusForAgentPolicy( showInactive: false, perPage: 0, page: 1, - kuery: agentPolicyId - ? kuery - ? `(${kuery}) and (${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}")` - : `${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}"` - : kuery, + kuery: joinKuerys( + ...[ + kuery, + filterKuery, + agentPolicyId ? `${AGENT_SAVED_OBJECT_TYPE}.policy_id:"${agentPolicyId}"` : undefined, + ] + ), }) ) ); + function joinKuerys(...kuerys: Array) { + return kuerys + .filter((kuery) => kuery !== undefined) + .reduce((acc, kuery) => { + if (acc === '') { + return `(${kuery})`; + } + + return `${acc} and (${kuery})`; + }, ''); + } + return { events: await getEventsCount(soClient, agentPolicyId), total: all.total, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index 6de94cd9c936d..3e9262c2a9124 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -246,5 +246,6 @@ export const UpdateAgentRequestSchema = { export const GetAgentStatusRequestSchema = { query: schema.object({ policyId: schema.maybe(schema.string()), + kuery: schema.maybe(schema.string()), }), }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eb1fd694114ce..2effc90946b58 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7190,22 +7190,15 @@ "xpack.fleet.agentList.policyFilterText": "エージェントポリシー", "xpack.fleet.agentList.reassignActionText": "新しいポリシーに割り当てる", "xpack.fleet.agentList.revisionNumber": "rev. {revNumber}", - "xpack.fleet.agentList.showInactiveSwitchLabel": "非アクティブ", "xpack.fleet.agentList.showUpgradeableFilterLabel": "アップグレードが利用可能です", "xpack.fleet.agentList.statusColumnTitle": "ステータス", - "xpack.fleet.agentList.statusErrorFilterText": "エラー", "xpack.fleet.agentList.statusFilterText": "ステータス", "xpack.fleet.agentList.statusOfflineFilterText": "オフライン", - "xpack.fleet.agentList.statusOnlineFilterText": "オンライン", "xpack.fleet.agentList.statusUpdatingFilterText": "更新中", "xpack.fleet.agentList.unenrollOneButton": "エージェントの登録解除", "xpack.fleet.agentList.upgradeOneButton": "エージェントをアップグレード", "xpack.fleet.agentList.versionTitle": "バージョン", "xpack.fleet.agentList.viewActionText": "エージェントを表示", - "xpack.fleet.agentListStatus.errorLabel": "エラー", - "xpack.fleet.agentListStatus.offlineLabel": "オフライン", - "xpack.fleet.agentListStatus.onlineLabel": "オンライン", - "xpack.fleet.agentListStatus.totalLabel": "エージェント", "xpack.fleet.agentPolicy.confirmModalCalloutDescription": "選択されたエージェントポリシー{policyName}が一部のエージェントですでに使用されていることをFleetが検出しました。このアクションの結果として、Fleetはこのポリシーで使用されているすべてのエージェントに更新をデプロイします。", "xpack.fleet.agentPolicy.confirmModalCancelButtonLabel": "キャンセル", "xpack.fleet.agentPolicy.confirmModalConfirmButtonLabel": "変更を保存してデプロイ", @@ -7359,7 +7352,6 @@ "xpack.fleet.dataStreamList.viewDashboardActionText": "ダッシュボードを表示", "xpack.fleet.dataStreamList.viewDashboardsActionText": "ダッシュボードを表示", "xpack.fleet.dataStreamList.viewDashboardsPanelTitle": "ダッシュボードを表示", - "xpack.fleet.defaultSearchPlaceholderText": "検索", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {#個のエージェントは} other {#個のエージェントは}}このエージェントポリシーに割り当てられました。このポリシーを削除する前に、これらのエージェントの割り当てを解除します。", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsTitle": "使用中のポリシー", "xpack.fleet.deleteAgentPolicy.confirmModal.cancelButtonLabel": "キャンセル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8ad261449854e..b2380d9ac226b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7196,22 +7196,15 @@ "xpack.fleet.agentList.policyFilterText": "代理策略", "xpack.fleet.agentList.reassignActionText": "分配到新策略", "xpack.fleet.agentList.revisionNumber": "修订 {revNumber}", - "xpack.fleet.agentList.showInactiveSwitchLabel": "非活动", "xpack.fleet.agentList.showUpgradeableFilterLabel": "升级可用", "xpack.fleet.agentList.statusColumnTitle": "状态", - "xpack.fleet.agentList.statusErrorFilterText": "错误", "xpack.fleet.agentList.statusFilterText": "状态", "xpack.fleet.agentList.statusOfflineFilterText": "脱机", - "xpack.fleet.agentList.statusOnlineFilterText": "联机", "xpack.fleet.agentList.statusUpdatingFilterText": "正在更新", "xpack.fleet.agentList.unenrollOneButton": "取消注册代理", "xpack.fleet.agentList.upgradeOneButton": "升级代理", "xpack.fleet.agentList.versionTitle": "版本", "xpack.fleet.agentList.viewActionText": "查看代理", - "xpack.fleet.agentListStatus.errorLabel": "错误", - "xpack.fleet.agentListStatus.offlineLabel": "脱机", - "xpack.fleet.agentListStatus.onlineLabel": "联机", - "xpack.fleet.agentListStatus.totalLabel": "代理", "xpack.fleet.agentPolicy.confirmModalCalloutDescription": "Fleet 检测到您的部分代理已在使用选定代理策略 {policyName}。由于此操作,Fleet 会将更新部署到使用此策略的所有代理。", "xpack.fleet.agentPolicy.confirmModalCalloutTitle": "此操作将更新 {agentCount, plural, one {# 个代理} other {# 个代理}}", "xpack.fleet.agentPolicy.confirmModalCancelButtonLabel": "取消", @@ -7366,7 +7359,6 @@ "xpack.fleet.dataStreamList.viewDashboardActionText": "查看仪表板", "xpack.fleet.dataStreamList.viewDashboardsActionText": "查看仪表板", "xpack.fleet.dataStreamList.viewDashboardsPanelTitle": "查看仪表板", - "xpack.fleet.defaultSearchPlaceholderText": "搜索", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsMessage": "{agentsCount, plural, one {# 个代理} other {# 个代理}}已分配到此代理策略。在删除此策略前取消分配这些代理。", "xpack.fleet.deleteAgentPolicy.confirmModal.affectedAgentsTitle": "在用的策略", "xpack.fleet.deleteAgentPolicy.confirmModal.cancelButtonLabel": "取消", From 0f408041b48a03fa46492f62d08fe5d8025390ea Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 9 Dec 2020 22:16:38 -0500 Subject: [PATCH 10/11] [SECURITY SOLUTION] Bundles _source -> Fields + able to sort on multiple fields in Timeline (#83761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace _source with fields * wip * unit test * regroup sorting and number together * fix bugs from review * mistake * Update x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx Co-authored-by: Patryk Kopyciński * fix snapshot * review + fix topN and filter from detail view * fix tests * fix test Co-authored-by: Patryk Kopyciński --- .../matrix_histogram/events/index.ts | 1 + .../common/search_strategy/timeline/index.ts | 2 +- .../common/types/timeline/index.ts | 7 +- .../cypress/integration/events_viewer.spec.ts | 4 +- .../expected_timelines_export.ndjson | 2 +- .../draggable_wrapper_hover_content.tsx | 4 +- .../events_viewer/events_viewer.test.tsx | 10 +- .../events_viewer/events_viewer.tsx | 15 +- .../public/common/mock/global_state.ts | 2 +- .../public/common/mock/timeline_results.ts | 14 +- .../components/alerts_table/actions.test.tsx | 52 +- .../public/graphql/introspection.json | 39 +- .../security_solution/public/graphql/types.ts | 58 +- .../components/open_timeline/helpers.test.ts | 80 ++- .../components/open_timeline/helpers.ts | 29 +- .../__snapshots__/index.test.tsx.snap | 12 +- .../body/column_headers/actions/index.tsx | 4 +- .../body/column_headers/column_header.tsx | 4 +- .../header/__snapshots__/index.test.tsx.snap | 20 +- .../column_headers/header/header_content.tsx | 8 +- .../body/column_headers/header/helpers.ts | 17 +- .../body/column_headers/header/index.test.tsx | 54 +- .../body/column_headers/header/index.tsx | 53 +- .../body/column_headers/index.test.tsx | 170 +++++- .../timeline/body/column_headers/index.tsx | 75 ++- .../body/column_headers/translations.ts | 4 + .../components/timeline/body/constants.ts | 10 +- .../components/timeline/body/index.test.tsx | 25 +- .../components/timeline/body/index.tsx | 4 +- .../timeline/body/renderers/constants.tsx | 1 + .../body/renderers/formatted_field.tsx | 3 + .../sort_indicator.test.tsx.snap | 3 + .../body/sort/sort_indicator.test.tsx | 14 +- .../timeline/body/sort/sort_indicator.tsx | 9 +- .../timeline/body/sort/sort_number.tsx | 26 + .../timelines/components/timeline/events.ts | 4 + .../components/timeline/index.test.tsx | 17 +- .../timelines/components/timeline/index.tsx | 10 +- .../__snapshots__/index.test.tsx.snap | 10 +- .../timeline/query_tab_content/index.test.tsx | 10 +- .../timeline/query_tab_content/index.tsx | 11 +- .../timelines/components/timeline/styles.tsx | 16 + .../public/timelines/containers/api.test.ts | 10 +- .../timelines/containers/details/index.tsx | 3 + .../public/timelines/containers/index.tsx | 12 +- .../containers/local_storage/index.tsx | 7 +- .../containers/one/index.gql_query.ts | 5 +- .../timelines/containers/persist.gql_query.ts | 5 +- .../timelines/store/timeline/actions.ts | 4 +- .../timelines/store/timeline/defaults.ts | 10 +- .../timelines/store/timeline/epic.test.ts | 12 +- .../timeline/epic_local_storage.test.tsx | 20 +- .../timelines/store/timeline/helpers.ts | 4 +- .../public/timelines/store/timeline/model.ts | 2 +- .../timelines/store/timeline/reducer.test.ts | 24 +- .../server/graphql/timeline/schema.gql.ts | 8 +- .../security_solution/server/graphql/types.ts | 35 +- .../convert_saved_object_to_savedtimeline.ts | 6 + .../search_strategy/helpers/to_array.ts | 36 +- .../factory/events/all/helpers.test.ts | 123 ++++ .../timeline/factory/events/all/helpers.ts | 18 +- .../timeline/factory/events/all/index.ts | 6 +- .../events/all/query.events_all.dsl.ts | 15 +- .../factory/events/details/helpers.test.ts | 153 +++++ .../factory/events/details/helpers.ts | 62 +- .../timeline/factory/events/details/index.ts | 9 +- .../details/query.events_details.dsl.ts | 1 + .../saved_objects/timeline.ts | 2 +- .../security_solution/timeline_details.ts | 577 +++++++++++++----- 69 files changed, 1474 insertions(+), 608 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts index f1307335215ed..4844b0c545198 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/matrix_histogram/events/index.ts @@ -28,6 +28,7 @@ export interface EventsActionGroupData { export interface EventHit extends SearchHit { sort: string[]; _source: EventSource; + fields: Record; aggregations: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [agg: string]: any; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index 578f905617746..6f71ed5c27516 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -31,7 +31,7 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest { export interface TimelineRequestOptionsPaginated extends TimelineRequestBasicOptions { pagination: Pick; - sort: SortField; + sort: Array>; } export type TimelineStrategyResponseType< diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 967b3870cb9e0..4b26b4157da0c 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -143,10 +143,15 @@ const SavedFavoriteRuntimeType = runtimeTypes.partial({ /* * Sort Types */ -const SavedSortRuntimeType = runtimeTypes.partial({ + +const SavedSortObject = runtimeTypes.partial({ columnId: unionWithNullType(runtimeTypes.string), sortDirection: unionWithNullType(runtimeTypes.string), }); +const SavedSortRuntimeType = runtimeTypes.union([ + runtimeTypes.array(SavedSortObject), + SavedSortObject, +]); /* * Timeline Statuses diff --git a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts index 9eb49c19c23f6..664de967b9aff 100644 --- a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts @@ -157,9 +157,9 @@ describe('Events Viewer', () => { it('re-orders columns via drag and drop', () => { const originalColumnOrder = - '@timestampmessagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; + '@timestamp1messagehost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; const expectedOrderAfterDragAndDrop = - 'message@timestamphost.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; + 'message@timestamp1host.nameevent.moduleevent.datasetevent.actionuser.namesource.ipdestination.ip'; cy.get(HEADERS_GROUP).invoke('text').should('equal', originalColumnOrder); dragAndDropColumn({ column: 0, newPosition: 0 }); diff --git a/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson b/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson index 9cca356a8b052..7ee8d189d7dca 100644 --- a/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson +++ b/x-pack/plugins/security_solution/cypress/test_files/expected_timelines_export.ndjson @@ -1 +1 @@ -{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]} +{"savedObjectId":"0162c130-78be-11ea-9718-118a926974a4","version":"WzcsMV0=","columns":[{"columnHeaderType":"not-filtered","id":"@timestamp"},{"columnHeaderType":"not-filtered","id":"message"},{"columnHeaderType":"not-filtered","id":"event.category"},{"columnHeaderType":"not-filtered","id":"event.action"},{"columnHeaderType":"not-filtered","id":"host.name"},{"columnHeaderType":"not-filtered","id":"source.ip"},{"columnHeaderType":"not-filtered","id":"destination.ip"},{"columnHeaderType":"not-filtered","id":"user.name"}],"created":1586256805054,"createdBy":"elastic","dataProviders":[],"dateRange":{"end":1586256837669,"start":1546343624710},"description":"description","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"expression":"host.name:*","kind":"kuery"},"serializedQuery":"{\"bool\":{\"should\":[{\"exists\":{\"field\":\"host.name\"}}],\"minimum_should_match\":1}}"}},"savedQueryId":null,"sort":[{"columnId":"@timestamp","sortDirection":"desc"}],"title":"SIEM test","updated":1586256839298,"updatedBy":"elastic","timelineType":"default","eventNotes":[],"globalNotes":[],"pinnedEventIds":[]} diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index f0eae407eedce..5aaef5cbb9ac4 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -19,7 +19,7 @@ import { allowTopN } from './helpers'; import * as i18n from './translations'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { TimelineId } from '../../../../common/types/timeline'; -import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../../../timelines/components/timeline/styles'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/components/timeline/styles'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; @@ -230,7 +230,7 @@ export const useGetTimelineId = function ( if ( myElem != null && myElem.classList != null && - myElem.classList.contains(SELECTOR_TIMELINE_BODY_CLASS_NAME) && + myElem.classList.contains(SELECTOR_TIMELINE_GLOBAL_CONTAINER) && myElem.hasAttribute('data-timeline-id') ) { setTimelineId(myElem.getAttribute('data-timeline-id')); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 7132add229edb..8710503924d84 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -79,10 +79,12 @@ const eventsViewerDefaultProps = { language: 'kql', }, start: from, - sort: { - columnId: 'foo', - sortDirection: 'none' as SortDirection, - }, + sort: [ + { + columnId: 'foo', + sortDirection: 'none' as SortDirection, + }, + ], scopeId: SourcererScopeName.timeline, utilityBar, }; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 208d60ac73865..c578e017c4d95 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -115,7 +115,7 @@ interface Props { query: Query; onRuleChange?: () => void; start: string; - sort: Sort; + sort: Sort[]; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; // If truthy, the graph viewer (Resolver) is showing graphEventId: string | undefined; @@ -202,11 +202,12 @@ const EventsViewerComponent: React.FC = ({ ]); const sortField = useMemo( - () => ({ - field: sort.columnId, - direction: sort.sortDirection as Direction, - }), - [sort.columnId, sort.sortDirection] + () => + sort.map(({ columnId, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + })), + [sort] ); const [ @@ -341,7 +342,7 @@ export const EventsViewer = React.memo( prevProps.kqlMode === nextProps.kqlMode && deepEqual(prevProps.query, nextProps.query) && prevProps.start === nextProps.start && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && prevProps.utilityBar === nextProps.utilityBar && prevProps.graphEventId === nextProps.graphEventId ); diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index db414dfab5c09..db21847991534 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -239,7 +239,7 @@ export const mockGlobalState: State = { pinnedEventIds: {}, pinnedEventsSaveObject: {}, itemsPerPageOptions: [5, 10, 20], - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], isSaving: false, version: null, status: TimelineStatus.active, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index d927fcb27e099..c8d9fc981d880 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2142,10 +2142,12 @@ export const mockTimelineModel: TimelineModel = { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ], status: TimelineStatus.active, title: 'Test rule', timelineType: TimelineType.default, @@ -2177,7 +2179,7 @@ export const mockTimelineResult: TimelineResult = { templateTimelineId: null, templateTimelineVersion: null, savedQueryId: null, - sort: { columnId: '@timestamp', sortDirection: 'desc' }, + sort: [{ columnId: '@timestamp', sortDirection: 'desc' }], version: '1', }; @@ -2247,7 +2249,7 @@ export const defaultTimelineProps: CreateTimelineProps = { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 55258af7332e1..d251cce381536 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -105,80 +105,38 @@ describe('alert actions', () => { activeTab: TimelineTabs.query, columns: [ { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: '@timestamp', - placeholder: undefined, - type: undefined, width: 190, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'message', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'event.category', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'host.name', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'source.ip', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'destination.ip', - placeholder: undefined, - type: undefined, width: 180, }, { - aggregatable: undefined, - category: undefined, columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, id: 'user.name', - placeholder: undefined, - type: undefined, width: 180, }, ], @@ -242,10 +200,12 @@ describe('alert actions', () => { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 30dec34ab39b7..9e0cf10a54aa9 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -2231,7 +2231,7 @@ "name": "sort", "description": "", "args": [], - "type": { "kind": "OBJECT", "name": "SortTimelineResult", "ofType": null }, + "type": { "kind": "SCALAR", "name": "ToAny", "ofType": null }, "isDeprecated": false, "deprecationReason": null }, @@ -2953,33 +2953,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "OBJECT", - "name": "SortTimelineResult", - "description": "", - "fields": [ - { - "name": "columnId", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "sortDirection", - "description": "", - "args": [], - "type": { "kind": "SCALAR", "name": "String", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "ENUM", "name": "TimelineStatus", @@ -3650,7 +3623,15 @@ { "name": "sort", "description": "", - "type": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null }, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { "kind": "INPUT_OBJECT", "name": "SortTimelineInput", "ofType": null } + } + }, "defaultValue": null }, { diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 17f8e19a60552..435576a02b30e 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -103,7 +103,7 @@ export interface TimelineInput { savedQueryId?: Maybe; - sort?: Maybe; + sort?: Maybe; status?: Maybe; } @@ -512,17 +512,17 @@ export interface CloudFields { machine?: Maybe; - provider?: Maybe[]>; + provider?: Maybe<(Maybe)[]>; - region?: Maybe[]>; + region?: Maybe<(Maybe)[]>; } export interface CloudInstance { - id?: Maybe[]>; + id?: Maybe<(Maybe)[]>; } export interface CloudMachine { - type?: Maybe[]>; + type?: Maybe<(Maybe)[]>; } export interface EndpointFields { @@ -632,7 +632,7 @@ export interface TimelineResult { savedObjectId: string; - sort?: Maybe; + sort?: Maybe; status?: Maybe; @@ -775,14 +775,8 @@ export interface KueryFilterQueryResult { expression?: Maybe; } -export interface SortTimelineResult { - columnId?: Maybe; - - sortDirection?: Maybe; -} - export interface ResponseTimelines { - timeline: Maybe[]; + timeline: (Maybe)[]; totalCount?: Maybe; @@ -1533,9 +1527,9 @@ export interface HostFields { id?: Maybe; - ip?: Maybe[]>; + ip?: Maybe<(Maybe)[]>; - mac?: Maybe[]>; + mac?: Maybe<(Maybe)[]>; name?: Maybe; @@ -1551,7 +1545,7 @@ export interface IndexField { /** Example of field's value */ example?: Maybe; /** whether the field's belong to an alias index */ - indexes: Maybe[]; + indexes: (Maybe)[]; /** The name of the field */ name: string; /** The type of the field's values as recognized by Kibana */ @@ -1749,7 +1743,7 @@ export namespace GetHostOverviewQuery { __typename?: 'AgentFields'; id: Maybe; - } + }; export type Host = { __typename?: 'HostEcsFields'; @@ -1788,21 +1782,21 @@ export namespace GetHostOverviewQuery { machine: Maybe; - provider: Maybe[]>; + provider: Maybe<(Maybe)[]>; - region: Maybe[]>; + region: Maybe<(Maybe)[]>; }; export type Instance = { __typename?: 'CloudInstance'; - id: Maybe[]>; + id: Maybe<(Maybe)[]>; }; export type Machine = { __typename?: 'CloudMachine'; - type: Maybe[]>; + type: Maybe<(Maybe)[]>; }; export type Inspect = { @@ -1985,7 +1979,7 @@ export namespace GetAllTimeline { favoriteCount: Maybe; - timeline: Maybe[]; + timeline: (Maybe)[]; }; export type Timeline = { @@ -2240,7 +2234,7 @@ export namespace GetOneTimeline { savedQueryId: Maybe; - sort: Maybe; + sort: Maybe; created: Maybe; @@ -2494,14 +2488,6 @@ export namespace GetOneTimeline { version: Maybe; }; - - export type Sort = { - __typename?: 'SortTimelineResult'; - - columnId: Maybe; - - sortDirection: Maybe; - }; } export namespace PersistTimelineMutation { @@ -2560,7 +2546,7 @@ export namespace PersistTimelineMutation { savedQueryId: Maybe; - sort: Maybe; + sort: Maybe; created: Maybe; @@ -2744,14 +2730,6 @@ export namespace PersistTimelineMutation { end: Maybe; }; - - export type Sort = { - __typename?: 'SortTimelineResult'; - - columnId: Maybe; - - sortDirection: Maybe; - }; } export namespace PersistTimelinePinnedEventMutation { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index 5a1540b970300..6c76da44c8557 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -312,10 +312,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -411,10 +413,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.template, @@ -510,10 +514,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -607,10 +613,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, title: '', timelineType: TimelineType.default, @@ -745,10 +753,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, id: 'savedObject-1', }); @@ -912,10 +922,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.draft, id: 'savedObject-1', }); @@ -1007,10 +1019,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.immutable, title: 'Awesome Timeline', timelineType: TimelineType.template, @@ -1106,10 +1120,12 @@ describe('helpers', () => { selectedEventIds: {}, show: false, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.active, title: 'Awesome Timeline', timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 1ee529cc77a91..76eb9196e8c5c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -108,21 +108,20 @@ const parseString = (params: string) => { } }; -const setTimelineColumn = (col: ColumnHeaderResult) => { - const timelineCols: ColumnHeaderOptions = { - ...col, - columnHeaderType: defaultColumnHeaderType, - id: col.id != null ? col.id : 'unknown', - placeholder: col.placeholder != null ? col.placeholder : undefined, - category: col.category != null ? col.category : undefined, - description: col.description != null ? col.description : undefined, - example: col.example != null ? col.example : undefined, - type: col.type != null ? col.type : undefined, - aggregatable: col.aggregatable != null ? col.aggregatable : undefined, - width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, - }; - return timelineCols; -}; +const setTimelineColumn = (col: ColumnHeaderResult) => + Object.entries(col).reduce( + (acc, [key, value]) => { + if (key !== 'id' && value != null) { + return { ...acc, [key]: value }; + } + return acc; + }, + { + columnHeaderType: defaultColumnHeaderType, + id: col.id != null ? col.id : 'unknown', + width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + } + ); const setTimelineFilters = (filter: FilterTimelineResult) => ({ $state: { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 7772bcede76fc..36e0652c3032a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx index c4c4e0e0c7065..8ec8827ccbed6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/actions/index.tsx @@ -18,7 +18,7 @@ interface Props { header: ColumnHeaderOptions; isLoading: boolean; onColumnRemoved: OnColumnRemoved; - sort: Sort; + sort: Sort[]; } /** Given a `header`, returns the `SortDirection` applicable to it */ @@ -53,7 +53,7 @@ CloseButton.displayName = 'CloseButton'; export const Actions = React.memo(({ header, onColumnRemoved, sort, isLoading }) => { return ( <> - {sort.columnId === header.id && isLoading ? ( + {sort.some((i) => i.columnId === header.id) && isLoading ? ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 8bf9b6ceb346a..543ffe2798947 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -26,7 +26,7 @@ interface ColumneHeaderProps { header: ColumnHeaderOptions; isDragging: boolean; onFilterChange?: OnFilterChange; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -131,6 +131,6 @@ export const ColumnHeader = React.memo( prevProps.timelineId === nextProps.timelineId && prevProps.isDragging === nextProps.isDragging && prevProps.onFilterChange === nextProps.onFilterChange && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && deepEqual(prevProps.header, nextProps.header) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap index 517f537b9a01b..fa9a4e78d88f2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/__snapshots__/index.test.tsx.snap @@ -14,10 +14,12 @@ exports[`Header renders correctly against snapshot 1`] = ` isResizing={false} onClick={[Function]} sort={ - Object { - "columnId": "@timestamp", - "sortDirection": "desc", - } + Array [ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + }, + ] } > diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx index 19d0220cd3462..656cf234ea662 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/header_content.tsx @@ -14,15 +14,14 @@ import { EventsHeading, EventsHeadingTitleButton, EventsHeadingTitleSpan } from import { Sort } from '../../sort'; import { SortIndicator } from '../../sort/sort_indicator'; import { HeaderToolTipContent } from '../header_tooltip_content'; -import { getSortDirection } from './helpers'; - +import { getSortDirection, getSortIndex } from './helpers'; interface HeaderContentProps { children: React.ReactNode; header: ColumnHeaderOptions; isLoading: boolean; isResizing: boolean; onClick: () => void; - sort: Sort; + sort: Sort[]; } const HeaderContentComponent: React.FC = ({ @@ -33,7 +32,7 @@ const HeaderContentComponent: React.FC = ({ onClick, sort, }) => ( - + {header.aggregatable ? ( = ({ ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts index 609f690903bf2..b2ad186ce1b1e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/helpers.ts @@ -11,7 +11,7 @@ import { Sort, SortDirection } from '../../sort'; interface GetNewSortDirectionOnClickParams { clickedHeader: ColumnHeaderOptions; - currentSort: Sort; + currentSort: Sort[]; } /** Given a `header`, returns the `SortDirection` applicable to it */ @@ -19,7 +19,10 @@ export const getNewSortDirectionOnClick = ({ clickedHeader, currentSort, }: GetNewSortDirectionOnClickParams): Direction => - clickedHeader.id === currentSort.columnId ? getNextSortDirection(currentSort) : Direction.desc; + currentSort.reduce( + (acc, item) => (clickedHeader.id === item.columnId ? getNextSortDirection(item) : acc), + Direction.desc + ); /** Given a current sort direction, it returns the next sort direction */ export const getNextSortDirection = (currentSort: Sort): Direction => { @@ -37,8 +40,14 @@ export const getNextSortDirection = (currentSort: Sort): Direction => { interface GetSortDirectionParams { header: ColumnHeaderOptions; - sort: Sort; + sort: Sort[]; } export const getSortDirection = ({ header, sort }: GetSortDirectionParams): SortDirection => - header.id === sort.columnId ? sort.sortDirection : 'none'; + sort.reduce( + (acc, item) => (header.id === item.columnId ? item.sortDirection : acc), + 'none' + ); + +export const getSortIndex = ({ header, sort }: GetSortDirectionParams): number => + sort.findIndex((s) => s.columnId === header.id); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx index 3ef9beb89309e..58d40c94ac338 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.test.tsx @@ -32,10 +32,12 @@ const filteredColumnHeader: ColumnHeaderType = 'text-filter'; describe('Header', () => { const columnHeader = defaultHeaders[0]; - const sort: Sort = { - columnId: columnHeader.id, - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: columnHeader.id, + sortDirection: Direction.desc, + }, + ]; const timelineId = 'fakeId'; test('renders correctly against snapshot', () => { @@ -119,10 +121,12 @@ describe('Header', () => { expect(mockDispatch).toBeCalledWith( timelineActions.updateSort({ id: timelineId, - sort: { - columnId: columnHeader.id, - sortDirection: Direction.asc, // (because the previous state was Direction.desc) - }, + sort: [ + { + columnId: columnHeader.id, + sortDirection: Direction.asc, // (because the previous state was Direction.desc) + }, + ], }) ); }); @@ -158,7 +162,7 @@ describe('Header', () => { ); - wrapper.find('[data-test-subj="header"]').first().simulate('click'); + wrapper.find(`[data-test-subj="header-${columnHeader.id}"]`).first().simulate('click'); expect(mockOnColumnSorted).not.toHaveBeenCalled(); }); @@ -180,14 +184,16 @@ describe('Header', () => { describe('getSortDirection', () => { test('it returns the sort direction when the header id matches the sort column id', () => { - expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort.sortDirection); + expect(getSortDirection({ header: columnHeader, sort })).toEqual(sort[0].sortDirection); }); test('it returns "none" when sort direction when the header id does NOT match the sort column id', () => { - const nonMatching: Sort = { - columnId: 'differentSocks', - sortDirection: Direction.desc, - }; + const nonMatching: Sort[] = [ + { + columnId: 'differentSocks', + sortDirection: Direction.desc, + }, + ]; expect(getSortDirection({ header: columnHeader, sort: nonMatching })).toEqual('none'); }); @@ -221,10 +227,12 @@ describe('Header', () => { describe('getNewSortDirectionOnClick', () => { test('it returns the expected new sort direction when the header id matches the sort column id', () => { - const sortMatches: Sort = { - columnId: columnHeader.id, - sortDirection: Direction.desc, - }; + const sortMatches: Sort[] = [ + { + columnId: columnHeader.id, + sortDirection: Direction.desc, + }, + ]; expect( getNewSortDirectionOnClick({ @@ -235,10 +243,12 @@ describe('Header', () => { }); test('it returns the expected new sort direction when the header id does NOT match the sort column id', () => { - const sortDoesNotMatch: Sort = { - columnId: 'someOtherColumn', - sortDirection: 'none', - }; + const sortDoesNotMatch: Sort[] = [ + { + columnId: 'someOtherColumn', + sortDirection: 'none', + }, + ]; expect( getNewSortDirectionOnClick({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx index 15d75cc9a4384..192a9c6b0973b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/header/index.tsx @@ -21,7 +21,7 @@ import { useManageTimeline } from '../../../../manage_timeline'; interface Props { header: ColumnHeaderOptions; onFilterChange?: OnFilterChange; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -33,22 +33,39 @@ export const HeaderComponent: React.FC = ({ }) => { const dispatch = useDispatch(); - const onClick = useCallback( - () => - dispatch( - timelineActions.updateSort({ - id: timelineId, - sort: { - columnId: header.id, - sortDirection: getNewSortDirectionOnClick({ - clickedHeader: header, - currentSort: sort, - }), - }, - }) - ), - [dispatch, header, timelineId, sort] - ); + const onColumnSort = useCallback(() => { + const columnId = header.id; + const sortDirection = getNewSortDirectionOnClick({ + clickedHeader: header, + currentSort: sort, + }); + const headerIndex = sort.findIndex((col) => col.columnId === columnId); + let newSort = []; + if (headerIndex === -1) { + newSort = [ + ...sort, + { + columnId, + sortDirection, + }, + ]; + } else { + newSort = [ + ...sort.slice(0, headerIndex), + { + columnId, + sortDirection, + }, + ...sort.slice(headerIndex + 1), + ]; + } + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: newSort, + }) + ); + }, [dispatch, header, sort, timelineId]); const onColumnRemoved = useCallback( (columnId) => dispatch(timelineActions.removeColumn({ id: timelineId, columnId })), @@ -68,7 +85,7 @@ export const HeaderComponent: React.FC = ({ header={header} isLoading={isLoading} isResizing={false} - onClick={onClick} + onClick={onColumnSort} sort={sort} > { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); +const timelineId = 'test'; describe('ColumnHeaders', () => { const mount = useMountAppended(); describe('rendering', () => { - const sort: Sort = { - columnId: 'fooColumn', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; test('renders correctly against snapshot', () => { const wrapper = shallow( @@ -39,7 +54,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -58,7 +73,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -78,7 +93,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} - timelineId={'test'} + timelineId={timelineId} /> ); @@ -88,4 +103,145 @@ describe('ColumnHeaders', () => { }); }); }); + + describe('#onColumnsSorted', () => { + let mockSort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + ]; + let mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + + beforeEach(() => { + mockDefaultHeaders = cloneDeep( + defaultHeaders.map((h) => (h.id === 'message' ? h : { ...h, aggregatable: true })) + ); + mockSort = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + ]; + }); + + test('Add column `event.category` as desc sorting', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-event.category"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { + columnId: 'host.name', + sortDirection: Direction.asc, + }, + { columnId: 'event.category', sortDirection: Direction.desc }, + ], + }) + ); + }); + + test('Change order of column `@timestamp` from desc to asc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-@timestamp"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.asc, + }, + { columnId: 'host.name', sortDirection: Direction.asc }, + ], + }) + ); + }); + + test('Change order of column `host.name` from asc to desc without changing index position', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="header-host.name"] [data-test-subj="header-sort-button"]') + .first() + .simulate('click'); + expect(mockDispatch).toHaveBeenCalledWith( + timelineActions.updateSort({ + id: timelineId, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + { columnId: 'host.name', sortDirection: Direction.desc }, + ], + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index aeab6a774ca41..66856f3bd6284 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiCheckbox, EuiToolTip } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiCheckbox, + EuiDataGridSorting, + EuiToolTip, + useDataGridColumnSorting, +} from '@elastic/eui'; +import deepEqual from 'fast-deep-equal'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; -import deepEqual from 'fast-deep-equal'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; import { DragEffects } from '../../../../../common/components/drag_and_drop/draggable_wrapper'; import { DraggableFieldBadge } from '../../../../../common/components/draggables/field_badge'; @@ -34,11 +42,18 @@ import { EventsThGroupData, EventsTrHeader, } from '../../styles'; -import { Sort } from '../sort'; +import { Sort, SortDirection } from '../sort'; import { EventsSelect } from './events_select'; import { ColumnHeader } from './column_header'; import * as i18n from './translations'; +import { timelineActions } from '../../../../store/timeline'; + +const SortingColumnsContainer = styled.div` + .euiPopover .euiButtonEmpty .euiButtonContent .euiButtonEmpty__text { + display: none; + } +`; interface Props { actionsColumnWidth: number; @@ -49,7 +64,7 @@ interface Props { onSelectAll: OnSelectAll; showEventsSelect: boolean; showSelectAllCheckbox: boolean; - sort: Sort; + sort: Sort[]; timelineId: string; } @@ -98,6 +113,7 @@ export const ColumnHeadersComponent = ({ sort, timelineId, }: Props) => { + const dispatch = useDispatch(); const [draggingIndex, setDraggingIndex] = useState(null); const { timelineFullScreen, @@ -189,6 +205,48 @@ export const ColumnHeadersComponent = ({ [ColumnHeaderList] ); + const myColumns = useMemo( + () => + columnHeaders.map(({ aggregatable, label, id, type }) => ({ + id, + isSortable: aggregatable, + displayAsText: label, + schema: type, + })), + [columnHeaders] + ); + + const onSortColumns = useCallback( + (cols: EuiDataGridSorting['columns']) => + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: cols.map(({ id, direction }) => ({ + columnId: id, + sortDirection: direction as SortDirection, + })), + }) + ), + [dispatch, timelineId] + ); + const sortedColumns = useMemo( + () => ({ + onSort: onSortColumns, + columns: sort.map<{ id: string; direction: 'asc' | 'desc' }>( + ({ columnId, sortDirection }) => ({ + id: columnId, + direction: sortDirection as 'asc' | 'desc', + }) + ), + }), + [onSortColumns, sort] + ); + const displayValues = useMemo( + () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.label ?? ch.id }), {}), + [columnHeaders] + ); + const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues); + return ( @@ -245,6 +303,13 @@ export const ColumnHeadersComponent = ({ + + + + {ColumnSorting} + + + {showEventsSelect && ( @@ -278,7 +343,7 @@ export const ColumnHeaders = React.memo( prevProps.onSelectAll === nextProps.onSelectAll && prevProps.showEventsSelect === nextProps.showEventsSelect && prevProps.showSelectAllCheckbox === nextProps.showSelectAllCheckbox && - prevProps.sort === nextProps.sort && + deepEqual(prevProps.sort, nextProps.sort) && prevProps.timelineId === nextProps.timelineId && deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && deepEqual(prevProps.browserFields, nextProps.browserFields) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts index 1ebfa957b654f..c946182ddfe06 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts @@ -22,6 +22,10 @@ export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullS defaultMessage: 'Full screen', }); +export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', { + defaultMessage: 'Sort fields', +}); + export const TYPE = i18n.translate('xpack.securitySolution.timeline.typeTooltip', { defaultMessage: 'Type', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index 6fddb5403561e..bf70d7bff1ff5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -7,15 +7,17 @@ /** The minimum (fixed) width of the Actions column */ export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px; +/** Additional column width to include when checkboxes are shown **/ +export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; + /** The (fixed) width of the Actions column */ -export const DEFAULT_ACTIONS_COLUMN_WIDTH = 24 * 4; // px; +export const DEFAULT_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 5; // px; /** * The (fixed) width of the Actions column when the timeline body is used as * an events viewer, which has fewer actions than a regular events viewer */ -export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 24 * 3; // px; -/** Additional column width to include when checkboxes are shown **/ -export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; +export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = SHOW_CHECK_BOXES_COLUMN_WIDTH * 4; // px; + /** The default minimum width of a column (when a width for the column type is not specified) */ export const DEFAULT_COLUMN_MIN_WIDTH = 180; // px /** The default minimum width of a column of type `date` */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index fc9967bdeff98..704af61b4a12f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -16,13 +16,14 @@ import { TestProviders } from '../../../../common/mock/test_providers'; import { BodyComponent, StatefulBodyProps } from '.'; import { Sort } from './sort'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; -import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; import { timelineActions } from '../../../store/timeline'; -const mockSort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, -}; +const mockSort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, +]; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -130,20 +131,6 @@ describe('Body', () => { }); }); }, 20000); - - test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_BODY_CLASS_NAME}`, () => { - const wrapper = mount( - - - - ); - expect( - wrapper - .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_BODY_CLASS_NAME}`) - .first() - .exists() - ).toEqual(true); - }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 45641a34f2cf4..ea397b67c31cc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,7 +33,7 @@ interface OwnProps { data: TimelineItem[]; id: string; isEventViewer?: boolean; - sort: Sort; + sort: Sort[]; refetch: inputsModel.Refetch; onRuleChange?: () => void; } @@ -144,7 +144,7 @@ export const BodyComponent = React.memo( return ( <> - + ); + } else if (fieldType === GEO_FIELD_TYPE) { + return <>{value}; } else if (fieldType === DATE_FIELD_TYPE) { return ( + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx index dcaedb90e7252..6593abf71e368 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx @@ -15,12 +15,12 @@ import { getDirection, SortIndicator } from './sort_indicator'; describe('SortIndicator', () => { describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the expected sort indicator when direction is ascending', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'sortUp' @@ -28,7 +28,7 @@ describe('SortIndicator', () => { }); test('it renders the expected sort indicator when direction is descending', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'sortDown' @@ -36,7 +36,7 @@ describe('SortIndicator', () => { }); test('it renders the expected sort indicator when direction is `none`', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'empty' @@ -60,7 +60,7 @@ describe('SortIndicator', () => { describe('sort indicator tooltip', () => { test('it returns the expected tooltip when the direction is ascending', () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content @@ -68,7 +68,7 @@ describe('SortIndicator', () => { }); test('it returns the expected tooltip when the direction is descending', () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content @@ -76,7 +76,7 @@ describe('SortIndicator', () => { }); test('it does NOT render a tooltip when sort direction is `none`', () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx index 8b842dfa2197e..518103e8cb643 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { Direction } from '../../../../../graphql/types'; import * as i18n from '../translations'; +import { SortNumber } from './sort_number'; import { SortDirection } from '.'; @@ -35,10 +36,11 @@ export const getDirection = (sortDirection: SortDirection): SortDirectionIndicat interface Props { sortDirection: SortDirection; + sortNumber: number; } /** Renders a sort indicator */ -export const SortIndicator = React.memo(({ sortDirection }) => { +export const SortIndicator = React.memo(({ sortDirection, sortNumber }) => { const direction = getDirection(sortDirection); if (direction != null) { @@ -51,7 +53,10 @@ export const SortIndicator = React.memo(({ sortDirection }) => { } data-test-subj="sort-indicator-tooltip" > - + <> + + + ); } else { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx new file mode 100644 index 0000000000000..48dd70a16e70a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_number.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiNotificationBadge } from '@elastic/eui'; +import React from 'react'; + +interface Props { + sortNumber: number; +} + +export const SortNumber = React.memo(({ sortNumber }) => { + if (sortNumber >= 0) { + return ( + + {sortNumber + 1} + + ); + } else { + return ; + } +}); + +SortNumber.displayName = 'SortNumber'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts index 54755fbc84277..11bc3da8c05bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/events.ts @@ -38,6 +38,10 @@ export type OnFilterChange = (filter: { columnId: ColumnId; filter: string }) => /** Invoked when a column is sorted */ export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void; +export type OnColumnsSorted = ( + sorted: Array<{ columnId: ColumnId; sortDirection: SortDirection }> +) => void; + export type OnColumnRemoved = (columnId: ColumnId) => void; export type OnColumnResized = ({ columnId, delta }: { columnId: ColumnId; delta: number }) => void; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index 085a9bf8cba3f..59a7b936dfbac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -20,6 +20,7 @@ import { import { StatefulTimeline, Props as StatefulTimelineOwnProps } from './index'; import { useTimelineEvents } from '../../containers/index'; +import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from './styles'; jest.mock('../../containers/index', () => ({ useTimelineEvents: jest.fn(), @@ -56,7 +57,7 @@ jest.mock('../../../common/containers/sourcerer', () => { }); describe('StatefulTimeline', () => { const props: StatefulTimelineOwnProps = { - timelineId: 'id', + timelineId: 'timeline-test', }; beforeEach(() => { @@ -71,4 +72,18 @@ describe('StatefulTimeline', () => { ); expect(wrapper.find('[data-test-subj="timeline"]')).toBeTruthy(); }); + + test(`it add attribute data-timeline-id in ${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`, () => { + const wrapper = mount( + + + + ); + expect( + wrapper + .find(`[data-timeline-id="timeline-test"].${SELECTOR_TIMELINE_GLOBAL_CONTAINER}`) + .first() + .exists() + ).toEqual(true); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 37145b9348ac1..4e6bca7fd9625 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -21,13 +21,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { activeTimeline } from '../../containers/active_timeline_context'; import * as i18n from './translations'; import { TabsContent } from './tabs_content'; - -const TimelineContainer = styled.div` - height: 100%; - display: flex; - flex-direction: column; - position: relative; -`; +import { TimelineContainer } from './styles'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -78,7 +72,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { }, []); return ( - + {timelineType === TimelineType.template && ( {i18n.TIMELINE_TEMPLATE} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap index c726e92455f25..c9355797193a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/__snapshots__/index.test.tsx.snap @@ -276,10 +276,12 @@ In other use cases the message field can be used to concatenate different values showCallOutUnauthorizedMsg={false} showEventDetails={false} sort={ - Object { - "columnId": "@timestamp", - "sortDirection": "desc", - } + Array [ + Object { + "columnId": "@timestamp", + "sortDirection": "desc", + }, + ] } start="2018-03-23T18:49:23.132Z" status="active" diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 4019f46b8c07b..7e60461a01574 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -64,10 +64,12 @@ jest.mock('../../../../common/lib/kibana', () => { describe('Timeline', () => { let props = {} as QueryTabContentComponentProps; - const sort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 8186ee8b77628..69a7299b9833d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -214,11 +214,12 @@ export const QueryTabContentComponent: React.FC = ({ }, [columns]); const timelineQuerySortField = useMemo( - () => ({ - field: sort.columnId, - direction: sort.sortDirection as Direction, - }), - [sort.columnId, sort.sortDirection] + () => + sort.map(({ columnId, sortDirection }) => ({ + field: columnId, + direction: sortDirection as Direction, + })), + [sort] ); const { initializeTimeline, setIsTimelineLoading } = useManageTimeline(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index 9f9940203960c..ef7c821bd652d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -11,6 +11,19 @@ import styled, { createGlobalStyle } from 'styled-components'; import { TimelineEventsType } from '../../../../common/types/timeline'; import { IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME } from '../../../common/components/drag_and_drop/helpers'; +/** + * TIMELINE BODY + */ +export const SELECTOR_TIMELINE_GLOBAL_CONTAINER = 'securitySolutionTimeline__container'; +export const TimelineContainer = styled.div.attrs(({ className = '' }) => ({ + className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`, +}))` + height: 100%; + display: flex; + flex-direction: column; + position: relative; +`; + /** * TIMELINE BODY */ @@ -99,6 +112,9 @@ export const EventsThGroupActions = styled.div.attrs(({ className = '' }) => ({ min-width: 0; padding-left: ${({ isEventViewer }) => !isEventViewer ? '4px;' : '0;'}; // match timeline event border + button { + color: ${({ theme }) => theme.eui.euiColorPrimary}; + } `; export const EventsThGroupData = styled.div.attrs(({ className = '' }) => ({ diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index a439699d27f6d..7e2a6fa1c15cf 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -73,10 +73,12 @@ const timelineData = { end: 1591084965409, }, savedQueryId: null, - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], status: TimelineStatus.active, }; const mockPatchTimelineResponse = { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 8f1644550d147..ebc86b3c5cf5e 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -85,6 +85,9 @@ export const useTimelineEventsDetails = ({ } }, error: () => { + if (!didCancel) { + setLoading(false); + } notifications.toasts.addDanger('Failed to run search'); }, }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx index a168e814208e7..3baab2024558f 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.tsx @@ -56,7 +56,7 @@ export interface UseTimelineEventsProps { fields: string[]; indexNames: string[]; limit: number; - sort: SortField; + sort: SortField[]; startDate: string; timerangeKind?: 'absolute' | 'relative'; } @@ -65,10 +65,12 @@ const getTimelineEvents = (timelineEdges: TimelineEdges[]): TimelineItem[] => timelineEdges.map((e: TimelineEdges) => e.node); const ID = 'timelineEventsQuery'; -export const initSortDefault = { - field: '@timestamp', - direction: Direction.asc, -}; +export const initSortDefault = [ + { + field: '@timestamp', + direction: Direction.asc, + }, +]; function usePreviousRequest(value: TimelineEventsAllRequestOptions | null) { const ref = useRef(value); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index 1a09868da7771..604767bcde26c 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -32,7 +32,12 @@ export const getTimelinesInStorageByIds = (storage: Storage, timelineIds: Timeli return { ...acc, - [timelineId]: timelineModel, + [timelineId]: { + ...timelineModel, + ...(timelineModel.sort != null && !Array.isArray(timelineModel.sort) + ? { sort: [timelineModel.sort] } + : {}), + }, }; }, {} as { [K in TimelineIdLiteral]: TimelineModel }); }; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts index fa0ecb349f9c8..9e34d3470d296 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/one/index.gql_query.ts @@ -138,10 +138,7 @@ export const oneTimelineQuery = gql` templateTimelineId templateTimelineVersion savedQueryId - sort { - columnId - sortDirection - } + sort created createdBy updated diff --git a/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts index 12d3e6bfd7172..e255ac5bdda5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/persist.gql_query.ts @@ -102,10 +102,7 @@ export const persistTimelineMutation = gql` end } savedQueryId - sort { - columnId - sortDirection - } + sort created createdBy updated diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index b8dfa698a9307..479c289cdd21d 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -72,7 +72,7 @@ export interface TimelineInput { filterQuery: SerializedFilterQuery | null; }; show?: boolean; - sort?: Sort; + sort?: Sort[]; showCheckboxes?: boolean; timelineType?: TimelineTypeLiteral; templateTimelineId?: string | null; @@ -216,7 +216,7 @@ export const updateRange = actionCreator<{ id: string; start: string; end: strin 'UPDATE_RANGE' ); -export const updateSort = actionCreator<{ id: string; sort: Sort }>('UPDATE_SORT'); +export const updateSort = actionCreator<{ id: string; sort: Sort[] }>('UPDATE_SORT'); export const updateAutoSaveMsg = actionCreator<{ timelineId: string | null; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts index 84551de9ec628..211bba3cc47d2 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/defaults.ts @@ -52,10 +52,12 @@ export const timelineDefaults: SubsetTimelineModel & Pick { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { columnId: '@timestamp', sortDirection: Direction.desc }, + sort: [{ columnId: '@timestamp', sortDirection: Direction.desc }], status: TimelineStatus.active, version: 'WzM4LDFd', id: '11169110-fc22-11e9-8ca9-072f15ce2685', @@ -286,10 +286,12 @@ describe('Epic Timeline', () => { }, }, savedQueryId: 'my endgame timeline query', - sort: { - columnId: '@timestamp', - sortDirection: 'desc', - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: 'desc', + }, + ], templateTimelineId: null, templateTimelineVersion: null, timelineType: TimelineType.default, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index a2bccaddb309e..5fcbcf434d3ee 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -58,10 +58,12 @@ describe('epicLocalStorage', () => { ); let props = {} as QueryTabContentComponentProps; - const sort: Sort = { - columnId: '@timestamp', - sortDirection: Direction.desc, - }; + const sort: Sort[] = [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ]; const startDate = '2018-03-23T18:49:23.132Z'; const endDate = '2018-03-24T03:33:52.253Z'; @@ -159,10 +161,12 @@ describe('epicLocalStorage', () => { store.dispatch( updateSort({ id: 'test', - sort: { - columnId: 'event.severity', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: 'event.severity', + sortDirection: Direction.desc, + }, + ], }) ); await waitFor(() => expect(addTimelineInStorageMock).toHaveBeenCalled()); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index 1122b7a94e0e0..f9f4622c9d63c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -179,7 +179,7 @@ interface AddNewTimelineParams { filterQuery: SerializedFilterQuery | null; }; show?: boolean; - sort?: Sort; + sort?: Sort[]; showCheckboxes?: boolean; timelineById: TimelineById; timelineType: TimelineTypeLiteral; @@ -762,7 +762,7 @@ export const updateTimelineRange = ({ interface UpdateTimelineSortParams { id: string; - sort: Sort; + sort: Sort[]; timelineById: TimelineById; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index e4d1a6b512689..9c71fabfffac5 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -124,7 +124,7 @@ export interface TimelineModel { /** When true, shows checkboxes enabling selection. Selected events store in selectedEventIds **/ showCheckboxes: boolean; /** Specifies which column the timeline is sorted on, and the direction (ascending / descending) */ - sort: Sort; + sort: Sort[]; /** status: active | draft */ status: TimelineStatus; /** updated saved object timestamp */ diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts index 2ca34742affef..59d5800271b8a 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.test.ts @@ -101,10 +101,12 @@ const basicTimeline: TimelineModel = { selectedEventIds: {}, show: true, showCheckboxes: false, - sort: { - columnId: '@timestamp', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: '@timestamp', + sortDirection: Direction.desc, + }, + ], status: TimelineStatus.active, templateTimelineId: null, templateTimelineVersion: null, @@ -953,10 +955,12 @@ describe('Timeline', () => { beforeAll(() => { update = updateTimelineSort({ id: 'foo', - sort: { - columnId: 'some column', - sortDirection: Direction.desc, - }, + sort: [ + { + columnId: 'some column', + sortDirection: Direction.desc, + }, + ], timelineById: timelineByIdMock, }); }); @@ -964,8 +968,8 @@ describe('Timeline', () => { expect(update).not.toBe(timelineByIdMock); }); - test('should update the timeline range', () => { - expect(update.foo.sort).toEqual({ columnId: 'some column', sortDirection: Direction.desc }); + test('should update the sort attribute', () => { + expect(update.foo.sort).toEqual([{ columnId: 'some column', sortDirection: Direction.desc }]); }); }); diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index 2358e78b044ed..ca6c57f025faf 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -174,7 +174,7 @@ export const timelineSchema = gql` timelineType: TimelineType dateRange: DateRangePickerInput savedQueryId: String - sort: SortTimelineInput + sort: [SortTimelineInput!] status: TimelineStatus } @@ -238,10 +238,6 @@ export const timelineSchema = gql` ${favoriteTimeline} } - type SortTimelineResult { - ${sortTimeline} - } - type FilterMetaTimelineResult { ${filtersMetaTimeline} } @@ -277,7 +273,7 @@ export const timelineSchema = gql` pinnedEventsSaveObject: [PinnedEvent!] savedQueryId: String savedObjectId: String! - sort: SortTimelineResult + sort: ToAny status: TimelineStatus title: String templateTimelineId: String diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index bda0fed494a6f..3ea964c0ee01f 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -105,7 +105,7 @@ export interface TimelineInput { savedQueryId?: Maybe; - sort?: Maybe; + sort?: Maybe; status?: Maybe; } @@ -634,7 +634,7 @@ export interface TimelineResult { savedObjectId: string; - sort?: Maybe; + sort?: Maybe; status?: Maybe; @@ -777,12 +777,6 @@ export interface KueryFilterQueryResult { expression?: Maybe; } -export interface SortTimelineResult { - columnId?: Maybe; - - sortDirection?: Maybe; -} - export interface ResponseTimelines { timeline: (Maybe)[]; @@ -2336,7 +2330,6 @@ export namespace AgentFieldsResolvers { > = Resolver; } - export namespace CloudFieldsResolvers { export interface Resolvers { instance?: InstanceResolver, TypeParent, TContext>; @@ -2665,7 +2658,7 @@ export namespace TimelineResultResolvers { savedObjectId?: SavedObjectIdResolver; - sort?: SortResolver, TypeParent, TContext>; + sort?: SortResolver, TypeParent, TContext>; status?: StatusResolver, TypeParent, TContext>; @@ -2785,7 +2778,7 @@ export namespace TimelineResultResolvers { TContext = SiemContext > = Resolver; export type SortResolver< - R = Maybe, + R = Maybe, Parent = TimelineResult, TContext = SiemContext > = Resolver; @@ -3245,25 +3238,6 @@ export namespace KueryFilterQueryResultResolvers { > = Resolver; } -export namespace SortTimelineResultResolvers { - export interface Resolvers { - columnId?: ColumnIdResolver, TypeParent, TContext>; - - sortDirection?: SortDirectionResolver, TypeParent, TContext>; - } - - export type ColumnIdResolver< - R = Maybe, - Parent = SortTimelineResult, - TContext = SiemContext - > = Resolver; - export type SortDirectionResolver< - R = Maybe, - Parent = SortTimelineResult, - TContext = SiemContext - > = Resolver; -} - export namespace ResponseTimelinesResolvers { export interface Resolvers { timeline?: TimelineResolver<(Maybe)[], TypeParent, TContext>; @@ -6091,7 +6065,6 @@ export type IResolvers = { SerializedFilterQueryResult?: SerializedFilterQueryResultResolvers.Resolvers; SerializedKueryQueryResult?: SerializedKueryQueryResultResolvers.Resolvers; KueryFilterQueryResult?: KueryFilterQueryResultResolvers.Resolvers; - SortTimelineResult?: SortTimelineResultResolvers.Resolvers; ResponseTimelines?: ResponseTimelinesResolvers.Resolvers; Mutation?: MutationResolvers.Resolvers; ResponseNote?: ResponseNoteResolvers.Resolvers; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts index f888675b60410..271e53d4e6c9b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/convert_saved_object_to_savedtimeline.ts @@ -60,6 +60,12 @@ export const convertSavedObjectToSavedTimeline = (savedObject: unknown): Timelin savedTimeline.attributes.timelineType, savedTimeline.attributes.status ), + sort: + savedTimeline.attributes.sort != null + ? Array.isArray(savedTimeline.attributes.sort) + ? savedTimeline.attributes.sort + : [savedTimeline.attributes.sort] + : [], }; return { savedObjectId: savedTimeline.id, diff --git a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts index 1aba6660677cd..9fd371c6f1cca 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/helpers/to_array.ts @@ -7,5 +7,37 @@ export const toArray = (value: T | T[] | null): T[] => Array.isArray(value) ? value : value == null ? [] : [value]; -export const toStringArray = (value: T | T[] | null): T[] | string[] => - Array.isArray(value) ? value : value == null ? [] : [`${value}`]; +export const toStringArray = (value: T | T[] | null): string[] => { + if (Array.isArray(value)) { + return value.reduce((acc, v) => { + if (v != null) { + switch (typeof v) { + case 'number': + case 'boolean': + return [...acc, v.toString()]; + case 'object': + try { + return [...acc, JSON.stringify(value)]; + } catch { + return [...acc, 'Invalid Object']; + } + case 'string': + return [...acc, v]; + default: + return [...acc, `${v}`]; + } + } + return acc; + }, []); + } else if (value == null) { + return []; + } else if (!Array.isArray(value) && typeof value === 'object') { + try { + return [JSON.stringify(value)]; + } catch { + return ['Invalid Object']; + } + } else { + return [`${value}`]; + } +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts new file mode 100644 index 0000000000000..b62ddc00f2e30 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventHit } from '../../../../../../common/search_strategy'; +import { TIMELINE_EVENTS_FIELDS } from './constants'; +import { formatTimelineData } from './helpers'; + +describe('#formatTimelineData', () => { + it('happy path', () => { + const response: EventHit = { + _index: 'auditbeat-7.8.0-2020.11.05-000003', + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _score: 0, + _type: '', + fields: { + 'event.category': ['process'], + 'process.ppid': [3977], + 'user.name': ['jenkins'], + 'process.args': ['go', 'vet', './...'], + message: ['Process go (PID: 4313) by user jenkins STARTED'], + 'process.pid': [4313], + 'process.working_directory': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + 'process.entity_id': ['Z59cIkAAIw8ZoK0H'], + 'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + 'process.name': ['go'], + 'event.action': ['process_started'], + 'agent.type': ['auditbeat'], + '@timestamp': ['2020-11-17T14:48:08.922Z'], + 'event.module': ['system'], + 'event.type': ['start'], + 'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + 'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + 'host.os.family': ['debian'], + 'event.kind': ['event'], + 'host.id': ['e59991e835905c65ed3e455b33e13bd6'], + 'event.dataset': ['process'], + 'process.executable': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + _source: {}, + sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'], + aggregations: {}, + }; + + expect( + formatTimelineData( + ['@timestamp', 'host.name', 'destination.ip', 'source.ip'], + TIMELINE_EVENTS_FIELDS, + response + ) + ).toEqual({ + cursor: { + tiebreaker: 'beats-ci-immutable-ubuntu-1804-1605624279743236239', + value: '1605624488922', + }, + node: { + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _index: 'auditbeat-7.8.0-2020.11.05-000003', + data: [ + { + field: '@timestamp', + value: ['2020-11-17T14:48:08.922Z'], + }, + { + field: 'host.name', + value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + }, + ], + ecs: { + '@timestamp': ['2020-11-17T14:48:08.922Z'], + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _index: 'auditbeat-7.8.0-2020.11.05-000003', + agent: { + type: ['auditbeat'], + }, + event: { + action: ['process_started'], + category: ['process'], + dataset: ['process'], + kind: ['event'], + module: ['system'], + type: ['start'], + }, + host: { + id: ['e59991e835905c65ed3e455b33e13bd6'], + ip: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + name: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + os: { + family: ['debian'], + }, + }, + message: ['Process go (PID: 4313) by user jenkins STARTED'], + process: { + args: ['go', 'vet', './...'], + entity_id: ['Z59cIkAAIw8ZoK0H'], + executable: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + hash: { + sha1: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + }, + name: ['go'], + pid: ['4313'], + ppid: ['3977'], + working_directory: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + }, + timestamp: '2020-11-17T14:48:08.922Z', + user: { + name: ['jenkins'], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index 8e2bfb5426610..a9aee2175b31d 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -7,6 +7,7 @@ import { get, has, merge, uniq } from 'lodash/fp'; import { EventHit, TimelineEdges } from '../../../../../../common/search_strategy'; import { toStringArray } from '../../../../helpers/to_array'; +import { formatGeoLocation, isGeoField } from '../details/helpers'; export const formatTimelineData = ( dataFields: readonly string[], @@ -18,7 +19,7 @@ export const formatTimelineData = ( flattenedFields.node._id = hit._id; flattenedFields.node._index = hit._index; flattenedFields.node.ecs._id = hit._id; - flattenedFields.node.ecs.timestamp = hit._source['@timestamp']; + flattenedFields.node.ecs.timestamp = (hit.fields['@timestamp'][0] ?? '') as string; flattenedFields.node.ecs._index = hit._index; if (hit.sort && hit.sort.length > 1) { flattenedFields.cursor.value = hit.sort[0]; @@ -40,13 +41,12 @@ const specialFields = ['_id', '_index', '_type', '_score']; const mergeTimelineFieldsWithHit = ( fieldName: string, flattenedFields: T, - hit: { _source: {} }, + hit: { fields: Record }, dataFields: readonly string[], ecsFields: readonly string[] ) => { if (fieldName != null || dataFields.includes(fieldName)) { - const esField = fieldName; - if (has(esField, hit._source) || specialFields.includes(esField)) { + if (has(fieldName, hit.fields) || specialFields.includes(fieldName)) { const objectWithProperty = { node: { ...get('node', flattenedFields), @@ -55,9 +55,11 @@ const mergeTimelineFieldsWithHit = ( ...get('node.data', flattenedFields), { field: fieldName, - value: specialFields.includes(esField) - ? toStringArray(get(esField, hit)) - : toStringArray(get(esField, hit._source)), + value: specialFields.includes(fieldName) + ? toStringArray(get(fieldName, hit)) + : isGeoField(fieldName) + ? formatGeoLocation(hit.fields[fieldName]) + : toStringArray(hit.fields[fieldName]), }, ] : get('node.data', flattenedFields), @@ -68,7 +70,7 @@ const mergeTimelineFieldsWithHit = ( ...fieldName.split('.').reduceRight( // @ts-expect-error (obj, next) => ({ [next]: obj }), - toStringArray(get(esField, hit._source)) + toStringArray(hit.fields[fieldName]) ), } : get('node.ecs', flattenedFields), diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts index 19535fa3dc8a8..de58e7cf44d64 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/index.ts @@ -9,11 +9,12 @@ import { cloneDeep, uniq } from 'lodash/fp'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { + EventHit, TimelineEventsQueries, TimelineEventsAllStrategyResponse, TimelineEventsAllRequestOptions, TimelineEdges, -} from '../../../../../../common/search_strategy/timeline'; +} from '../../../../../../common/search_strategy'; import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionTimelineFactory } from '../../types'; import { buildTimelineEventsAllQuery } from './query.events_all.dsl'; @@ -39,8 +40,7 @@ export const timelineEventsAll: SecuritySolutionTimelineFactory - // @ts-expect-error - formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit) + formatTimelineData(options.fieldRequested, TIMELINE_EVENTS_FIELDS, hit as EventHit) ); const inspect = { dsl: [inspectStringifyObject(buildTimelineEventsAllQuery(queryOptions))], diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts index a5a0c877ecdd3..034a2b3c6ea95 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/query.events_all.dsl.ts @@ -44,14 +44,11 @@ export const buildTimelineEventsAllQuery = ({ const filter = [...filterClause, ...getTimerangeFilter(timerange), { match_all: {} }]; - const getSortField = (sortField: SortField) => { - if (sortField.field) { - const field: string = sortField.field === 'timestamp' ? '@timestamp' : sortField.field; - - return [{ [field]: sortField.direction }]; - } - return []; - }; + const getSortField = (sortFields: SortField[]) => + sortFields.map((item) => { + const field: string = item.field === 'timestamp' ? '@timestamp' : item.field; + return { [field]: item.direction }; + }); const dslQuery = { allowNoIndices: true, @@ -68,7 +65,7 @@ export const buildTimelineEventsAllQuery = ({ size: querySize, track_total_hits: true, sort: getSortField(sort), - _source: fields, + fields, }, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts new file mode 100644 index 0000000000000..34610da7d7aa3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventHit } from '../../../../../../common/search_strategy'; +import { getDataFromHits } from './helpers'; + +describe('#getDataFromHits', () => { + it('happy path', () => { + const response: EventHit = { + _index: 'auditbeat-7.8.0-2020.11.05-000003', + _id: 'tkCt1nUBaEgqnrVSZ8R_', + _score: 0, + _type: '', + fields: { + 'event.category': ['process'], + 'process.ppid': [3977], + 'user.name': ['jenkins'], + 'process.args': ['go', 'vet', './...'], + message: ['Process go (PID: 4313) by user jenkins STARTED'], + 'process.pid': [4313], + 'process.working_directory': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + 'process.entity_id': ['Z59cIkAAIw8ZoK0H'], + 'host.ip': ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + 'process.name': ['go'], + 'event.action': ['process_started'], + 'agent.type': ['auditbeat'], + '@timestamp': ['2020-11-17T14:48:08.922Z'], + 'event.module': ['system'], + 'event.type': ['start'], + 'host.name': ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + 'process.hash.sha1': ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + 'host.os.family': ['debian'], + 'event.kind': ['event'], + 'host.id': ['e59991e835905c65ed3e455b33e13bd6'], + 'event.dataset': ['process'], + 'process.executable': [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + _source: {}, + sort: ['1605624488922', 'beats-ci-immutable-ubuntu-1804-1605624279743236239'], + aggregations: {}, + }; + + expect(getDataFromHits(response.fields)).toEqual([ + { + category: 'event', + field: 'event.category', + originalValue: ['process'], + values: ['process'], + }, + { category: 'process', field: 'process.ppid', originalValue: ['3977'], values: ['3977'] }, + { category: 'user', field: 'user.name', originalValue: ['jenkins'], values: ['jenkins'] }, + { + category: 'process', + field: 'process.args', + originalValue: ['go', 'vet', './...'], + values: ['go', 'vet', './...'], + }, + { + category: 'base', + field: 'message', + originalValue: ['Process go (PID: 4313) by user jenkins STARTED'], + values: ['Process go (PID: 4313) by user jenkins STARTED'], + }, + { category: 'process', field: 'process.pid', originalValue: ['4313'], values: ['4313'] }, + { + category: 'process', + field: 'process.working_directory', + originalValue: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + values: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/src/github.com/elastic/beats/libbeat', + ], + }, + { + category: 'process', + field: 'process.entity_id', + originalValue: ['Z59cIkAAIw8ZoK0H'], + values: ['Z59cIkAAIw8ZoK0H'], + }, + { + category: 'host', + field: 'host.ip', + originalValue: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + values: ['10.224.1.237', 'fe80::4001:aff:fee0:1ed', '172.17.0.1'], + }, + { category: 'process', field: 'process.name', originalValue: ['go'], values: ['go'] }, + { + category: 'event', + field: 'event.action', + originalValue: ['process_started'], + values: ['process_started'], + }, + { + category: 'agent', + field: 'agent.type', + originalValue: ['auditbeat'], + values: ['auditbeat'], + }, + { + category: 'base', + field: '@timestamp', + originalValue: ['2020-11-17T14:48:08.922Z'], + values: ['2020-11-17T14:48:08.922Z'], + }, + { category: 'event', field: 'event.module', originalValue: ['system'], values: ['system'] }, + { category: 'event', field: 'event.type', originalValue: ['start'], values: ['start'] }, + { + category: 'host', + field: 'host.name', + originalValue: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + values: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], + }, + { + category: 'process', + field: 'process.hash.sha1', + originalValue: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + values: ['1eac22336a41e0660fb302add9d97daa2bcc7040'], + }, + { category: 'host', field: 'host.os.family', originalValue: ['debian'], values: ['debian'] }, + { category: 'event', field: 'event.kind', originalValue: ['event'], values: ['event'] }, + { + category: 'host', + field: 'host.id', + originalValue: ['e59991e835905c65ed3e455b33e13bd6'], + values: ['e59991e835905c65ed3e455b33e13bd6'], + }, + { + category: 'event', + field: 'event.dataset', + originalValue: ['process'], + values: ['process'], + }, + { + category: 'process', + field: 'process.executable', + originalValue: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + values: [ + '/var/lib/jenkins/workspace/Beats_beats_PR-22624/.gvm/versions/go1.14.7.linux.amd64/bin/go', + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts index 2dd406ffaa450..68bef2e8c656a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/helpers.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, isEmpty, isNumber, isObject, isString } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy/timeline'; +import { toStringArray } from '../../../../helpers/to_array'; export const baseCategoryFields = ['@timestamp', 'labels', 'message', 'tags']; @@ -18,39 +19,32 @@ export const getFieldCategory = (field: string): string => { return fieldCategory; }; -export const getDataFromHits = ( - sources: EventSource, - category?: string, - path?: string -): TimelineEventsDetailsItem[] => - Object.keys(sources).reduce((accumulator, source) => { - const item: EventSource = get(source, sources); - if (Array.isArray(item) || isString(item) || isNumber(item)) { - const field = path ? `${path}.${source}` : source; - const fieldCategory = getFieldCategory(field); +export const formatGeoLocation = (item: unknown[]) => { + const itemGeo = item.length > 0 ? (item[0] as { coordinates: number[] }) : null; + if (itemGeo != null && !isEmpty(itemGeo.coordinates)) { + try { + return toStringArray({ long: itemGeo.coordinates[0], lat: itemGeo.coordinates[1] }); + } catch { + return toStringArray(item); + } + } + return toStringArray(item); +}; - return [ - ...accumulator, - { - category: fieldCategory, - field, - values: Array.isArray(item) - ? item.map((value) => { - if (isObject(value)) { - return JSON.stringify(value); - } +export const isGeoField = (field: string) => + field.includes('geo.location') || field.includes('geoip.location'); - return value; - }) - : [item], - originalValue: item, - } as TimelineEventsDetailsItem, - ]; - } else if (isObject(item)) { - return [ - ...accumulator, - ...getDataFromHits(item, category || source, path ? `${path}.${source}` : source), - ]; - } - return accumulator; +export const getDataFromHits = (fields: Record): TimelineEventsDetailsItem[] => + Object.keys(fields).reduce((accumulator, field) => { + const item: unknown[] = fields[field]; + const fieldCategory = getFieldCategory(field); + return [ + ...accumulator, + { + category: fieldCategory, + field, + values: isGeoField(field) ? formatGeoLocation(item) : toStringArray(item), + originalValue: toStringArray(item), + } as TimelineEventsDetailsItem, + ]; }, []); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts index 54e138c1e9d6f..0a011d2bfe878 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr, merge } from 'lodash/fp'; +import { cloneDeep, merge } from 'lodash/fp'; import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; import { @@ -27,13 +27,14 @@ export const timelineEventsDetails: SecuritySolutionTimelineFactory ): Promise => { const { indexName, eventId, docValueFields = [] } = options; - const sourceData = getOr({}, 'hits.hits.0._source', response.rawResponse); - const hitsData = getOr({}, 'hits.hits.0', response.rawResponse); + const fieldsData = cloneDeep(response.rawResponse.hits.hits[0].fields ?? {}); + const hitsData = cloneDeep(response.rawResponse.hits.hits[0] ?? {}); delete hitsData._source; + delete hitsData.fields; const inspect = { dsl: [inspectStringifyObject(buildTimelineDetailsQuery(indexName, eventId, docValueFields))], }; - const data = getDataFromHits(merge(sourceData, hitsData)); + const data = getDataFromHits(merge(fieldsData, hitsData)); return { ...response, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts index 216e8f947d261..8d70a08c214d8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/details/query.events_details.dsl.ts @@ -21,6 +21,7 @@ export const buildTimelineDetailsQuery = ( _id: [id], }, }, + fields: ['*'], }, size: 1, }); diff --git a/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts b/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts index a399c07e31065..07e7cad89c24a 100644 --- a/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts +++ b/x-pack/test/api_integration/apis/security_solution/saved_objects/timeline.ts @@ -171,7 +171,7 @@ export default function ({ getService }: FtrProviderContext) { expect(kqlMode).to.be(timelineObject.kqlMode); expect(kqlQuery).to.eql(timelineObject.kqlQuery); expect(savedObjectId).to.not.be.empty(); - expect(sort).to.eql(timelineObject.sort); + expect(sort).to.eql([timelineObject.sort]); expect(title).to.be(timelineObject.title); expect(version).to.not.be.empty(); }); diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts index d3f40188aa6d3..a04c2fef92329 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_details.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_details.ts @@ -15,250 +15,547 @@ const INDEX_NAME = 'filebeat-7.0.0-iot-2019.06'; const ID = 'QRhG1WgBqd-n62SwZYDT'; const EXPECTED_DATA = [ { - category: 'base', - field: '@timestamp', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + category: 'file', + field: 'file.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, - { category: '@version', field: '@version', values: ['1'], originalValue: '1' }, { - category: 'agent', - field: 'agent.ephemeral_id', - values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], - originalValue: '909cd6a1-527d-41a5-9585-a7fb5386f851', + category: 'traefik', + field: 'traefik.access.geoip.region_iso_code', + values: ['US-WA'], + originalValue: ['US-WA'], }, { - category: 'agent', - field: 'agent.hostname', + category: 'host', + field: 'host.hostname', values: ['raspberrypi'], - originalValue: 'raspberrypi', + originalValue: ['raspberrypi'], }, { - category: 'agent', - field: 'agent.id', - values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], - originalValue: '4d3ea604-27e5-4ec7-ab64-44f82285d776', + category: 'traefik', + field: 'traefik.access.geoip.location', + values: ['{"long":-122.3341,"lat":47.6103}'], + originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'], + }, + { + category: 'suricata', + field: 'suricata.eve.src_port', + values: ['80'], + originalValue: ['80'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.city_name', + values: ['Seattle'], + originalValue: ['Seattle'], + }, + { + category: 'service', + field: 'service.type', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'http', + field: 'http.request.method', + values: ['get'], + originalValue: ['get'], + }, + { + category: 'host', + field: 'host.os.version', + values: ['9 (stretch)'], + originalValue: ['9 (stretch)'], + }, + { + category: 'source', + field: 'source.geo.region_name', + values: ['Washington'], + originalValue: ['Washington'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.protocol', + values: ['HTTP/1.1'], + originalValue: ['HTTP/1.1'], + }, + { + category: 'host', + field: 'host.os.name', + values: ['Raspbian GNU/Linux'], + originalValue: ['Raspbian GNU/Linux'], + }, + { + category: 'source', + field: 'source.ip', + values: ['54.239.219.210'], + originalValue: ['54.239.219.210'], + }, + { + category: 'host', + field: 'host.name', + values: ['raspberrypi'], + originalValue: ['raspberrypi'], + }, + { + category: 'source', + field: 'source.geo.region_iso_code', + values: ['US-WA'], + originalValue: ['US-WA'], + }, + { + category: 'http', + field: 'http.response.status_code', + values: ['206'], + originalValue: ['206'], + }, + { + category: 'event', + field: 'event.kind', + values: ['event'], + originalValue: ['event'], + }, + { + category: 'suricata', + field: 'suricata.eve.flow_id', + values: ['196625917175466'], + originalValue: ['196625917175466'], + }, + { + category: 'source', + field: 'source.geo.city_name', + values: ['Seattle'], + originalValue: ['Seattle'], + }, + { + category: 'suricata', + field: 'suricata.eve.proto', + values: ['tcp'], + originalValue: ['tcp'], + }, + { + category: 'flow', + field: 'flow.locality', + values: ['public'], + originalValue: ['public'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.country_iso_code', + values: ['US'], + originalValue: ['US'], + }, + { + category: 'fileset', + field: 'fileset.name', + values: ['eve'], + originalValue: ['eve'], + }, + { + category: 'input', + field: 'input.type', + values: ['log'], + originalValue: ['log'], + }, + { + category: 'log', + field: 'log.offset', + values: ['1856288115'], + originalValue: ['1856288115'], }, - { category: 'agent', field: 'agent.type', values: ['filebeat'], originalValue: 'filebeat' }, - { category: 'agent', field: 'agent.version', values: ['7.0.0'], originalValue: '7.0.0' }, { category: 'destination', field: 'destination.domain', values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { - category: 'destination', - field: 'destination.ip', - values: ['10.100.7.196'], - originalValue: '10.100.7.196', + category: 'agent', + field: 'agent.hostname', + values: ['raspberrypi'], + originalValue: ['raspberrypi'], }, - { category: 'destination', field: 'destination.port', values: [40684], originalValue: 40684 }, - { category: 'ecs', field: 'ecs.version', values: ['1.0.0-beta2'], originalValue: '1.0.0-beta2' }, { - category: 'event', - field: 'event.dataset', - values: ['suricata.eve'], - originalValue: 'suricata.eve', + category: 'suricata', + field: 'suricata.eve.http.hostname', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], }, { - category: 'event', - field: 'event.end', - values: ['2019-02-10T02:39:44.107Z'], - originalValue: '2019-02-10T02:39:44.107Z', + category: 'suricata', + field: 'suricata.eve.in_iface', + values: ['eth0'], + originalValue: ['eth0'], }, - { category: 'event', field: 'event.kind', values: ['event'], originalValue: 'event' }, - { category: 'event', field: 'event.module', values: ['suricata'], originalValue: 'suricata' }, - { category: 'event', field: 'event.type', values: ['fileinfo'], originalValue: 'fileinfo' }, { - category: 'file', - field: 'file.path', + category: 'base', + field: 'tags', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'host', + field: 'host.architecture', + values: ['armv7l'], + originalValue: ['armv7l'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.status', + values: ['206'], + originalValue: ['206'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.url', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, - { category: 'file', field: 'file.size', values: [48277], originalValue: 48277 }, - { category: 'fileset', field: 'fileset.name', values: ['eve'], originalValue: 'eve' }, - { category: 'flow', field: 'flow.locality', values: ['public'], originalValue: 'public' }, - { category: 'host', field: 'host.architecture', values: ['armv7l'], originalValue: 'armv7l' }, { - category: 'host', - field: 'host.hostname', - values: ['raspberrypi'], - originalValue: 'raspberrypi', + category: 'url', + field: 'url.path', + values: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + originalValue: [ + '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], }, { - category: 'host', - field: 'host.id', - values: ['b19a781f683541a7a25ee345133aa399'], - originalValue: 'b19a781f683541a7a25ee345133aa399', + category: 'source', + field: 'source.port', + values: ['80'], + originalValue: ['80'], }, - { category: 'host', field: 'host.name', values: ['raspberrypi'], originalValue: 'raspberrypi' }, - { category: 'host', field: 'host.os.codename', values: ['stretch'], originalValue: 'stretch' }, - { category: 'host', field: 'host.os.family', values: [''], originalValue: '' }, { - category: 'host', - field: 'host.os.kernel', - values: ['4.14.50-v7+'], - originalValue: '4.14.50-v7+', + category: 'agent', + field: 'agent.id', + values: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], + originalValue: ['4d3ea604-27e5-4ec7-ab64-44f82285d776'], }, { category: 'host', - field: 'host.os.name', - values: ['Raspbian GNU/Linux'], - originalValue: 'Raspbian GNU/Linux', + field: 'host.containerized', + values: ['false'], + originalValue: ['false'], + }, + { + category: 'ecs', + field: 'ecs.version', + values: ['1.0.0-beta2'], + originalValue: ['1.0.0-beta2'], + }, + { + category: 'agent', + field: 'agent.version', + values: ['7.0.0'], + originalValue: ['7.0.0'], + }, + { + category: 'suricata', + field: 'suricata.eve.fileinfo.stored', + values: ['false'], + originalValue: ['false'], }, - { category: 'host', field: 'host.os.platform', values: ['raspbian'], originalValue: 'raspbian' }, { category: 'host', - field: 'host.os.version', - values: ['9 (stretch)'], - originalValue: '9 (stretch)', + field: 'host.os.family', + values: [''], + originalValue: [''], }, - { category: 'http', field: 'http.request.method', values: ['get'], originalValue: 'get' }, - { category: 'http', field: 'http.response.body.bytes', values: [48277], originalValue: 48277 }, - { category: 'http', field: 'http.response.status_code', values: [206], originalValue: 206 }, - { category: 'input', field: 'input.type', values: ['log'], originalValue: 'log' }, { category: 'base', field: 'labels.pipeline', values: ['filebeat-7.0.0-suricata-eve-pipeline'], - originalValue: 'filebeat-7.0.0-suricata-eve-pipeline', + originalValue: ['filebeat-7.0.0-suricata-eve-pipeline'], }, { - category: 'log', - field: 'log.file.path', - values: ['/var/log/suricata/eve.json'], - originalValue: '/var/log/suricata/eve.json', + category: 'suricata', + field: 'suricata.eve.src_ip', + values: ['54.239.219.210'], + originalValue: ['54.239.219.210'], + }, + { + category: 'suricata', + field: 'suricata.eve.fileinfo.state', + values: ['CLOSED'], + originalValue: ['CLOSED'], + }, + { + category: 'destination', + field: 'destination.port', + values: ['40684'], + originalValue: ['40684'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.region_name', + values: ['Washington'], + originalValue: ['Washington'], }, - { category: 'log', field: 'log.offset', values: [1856288115], originalValue: 1856288115 }, - { category: 'network', field: 'network.name', values: ['iot'], originalValue: 'iot' }, - { category: 'network', field: 'network.protocol', values: ['http'], originalValue: 'http' }, - { category: 'network', field: 'network.transport', values: ['tcp'], originalValue: 'tcp' }, - { category: 'service', field: 'service.type', values: ['suricata'], originalValue: 'suricata' }, - { category: 'source', field: 'source.as.num', values: [16509], originalValue: 16509 }, { category: 'source', - field: 'source.as.org', - values: ['Amazon.com, Inc.'], - originalValue: 'Amazon.com, Inc.', + field: 'source.as.num', + values: ['16509'], + originalValue: ['16509'], + }, + { + category: 'event', + field: 'event.end', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], + }, + { + category: 'source', + field: 'source.geo.location', + values: ['{"long":-122.3341,"lat":47.6103}'], + originalValue: ['[{"coordinates":[-122.3341,47.6103],"type":"Point"}]'], }, { category: 'source', field: 'source.domain', values: ['server-54-239-219-210.jfk51.r.cloudfront.net'], - originalValue: 'server-54-239-219-210.jfk51.r.cloudfront.net', + originalValue: ['server-54-239-219-210.jfk51.r.cloudfront.net'], }, { - category: 'source', - field: 'source.geo.city_name', - values: ['Seattle'], - originalValue: 'Seattle', + category: 'suricata', + field: 'suricata.eve.fileinfo.size', + values: ['48277'], + originalValue: ['48277'], }, { - category: 'source', - field: 'source.geo.continent_name', - values: ['North America'], - originalValue: 'North America', + category: 'suricata', + field: 'suricata.eve.app_proto', + values: ['http'], + originalValue: ['http'], }, - { category: 'source', field: 'source.geo.country_iso_code', values: ['US'], originalValue: 'US' }, { - category: 'source', - field: 'source.geo.location.lat', - values: [47.6103], - originalValue: 47.6103, + category: 'agent', + field: 'agent.type', + values: ['filebeat'], + originalValue: ['filebeat'], }, { - category: 'source', - field: 'source.geo.location.lon', - values: [-122.3341], - originalValue: -122.3341, + category: 'suricata', + field: 'suricata.eve.fileinfo.tx_id', + values: ['301'], + originalValue: ['301'], }, { - category: 'source', - field: 'source.geo.region_iso_code', - values: ['US-WA'], - originalValue: 'US-WA', + category: 'event', + field: 'event.module', + values: ['suricata'], + originalValue: ['suricata'], + }, + { + category: 'network', + field: 'network.protocol', + values: ['http'], + originalValue: ['http'], + }, + { + category: 'host', + field: 'host.os.kernel', + values: ['4.14.50-v7+'], + originalValue: ['4.14.50-v7+'], }, { category: 'source', - field: 'source.geo.region_name', - values: ['Washington'], - originalValue: 'Washington', + field: 'source.geo.country_iso_code', + values: ['US'], + originalValue: ['US'], + }, + { + category: '@version', + field: '@version', + values: ['1'], + originalValue: ['1'], + }, + { + category: 'host', + field: 'host.id', + values: ['b19a781f683541a7a25ee345133aa399'], + originalValue: ['b19a781f683541a7a25ee345133aa399'], }, { category: 'source', - field: 'source.ip', - values: ['54.239.219.210'], - originalValue: '54.239.219.210', + field: 'source.as.org', + values: ['Amazon.com, Inc.'], + originalValue: ['Amazon.com, Inc.'], }, - { category: 'source', field: 'source.port', values: [80], originalValue: 80 }, { category: 'suricata', - field: 'suricata.eve.fileinfo.state', - values: ['CLOSED'], - originalValue: 'CLOSED', + field: 'suricata.eve.timestamp', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], }, - { category: 'suricata', field: 'suricata.eve.fileinfo.tx_id', values: [301], originalValue: 301 }, { - category: 'suricata', - field: 'suricata.eve.flow_id', - values: [196625917175466], - originalValue: 196625917175466, + category: 'host', + field: 'host.os.codename', + values: ['stretch'], + originalValue: ['stretch'], + }, + { + category: 'source', + field: 'source.geo.continent_name', + values: ['North America'], + originalValue: ['North America'], + }, + { + category: 'network', + field: 'network.name', + values: ['iot'], + originalValue: ['iot'], }, { category: 'suricata', - field: 'suricata.eve.http.http_content_type', - values: ['video/mp4'], - originalValue: 'video/mp4', + field: 'suricata.eve.http.http_method', + values: ['get'], + originalValue: ['get'], + }, + { + category: 'traefik', + field: 'traefik.access.geoip.continent_name', + values: ['North America'], + originalValue: ['North America'], + }, + { + category: 'file', + field: 'file.size', + values: ['48277'], + originalValue: ['48277'], + }, + { + category: 'destination', + field: 'destination.ip', + values: ['10.100.7.196'], + originalValue: ['10.100.7.196'], }, { category: 'suricata', - field: 'suricata.eve.http.protocol', - values: ['HTTP/1.1'], - originalValue: 'HTTP/1.1', + field: 'suricata.eve.http.length', + values: ['48277'], + originalValue: ['48277'], }, - { category: 'suricata', field: 'suricata.eve.in_iface', values: ['eth0'], originalValue: 'eth0' }, - { category: 'base', field: 'tags', values: ['suricata'], originalValue: ['suricata'] }, { - category: 'url', - field: 'url.domain', - values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], - originalValue: 's3-iad-2.cf.dash.row.aiv-cdn.net', + category: 'http', + field: 'http.response.body.bytes', + values: ['48277'], + originalValue: ['48277'], }, { - category: 'url', - field: 'url.original', + category: 'suricata', + field: 'suricata.eve.fileinfo.filename', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + }, + { + category: 'suricata', + field: 'suricata.eve.dest_ip', + values: ['10.100.7.196'], + originalValue: ['10.100.7.196'], + }, + { + category: 'network', + field: 'network.transport', + values: ['tcp'], + originalValue: ['tcp'], }, { category: 'url', - field: 'url.path', + field: 'url.original', values: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', ], - originalValue: + originalValue: [ '/dm/2$XTMWANo0Q2RZKlH-95UoAahZrOg~/0a9a/bf72/e1da/4c20-919e-0cbabcf7bfe8/75f50c57-d25f-4e97-9e37-01b9f5caa293_audio_13.mp4', + ], + }, + { + category: 'base', + field: '@timestamp', + values: ['2019-02-10T02:39:44.107Z'], + originalValue: ['2019-02-10T02:39:44.107Z'], + }, + { + category: 'host', + field: 'host.os.platform', + values: ['raspbian'], + originalValue: ['raspbian'], + }, + { + category: 'suricata', + field: 'suricata.eve.dest_port', + values: ['40684'], + originalValue: ['40684'], + }, + { + category: 'event', + field: 'event.type', + values: ['fileinfo'], + originalValue: ['fileinfo'], + }, + { + category: 'log', + field: 'log.file.path', + values: ['/var/log/suricata/eve.json'], + originalValue: ['/var/log/suricata/eve.json'], + }, + { + category: 'url', + field: 'url.domain', + values: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + originalValue: ['s3-iad-2.cf.dash.row.aiv-cdn.net'], + }, + { + category: 'agent', + field: 'agent.ephemeral_id', + values: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], + originalValue: ['909cd6a1-527d-41a5-9585-a7fb5386f851'], + }, + { + category: 'suricata', + field: 'suricata.eve.http.http_content_type', + values: ['video/mp4'], + originalValue: ['video/mp4'], + }, + { + category: 'event', + field: 'event.dataset', + values: ['suricata.eve'], + originalValue: ['suricata.eve'], }, { category: '_index', field: '_index', values: ['filebeat-7.0.0-iot-2019.06'], - originalValue: 'filebeat-7.0.0-iot-2019.06', + originalValue: ['filebeat-7.0.0-iot-2019.06'], }, { category: '_id', field: '_id', values: ['QRhG1WgBqd-n62SwZYDT'], - originalValue: 'QRhG1WgBqd-n62SwZYDT', + originalValue: ['QRhG1WgBqd-n62SwZYDT'], + }, + { + category: '_score', + field: '_score', + values: ['1'], + originalValue: ['1'], }, - { category: '_score', field: '_score', values: [1], originalValue: 1 }, ]; export default function ({ getService }: FtrProviderContext) { From b46655d7f6c387755cde0bd93c02201efa470190 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 10 Dec 2020 09:41:27 +0200 Subject: [PATCH 11/11] [Advanced settings] Reset to default for empty strings (#85137) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/management_app/lib/is_default_value.test.ts | 4 ++-- .../public/management_app/lib/is_default_value.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/plugins/advanced_settings/public/management_app/lib/is_default_value.test.ts b/src/plugins/advanced_settings/public/management_app/lib/is_default_value.test.ts index 4cde8bf517499..c8a80bd3d6aab 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/is_default_value.test.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/is_default_value.test.ts @@ -50,9 +50,9 @@ describe('Settings', function () { }); describe('without a value', function () { - it('should return true', function () { + it('should return false for empty string but true for undefined', function () { expect(isDefaultValue({ ...setting, value: undefined })).to.be(true); - expect(isDefaultValue({ ...setting, value: '' })).to.be(true); + expect(isDefaultValue({ ...setting, value: '' })).to.be(false); }); }); diff --git a/src/plugins/advanced_settings/public/management_app/lib/is_default_value.ts b/src/plugins/advanced_settings/public/management_app/lib/is_default_value.ts index 53c2ef3187f09..080aee369dc0f 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/is_default_value.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/is_default_value.ts @@ -22,7 +22,6 @@ export function isDefaultValue(setting: FieldSetting) { return ( setting.isCustom || setting.value === undefined || - setting.value === '' || String(setting.value) === String(setting.defVal) ); }