diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27a7f01ec78e9..c30906c56c2ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1158,12 +1158,15 @@ x-pack/test/threat_intelligence_cypress @elastic/protections-experience /x-pack/plugins/security_solution/public/detection_engine/rule_response_actions @elastic/security-defend-workflows /x-pack/plugins/security_solution/server/lib/detection_engine/rule_response_actions @elastic/security-defend-workflows +# Cloud Defend +/x-pack/plugins/cloud_defend/ @elastic/sec-cloudnative-integrations +/x-pack/plugins/security_solution/public/cloud_defend @elastic/sec-cloudnative-integrations + # Cloud Security Posture /x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture /x-pack/test/api_integration/apis/cloud_security_posture/ @elastic/kibana-cloud-security-posture /x-pack/test/cloud_security_posture_functional/ @elastic/kibana-cloud-security-posture - # Security Solution onboarding tour /x-pack/plugins/security_solution/public/common/components/guided_onboarding @elastic/security-threat-hunting-explore /x-pack/plugins/security_solution/cypress/e2e/guided_onboarding @elastic/security-threat-hunting-explore diff --git a/x-pack/plugins/cloud_defend/README.md b/x-pack/plugins/cloud_defend/README.md index c0175af9cc2a6..9df1a13d328d2 100755 --- a/x-pack/plugins/cloud_defend/README.md +++ b/x-pack/plugins/cloud_defend/README.md @@ -42,6 +42,7 @@ responses: ``` node scripts/type_check.js --project x-pack/plugins/cloud_defend/tsconfig.json +node scripts/eslint.js x-pack/plugins/cloud_defend yarn test:jest x-pack/plugins/cloud_defend ``` diff --git a/x-pack/plugins/cloud_defend/common/constants.ts b/x-pack/plugins/cloud_defend/common/constants.ts index 860f3d4adffea..8fc54773da295 100755 --- a/x-pack/plugins/cloud_defend/common/constants.ts +++ b/x-pack/plugins/cloud_defend/common/constants.ts @@ -4,9 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; export const PLUGIN_ID = 'cloudDefend'; -export const PLUGIN_NAME = 'cloudDefend'; +export const PLUGIN_NAME = 'Cloud Defend'; export const INTEGRATION_PACKAGE_NAME = 'cloud_defend'; export const INPUT_CONTROL = 'cloud_defend/control'; export const ALERTS_DATASET = 'cloud_defend.alerts'; +export const ALERTS_INDEX_PATTERN = 'cloud_defend.alerts*'; + +export const POLICIES_ROUTE_PATH = '/internal/cloud_defend/policies'; +export const STATUS_ROUTE_PATH = '/internal/cloud_defend/status'; + +export const CLOUD_DEFEND_FLEET_PACKAGE_KUERY = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${INTEGRATION_PACKAGE_NAME}`; diff --git a/x-pack/plugins/cloud_defend/common/schemas/policy.ts b/x-pack/plugins/cloud_defend/common/schemas/policy.ts new file mode 100644 index 0000000000000..c6fff63b6bb81 --- /dev/null +++ b/x-pack/plugins/cloud_defend/common/schemas/policy.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { type TypeOf, schema } from '@kbn/config-schema'; + +export const DEFAULT_POLICIES_PER_PAGE = 20; +export const POLICIES_PACKAGE_POLICY_PREFIX = 'package_policy.'; +export const policiesQueryParamsSchema = schema.object({ + /** + * The page of objects to return + */ + page: schema.number({ defaultValue: 1, min: 1 }), + /** + * The number of objects to include in each page + */ + per_page: schema.number({ defaultValue: DEFAULT_POLICIES_PER_PAGE, min: 0 }), + /** + * Once of PackagePolicy fields for sorting the found objects. + * Sortable fields: + * - package_policy.id + * - package_policy.name + * - package_policy.policy_id + * - package_policy.namespace + * - package_policy.updated_at + * - package_policy.updated_by + * - package_policy.created_at + * - package_policy.created_by, + * - package_policy.package.name + * - package_policy.package.title + * - package_policy.package.version + */ + sort_field: schema.oneOf( + [ + schema.literal('package_policy.id'), + schema.literal('package_policy.name'), + schema.literal('package_policy.policy_id'), + schema.literal('package_policy.namespace'), + schema.literal('package_policy.updated_at'), + schema.literal('package_policy.updated_by'), + schema.literal('package_policy.created_at'), + schema.literal('package_policy.created_by'), + schema.literal('package_policy.package.name'), + schema.literal('package_policy.package.title'), + ], + { defaultValue: 'package_policy.name' } + ), + /** + * The order to sort by + */ + sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { + defaultValue: 'asc', + }), + /** + * Policy filter + */ + policy_name: schema.maybe(schema.string()), +}); + +export type PoliciesQueryParams = TypeOf; diff --git a/x-pack/plugins/cloud_defend/common/types.ts b/x-pack/plugins/cloud_defend/common/types.ts new file mode 100644 index 0000000000000..c3bdbb4418183 --- /dev/null +++ b/x-pack/plugins/cloud_defend/common/types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PackagePolicy, AgentPolicy } from '@kbn/fleet-plugin/common'; + +export type IndexStatus = + | 'not-empty' // Index contains documents + | 'empty' // Index doesn't contain documents (or doesn't exist) + | 'unprivileged'; // User doesn't have access to query the index + +export type CloudDefendStatusCode = + | 'indexed' // alerts index exists and has results + | 'indexing' // index timeout was not surpassed since installation, assumes data is being indexed + | 'unprivileged' // user lacks privileges for the alerts index + | 'index-timeout' // index timeout was surpassed since installation + | 'not-deployed' // no healthy agents were deployed + | 'not-installed'; // number of installed integrations is 0; + +export interface IndexDetails { + index: string; + status: IndexStatus; +} + +interface BaseCloudDefendSetupStatus { + indicesDetails: IndexDetails[]; + latestPackageVersion: string; + installedPackagePolicies: number; + healthyAgents: number; +} + +interface CloudDefendSetupNotInstalledStatus extends BaseCloudDefendSetupStatus { + status: Extract; +} + +interface CloudDefendSetupInstalledStatus extends BaseCloudDefendSetupStatus { + status: Exclude; + // status can be `indexed` but return with undefined package information in this case + installedPackageVersion: string | undefined; +} + +export type CloudDefendSetupStatus = + | CloudDefendSetupInstalledStatus + | CloudDefendSetupNotInstalledStatus; + +export type AgentPolicyStatus = Pick & { agents: number }; + +export interface CloudDefendPolicy { + package_policy: PackagePolicy; + agent_policy: AgentPolicyStatus; +} diff --git a/x-pack/plugins/cloud_defend/common/utils/helpers.ts b/x-pack/plugins/cloud_defend/common/utils/helpers.ts new file mode 100644 index 0000000000000..14b506e8d2e70 --- /dev/null +++ b/x-pack/plugins/cloud_defend/common/utils/helpers.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Truthy } from 'lodash'; +import { INTEGRATION_PACKAGE_NAME } from '../constants'; + +/** + * @example + * declare const foo: Array + * foo.filter(isNonNullable) // foo is Array + */ +export const isNonNullable = (v: T): v is NonNullable => + v !== null && v !== undefined; + +export const truthy = (value: T): value is Truthy => !!value; + +export const extractErrorMessage = (e: unknown, defaultMessage = 'Unknown Error'): string => { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + + return defaultMessage; // TODO: i18n +}; + +export function assert(condition: any, msg?: string): asserts condition { + if (!condition) { + throw new Error(msg); + } +} + +export const isCloudDefendPackage = (packageName?: string) => + packageName === INTEGRATION_PACKAGE_NAME; diff --git a/x-pack/plugins/cloud_defend/common/utils/subscription.test.ts b/x-pack/plugins/cloud_defend/common/utils/subscription.test.ts new file mode 100644 index 0000000000000..e47b887ae520f --- /dev/null +++ b/x-pack/plugins/cloud_defend/common/utils/subscription.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { isSubscriptionAllowed } from './subscription'; +import { licenseMock } from '@kbn/licensing-plugin/common/licensing.mock'; + +const ON_PREM_ALLOWED_LICENSES: readonly LicenseType[] = ['enterprise', 'trial']; +const ON_PREM_NOT_ALLOWED_LICENSES: readonly LicenseType[] = ['basic', 'gold', 'platinum']; +const ALL_LICENSE_TYPES: readonly LicenseType[] = [ + 'standard', + ...ON_PREM_NOT_ALLOWED_LICENSES, + ...ON_PREM_NOT_ALLOWED_LICENSES, +]; + +describe('isSubscriptionAllowed', () => { + it('should allow any cloud subscription', () => { + const isCloudEnabled = true; + ALL_LICENSE_TYPES.forEach((licenseType) => { + const license = licenseMock.createLicense({ license: { type: licenseType } }); + expect(isSubscriptionAllowed(isCloudEnabled, license)).toBeTruthy(); + }); + }); + + it('should allow enterprise and trial licenses for on-prem', () => { + const isCloudEnabled = false; + ON_PREM_ALLOWED_LICENSES.forEach((licenseType) => { + const license = licenseMock.createLicense({ license: { type: licenseType } }); + expect(isSubscriptionAllowed(isCloudEnabled, license)).toBeTruthy(); + }); + }); + + it('should not allow enterprise and trial licenses for on-prem', () => { + const isCloudEnabled = false; + ON_PREM_NOT_ALLOWED_LICENSES.forEach((licenseType) => { + const license = licenseMock.createLicense({ license: { type: licenseType } }); + expect(isSubscriptionAllowed(isCloudEnabled, license)).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/cloud_defend/common/utils/subscription.ts b/x-pack/plugins/cloud_defend/common/utils/subscription.ts new file mode 100644 index 0000000000000..e54eb0c4d4581 --- /dev/null +++ b/x-pack/plugins/cloud_defend/common/utils/subscription.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; +import { PLUGIN_NAME } from '../constants'; + +const MINIMUM_NON_CLOUD_LICENSE_TYPE: LicenseType = 'enterprise'; + +export const isSubscriptionAllowed = (isCloudEnabled?: boolean, license?: ILicense): boolean => { + if (isCloudEnabled) { + return true; + } + + if (!license) { + return false; + } + + const licenseCheck = license.check(PLUGIN_NAME, MINIMUM_NON_CLOUD_LICENSE_TYPE); + return licenseCheck.state === 'valid'; +}; diff --git a/x-pack/plugins/cloud_defend/kibana.jsonc b/x-pack/plugins/cloud_defend/kibana.jsonc index f561c33a5f832..392724467fd70 100644 --- a/x-pack/plugins/cloud_defend/kibana.jsonc +++ b/x-pack/plugins/cloud_defend/kibana.jsonc @@ -2,14 +2,31 @@ "type": "plugin", "id": "@kbn/cloud-defend-plugin", "owner": "@elastic/sec-cloudnative-integrations", - "description": "Defend for Containers", + "description": "Defend for containers (D4C)", "plugin": { "id": "cloudDefend", - "server": false, + "server": true, "browser": true, + "configPath": [ + "xpack", + "cloudDefend" + ], "requiredPlugins": [ + "navigation", + "data", "fleet", - "kibanaReact" + "unifiedSearch", + "kibanaReact", + "cloud", + "security", + "licensing" + ], + "optionalPlugins": [ + "usageCollection" + ], + "requiredBundles": [ + "kibanaReact", + "usageCollection" ] } } diff --git a/x-pack/plugins/cloud_defend/public/application/route.tsx b/x-pack/plugins/cloud_defend/public/application/route.tsx new file mode 100644 index 0000000000000..27959ec0845e5 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/application/route.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route } from '@kbn/shared-ux-router'; +import { type RouteProps } from 'react-router-dom'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { cloudDefendPages } from '../common/navigation/constants'; +import { useSecuritySolutionContext } from './security_solution_context'; +import type { CloudDefendPageNavigationItem } from '../common/navigation/types'; + +type CloudDefendRouteProps = Omit & CloudDefendPageNavigationItem; + +// Security SpyRoute can be automatically rendered for pages with static paths, Security will manage everything using the `links` object. +// Pages with dynamic paths are not in the Security `links` object, they must render SpyRoute with the parameters values, if needed. +const STATIC_PATH_PAGE_IDS = Object.fromEntries( + Object.values(cloudDefendPages).map(({ id }) => [id, true]) +); + +export const CloudDefendRoute: React.FC = ({ + id, + children, + component: Component, + disabled = false, + ...cloudDefendRouteProps +}) => { + const SpyRoute = useSecuritySolutionContext()?.getSpyRouteComponent(); + + if (disabled) { + return null; + } + + const routeProps: RouteProps = { + ...cloudDefendRouteProps, + ...(Component && { + render: (renderProps) => ( + + {STATIC_PATH_PAGE_IDS[id] && SpyRoute && } + + + ), + }), + }; + + return {children}; +}; diff --git a/x-pack/plugins/cloud_defend/public/application/router.test.tsx b/x-pack/plugins/cloud_defend/public/application/router.test.tsx new file mode 100644 index 0000000000000..af7766a290fca --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/application/router.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import CloudDefendRouter from './router'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import type { CloudDefendPage, CloudDefendPageNavigationItem } from '../common/navigation/types'; +import { CloudDefendSecuritySolutionContext } from '../types'; +import { createMemoryHistory, MemoryHistory } from 'history'; +import * as constants from '../common/navigation/constants'; +import { QueryClientProviderProps } from '@tanstack/react-query'; + +jest.mock('../pages/policies', () => ({ + Policies: () =>
Policies
, +})); + +jest.mock('@tanstack/react-query', () => ({ + QueryClientProvider: ({ children }: QueryClientProviderProps) => <>{children}, + QueryClient: jest.fn(), +})); + +describe('CloudDefendRouter', () => { + const originalCloudDefendPages = { ...constants.cloudDefendPages }; + const mockConstants = constants as { + cloudDefendPages: Record; + }; + + const securityContext: CloudDefendSecuritySolutionContext = { + getFiltersGlobalComponent: jest.fn(), + getSpyRouteComponent: () => () =>
, + }; + + let history: MemoryHistory; + + const renderCloudDefendRouter = () => + render( + + + + ); + + beforeEach(() => { + mockConstants.cloudDefendPages = originalCloudDefendPages; + jest.clearAllMocks(); + history = createMemoryHistory(); + }); + + describe('happy path', () => { + it('should render Policies', () => { + history.push('/cloud_defend/policies'); + const result = renderCloudDefendRouter(); + + expect(result.queryByTestId('Policies')).toBeInTheDocument(); + }); + }); + + describe('unhappy path', () => { + it('should redirect base path to policies', () => { + history.push('/cloud_defend/some_wrong_path'); + const result = renderCloudDefendRouter(); + + expect(history.location.pathname).toEqual('/cloud_defend/policies'); + expect(result.queryByTestId('Policies')).toBeInTheDocument(); + }); + }); + + describe('CloudDefendRoute', () => { + it('should not render disabled path', () => { + mockConstants.cloudDefendPages = { + ...constants.cloudDefendPages, + policies: { + ...constants.cloudDefendPages.policies, + disabled: true, + }, + }; + + history.push('/cloud_defend/policies'); + const result = renderCloudDefendRouter(); + + expect(result.queryByTestId('Policies')).not.toBeInTheDocument(); + }); + + it('should render SpyRoute for static paths', () => { + history.push('/cloud_defend/policies'); + const result = renderCloudDefendRouter(); + + expect(result.queryByTestId('mockedSpyRoute')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cloud_defend/public/application/router.tsx b/x-pack/plugins/cloud_defend/public/application/router.tsx new file mode 100644 index 0000000000000..3fa261d35c765 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/application/router.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Redirect, Switch } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; +import { cloudDefendPages } from '../common/navigation/constants'; +import type { CloudDefendSecuritySolutionContext } from '../types'; +import { SecuritySolutionContext } from './security_solution_context'; +import { Policies } from '../pages/policies'; +import { CloudDefendRoute } from './route'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false } }, +}); + +export interface CloudDefendRouterProps { + securitySolutionContext?: CloudDefendSecuritySolutionContext; +} + +export const CloudDefendRouter = ({ securitySolutionContext }: CloudDefendRouterProps) => { + const routerElement = ( + + + + + + + + + + ); + + if (securitySolutionContext) { + return ( + + {routerElement} + + ); + } + + return <>{routerElement}; +}; + +// Using a default export for usage with `React.lazy` +// eslint-disable-next-line import/no-default-export +export { CloudDefendRouter as default }; diff --git a/x-pack/plugins/cloud_defend/public/application/security_solution_context.ts b/x-pack/plugins/cloud_defend/public/application/security_solution_context.ts new file mode 100644 index 0000000000000..fcbd10057021c --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/application/security_solution_context.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useContext } from 'react'; +import type { CloudDefendSecuritySolutionContext } from '../types'; + +export const SecuritySolutionContext = React.createContext< + CloudDefendSecuritySolutionContext | undefined +>(undefined); + +export const useSecuritySolutionContext = () => { + return useContext(SecuritySolutionContext); +}; diff --git a/x-pack/plugins/cloud_defend/public/application/setup_context.ts b/x-pack/plugins/cloud_defend/public/application/setup_context.ts new file mode 100644 index 0000000000000..574404ace38ce --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/application/setup_context.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { createContext } from 'react'; + +interface SetupContextValue { + isCloudEnabled?: boolean; +} + +/** + * A utility to pass data from the plugin setup lifecycle stage to application components + */ +export const SetupContext = createContext({}); diff --git a/x-pack/plugins/cloud_defend/public/assets/icons/logo.svg b/x-pack/plugins/cloud_defend/public/assets/icons/logo.svg new file mode 100644 index 0000000000000..a0534292eb717 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/assets/icons/logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/x-pack/plugins/cloud_defend/public/common/api/use_cloud_defend_integration.tsx b/x-pack/plugins/cloud_defend/public/common/api/use_cloud_defend_integration.tsx new file mode 100644 index 0000000000000..c41ffdab0851c --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/api/use_cloud_defend_integration.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { + epmRouteService, + type GetInfoResponse, + type DefaultPackagesInstallationError, +} from '@kbn/fleet-plugin/common'; +import { INTEGRATION_PACKAGE_NAME } from '../../../common/constants'; +import { useKibana } from '../hooks/use_kibana'; + +/** + * This hook will find our integration and return its PackageInfo + * */ +export const useCloudDefendIntegration = () => { + const { http } = useKibana().services; + + return useQuery(['integrations'], () => + http.get(epmRouteService.getInfoPath(INTEGRATION_PACKAGE_NAME)) + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/common/api/use_setup_status_api.ts b/x-pack/plugins/cloud_defend/public/common/api/use_setup_status_api.ts new file mode 100644 index 0000000000000..2d07d138e8d92 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/api/use_setup_status_api.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { useKibana } from '../hooks/use_kibana'; +import { CloudDefendSetupStatus } from '../../../common/types'; +import { STATUS_ROUTE_PATH } from '../../../common/constants'; + +const getCloudDefendSetupStatusQueryKey = 'cloud_defend_status_key'; + +export const useCloudDefendSetupStatusApi = () => { + const { http } = useKibana().services; + return useQuery( + [getCloudDefendSetupStatusQueryKey], + () => http.get(STATUS_ROUTE_PATH) + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/common/constants.ts b/x-pack/plugins/cloud_defend/public/common/constants.ts index d0baec8804ff7..1e101a47c0122 100644 --- a/x-pack/plugins/cloud_defend/public/common/constants.ts +++ b/x-pack/plugins/cloud_defend/public/common/constants.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +export const DEFAULT_VISIBLE_ROWS_PER_PAGE = 10; // generic default # of table rows to show (currently we only have a list of policies) +export const LOCAL_STORAGE_PAGE_SIZE = 'cloudDefend:userPageSize'; export const VALID_SELECTOR_NAME_REGEX = /^[a-z0-9][a-z0-9_\-]+$/i; // alphanumberic (no - or _ allowed on first char) export const MAX_SELECTOR_NAME_LENGTH = 128; // chars export const MAX_CONDITION_VALUE_LENGTH_BYTES = 511; diff --git a/x-pack/plugins/cloud_defend/public/common/hooks/use_kibana.ts b/x-pack/plugins/cloud_defend/public/common/hooks/use_kibana.ts new file mode 100644 index 0000000000000..261afc07db00f --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/hooks/use_kibana.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreStart } from '@kbn/core/public'; +import { useKibana as useKibanaBase } from '@kbn/kibana-react-plugin/public'; +import type { CloudDefendPluginStartDeps } from '../../types'; + +type CloudDefendKibanaContext = CoreStart & CloudDefendPluginStartDeps; + +export const useKibana = () => useKibanaBase(); diff --git a/x-pack/plugins/cloud_defend/public/common/hooks/use_page_size.ts b/x-pack/plugins/cloud_defend/public/common/hooks/use_page_size.ts new file mode 100644 index 0000000000000..314dfbe661d93 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/hooks/use_page_size.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { DEFAULT_VISIBLE_ROWS_PER_PAGE } from '../constants'; + +/** + * @description handles persisting the users table row size selection + */ +export const usePageSize = (localStorageKey: string) => { + const [persistedPageSize, setPersistedPageSize] = useLocalStorage( + localStorageKey, + DEFAULT_VISIBLE_ROWS_PER_PAGE + ); + + let pageSize: number = DEFAULT_VISIBLE_ROWS_PER_PAGE; + + if (persistedPageSize) { + pageSize = persistedPageSize; + } + + return { pageSize, setPageSize: setPersistedPageSize }; +}; diff --git a/x-pack/plugins/cloud_defend/public/common/hooks/use_subscription_status.ts b/x-pack/plugins/cloud_defend/public/common/hooks/use_subscription_status.ts new file mode 100644 index 0000000000000..26dd4caa33c4d --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/hooks/use_subscription_status.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useContext } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { SetupContext } from '../../application/setup_context'; +import { isSubscriptionAllowed } from '../../../common/utils/subscription'; +import { useKibana } from './use_kibana'; + +const SUBSCRIPTION_QUERY_KEY = 'cloud_defend_subscription_query_key'; + +export const useSubscriptionStatus = () => { + const { licensing } = useKibana().services; + const { isCloudEnabled } = useContext(SetupContext); + return useQuery([SUBSCRIPTION_QUERY_KEY], async () => { + const license = await licensing.refresh(); + return isSubscriptionAllowed(isCloudEnabled, license); + }); +}; diff --git a/x-pack/plugins/cloud_defend/public/common/navigation/constants.ts b/x-pack/plugins/cloud_defend/public/common/navigation/constants.ts new file mode 100644 index 0000000000000..b166184dddb87 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/navigation/constants.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { CloudDefendPage, CloudDefendPageNavigationItem } from './types'; + +const NAV_ITEMS_NAMES = { + POLICIES: i18n.translate('xpack.cloudDefend.navigation.policiesNavItemLabel', { + defaultMessage: 'Defend for containers (D4C)', + }), +}; + +/** The base path for all cloud defend pages. */ +export const CLOUD_DEFEND_BASE_PATH = '/cloud_defend'; + +export const cloudDefendPages: Record = { + policies: { + name: NAV_ITEMS_NAMES.POLICIES, + path: `${CLOUD_DEFEND_BASE_PATH}/policies`, + id: 'cloud_defend-policies', + }, +}; diff --git a/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.test.ts b/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.test.ts new file mode 100644 index 0000000000000..f6cfe42d0583a --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloudDefendPages } from './constants'; +import { getSecuritySolutionLink, getSecuritySolutionNavTab } from './security_solution_links'; +import { Chance } from 'chance'; +import type { CloudDefendPage } from './types'; + +const chance = new Chance(); + +describe('getSecuritySolutionLink', () => { + it('gets the correct link properties', () => { + const cloudDefendPage = chance.pickone(['policies']); + + const link = getSecuritySolutionLink(cloudDefendPage); + + expect(link.id).toEqual(cloudDefendPages[cloudDefendPage].id); + expect(link.path).toEqual(cloudDefendPages[cloudDefendPage].path); + expect(link.title).toEqual(cloudDefendPages[cloudDefendPage].name); + }); +}); + +describe('getSecuritySolutionNavTab', () => { + it('gets the correct nav tab properties', () => { + const cloudDefendPage = chance.pickone(['policies']); + const basePath = chance.word(); + + const navTab = getSecuritySolutionNavTab(cloudDefendPage, basePath); + + expect(navTab.id).toEqual(cloudDefendPages[cloudDefendPage].id); + expect(navTab.name).toEqual(cloudDefendPages[cloudDefendPage].name); + expect(navTab.href).toEqual(`${basePath}${cloudDefendPages[cloudDefendPage].path}`); + expect(navTab.disabled).toEqual(!!cloudDefendPages[cloudDefendPage].disabled); + }); +}); diff --git a/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.ts b/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.ts new file mode 100644 index 0000000000000..58e816c135593 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/navigation/security_solution_links.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloudDefendPages } from './constants'; +import type { CloudDefendPageId, CloudDefendPage } from './types'; + +interface CloudDefendLinkItem { + id: TId; + title: string; + path: string; +} + +interface CloudDefendNavTab { + id: TId; + name: string; + href: string; + disabled: boolean; +} + +/** + * Gets the cloud_defend link properties of a Cloud Defend page for navigation in the security solution. + * @param cloudDefendPage the name of the cloud defend page. + */ +export const getSecuritySolutionLink = ( + cloudDefendPage: CloudDefendPage +): CloudDefendLinkItem => { + return { + id: cloudDefendPages[cloudDefendPage].id as TId, + title: cloudDefendPages[cloudDefendPage].name, + path: cloudDefendPages[cloudDefendPage].path, + }; +}; + +/** + * Gets the link properties of a Cloud Defend page for navigation in the old security solution navigation. + * @param cloudDefendPage the name of the cloud defend page. + * @param basePath the base path for links. + */ +export const getSecuritySolutionNavTab = ( + cloudDefendPage: CloudDefendPage, + basePath: string +): CloudDefendNavTab => ({ + id: cloudDefendPages[cloudDefendPage].id as TId, + name: cloudDefendPages[cloudDefendPage].name, + href: `${basePath}${cloudDefendPages[cloudDefendPage].path}`, + disabled: !!cloudDefendPages[cloudDefendPage].disabled, +}); diff --git a/x-pack/plugins/cloud_defend/public/common/navigation/types.ts b/x-pack/plugins/cloud_defend/public/common/navigation/types.ts new file mode 100644 index 0000000000000..56da6ac4c3948 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/navigation/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export interface CloudDefendNavigationItem { + readonly name: string; + readonly path: string; + readonly disabled?: boolean; +} + +export interface CloudDefendPageNavigationItem extends CloudDefendNavigationItem { + id: CloudDefendPageId; +} + +export type CloudDefendPage = 'policies'; + +/** + * All the IDs for the cloud defend pages. + * This needs to match the cloud defend page entries in `SecurityPageName` in `x-pack/plugins/security_solution/common/constants.ts`. + */ +export type CloudDefendPageId = 'cloud_defend-policies'; diff --git a/x-pack/plugins/cloud_defend/public/common/navigation/use_cloud_defend_integration_links.ts b/x-pack/plugins/cloud_defend/public/common/navigation/use_cloud_defend_integration_links.ts new file mode 100644 index 0000000000000..b2d0cca31fcba --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/common/navigation/use_cloud_defend_integration_links.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pagePathGetters, pkgKeyFromPackageInfo } from '@kbn/fleet-plugin/public'; +import { INTEGRATION_PACKAGE_NAME } from '../../../common/constants'; +import { useCloudDefendIntegration } from '../api/use_cloud_defend_integration'; +import { useKibana } from '../hooks/use_kibana'; + +export const useCloudDefendIntegrationLinks = (): { + addIntegrationLink: string | undefined; + docsLink: string; +} => { + const { http } = useKibana().services; + const cloudDefendIntegration = useCloudDefendIntegration(); + + if (!cloudDefendIntegration.isSuccess) + return { + addIntegrationLink: undefined, + docsLink: 'https://www.elastic.co/guide/index.html', + }; + + const addIntegrationLink = pagePathGetters + .add_integration_to_policy({ + integration: INTEGRATION_PACKAGE_NAME, + pkgkey: pkgKeyFromPackageInfo({ + name: cloudDefendIntegration.data.item.name, + version: cloudDefendIntegration.data.item.version, + }), + }) + .join(''); + + const docsLink = pagePathGetters + .integration_details_overview({ + integration: INTEGRATION_PACKAGE_NAME, + pkgkey: pkgKeyFromPackageInfo({ + name: cloudDefendIntegration.data.item.name, + version: cloudDefendIntegration.data.item.version, + }), + }) + .join(''); + + return { + addIntegrationLink: http.basePath.prepend(addIntegrationLink), + docsLink: http.basePath.prepend(docsLink), + }; +}; diff --git a/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.test.tsx b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.test.tsx new file mode 100644 index 0000000000000..4c620a73dcfa9 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.test.tsx @@ -0,0 +1,361 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; +import Chance from 'chance'; +import { + CloudDefendPage, + DEFAULT_NO_DATA_TEST_SUBJECT, + ERROR_STATE_TEST_SUBJECT, + isCommonError, + LOADING_STATE_TEST_SUBJECT, + PACKAGE_NOT_INSTALLED_TEST_SUBJECT, + SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT, +} from '.'; +import { createReactQueryResponse } from '../../test/fixtures/react_query'; +import { TestProvider } from '../../test/test_provider'; +import { coreMock } from '@kbn/core/public/mocks'; +import { render, screen } from '@testing-library/react'; +import React, { ComponentProps } from 'react'; +import { UseQueryResult } from '@tanstack/react-query'; +import { NoDataPage } from '@kbn/kibana-react-plugin/public'; +import { useCloudDefendSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { useCloudDefendIntegrationLinks } from '../../common/navigation/use_cloud_defend_integration_links'; + +const chance = new Chance(); + +jest.mock('../../common/api/use_setup_status_api'); +jest.mock('../../common/hooks/use_subscription_status'); +jest.mock('../../common/navigation/use_cloud_defend_integration_links'); + +describe('', () => { + beforeEach(() => { + jest.resetAllMocks(); + (useCloudDefendSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { status: 'indexed' }, + }) + ); + + (useCloudDefendIntegrationLinks as jest.Mock).mockImplementation(() => ({ + addIntegrationLink: chance.url(), + docsLink: chance.url(), + })); + + (useSubscriptionStatus as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: true, + }) + ); + }); + + const renderCloudDefendPage = ( + props: ComponentProps = { children: null } + ) => { + const mockCore = coreMock.createStart(); + + render( + + + + ); + }; + + it('renders children if setup status is indexed', () => { + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByText(children)).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default loading state when the subscription query is loading', () => { + (useSubscriptionStatus as jest.Mock).mockImplementation( + () => + createReactQueryResponse({ + status: 'loading', + }) as unknown as UseQueryResult + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default error state when the subscription query has an error', () => { + (useSubscriptionStatus as jest.Mock).mockImplementation( + () => + createReactQueryResponse({ + status: 'error', + error: new Error('error'), + }) as unknown as UseQueryResult + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders subscription not allowed prompt if subscription is not installed', () => { + (useSubscriptionStatus as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: false, + }) + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.getByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders integrations installation prompt if integration is not installed', () => { + (useCloudDefendSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { status: 'not-installed' }, + }) + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default loading state when the integration query is loading', () => { + (useCloudDefendSetupStatusApi as jest.Mock).mockImplementation( + () => + createReactQueryResponse({ + status: 'loading', + }) as unknown as UseQueryResult + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default error state when the integration query has an error', () => { + (useCloudDefendSetupStatusApi as jest.Mock).mockImplementation( + () => + createReactQueryResponse({ + status: 'error', + error: new Error('error'), + }) as unknown as UseQueryResult + ); + + const children = chance.sentence(); + renderCloudDefendPage({ children }); + + expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default loading text when query isLoading', () => { + const query = createReactQueryResponse({ + status: 'loading', + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ children, query }); + + expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default loading text when query is idle', () => { + const query = createReactQueryResponse({ + status: 'idle', + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ children, query }); + + expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders default error texts when query isError', () => { + const error = chance.sentence(); + const message = chance.sentence(); + const statusCode = chance.integer(); + + const query = createReactQueryResponse({ + status: 'error', + error: { + body: { + error, + message, + statusCode, + }, + }, + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ children, query }); + + [error, message, statusCode].forEach((text) => + expect(screen.getByText(text, { exact: false })).toBeInTheDocument() + ); + expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('prefers custom error render', () => { + const error = chance.sentence(); + const message = chance.sentence(); + const statusCode = chance.integer(); + + const query = createReactQueryResponse({ + status: 'error', + error: { + body: { + error, + message, + statusCode, + }, + }, + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ + children, + query, + errorRender: (err) =>
{isCommonError(err) && err.body.message}
, + }); + + expect(screen.getByText(message)).toBeInTheDocument(); + [error, statusCode].forEach((text) => expect(screen.queryByText(text)).not.toBeInTheDocument()); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('prefers custom loading render', () => { + const loading = chance.sentence(); + + const query = createReactQueryResponse({ + status: 'loading', + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ + children, + query, + loadingRender: () =>
{loading}
, + }); + + expect(screen.getByText(loading)).toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('renders no data prompt when query data is undefined', () => { + const query = createReactQueryResponse({ + status: 'success', + data: undefined, + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ children, query }); + + expect(screen.getByTestId(DEFAULT_NO_DATA_TEST_SUBJECT)).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); + + it('prefers custom no data prompt', () => { + const pageTitle = chance.sentence(); + const solution = chance.sentence(); + const docsLink = chance.sentence(); + const noDataRenderer = () => ( + + ); + + const query = createReactQueryResponse({ + status: 'success', + data: undefined, + }) as unknown as UseQueryResult; + + const children = chance.sentence(); + renderCloudDefendPage({ + children, + query, + noDataRenderer, + }); + + expect(screen.getByText(pageTitle)).toBeInTheDocument(); + expect(screen.getAllByText(solution, { exact: false })[0]).toBeInTheDocument(); + expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByText(children)).not.toBeInTheDocument(); + expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); + expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.tsx b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.tsx new file mode 100644 index 0000000000000..bc10db7ace74b --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page/index.tsx @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { + EuiButton, + EuiEmptyPrompt, + EuiImage, + EuiFlexGroup, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { NoDataPage, NoDataPageProps } from '@kbn/kibana-react-plugin/public'; +import { css } from '@emotion/react'; +import { SubscriptionNotAllowed } from '../subscription_not_allowed'; +import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; +import { FullSizeCenteredPage } from '../full_size_page'; +import { useCloudDefendSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { LoadingState } from '../loading_state'; +import { useCloudDefendIntegrationLinks } from '../../common/navigation/use_cloud_defend_integration_links'; + +import noDataIllustration from '../../assets/icons/logo.svg'; + +export const LOADING_STATE_TEST_SUBJECT = 'cloud_defend_page_loading'; +export const ERROR_STATE_TEST_SUBJECT = 'cloud_defend_page_error'; +export const PACKAGE_NOT_INSTALLED_TEST_SUBJECT = 'cloud_defend_page_package_not_installed'; +export const DEFAULT_NO_DATA_TEST_SUBJECT = 'cloud_defend_page_no_data'; +export const SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT = 'cloud_defend_page_subscription_not_allowed'; + +interface CommonError { + body: { + error: string; + message: string; + statusCode: number; + }; +} + +export const isCommonError = (error: unknown): error is CommonError => { + if ( + !(error as any)?.body || + !(error as any)?.body?.error || + !(error as any)?.body?.message || + !(error as any)?.body?.statusCode + ) { + return false; + } + + return true; +}; + +export interface CloudDefendNoDataPageProps { + pageTitle: NoDataPageProps['pageTitle']; + docsLink: NoDataPageProps['docsLink']; + actionHref: NoDataPageProps['actions']['elasticAgent']['href']; + actionTitle: NoDataPageProps['actions']['elasticAgent']['title']; + actionDescription: NoDataPageProps['actions']['elasticAgent']['description']; + testId: string; +} + +export const CloudDefendNoDataPage = ({ + pageTitle, + docsLink, + actionHref, + actionTitle, + actionDescription, + testId, +}: CloudDefendNoDataPageProps) => { + return ( + :nth-child(3) { + display: block; + margin: auto; + width: 450px; + } + `} + pageTitle={pageTitle} + solution={i18n.translate( + 'xpack.cloudDefend.cloudDefendPage.packageNotInstalled.solutionNameLabel', + { + defaultMessage: 'Defend for containers (D4C)', + } + )} + docsLink={docsLink} + logo="logoSecurity" + actions={{ + elasticAgent: { + href: actionHref, + isDisabled: !actionHref, + title: actionTitle, + description: actionDescription, + }, + }} + /> + ); +}; + +const packageNotInstalledRenderer = ({ + addIntegrationLink, + docsLink, +}: { + addIntegrationLink?: string; + docsLink?: string; +}) => { + return ( + + } + title={ +

+ +

+ } + layout="horizontal" + color="plain" + body={ +

+ + + + ), + }} + /> +

+ } + actions={ + + + + + + + + } + /> +
+ ); +}; + +const defaultLoadingRenderer = () => ( + + + +); + +const defaultErrorRenderer = (error: unknown) => ( + + + + + } + body={ + isCommonError(error) ? ( +

+ +

+ ) : undefined + } + /> +
+); + +const defaultNoDataRenderer = (docsLink: string) => ( + + + +); + +const subscriptionNotAllowedRenderer = () => ( + + + +); + +interface CloudDefendPageProps { + children: React.ReactNode; + query?: UseQueryResult; + loadingRender?: () => React.ReactNode; + errorRender?: (error: TError) => React.ReactNode; + noDataRenderer?: (docsLink: string) => React.ReactNode; +} + +export const CloudDefendPage = ({ + children, + query, + loadingRender = defaultLoadingRenderer, + errorRender = defaultErrorRenderer, + noDataRenderer = defaultNoDataRenderer, +}: CloudDefendPageProps) => { + const subscriptionStatus = useSubscriptionStatus(); + const getSetupStatus = useCloudDefendSetupStatusApi(); + const { addIntegrationLink, docsLink } = useCloudDefendIntegrationLinks(); + + const render = () => { + if (subscriptionStatus.isError) { + return defaultErrorRenderer(subscriptionStatus.error); + } + + if (subscriptionStatus.isLoading) { + return defaultLoadingRenderer(); + } + + if (!subscriptionStatus.data) { + return subscriptionNotAllowedRenderer(); + } + + if (getSetupStatus.isError) { + return defaultErrorRenderer(getSetupStatus.error); + } + + if (getSetupStatus.isLoading) { + return defaultLoadingRenderer(); + } + + if (getSetupStatus.data.status === 'not-installed') { + return packageNotInstalledRenderer({ addIntegrationLink, docsLink }); + } + + if (!query) { + return children; + } + + if (query.isError) { + return errorRender(query.error); + } + + if (query.isLoading) { + return loadingRender(); + } + + if (!query.data) { + return noDataRenderer(docsLink); + } + + return children; + }; + + return <>{render()}; +}; diff --git a/x-pack/plugins/cloud_defend/public/components/cloud_defend_page_title/index.tsx b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page_title/index.tsx new file mode 100644 index 0000000000000..43422ff2db1e6 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/cloud_defend_page_title/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; + +export const CloudDefendPageTitle = ({ title }: { title: string }) => ( + + + +

{title}

+
+
+
+); diff --git a/x-pack/plugins/cloud_defend/public/components/full_size_page/index.tsx b/x-pack/plugins/cloud_defend/public/components/full_size_page/index.tsx new file mode 100644 index 0000000000000..4e68797c8c21f --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/full_size_page/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexGroup, type CommonProps } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +// Keep this component lean as it is part of the main app bundle +export const FullSizeCenteredPage = ({ + children, + ...rest +}: { children: React.ReactNode } & CommonProps) => ( + + {children} + +); diff --git a/x-pack/plugins/cloud_defend/public/components/loading_state/index.tsx b/x-pack/plugins/cloud_defend/public/components/loading_state/index.tsx new file mode 100644 index 0000000000000..608eabf0e30a8 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/loading_state/index.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import { FullSizeCenteredPage } from '../full_size_page'; + +// Keep this component lean as it is part of the main app bundle +export const LoadingState: React.FunctionComponent<{ ['data-test-subj']?: string }> = ({ + children, + ...rest +}) => { + return ( + + + + {children} + + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/components/policies_table/index.test.tsx b/x-pack/plugins/cloud_defend/public/components/policies_table/index.test.tsx new file mode 100644 index 0000000000000..e473fd9f990b1 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/policies_table/index.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import Chance from 'chance'; +import { render, screen } from '@testing-library/react'; +import moment from 'moment'; +import { createCloudDefendIntegrationFixture } from '../../test/fixtures/cloud_defend_integration'; +import { PoliciesTable } from '.'; +import { TestProvider } from '../../test/test_provider'; + +describe('', () => { + const chance = new Chance(); + + const tableProps = { + pageIndex: 1, + pageSize: 10, + error: undefined, + loading: false, + setQuery: jest.fn(), + }; + + it('renders integration name', () => { + const item = createCloudDefendIntegrationFixture(); + const policies = [item]; + + render( + + + + ); + + expect(screen.getByText(item.package_policy.name)).toBeInTheDocument(); + }); + + it('renders agent policy name', () => { + const agentPolicy = { + id: chance.guid(), + name: chance.sentence(), + agents: chance.integer({ min: 1 }), + }; + + const policies = [createCloudDefendIntegrationFixture({ agent_policy: agentPolicy })]; + + render( + + + + ); + + expect(screen.getByText(agentPolicy.name)).toBeInTheDocument(); + }); + + it('renders number of agents', () => { + const item = createCloudDefendIntegrationFixture(); + const policies = [item]; + + render( + + + + ); + + // TODO too loose + expect(screen.getByText(item.agent_policy.agents as number)).toBeInTheDocument(); + }); + + it('renders created by', () => { + const item = createCloudDefendIntegrationFixture(); + const policies = [item]; + + render( + + + + ); + + expect(screen.getByText(item.package_policy.created_by)).toBeInTheDocument(); + }); + + it('renders created at', () => { + const item = createCloudDefendIntegrationFixture(); + const policies = [item]; + + render( + + + + ); + + expect(screen.getByText(moment(item.package_policy.created_at).fromNow())).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cloud_defend/public/components/policies_table/index.tsx b/x-pack/plugins/cloud_defend/public/components/policies_table/index.tsx new file mode 100644 index 0000000000000..bb4134b083d9f --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/policies_table/index.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBasicTable, + type EuiBasicTableColumn, + type EuiBasicTableProps, + type Pagination, + type CriteriaWithPagination, + EuiLink, +} from '@elastic/eui'; +import React from 'react'; +import { pagePathGetters } from '@kbn/fleet-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { TimestampTableCell } from '../timestamp_table_cell'; +import type { CloudDefendPolicy } from '../../../common/types'; +import { useKibana } from '../../common/hooks/use_kibana'; +import * as TEST_SUBJ from '../../pages/policies/test_subjects'; + +interface PoliciesTableProps + extends Pick< + EuiBasicTableProps, + 'loading' | 'error' | 'noItemsMessage' | 'sorting' + >, + Pagination { + policies: CloudDefendPolicy[]; + setQuery(pagination: CriteriaWithPagination): void; + 'data-test-subj'?: string; +} + +const AgentPolicyButtonLink = ({ name, id: policyId }: { name: string; id: string }) => { + const { http } = useKibana().services; + const [fleetBase, path] = pagePathGetters.policy_details({ policyId }); + + return {name}; +}; + +const IntegrationButtonLink = ({ + packageName, + policyId, + packagePolicyId, +}: { + packageName: string; + packagePolicyId: string; + policyId: string; +}) => { + const editIntegrationLink = pagePathGetters + .edit_integration({ + packagePolicyId, + policyId, + }) + .join(''); + + return {packageName}; +}; + +const POLICIES_TABLE_COLUMNS: Array> = [ + { + field: 'package_policy.name', + name: i18n.translate('xpack.cloudDefend.policies.policiesTable.integrationNameColumnTitle', { + defaultMessage: 'Integration Name', + }), + render: (packageName, policy) => ( + + ), + truncateText: true, + sortable: true, + 'data-test-subj': TEST_SUBJ.POLICIES_TABLE_COLUMNS.INTEGRATION_NAME, + }, + { + field: 'agent_policy.name', + name: i18n.translate('xpack.cloudDefend.policies.policiesTable.agentPolicyColumnTitle', { + defaultMessage: 'Agent Policy', + }), + render: (name, policy) => , + truncateText: true, + 'data-test-subj': TEST_SUBJ.POLICIES_TABLE_COLUMNS.AGENT_POLICY, + }, + { + field: 'agent_policy.agents', + name: i18n.translate('xpack.cloudDefend.policies.policiesTable.numberOfAgentsColumnTitle', { + defaultMessage: 'Number of Agents', + }), + truncateText: true, + 'data-test-subj': TEST_SUBJ.POLICIES_TABLE_COLUMNS.NUMBER_OF_AGENTS, + }, + { + field: 'package_policy.created_by', + name: i18n.translate('xpack.cloudDefend.policies.policiesTable.createdByColumnTitle', { + defaultMessage: 'Created by', + }), + dataType: 'string', + truncateText: true, + sortable: true, + 'data-test-subj': TEST_SUBJ.POLICIES_TABLE_COLUMNS.CREATED_BY, + }, + { + field: 'package_policy.created_at', + name: i18n.translate('xpack.cloudDefend.policies.policiesTable.createdAtColumnTitle', { + defaultMessage: 'Created at', + }), + dataType: 'date', + truncateText: true, + render: (timestamp: CloudDefendPolicy['package_policy']['created_at']) => ( + + ), + sortable: true, + 'data-test-subj': TEST_SUBJ.POLICIES_TABLE_COLUMNS.CREATED_AT, + }, +]; + +export const PoliciesTable = ({ + policies, + pageIndex, + pageSize, + totalItemCount, + loading, + error, + setQuery, + noItemsMessage, + sorting, + ...rest +}: PoliciesTableProps) => { + const pagination: Pagination = { + pageIndex: Math.max(pageIndex - 1, 0), + pageSize, + totalItemCount, + }; + + const onChange = ({ page, sort }: CriteriaWithPagination) => { + setQuery({ page: { ...page, index: page.index + 1 }, sort }); + }; + + return ( + [item.agent_policy.id, item.package_policy.id].join('/')} + pagination={pagination} + onChange={onChange} + tableLayout="fixed" + loading={loading} + noItemsMessage={noItemsMessage} + error={error} + sorting={sorting} + /> + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/components/subscription_not_allowed/index.tsx b/x-pack/plugins/cloud_defend/public/components/subscription_not_allowed/index.tsx new file mode 100644 index 0000000000000..7ab4afa3fb06e --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/subscription_not_allowed/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiEmptyPrompt, EuiPageSection, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; +import { useKibana } from '../../common/hooks/use_kibana'; + +export const SubscriptionNotAllowed = () => { + const { application } = useKibana().services; + return ( + + + + + } + body={ +

+ + + + ), + }} + /> +

+ } + /> +
+ ); +}; diff --git a/x-pack/plugins/cloud_defend/public/components/timestamp_table_cell/index.tsx b/x-pack/plugins/cloud_defend/public/components/timestamp_table_cell/index.tsx new file mode 100644 index 0000000000000..b6b6934b92cde --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/components/timestamp_table_cell/index.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import moment, { type MomentInput } from 'moment'; +import { EuiToolTip, formatDate } from '@elastic/eui'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; + +const DEFAULT_DATE_FORMAT = 'dateFormat'; + +export const TimestampTableCell = ({ timestamp }: { timestamp: MomentInput }) => { + const dateFormat = useUiSetting(DEFAULT_DATE_FORMAT); + const formatted = formatDate(timestamp, dateFormat); + + return ( + + {moment(timestamp).fromNow()} + + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/index.ts b/x-pack/plugins/cloud_defend/public/index.ts index fd8099aa2ed11..b74c04111c91b 100755 --- a/x-pack/plugins/cloud_defend/public/index.ts +++ b/x-pack/plugins/cloud_defend/public/index.ts @@ -6,6 +6,14 @@ */ import { CloudDefendPlugin } from './plugin'; +export type { CloudDefendSecuritySolutionContext } from './types'; +export { + getSecuritySolutionLink, + getSecuritySolutionNavTab, +} from './common/navigation/security_solution_links'; +export { CLOUD_DEFEND_BASE_PATH } from './common/navigation/constants'; +export type { CloudDefendPageId } from './common/navigation/types'; + // This exports static code and TypeScript types, // as well as, Kibana Platform `plugin()` initializer. export function plugin() { diff --git a/x-pack/plugins/cloud_defend/public/pages/index.ts b/x-pack/plugins/cloud_defend/public/pages/index.ts new file mode 100644 index 0000000000000..ec35f87e70811 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/pages/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Policies } from './policies'; diff --git a/x-pack/plugins/cloud_defend/public/pages/policies/index.test.tsx b/x-pack/plugins/cloud_defend/public/pages/policies/index.test.tsx new file mode 100644 index 0000000000000..61fcf6f0a844a --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/pages/policies/index.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import Chance from 'chance'; +import { render, screen } from '@testing-library/react'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { createCloudDefendIntegrationFixture } from '../../test/fixtures/cloud_defend_integration'; +import { createReactQueryResponse } from '../../test/fixtures/react_query'; +import { TestProvider } from '../../test/test_provider'; +import { Policies } from '.'; +import * as TEST_SUBJ from './test_subjects'; +import { useCloudDefendPolicies } from './use_cloud_defend_policies'; +import { useCloudDefendSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; +import { useCloudDefendIntegrationLinks } from '../../common/navigation/use_cloud_defend_integration_links'; + +jest.mock('./use_cloud_defend_policies'); +jest.mock('../../common/api/use_setup_status_api'); +jest.mock('../../common/hooks/use_subscription_status'); +jest.mock('../../common/navigation/use_cloud_defend_integration_links'); + +const chance = new Chance(); + +describe('', () => { + beforeEach(() => { + jest.resetAllMocks(); + (useCloudDefendSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { status: 'indexed' }, + }) + ); + + (useSubscriptionStatus as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: true, + }) + ); + + (useCloudDefendIntegrationLinks as jest.Mock).mockImplementation(() => ({ + addIntegrationLink: chance.url(), + docsLink: chance.url(), + })); + }); + + const renderPolicies = (queryResponse: Partial = createReactQueryResponse()) => { + (useCloudDefendPolicies as jest.Mock).mockImplementation(() => queryResponse); + + return render( + + + + ); + }; + + it('renders the page header', () => { + renderPolicies(); + + expect(screen.getByTestId(TEST_SUBJ.POLICIES_PAGE_HEADER)).toBeInTheDocument(); + }); + + it('renders the "add integration" button', () => { + renderPolicies(); + + expect(screen.getByTestId(TEST_SUBJ.ADD_INTEGRATION_TEST_SUBJ)).toBeInTheDocument(); + }); + + it('renders error state while there is an error', () => { + const error = new Error('message'); + renderPolicies(createReactQueryResponse({ status: 'error', error })); + + expect(screen.getByText(error.message)).toBeInTheDocument(); + }); + + it('renders the benchmarks table', () => { + renderPolicies( + createReactQueryResponse({ + status: 'success', + data: { total: 1, items: [createCloudDefendIntegrationFixture()] }, + }) + ); + + expect(screen.getByTestId(TEST_SUBJ.POLICIES_TABLE_DATA_TEST_SUBJ)).toBeInTheDocument(); + Object.values(TEST_SUBJ.POLICIES_TABLE_COLUMNS).forEach((testId) => + expect(screen.getAllByTestId(testId)[0]).toBeInTheDocument() + ); + }); +}); diff --git a/x-pack/plugins/cloud_defend/public/pages/policies/index.tsx b/x-pack/plugins/cloud_defend/public/pages/policies/index.tsx new file mode 100644 index 0000000000000..c732be5421a17 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/pages/policies/index.tsx @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiButton, + EuiFieldSearch, + EuiFieldSearchProps, + EuiFlexGroup, + EuiFlexItem, + EuiPageHeader, + EuiSpacer, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; +import { CloudDefendPageTitle } from '../../components/cloud_defend_page_title'; +import { CloudDefendPage } from '../../components/cloud_defend_page'; +import { PoliciesTable } from '../../components/policies_table'; +import { useCloudDefendPolicies, UseCloudDefendPoliciesProps } from './use_cloud_defend_policies'; +import { extractErrorMessage } from '../../../common/utils/helpers'; +import * as TEST_SUBJ from './test_subjects'; +import { LOCAL_STORAGE_PAGE_SIZE } from '../../common/constants'; +import { usePageSize } from '../../common/hooks/use_page_size'; +import { useCloudDefendIntegrationLinks } from '../../common/navigation/use_cloud_defend_integration_links'; + +const SEARCH_DEBOUNCE_MS = 300; + +const AddIntegrationButton = () => { + const { addIntegrationLink } = useCloudDefendIntegrationLinks(); + + return ( + + + + ); +}; + +const EmptyState = ({ name }: { name: string }) => ( +
+ + { + + + + {name && ( + + )} + + + } + + + + + + + +
+); + +const TotalIntegrationsCount = ({ + pageCount, + totalCount, +}: Record<'pageCount' | 'totalCount', number>) => ( + + + + + +); + +const SearchField = ({ + onSearch, + isLoading, +}: Required>) => { + const [localValue, setLocalValue] = useState(''); + + useDebounce(() => onSearch(localValue), SEARCH_DEBOUNCE_MS, [localValue]); + + return ( + + + + + + ); +}; + +export const Policies = () => { + const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE); + const [query, setQuery] = useState({ + name: '', + page: 1, + perPage: pageSize, + sortField: 'package_policy.name', + sortOrder: 'asc', + }); + + const queryResult = useCloudDefendPolicies(query); + const totalItemCount = queryResult.data?.total || 0; + + return ( + + + } + rightSideItems={[]} + bottomBorder + /> + + setQuery((current) => ({ ...current, name }))} + /> + + + + { + setPageSize(page.size); + setQuery((current) => ({ + ...current, + page: page.index, + perPage: page.size, + sortField: + (sort?.field as UseCloudDefendPoliciesProps['sortField']) || current.sortField, + sortOrder: sort?.direction || current.sortOrder, + })); + }} + noItemsMessage={ + queryResult.isSuccess && !queryResult.data.total ? ( + + ) : undefined + } + /> + + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/pages/policies/test_subjects.ts b/x-pack/plugins/cloud_defend/public/pages/policies/test_subjects.ts new file mode 100644 index 0000000000000..9709360512727 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/pages/policies/test_subjects.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const POLICIES_PAGE_HEADER = 'policies-page-header'; +export const POLICIES_TABLE_DATA_TEST_SUBJ = 'cloud_defend_policies_table'; +export const ADD_INTEGRATION_TEST_SUBJ = 'cloud_defend_add_integration'; +export const POLICIES_TABLE_COLUMNS = { + INTEGRATION_NAME: 'policies-table-column-integration-name', + AGENT_POLICY: 'policies-table-column-agent-policy', + NUMBER_OF_AGENTS: 'policies-table-column-number-of-agents', + CREATED_BY: 'policies-table-column-created-by', + CREATED_AT: 'policies-table-column-created-at', +}; diff --git a/x-pack/plugins/cloud_defend/public/pages/policies/use_cloud_defend_policies.ts b/x-pack/plugins/cloud_defend/public/pages/policies/use_cloud_defend_policies.ts new file mode 100644 index 0000000000000..e0766026e12b8 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/pages/policies/use_cloud_defend_policies.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import type { ListResult } from '@kbn/fleet-plugin/common'; +import { POLICIES_ROUTE_PATH } from '../../../common/constants'; +import type { PoliciesQueryParams } from '../../../common/schemas/policy'; +import { useKibana } from '../../common/hooks/use_kibana'; +import type { CloudDefendPolicy } from '../../../common/types'; + +const QUERY_KEY = 'cloud_defend_policies'; + +export interface UseCloudDefendPoliciesProps { + name: string; + page: number; + perPage: number; + sortField: PoliciesQueryParams['sort_field']; + sortOrder: PoliciesQueryParams['sort_order']; +} + +export const useCloudDefendPolicies = ({ + name, + perPage, + page, + sortField, + sortOrder, +}: UseCloudDefendPoliciesProps) => { + const { http } = useKibana().services; + const query: PoliciesQueryParams = { + policy_name: name, + per_page: perPage, + page, + sort_field: sortField, + sort_order: sortOrder, + }; + + return useQuery( + [QUERY_KEY, query], + () => http.get>(POLICIES_ROUTE_PATH, { query }), + { keepPreviousData: true } + ); +}; diff --git a/x-pack/plugins/cloud_defend/public/plugin.ts b/x-pack/plugins/cloud_defend/public/plugin.ts deleted file mode 100755 index 5bbb1215e2270..0000000000000 --- a/x-pack/plugins/cloud_defend/public/plugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { lazy } from 'react'; -import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; -import { - CloudDefendPluginSetup, - CloudDefendPluginStart, - CloudDefendPluginStartDeps, -} from './types'; -import { INTEGRATION_PACKAGE_NAME } from '../common/constants'; - -const LazyEditPolicy = lazy(() => import('./components/fleet_extensions/policy_extension_edit')); -const LazyCreatePolicy = lazy( - () => import('./components/fleet_extensions/policy_extension_create') -); - -export class CloudDefendPlugin implements Plugin { - public setup(core: CoreSetup): CloudDefendPluginSetup { - // Return methods that should be available to other plugins - return {}; - } - - public start(core: CoreStart, plugins: CloudDefendPluginStartDeps): CloudDefendPluginStart { - plugins.fleet.registerExtension({ - package: INTEGRATION_PACKAGE_NAME, - view: 'package-policy-create', - Component: LazyCreatePolicy, - }); - - plugins.fleet.registerExtension({ - package: INTEGRATION_PACKAGE_NAME, - view: 'package-policy-edit', - Component: LazyEditPolicy, - }); - - return {}; - } - - public stop() {} -} diff --git a/x-pack/plugins/cloud_defend/public/plugin.tsx b/x-pack/plugins/cloud_defend/public/plugin.tsx new file mode 100755 index 0000000000000..b9272993e6b55 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/plugin.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import React, { lazy, Suspense } from 'react'; +import type { CloudDefendRouterProps } from './application/router'; +import { + CloudDefendPluginSetup, + CloudDefendPluginStart, + CloudDefendPluginStartDeps, + CloudDefendPluginSetupDeps, +} from './types'; +import { INTEGRATION_PACKAGE_NAME } from '../common/constants'; +import { LoadingState } from './components/loading_state'; +import { SetupContext } from './application/setup_context'; + +const LazyEditPolicy = lazy(() => import('./components/fleet_extensions/policy_extension_edit')); +const LazyCreatePolicy = lazy( + () => import('./components/fleet_extensions/policy_extension_create') +); + +const RouterLazy = lazy(() => import('./application/router')); +const Router = (props: CloudDefendRouterProps) => ( + }> + + +); + +export class CloudDefendPlugin + implements + Plugin< + CloudDefendPluginSetup, + CloudDefendPluginStart, + CloudDefendPluginSetupDeps, + CloudDefendPluginStartDeps + > +{ + private isCloudEnabled?: boolean; + + public setup( + core: CoreSetup, + plugins: CloudDefendPluginSetupDeps + ): CloudDefendPluginSetup { + this.isCloudEnabled = plugins.cloud.isCloudEnabled; + + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart, plugins: CloudDefendPluginStartDeps): CloudDefendPluginStart { + plugins.fleet.registerExtension({ + package: INTEGRATION_PACKAGE_NAME, + view: 'package-policy-create', + Component: LazyCreatePolicy, + }); + + plugins.fleet.registerExtension({ + package: INTEGRATION_PACKAGE_NAME, + view: 'package-policy-edit', + Component: LazyEditPolicy, + }); + + const CloudDefendRouter = (props: CloudDefendRouterProps) => ( + + +
+ + + +
+
+
+ ); + + return { + getCloudDefendRouter: () => CloudDefendRouter, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/cloud_defend/public/test/fixtures/cloud_defend_integration.ts b/x-pack/plugins/cloud_defend/public/test/fixtures/cloud_defend_integration.ts new file mode 100644 index 0000000000000..830977947673c --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/test/fixtures/cloud_defend_integration.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import Chance from 'chance'; +import type { CloudDefendPolicy } from '../../../common/types'; + +type CreateCloudDefendIntegrationFixtureInput = { + chance?: Chance.Chance; +} & Partial; + +export const createCloudDefendIntegrationFixture = ({ + chance = new Chance(), + package_policy = { + revision: chance?.integer(), + enabled: true, + id: chance.guid(), + name: chance.string(), + policy_id: chance.guid(), + namespace: chance.string(), + updated_at: chance.date().toISOString(), + updated_by: chance.word(), + created_at: chance.date().toISOString(), + created_by: chance.word(), + inputs: [ + { + type: 'cloud_defend/control', + policy_template: 'cloud_defend', + enabled: true, + streams: [ + { + id: chance?.guid(), + enabled: true, + data_stream: { + type: 'logs', + dataset: 'cloud_defend.alerts', + }, + }, + ], + }, + ], + package: { + name: chance.string(), + title: chance.string(), + version: chance.string(), + }, + }, + agent_policy = { + id: chance.guid(), + name: chance.sentence(), + agents: chance.integer({ min: 0 }), + }, +}: CreateCloudDefendIntegrationFixtureInput = {}): CloudDefendPolicy => ({ + package_policy, + agent_policy, +}); diff --git a/x-pack/plugins/cloud_defend/public/test/fixtures/navigation_item.ts b/x-pack/plugins/cloud_defend/public/test/fixtures/navigation_item.ts new file mode 100644 index 0000000000000..74fdbce35f95b --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/test/fixtures/navigation_item.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import Chance from 'chance'; +import type { CloudDefendPageNavigationItem } from '../../common/navigation/types'; + +type CreateNavigationItemFixtureInput = { + chance?: Chance.Chance; +} & Partial; +export const createPageNavigationItemFixture = ({ + chance = new Chance(), + name = chance.word(), + path = `/${chance.word()}`, + disabled = undefined, + id = 'cloud_defend-policies', +}: CreateNavigationItemFixtureInput = {}): CloudDefendPageNavigationItem => ({ + name, + path, + disabled, + id, +}); diff --git a/x-pack/plugins/cloud_defend/public/test/fixtures/react_query.ts b/x-pack/plugins/cloud_defend/public/test/fixtures/react_query.ts new file mode 100644 index 0000000000000..9605515618882 --- /dev/null +++ b/x-pack/plugins/cloud_defend/public/test/fixtures/react_query.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UseQueryResult } from '@tanstack/react-query'; + +interface CreateReactQueryResponseInput { + status?: UseQueryResult['status'] | 'idle'; + data?: TData; + error?: TError; +} + +// TODO: Consider alternatives to using `Partial` over `UseQueryResult` for the return type: +// 1. Fully mock `UseQueryResult` +// 2. Mock the network layer instead of `useQuery` - see: https://tkdodo.eu/blog/testing-react-query +export const createReactQueryResponse = ({ + status = 'loading', + error = undefined, + data = undefined, +}: CreateReactQueryResponseInput = {}): Partial> => { + if (status === 'success') { + return { status, data, isSuccess: true, isLoading: false, isError: false }; + } + + if (status === 'error') { + return { status, error, isSuccess: false, isLoading: false, isError: true }; + } + + if (status === 'loading') { + return { status, data: undefined, isSuccess: false, isLoading: true, isError: false }; + } + + if (status === 'idle') { + return { + status: 'loading', + data: undefined, + isSuccess: false, + isLoading: true, + isError: false, + fetchStatus: 'idle', + }; + } + + return { status }; +}; diff --git a/x-pack/plugins/cloud_defend/public/test/mocks.ts b/x-pack/plugins/cloud_defend/public/test/mocks.ts index 7dcf8d99d9116..4921d3f6d0f98 100644 --- a/x-pack/plugins/cloud_defend/public/test/mocks.ts +++ b/x-pack/plugins/cloud_defend/public/test/mocks.ts @@ -71,8 +71,8 @@ export const getCloudDefendNewPolicyMock = (yaml = MOCK_YAML_CONFIGURATION): New ], package: { name: 'cloud_defend', - title: 'Kubernetes Security Posture Management', - version: '0.0.21', + title: 'Container drift prevention', + version: '1.0.0', }, }); @@ -114,7 +114,7 @@ export const getCloudDefendPolicyMock = (yaml = MOCK_YAML_CONFIGURATION): Packag ], package: { name: 'cloud_defend', - title: 'Kubernetes Security Posture Management', - version: '0.0.21', + title: 'Container drift prevention', + version: '1.0.0', }, }); diff --git a/x-pack/plugins/cloud_defend/public/test/test_provider.tsx b/x-pack/plugins/cloud_defend/public/test/test_provider.tsx index 472f0ea04ecd1..c5deff816241c 100755 --- a/x-pack/plugins/cloud_defend/public/test/test_provider.tsx +++ b/x-pack/plugins/cloud_defend/public/test/test_provider.tsx @@ -37,13 +37,13 @@ Object.defineProperty(window, 'matchMedia', { })), }); -interface CspAppDeps { +interface CloudDefendAppDeps { core: CoreStart; deps: CloudDefendPluginStartDeps; params: AppMountParameters; } -export const TestProvider: React.FC> = ({ +export const TestProvider: React.FC> = ({ core = coreMock.createStart(), deps = { data: dataPluginMock.createStartContract(), diff --git a/x-pack/plugins/cloud_defend/public/types.ts b/x-pack/plugins/cloud_defend/public/types.ts index 801f5ee67891e..3b242b9fc2add 100755 --- a/x-pack/plugins/cloud_defend/public/types.ts +++ b/x-pack/plugins/cloud_defend/public/types.ts @@ -4,22 +4,53 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { CloudSetup } from '@kbn/cloud-plugin/public'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { FleetSetup, FleetStart } from '@kbn/fleet-plugin/public'; import { NewPackagePolicy } from '@kbn/fleet-plugin/public'; +import type { ComponentType, ReactNode } from 'react'; +import type { + UsageCollectionSetup, + UsageCollectionStart, +} from '@kbn/usage-collection-plugin/public'; +import type { CloudDefendRouterProps } from './application/router'; +import type { CloudDefendPageId } from './common/navigation/types'; + +/** + * cloud_defend plugin types + */ // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CloudDefendPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface CloudDefendPluginStart {} +export interface CloudDefendPluginStart { + /** Gets the cloud defend router component for embedding in the security solution. */ + getCloudDefendRouter(): ComponentType; +} export interface CloudDefendPluginSetupDeps { fleet: FleetSetup; + cloud: CloudSetup; + usageCollection?: UsageCollectionSetup; } export interface CloudDefendPluginStartDeps { fleet: FleetStart; + licensing: LicensingPluginStart; + usageCollection?: UsageCollectionStart; } +export interface CloudDefendSecuritySolutionContext { + /** Gets the `FiltersGlobal` component for embedding a filter bar in the security solution application. */ + getFiltersGlobalComponent: () => ComponentType<{ children: ReactNode }>; + /** Gets the `SpyRoute` component for navigation highlighting and breadcrumbs. */ + getSpyRouteComponent: () => ComponentType<{ + pageName: CloudDefendPageId; + state?: Record; + }>; +} + +/** + * cloud_defend/control types + */ export enum ControlResponseAction { alert = 'alert', block = 'block', diff --git a/x-pack/plugins/cloud_defend/server/index.ts b/x-pack/plugins/cloud_defend/server/index.ts new file mode 100644 index 0000000000000..2cb2e1c2b55e5 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; +import { CloudDefendPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new CloudDefendPlugin(initializerContext); +} + +export type { CloudDefendPluginSetup, CloudDefendPluginStart } from './types'; diff --git a/x-pack/plugins/cloud_defend/server/lib/check_index_status.ts b/x-pack/plugins/cloud_defend/server/lib/check_index_status.ts new file mode 100644 index 0000000000000..984c68a76a6b6 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/lib/check_index_status.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, type Logger } from '@kbn/core/server'; +import { IndexStatus } from '../../common/types'; + +export const checkIndexStatus = async ( + esClient: ElasticsearchClient, + index: string, + logger: Logger +): Promise => { + try { + const queryResult = await esClient.search({ + index, + query: { + match_all: {}, + }, + size: 1, + }); + + return queryResult.hits.hits.length ? 'not-empty' : 'empty'; + } catch (e) { + logger.debug(e); + if (e?.meta?.body?.error?.type === 'security_exception') { + return 'unprivileged'; + } + + // Assuming index doesn't exist + return 'empty'; + } +}; diff --git a/x-pack/plugins/cloud_defend/server/lib/fleet_util.ts b/x-pack/plugins/cloud_defend/server/lib/fleet_util.ts new file mode 100644 index 0000000000000..ae9866b4f03ca --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/lib/fleet_util.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { map, uniq } from 'lodash'; +import type { SavedObjectsClientContract, Logger } from '@kbn/core/server'; +import type { + AgentPolicyServiceInterface, + AgentService, + PackagePolicyClient, +} from '@kbn/fleet-plugin/server'; +import type { + AgentPolicy, + GetAgentStatusResponse, + ListResult, + PackagePolicy, +} from '@kbn/fleet-plugin/common'; +import { errors } from '@elastic/elasticsearch'; +import { INPUT_CONTROL, CLOUD_DEFEND_FLEET_PACKAGE_KUERY } from '../../common/constants'; +import { POLICIES_PACKAGE_POLICY_PREFIX, PoliciesQueryParams } from '../../common/schemas/policy'; + +export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; + +const isFleetMissingAgentHttpError = (error: unknown) => + error instanceof errors.ResponseError && error.statusCode === 404; + +const isPolicyTemplate = (input: any) => input === INPUT_CONTROL; + +const getPackageNameQuery = (packageName: string, benchmarkFilter?: string): string => { + const integrationNameQuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; + const kquery = benchmarkFilter + ? `${integrationNameQuery} AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *${benchmarkFilter}*` + : integrationNameQuery; + + return kquery; +}; + +export type AgentStatusByAgentPolicyMap = Record; + +export const getAgentStatusesByAgentPolicies = async ( + agentService: AgentService, + agentPolicies: AgentPolicy[] | undefined, + logger: Logger +): Promise => { + if (!agentPolicies?.length) return {}; + + const internalAgentService = agentService.asInternalUser; + const result: AgentStatusByAgentPolicyMap = {}; + + try { + for (const agentPolicy of agentPolicies) { + result[agentPolicy.id] = await internalAgentService.getAgentStatusForAgentPolicy( + agentPolicy.id + ); + } + } catch (error) { + if (isFleetMissingAgentHttpError(error)) { + logger.debug('failed to get agent status for agent policy'); + } else { + throw error; + } + } + + return result; +}; + +export const getCloudDefendAgentPolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicies: PackagePolicy[], + agentPolicyService: AgentPolicyServiceInterface +): Promise => + agentPolicyService.getByIds(soClient, uniq(map(packagePolicies, 'policy_id')), { + withPackagePolicies: true, + ignoreMissing: true, + }); + +export const getCloudDefendPackagePolicies = ( + soClient: SavedObjectsClientContract, + packagePolicyService: PackagePolicyClient, + packageName: string, + queryParams: Partial +): Promise> => { + const sortField = queryParams.sort_field?.replaceAll(POLICIES_PACKAGE_POLICY_PREFIX, ''); + + return packagePolicyService.list(soClient, { + kuery: getPackageNameQuery(packageName, queryParams.policy_name), + page: queryParams.page, + perPage: queryParams.per_page, + sortField, + sortOrder: queryParams.sort_order, + }); +}; + +export const getInstalledPolicyTemplates = async ( + packagePolicyClient: PackagePolicyClient, + soClient: SavedObjectsClientContract +) => { + try { + // getting all installed cloud_defend package policies + const queryResult = await packagePolicyClient.list(soClient, { + kuery: CLOUD_DEFEND_FLEET_PACKAGE_KUERY, + perPage: 1000, + }); + + // getting installed policy templates + const enabledPolicyTemplates = queryResult.items + .map((policy) => { + return policy.inputs.find((input) => input.enabled)?.policy_template; + }) + .filter(isPolicyTemplate); + + // removing duplicates + return [...new Set(enabledPolicyTemplates)]; + } catch (e) { + return []; + } +}; diff --git a/x-pack/plugins/cloud_defend/server/mocks.ts b/x-pack/plugins/cloud_defend/server/mocks.ts new file mode 100644 index 0000000000000..a3a1fb895e3c8 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/mocks.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { coreMock } from '@kbn/core/server/mocks'; +import { + createFleetRequestHandlerContextMock, + createMockAgentService, + createMockAgentPolicyService, + createPackagePolicyServiceMock, + createMockPackageService, +} from '@kbn/fleet-plugin/server/mocks'; +import { mockAuthenticatedUser } from '@kbn/security-plugin/common/model/authenticated_user.mock'; + +export const createCloudDefendRequestHandlerContextMock = () => { + const coreMockRequestContext = coreMock.createRequestHandlerContext(); + + return { + core: coreMockRequestContext, + fleet: createFleetRequestHandlerContextMock(), + cloudDefend: { + user: mockAuthenticatedUser(), + logger: loggingSystemMock.createLogger(), + esClient: coreMockRequestContext.elasticsearch.client, + soClient: coreMockRequestContext.savedObjects.client, + agentPolicyService: createMockAgentPolicyService(), + agentService: createMockAgentService(), + packagePolicyService: createPackagePolicyServiceMock(), + packageService: createMockPackageService(), + }, + }; +}; diff --git a/x-pack/plugins/cloud_defend/server/plugin.ts b/x-pack/plugins/cloud_defend/server/plugin.ts new file mode 100644 index 0000000000000..05926e82cf796 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/plugin.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; +import type { NewPackagePolicy } from '@kbn/fleet-plugin/common'; +import { + CloudDefendPluginSetup, + CloudDefendPluginStart, + CloudDefendPluginStartDeps, + CloudDefendPluginSetupDeps, +} from './types'; +import { setupRoutes } from './routes/setup_routes'; +import { isCloudDefendPackage } from '../common/utils/helpers'; +import { isSubscriptionAllowed } from '../common/utils/subscription'; + +export class CloudDefendPlugin implements Plugin { + private readonly logger: Logger; + private isCloudEnabled?: boolean; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup( + core: CoreSetup, + plugins: CloudDefendPluginSetupDeps + ) { + this.logger.debug('cloudDefend: Setup'); + + setupRoutes({ + core, + logger: this.logger, + }); + + this.isCloudEnabled = plugins.cloud.isCloudEnabled; + + return {}; + } + + public start(core: CoreStart, plugins: CloudDefendPluginStartDeps): CloudDefendPluginStart { + this.logger.debug('cloudDefend: Started'); + + plugins.fleet.fleetSetupCompleted().then(async () => { + plugins.fleet.registerExternalCallback( + 'packagePolicyCreate', + async (packagePolicy: NewPackagePolicy): Promise => { + const license = await plugins.licensing.refresh(); + if (isCloudDefendPackage(packagePolicy.package?.name)) { + if (!isSubscriptionAllowed(this.isCloudEnabled, license)) { + throw new Error( + 'To use this feature you must upgrade your subscription or start a trial' + ); + } + } + + return packagePolicy; + } + ); + }); + + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/cloud_defend/server/routes/policies/policies.test.ts b/x-pack/plugins/cloud_defend/server/routes/policies/policies.test.ts new file mode 100644 index 0000000000000..51dbabe1d936f --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/routes/policies/policies.test.ts @@ -0,0 +1,261 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { httpServerMock, httpServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { + policiesQueryParamsSchema, + DEFAULT_POLICIES_PER_PAGE, +} from '../../../common/schemas/policy'; +import { + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + getCloudDefendPackagePolicies, + getCloudDefendAgentPolicies, +} from '../../lib/fleet_util'; +import { defineGetPoliciesRoute } from './policies'; + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { + createMockAgentPolicyService, + createPackagePolicyServiceMock, +} from '@kbn/fleet-plugin/server/mocks'; +import { createPackagePolicyMock } from '@kbn/fleet-plugin/common/mocks'; +import { createCloudDefendRequestHandlerContextMock } from '../../mocks'; + +describe('policies API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('validate the API route path', async () => { + const router = httpServiceMock.createRouter(); + + defineGetPoliciesRoute(router); + + const [config] = router.get.mock.calls[0]; + + expect(config.path).toEqual('/internal/cloud_defend/policies'); + }); + + it('should accept to a user with fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + + defineGetPoliciesRoute(router); + + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = createCloudDefendRequestHandlerContextMock(); + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(0); + }); + + it('should reject to a user without fleet.all privilege', async () => { + const router = httpServiceMock.createRouter(); + + defineGetPoliciesRoute(router); + + const [_, handler] = router.get.mock.calls[0]; + + const mockContext = createCloudDefendRequestHandlerContextMock(); + mockContext.fleet.authz.fleet.all = false; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledTimes(1); + }); + + describe('test input schema', () => { + it('expect to find default values', async () => { + const validatedQuery = policiesQueryParamsSchema.validate({}); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_POLICIES_PER_PAGE, + }); + }); + + it('expect to find policy_name', async () => { + const validatedQuery = policiesQueryParamsSchema.validate({ + policy_name: 'my_cis_policy', + }); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_POLICIES_PER_PAGE, + policy_name: 'my_cis_policy', + }); + }); + + it('should throw when page field is not a positive integer', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ page: -2 }); + }).toThrow(); + }); + + it('should throw when per_page field is not a positive integer', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ per_page: -2 }); + }).toThrow(); + }); + }); + + it('should throw when sort_field is not string', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_field: true }); + }).toThrow(); + }); + + it('should not throw when sort_field is a string', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_field: 'package_policy.name' }); + }).not.toThrow(); + }); + + it('should throw when sort_order is not `asc` or `desc`', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_order: 'Other Direction' }); + }).toThrow(); + }); + + it('should not throw when `asc` is input for sort_order field', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_order: 'asc' }); + }).not.toThrow(); + }); + + it('should not throw when `desc` is input for sort_order field', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_order: 'desc' }); + }).not.toThrow(); + }); + + it('should not throw when fields is a known string literal', async () => { + expect(() => { + policiesQueryParamsSchema.validate({ sort_field: 'package_policy.name' }); + }).not.toThrow(); + }); + + describe('test policies utils', () => { + let mockSoClient: jest.Mocked; + + beforeEach(() => { + mockSoClient = savedObjectsClientMock.create(); + }); + + describe('test getPackagePolicies', () => { + it('should format request by package name', async () => { + const mockPackagePolicyService = createPackagePolicyServiceMock(); + + await getCloudDefendPackagePolicies(mockSoClient, mockPackagePolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_order: 'desc', + }); + + expect(mockPackagePolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + }) + ); + }); + + it('should build sort request by `sort_field` and default `sort_order`', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getCloudDefendPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_field: 'package_policy.name', + sort_order: 'desc', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + sortField: 'name', + sortOrder: 'desc', + }) + ); + }); + + it('should build sort request by `sort_field` and asc `sort_order`', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getCloudDefendPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_field: 'package_policy.name', + sort_order: 'asc', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + sortField: 'name', + sortOrder: 'asc', + }) + ); + }); + }); + + it('should format request by policy_name', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getCloudDefendPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + sort_order: 'desc', + policy_name: 'cloud_defend_1', + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage AND ${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: *cloud_defend_1*`, + page: 1, + perPage: 100, + }) + ); + }); + + describe('test getAgentPolicies', () => { + it('should return one agent policy id when there is duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + const packagePolicies = [createPackagePolicyMock(), createPackagePolicyMock()]; + + await getCloudDefendAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(1); + }); + + it('should return full policy ids list when there is no id duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + + const packagePolicy1 = createPackagePolicyMock(); + const packagePolicy2 = createPackagePolicyMock(); + packagePolicy2.policy_id = 'AnotherId'; + const packagePolicies = [packagePolicy1, packagePolicy2]; + + await getCloudDefendAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(2); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts b/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts new file mode 100644 index 0000000000000..a0dd3827ff618 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/routes/policies/policies.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { AgentPolicy, PackagePolicy } from '@kbn/fleet-plugin/common'; +import { POLICIES_ROUTE_PATH, INTEGRATION_PACKAGE_NAME } from '../../../common/constants'; +import { policiesQueryParamsSchema } from '../../../common/schemas/policy'; +import type { CloudDefendPolicy } from '../../../common/types'; +import { isNonNullable } from '../../../common/utils/helpers'; +import { CloudDefendRouter } from '../../types'; +import { + getAgentStatusesByAgentPolicies, + type AgentStatusByAgentPolicyMap, + getCloudDefendAgentPolicies, + getCloudDefendPackagePolicies, +} from '../../lib/fleet_util'; + +export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; + +const createPolicies = ( + agentPolicies: AgentPolicy[], + agentStatusByAgentPolicyId: AgentStatusByAgentPolicyMap, + cloudDefendPackagePolicies: PackagePolicy[] +): Promise => { + const cloudDefendPackagePoliciesMap = new Map( + cloudDefendPackagePolicies.map((packagePolicy) => [packagePolicy.id, packagePolicy]) + ); + + return Promise.all( + agentPolicies.flatMap((agentPolicy) => { + const cloudDefendPackagesOnAgent = + agentPolicy.package_policies + ?.map(({ id: pckPolicyId }) => { + return cloudDefendPackagePoliciesMap.get(pckPolicyId); + }) + .filter(isNonNullable) ?? []; + + const policies = cloudDefendPackagesOnAgent.map(async (cloudDefendPackage) => { + const agentPolicyStatus = { + id: agentPolicy.id, + name: agentPolicy.name, + agents: agentStatusByAgentPolicyId[agentPolicy.id]?.total, + }; + return { + package_policy: cloudDefendPackage, + agent_policy: agentPolicyStatus, + }; + }); + + return policies; + }) + ); +}; + +export const defineGetPoliciesRoute = (router: CloudDefendRouter): void => + router.get( + { + path: POLICIES_ROUTE_PATH, + validate: { query: policiesQueryParamsSchema }, + options: { + tags: ['access:cloud-defend-read'], + }, + }, + async (context, request, response) => { + if (!(await context.fleet).authz.fleet.all) { + return response.forbidden(); + } + + const cloudDefendContext = await context.cloudDefend; + + try { + const cloudDefendPackagePolicies = await getCloudDefendPackagePolicies( + cloudDefendContext.soClient, + cloudDefendContext.packagePolicyService, + INTEGRATION_PACKAGE_NAME, + request.query + ); + + const agentPolicies = await getCloudDefendAgentPolicies( + cloudDefendContext.soClient, + cloudDefendPackagePolicies.items, + cloudDefendContext.agentPolicyService + ); + + const agentStatusesByAgentPolicyId = await getAgentStatusesByAgentPolicies( + cloudDefendContext.agentService, + agentPolicies, + cloudDefendContext.logger + ); + + const policies = await createPolicies( + agentPolicies, + agentStatusesByAgentPolicyId, + cloudDefendPackagePolicies.items + ); + + return response.ok({ + body: { + ...cloudDefendPackagePolicies, + items: policies, + }, + }); + } catch (err) { + const error = transformError(err); + cloudDefendContext.logger.error(`Failed to fetch policies ${err}`); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts b/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts new file mode 100644 index 0000000000000..1a9ef57ba83c7 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/routes/setup_routes.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { CoreSetup, Logger } from '@kbn/core/server'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import type { + CloudDefendRequestHandlerContext, + CloudDefendPluginStart, + CloudDefendPluginStartDeps, +} from '../types'; +import { PLUGIN_ID } from '../../common/constants'; +import { defineGetPoliciesRoute } from './policies/policies'; +import { defineGetCloudDefendStatusRoute } from './status/status'; + +/** + * 1. Registers routes + * 2. Registers routes handler context + */ +export function setupRoutes({ + core, + logger, +}: { + core: CoreSetup; + logger: Logger; +}) { + const router = core.http.createRouter(); + defineGetPoliciesRoute(router); + defineGetCloudDefendStatusRoute(router); + + core.http.registerRouteHandlerContext( + PLUGIN_ID, + async (context, request) => { + const [, { security, fleet }] = await core.getStartServices(); + const coreContext = await context.core; + await fleet.fleetSetupCompleted(); + + let user: AuthenticatedUser | null = null; + + return { + get user() { + // We want to call getCurrentUser only when needed and only once + if (!user) { + user = security.authc.getCurrentUser(request); + } + return user; + }, + logger, + esClient: coreContext.elasticsearch.client, + soClient: coreContext.savedObjects.client, + agentPolicyService: fleet.agentPolicyService, + agentService: fleet.agentService, + packagePolicyService: fleet.packagePolicyService, + packageService: fleet.packageService, + }; + } + ); +} diff --git a/x-pack/plugins/cloud_defend/server/routes/status/status.test.ts b/x-pack/plugins/cloud_defend/server/routes/status/status.test.ts new file mode 100644 index 0000000000000..124d8f4738cb2 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/routes/status/status.test.ts @@ -0,0 +1,493 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { defineGetCloudDefendStatusRoute, INDEX_TIMEOUT_IN_MINUTES } from './status'; +import { httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; +import type { ESSearchResponse } from '@kbn/es-types'; +import { + AgentClient, + AgentPolicyServiceInterface, + AgentService, + PackageClient, + PackagePolicyClient, + PackageService, +} from '@kbn/fleet-plugin/server'; +import { + AgentPolicy, + GetAgentStatusResponse, + Installation, + RegistryPackage, +} from '@kbn/fleet-plugin/common'; +import { createPackagePolicyMock } from '@kbn/fleet-plugin/common/mocks'; +import { createCloudDefendRequestHandlerContextMock } from '../../mocks'; +import { errors } from '@elastic/elasticsearch'; + +const mockCloudDefendPackageInfo: Installation = { + verification_status: 'verified', + installed_kibana: [], + installed_kibana_space_id: 'default', + installed_es: [], + package_assets: [], + es_index_patterns: { alerts: 'logs-cloud_defend.alerts-*' }, + name: 'cloud_defend', + version: '1.0.0', + install_version: '1.0.0', + install_status: 'installed', + install_started_at: '2022-06-16T15:24:58.281Z', + install_source: 'registry', +}; + +const mockLatestCloudDefendPackageInfo: RegistryPackage = { + format_version: 'mock', + name: 'cloud_defend', + title: 'Defend for containers (D4C)', + version: '1.0.0', + release: 'experimental', + description: 'Container drift prevention', + type: 'integration', + download: '/epr/cloud_defend/cloud_defend-1.0.0.zip', + path: '/package/cloud_defend/1.0.0', + policy_templates: [], + owner: { github: 'elastic/sec-cloudnative-integrations' }, + categories: ['containers', 'kubernetes'], +}; + +describe('CloudDefendSetupStatus route', () => { + const router = httpServiceMock.createRouter(); + let mockContext: ReturnType; + let mockPackagePolicyService: jest.Mocked; + let mockAgentPolicyService: jest.Mocked; + let mockAgentService: jest.Mocked; + let mockAgentClient: jest.Mocked; + let mockPackageService: PackageService; + let mockPackageClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext = createCloudDefendRequestHandlerContextMock(); + mockPackagePolicyService = mockContext.cloudDefend.packagePolicyService; + mockAgentPolicyService = mockContext.cloudDefend.agentPolicyService; + mockAgentService = mockContext.cloudDefend.agentService; + mockPackageService = mockContext.cloudDefend.packageService; + + mockAgentClient = mockAgentService.asInternalUser as jest.Mocked; + mockPackageClient = mockPackageService.asInternalUser as jest.Mocked; + }); + + it('validate the API route path', async () => { + defineGetCloudDefendStatusRoute(router); + const [config, _] = router.get.mock.calls[0]; + + expect(config.path).toEqual('/internal/cloud_defend/status'); + }); + + const indices = [ + { + index: 'logs-cloud_defend.alerts-default*', + expected_status: 'not-installed', + }, + ]; + + indices.forEach((idxTestCase) => { + it( + 'Verify the API result when there are no permissions to index: ' + idxTestCase.index, + async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseImplementation( + (req) => { + if (req?.index === idxTestCase.index) { + throw new errors.ResponseError({ + body: { + error: { + type: 'security_exception', + }, + }, + statusCode: 503, + headers: {}, + warnings: [], + meta: {} as any, + }); + } + + return { + hits: { + hits: [{}], + }, + } as any; + } + ); + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 0, + page: 1, + perPage: 100, + }); + + // Act + defineGetCloudDefendStatusRoute(router); + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]?.body; + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: idxTestCase.expected_status, + }); + } + ); + }); + + it('Verify the API result when there are alerts and no installed policies', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [{ Alerts: 'foo' }], + }, + } as unknown as ESSearchResponse); + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 0, + page: 1, + perPage: 100, + }); + + // Act + defineGetCloudDefendStatusRoute(router); + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]?.body; + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + await expect(body).toMatchObject({ + status: 'indexed', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 0, + healthyAgents: 0, + installedPackageVersion: undefined, + }); + }); + + it('Verify the API result when there are alerts, installed policies, no running agents', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [{ Alerts: 'foo' }], + }, + } as unknown as ESSearchResponse); + + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + mockPackageClient.getInstallation.mockResolvedValueOnce(mockCloudDefendPackageInfo); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 3, + page: 1, + perPage: 100, + }); + + // Act + defineGetCloudDefendStatusRoute(router); + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]?.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + await expect(body).toMatchObject({ + status: 'indexed', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 3, + healthyAgents: 0, + installedPackageVersion: '1.0.0', + }); + }); + + it('Verify the API result when there are alerts, installed policies, running agents', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [{ Alerts: 'foo' }], + }, + } as unknown as ESSearchResponse); + + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + mockPackageClient.getInstallation.mockResolvedValueOnce(mockCloudDefendPackageInfo); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 3, + page: 1, + perPage: 100, + }); + + mockAgentPolicyService.getByIds.mockResolvedValue([ + { package_policies: createPackagePolicyMock() }, + ] as unknown as AgentPolicy[]); + + mockAgentClient.getAgentStatusForAgentPolicy.mockResolvedValue({ + online: 1, + updating: 0, + } as unknown as GetAgentStatusResponse['results']); + + // Act + defineGetCloudDefendStatusRoute(router); + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: 'indexed', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 3, + healthyAgents: 1, + installedPackageVersion: '1.0.0', + }); + }); + + it('Verify the API result when there are no alerts and no installed policies', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [], + }, + } as unknown as ESSearchResponse); + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 0, + page: 1, + perPage: 100, + }); + defineGetCloudDefendStatusRoute(router); + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + + // Act + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: 'not-installed', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 0, + healthyAgents: 0, + }); + }); + + it('Verify the API result when there are no alerts, installed agent but no deployed agent', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [], + }, + } as unknown as ESSearchResponse); + + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + mockPackageClient.getInstallation.mockResolvedValueOnce(mockCloudDefendPackageInfo); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 1, + page: 1, + perPage: 100, + }); + + mockAgentPolicyService.getByIds.mockResolvedValue([ + { package_policies: createPackagePolicyMock() }, + ] as unknown as AgentPolicy[]); + + mockAgentClient.getAgentStatusForAgentPolicy.mockResolvedValue({ + online: 0, + updating: 0, + } as unknown as GetAgentStatusResponse['results']); + + // Act + defineGetCloudDefendStatusRoute(router); + + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: 'not-deployed', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 1, + healthyAgents: 0, + installedPackageVersion: '1.0.0', + }); + }); + + it('Verify the API result when there are no alerts, installed agent, deployed agent, before index timeout', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [], + }, + } as unknown as ESSearchResponse); + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + + const currentTime = new Date(); + mockCloudDefendPackageInfo.install_started_at = new Date( + currentTime.setMinutes(currentTime.getMinutes() - INDEX_TIMEOUT_IN_MINUTES + 1) + ).toUTCString(); + + mockPackageClient.getInstallation.mockResolvedValueOnce(mockCloudDefendPackageInfo); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 1, + page: 1, + perPage: 100, + }); + + mockAgentPolicyService.getByIds.mockResolvedValue([ + { package_policies: createPackagePolicyMock() }, + ] as unknown as AgentPolicy[]); + + mockAgentClient.getAgentStatusForAgentPolicy.mockResolvedValue({ + online: 1, + updating: 0, + } as unknown as GetAgentStatusResponse['results']); + + // Act + defineGetCloudDefendStatusRoute(router); + + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + const [context, req, res] = [mockContext, mockRequest, mockResponse]; + + await handler(context, req, res); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: 'indexing', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 1, + healthyAgents: 1, + installedPackageVersion: '1.0.0', + }); + }); + + it('Verify the API result when there are no alerts, installed agent, deployed agent, after index timeout', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.search.mockResponseOnce({ + hits: { + hits: [], + }, + } as unknown as ESSearchResponse); + mockPackageClient.fetchFindLatestPackage.mockResolvedValueOnce( + mockLatestCloudDefendPackageInfo + ); + + const currentTime = new Date(); + mockCloudDefendPackageInfo.install_started_at = new Date( + currentTime.setMinutes(currentTime.getMinutes() - INDEX_TIMEOUT_IN_MINUTES - 1) + ).toUTCString(); + + mockPackageClient.getInstallation.mockResolvedValueOnce(mockCloudDefendPackageInfo); + + mockPackagePolicyService.list.mockResolvedValueOnce({ + items: [], + total: 1, + page: 1, + perPage: 100, + }); + + mockAgentPolicyService.getByIds.mockResolvedValue([ + { package_policies: createPackagePolicyMock() }, + ] as unknown as AgentPolicy[]); + + mockAgentClient.getAgentStatusForAgentPolicy.mockResolvedValue({ + online: 1, + updating: 0, + } as unknown as GetAgentStatusResponse['results']); + + // Act + defineGetCloudDefendStatusRoute(router); + + const [_, handler] = router.get.mock.calls[0]; + + const mockResponse = httpServerMock.createResponseFactory(); + const mockRequest = httpServerMock.createKibanaRequest(); + + await handler(mockContext, mockRequest, mockResponse); + + // Assert + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body; + + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + + expect(body).toMatchObject({ + status: 'index-timeout', + latestPackageVersion: '1.0.0', + installedPackagePolicies: 1, + healthyAgents: 1, + installedPackageVersion: '1.0.0', + }); + }); +}); diff --git a/x-pack/plugins/cloud_defend/server/routes/status/status.ts b/x-pack/plugins/cloud_defend/server/routes/status/status.ts new file mode 100644 index 0000000000000..0283843254978 --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/routes/status/status.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { SavedObjectsClientContract, Logger } from '@kbn/core/server'; +import type { AgentPolicyServiceInterface, AgentService } from '@kbn/fleet-plugin/server'; +import moment from 'moment'; +import { PackagePolicy } from '@kbn/fleet-plugin/common'; +import { + ALERTS_INDEX_PATTERN, + INTEGRATION_PACKAGE_NAME, + STATUS_ROUTE_PATH, +} from '../../../common/constants'; +import type { CloudDefendApiRequestHandlerContext, CloudDefendRouter } from '../../types'; +import type { + CloudDefendSetupStatus, + CloudDefendStatusCode, + IndexStatus, +} from '../../../common/types'; +import { + getAgentStatusesByAgentPolicies, + getCloudDefendAgentPolicies, + getCloudDefendPackagePolicies, + getInstalledPolicyTemplates, +} from '../../lib/fleet_util'; +import { checkIndexStatus } from '../../lib/check_index_status'; + +export const INDEX_TIMEOUT_IN_MINUTES = 10; + +const calculateDiffFromNowInMinutes = (date: string | number): number => + moment().diff(moment(date), 'minutes'); + +const getHealthyAgents = async ( + soClient: SavedObjectsClientContract, + installedCloudDefendPackagePolicies: PackagePolicy[], + agentPolicyService: AgentPolicyServiceInterface, + agentService: AgentService, + logger: Logger +): Promise => { + // Get agent policies of package policies (from installed package policies) + const agentPolicies = await getCloudDefendAgentPolicies( + soClient, + installedCloudDefendPackagePolicies, + agentPolicyService + ); + + // Get agents statuses of the following agent policies + const agentStatusesByAgentPolicyId = await getAgentStatusesByAgentPolicies( + agentService, + agentPolicies, + logger + ); + + return Object.values(agentStatusesByAgentPolicyId).reduce( + (sum, status) => sum + status.online + status.updating, + 0 + ); +}; + +const calculateCloudDefendStatusCode = ( + indicesStatus: { + alerts: IndexStatus; + }, + installedCloudDefendPackagePolicies: number, + healthyAgents: number, + timeSinceInstallationInMinutes: number +): CloudDefendStatusCode => { + // We check privileges only for the relevant indices for our pages to appear + if (indicesStatus.alerts === 'unprivileged') return 'unprivileged'; + if (indicesStatus.alerts === 'not-empty') return 'indexed'; + if (installedCloudDefendPackagePolicies === 0) return 'not-installed'; + if (healthyAgents === 0) return 'not-deployed'; + if (timeSinceInstallationInMinutes <= INDEX_TIMEOUT_IN_MINUTES) return 'indexing'; + if (timeSinceInstallationInMinutes > INDEX_TIMEOUT_IN_MINUTES) return 'index-timeout'; + + throw new Error('Could not determine cloud defend status'); +}; + +const assertResponse = ( + resp: CloudDefendSetupStatus, + logger: CloudDefendApiRequestHandlerContext['logger'] +) => { + if ( + resp.status === 'unprivileged' && + !resp.indicesDetails.some((idxDetails) => idxDetails.status === 'unprivileged') + ) { + logger.warn('Returned status in `unprivileged` but response is missing the unprivileged index'); + } +}; + +const getCloudDefendStatus = async ({ + logger, + esClient, + soClient, + packageService, + packagePolicyService, + agentPolicyService, + agentService, +}: CloudDefendApiRequestHandlerContext): Promise => { + const [ + alertsIndexStatus, + installation, + latestCloudDefendPackage, + installedPackagePolicies, + installedPolicyTemplates, + ] = await Promise.all([ + checkIndexStatus(esClient.asCurrentUser, ALERTS_INDEX_PATTERN, logger), + packageService.asInternalUser.getInstallation(INTEGRATION_PACKAGE_NAME), + packageService.asInternalUser.fetchFindLatestPackage(INTEGRATION_PACKAGE_NAME), + getCloudDefendPackagePolicies(soClient, packagePolicyService, INTEGRATION_PACKAGE_NAME, { + per_page: 10000, + }), + getInstalledPolicyTemplates(packagePolicyService, soClient), + ]); + + const healthyAgents = await getHealthyAgents( + soClient, + installedPackagePolicies.items, + agentPolicyService, + agentService, + logger + ); + + const installedPackagePoliciesTotal = installedPackagePolicies.total; + const latestCloudDefendPackageVersion = latestCloudDefendPackage.version; + + const MIN_DATE = 0; + const indicesDetails = [ + { + index: ALERTS_INDEX_PATTERN, + status: alertsIndexStatus, + }, + ]; + + const status = calculateCloudDefendStatusCode( + { + alerts: alertsIndexStatus, + }, + installedPackagePoliciesTotal, + healthyAgents, + calculateDiffFromNowInMinutes(installation?.install_started_at || MIN_DATE) + ); + + if (status === 'not-installed') + return { + status, + indicesDetails, + latestPackageVersion: latestCloudDefendPackageVersion, + healthyAgents, + installedPackagePolicies: installedPackagePoliciesTotal, + }; + + const response = { + status, + indicesDetails, + latestPackageVersion: latestCloudDefendPackageVersion, + healthyAgents, + installedPolicyTemplates, + installedPackagePolicies: installedPackagePoliciesTotal, + installedPackageVersion: installation?.install_version, + }; + + assertResponse(response, logger); + return response; +}; + +export const defineGetCloudDefendStatusRoute = (router: CloudDefendRouter): void => + router.get( + { + path: STATUS_ROUTE_PATH, + validate: {}, + options: { + tags: ['access:cloud-defend-read'], + }, + }, + async (context, request, response) => { + const cloudDefendContext = await context.cloudDefend; + try { + const status = await getCloudDefendStatus(cloudDefendContext); + return response.ok({ + body: status, + }); + } catch (err) { + cloudDefendContext.logger.error(`Error getting cloud_defend status`); + cloudDefendContext.logger.error(err); + + const error = transformError(err); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_defend/server/types.ts b/x-pack/plugins/cloud_defend/server/types.ts new file mode 100644 index 0000000000000..121a97ee926be --- /dev/null +++ b/x-pack/plugins/cloud_defend/server/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { CloudSetup } from '@kbn/cloud-plugin/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { + IRouter, + CustomRequestHandlerContext, + Logger, + SavedObjectsClientContract, + IScopedClusterClient, +} from '@kbn/core/server'; +import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; +import type { + FleetStartContract, + FleetRequestHandlerContext, + AgentService, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyClient, +} from '@kbn/fleet-plugin/server'; +import type { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '@kbn/data-plugin/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CloudDefendPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface CloudDefendPluginStart {} + +export interface CloudDefendPluginSetupDeps { + data: DataPluginSetup; + security: SecurityPluginSetup; + cloud: CloudSetup; +} +export interface CloudDefendPluginStartDeps { + data: DataPluginStart; + fleet: FleetStartContract; + security: SecurityPluginStart; + licensing: LicensingPluginStart; +} + +export interface CloudDefendApiRequestHandlerContext { + user: ReturnType; + logger: Logger; + esClient: IScopedClusterClient; + soClient: SavedObjectsClientContract; + agentPolicyService: AgentPolicyServiceInterface; + agentService: AgentService; + packagePolicyService: PackagePolicyClient; + packageService: PackageService; +} + +export type CloudDefendRequestHandlerContext = CustomRequestHandlerContext<{ + cloudDefend: CloudDefendApiRequestHandlerContext; + fleet: FleetRequestHandlerContext['fleet']; +}>; + +/** + * Convenience type for routers in cloud_defend that includes the CloudDefendRequestHandlerContext type + * @internal + */ +export type CloudDefendRouter = IRouter; diff --git a/x-pack/plugins/cloud_defend/tsconfig.json b/x-pack/plugins/cloud_defend/tsconfig.json index f96b98d8c44dd..d12ee82da6fce 100755 --- a/x-pack/plugins/cloud_defend/tsconfig.json +++ b/x-pack/plugins/cloud_defend/tsconfig.json @@ -6,23 +6,30 @@ "include": [ "common/**/*", "public/**/*", + "server/**/*", "../../../typings/**/*", - "server/**/*.json", - "public/**/*.json" + "public/**/*.json", + "server/**/*.json" ], "kbn_references": [ "@kbn/core", + "@kbn/data-plugin", + "@kbn/security-plugin", "@kbn/fleet-plugin", - "@kbn/fleet-plugin", - "@kbn/core", "@kbn/i18n-react", + "@kbn/config-schema", + "@kbn/licensing-plugin", "@kbn/data-plugin", "@kbn/kibana-react-plugin", "@kbn/monaco", "@kbn/i18n", - "@kbn/shared-ux-router" + "@kbn/usage-collection-plugin", + "@kbn/cloud-plugin", + "@kbn/shared-ux-router", + "@kbn/shared-ux-link-redirect-app", + "@kbn/core-logging-server-mocks", + "@kbn/securitysolution-es-utils", + "@kbn/es-types" ], - "exclude": [ - "target/**/*" - ] + "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 55b802ae06d8d..5c903966bd0cc 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -91,6 +91,11 @@ export enum SecurityPageName { cloudSecurityPostureDashboard = 'cloud_security_posture-dashboard', cloudSecurityPostureFindings = 'cloud_security_posture-findings', cloudSecurityPostureRules = 'cloud_security_posture-rules', + /* + * Warning: Computed values are not permitted in an enum with string valued members + * All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts + */ + cloudDefendPolicies = 'cloud_defend-policies', dashboardsLanding = 'dashboards', dataQuality = 'data_quality', detections = 'detections', diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 6f6c371f5f27f..85418bdeb31b7 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -15,6 +15,7 @@ "alerting", "cases", "cloud", + "cloudDefend", "cloudSecurityPosture", "dashboard", "data", diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 9853c52502fb2..87d43742a9433 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -7,7 +7,8 @@ import { i18n } from '@kbn/i18n'; -import { getSecuritySolutionLink } from '@kbn/cloud-security-posture-plugin/public'; +import { getSecuritySolutionLink as getCloudDefendSecuritySolutionLink } from '@kbn/cloud-defend-plugin/public'; +import { getSecuritySolutionLink as getCloudPostureSecuritySolutionLink } from '@kbn/cloud-security-posture-plugin/public'; import { getSecuritySolutionDeepLink } from '@kbn/threat-intelligence-plugin/public'; import type { LicenseType } from '@kbn/licensing-plugin/common/types'; import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; @@ -167,7 +168,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], }, { - ...getSecuritySolutionLink('dashboard'), + ...getCloudPostureSecuritySolutionLink('dashboard'), features: [FEATURE.general], }, { @@ -251,7 +252,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ ], }, { - ...getSecuritySolutionLink('findings'), + ...getCloudPostureSecuritySolutionLink('findings'), features: [FEATURE.general], navLinkStatus: AppNavLinkStatus.visible, order: 9002, @@ -529,7 +530,10 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ path: RESPONSE_ACTIONS_HISTORY_PATH, }, { - ...getSecuritySolutionLink('benchmarks'), + ...getCloudPostureSecuritySolutionLink('benchmarks'), + }, + { + ...getCloudDefendSecuritySolutionLink('policies'), }, ], }, diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index bbff6ffa0a6f9..d9a988004ac1a 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -7,6 +7,7 @@ import { getSecuritySolutionNavTab as getSecuritySolutionCSPNavTab } from '@kbn/cloud-security-posture-plugin/public'; import { getSecuritySolutionNavTab as getSecuritySolutionTINavTab } from '@kbn/threat-intelligence-plugin/public'; +import { getSecuritySolutionNavTab as getSecuritySolutionCloudDefendNavTab } from '@kbn/cloud-defend-plugin/public'; import * as i18n from '../translations'; import type { SecurityNav, SecurityNavGroup } from '../../common/components/navigation/types'; import { SecurityNavGroupKey } from '../../common/components/navigation/types'; @@ -186,6 +187,10 @@ export const navTabs: SecurityNav = { ...getSecuritySolutionCSPNavTab('benchmarks', APP_PATH), urlKey: 'administration', }, + [SecurityPageName.cloudDefendPolicies]: { + ...getSecuritySolutionCloudDefendNavTab('policies', APP_PATH), + urlKey: 'administration', + }, [SecurityPageName.entityAnalytics]: { id: SecurityPageName.entityAnalytics, name: i18n.ENTITY_ANALYTICS, diff --git a/x-pack/plugins/security_solution/public/cloud_defend/index.ts b/x-pack/plugins/security_solution/public/cloud_defend/index.ts new file mode 100644 index 0000000000000..4ec2329d36bd5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_defend/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SecuritySubPlugin } from '../app/types'; +import { routes } from './routes'; + +export class CloudDefend { + public setup() {} + + public start(): SecuritySubPlugin { + return { routes }; + } +} diff --git a/x-pack/plugins/security_solution/public/cloud_defend/links.ts b/x-pack/plugins/security_solution/public/cloud_defend/links.ts new file mode 100644 index 0000000000000..652ebe6181151 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_defend/links.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { getSecuritySolutionLink } from '@kbn/cloud-defend-plugin/public'; +import { i18n } from '@kbn/i18n'; +import type { SecurityPageName } from '../../common/constants'; +import { SERVER_APP_ID } from '../../common/constants'; +import type { LinkItem } from '../common/links/types'; +import { IconCloudDefend } from '../management/icons/cloud_defend'; + +const commonLinkProperties: Partial = { + hideTimeline: true, + capabilities: [`${SERVER_APP_ID}.show`], +}; + +export const manageLinks: LinkItem = { + ...getSecuritySolutionLink('policies'), + description: i18n.translate('xpack.securitySolution.appLinks.cloudDefendPoliciesDescription', { + defaultMessage: 'View drift prevention policies.', + }), + landingIcon: IconCloudDefend, + ...commonLinkProperties, +}; diff --git a/x-pack/plugins/security_solution/public/cloud_defend/routes.tsx b/x-pack/plugins/security_solution/public/cloud_defend/routes.tsx new file mode 100644 index 0000000000000..18bd7641addf3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cloud_defend/routes.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { + CloudDefendPageId, + CloudDefendSecuritySolutionContext, +} from '@kbn/cloud-defend-plugin/public'; +import { CLOUD_DEFEND_BASE_PATH } from '@kbn/cloud-defend-plugin/public'; +import type { SecurityPageName, SecuritySubPluginRoutes } from '../app/types'; +import { useKibana } from '../common/lib/kibana'; +import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper'; +import { SpyRoute } from '../common/utils/route/spy_routes'; +import { FiltersGlobal } from '../common/components/filters_global'; +import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; + +// This exists only for the type signature cast +const CloudDefendSpyRoute = ({ pageName, ...rest }: { pageName?: CloudDefendPageId }) => ( + +); + +const cloudDefendSecuritySolutionContext: CloudDefendSecuritySolutionContext = { + getFiltersGlobalComponent: () => FiltersGlobal, + getSpyRouteComponent: () => CloudDefendSpyRoute, +}; + +const CloudDefend = () => { + const { cloudDefend } = useKibana().services; + const CloudDefendRouter = cloudDefend.getCloudDefendRouter(); + + return ( + + + + + + ); +}; + +CloudDefend.displayName = 'CloudDefend'; + +export const routes: SecuritySubPluginRoutes = [ + { + path: CLOUD_DEFEND_BASE_PATH, + component: CloudDefend, + }, +]; diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts index def3b0ed9f5eb..e4c0dfd1f20db 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/links.ts @@ -51,6 +51,9 @@ export const manageCategories: LinkCategories = [ label: i18n.translate('xpack.securitySolution.appLinks.category.cloudSecurityPosture', { defaultMessage: 'CLOUD SECURITY POSTURE', }), - linkIds: [SecurityPageName.cloudSecurityPostureBenchmarks], + linkIds: [ + SecurityPageName.cloudSecurityPostureBenchmarks, + SecurityPageName.cloudDefendPolicies, + ], }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index b0190d40ced78..29f69700afdb7 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -42,6 +42,7 @@ export type UrlStateType = | 'explore' | 'dashboards' | 'indicators' + | 'cloud_defend' | 'cloud_posture' | 'findings' | 'entity_analytics' @@ -84,6 +85,7 @@ export const securityNavKeys = [ SecurityPageName.cloudSecurityPostureDashboard, SecurityPageName.cloudSecurityPostureFindings, SecurityPageName.cloudSecurityPostureBenchmarks, + SecurityPageName.cloudDefendPolicies, SecurityPageName.entityAnalytics, SecurityPageName.dataQuality, ] as const; diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index 1d93699ff1b47..66aefc6db3e08 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -20,6 +20,7 @@ import { Rules } from './rules'; import { Timelines } from './timelines'; import { Management } from './management'; import { LandingPages } from './landing_pages'; +import { CloudDefend } from './cloud_defend'; import { CloudSecurityPosture } from './cloud_security_posture'; import { ThreatIntelligence } from './threat_intelligence'; @@ -37,6 +38,7 @@ const subPluginClasses = { Timelines, Management, LandingPages, + CloudDefend, CloudSecurityPosture, ThreatIntelligence, }; diff --git a/x-pack/plugins/security_solution/public/management/icons/cloud_defend.tsx b/x-pack/plugins/security_solution/public/management/icons/cloud_defend.tsx new file mode 100644 index 0000000000000..1eb5a656b3628 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/icons/cloud_defend.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SVGProps } from 'react'; +import React from 'react'; +export const IconCloudDefend: React.FC> = ({ ...props }) => ( + + + + + + + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 1db32eae3d5f2..57f2c53a7be19 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -49,6 +49,7 @@ import { manageCategories as cloudSecurityPostureCategories, manageLinks as cloudSecurityPostureLinks, } from '../cloud_security_posture/links'; +import { manageLinks as cloudDefendLinks } from '../cloud_defend/links'; import { IconActionHistory } from './icons/action_history'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; @@ -226,6 +227,7 @@ export const links: LinkItem = { hideTimeline: true, }, cloudSecurityPostureLinks, + cloudDefendLinks, ], }; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index abc9c41101e25..78680086d45c0 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -395,6 +395,7 @@ export class Plugin implements IPlugin; management: ReturnType; landingPages: ReturnType; + cloudDefend: ReturnType; cloudSecurityPosture: ReturnType; threatIntelligence: ReturnType; } diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 373c561888a23..1cc1a355d3537 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -36,6 +36,7 @@ "@kbn/actions-plugin", "@kbn/alerting-plugin", "@kbn/cases-plugin", + "@kbn/cloud-defend-plugin", "@kbn/cloud-experiments-plugin", "@kbn/cloud-security-posture-plugin", "@kbn/encrypted-saved-objects-plugin",