[Defend for containers] cloud_defend plugin UI boilerplate (#151533)

## Summary

This PR aims to create a footprint in the "security_solution" manage
page for the new cloud_defend integration (and kibana plugin). Most of
the changes are taken from the cloud_security_posture plugin
architecture. The following additions have been made:
- "Cloud security posture" category in manage page now includes a link
to a new "Defend for containers" policies page (I originally created a
new category for cloud_defend, but having two full categories with a
single link is kind of a waste of real estate).
- Cloud defend page wrapper to handle empty states, and the flows for
when the integration is not yet installed, or user doesn't have cloud
enterprise subscription.
- Basic policies page to serve as a baseline for future detail around
each cloud_defend integration (e.g selector / response / alert counts).
- Application router and sub plugin tie'ins to security_solution.

*Apologies in advance for the large PR :)*

### Screenshots

Manage link

![image](https://user-images.githubusercontent.com/16198204/219512810-37f03d1b-0170-49e8-b5be-64b5b6ae20df.png)

Policies page

![image](https://user-images.githubusercontent.com/16198204/219512772-a68fbb6d-4a30-439b-a11d-9ac2c95c8bda.png)


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Karl Godard 2023-02-28 16:46:30 -08:00 committed by GitHub
parent d86f2f8eb9
commit d783aae004
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 4166 additions and 71 deletions

5
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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
```

View file

@ -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}`;

View file

@ -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<typeof policiesQueryParamsSchema>;

View file

@ -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<CloudDefendStatusCode, 'not-installed'>;
}
interface CloudDefendSetupInstalledStatus extends BaseCloudDefendSetupStatus {
status: Exclude<CloudDefendStatusCode, 'not-installed'>;
// 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<AgentPolicy, 'id' | 'name'> & { agents: number };
export interface CloudDefendPolicy {
package_policy: PackagePolicy;
agent_policy: AgentPolicyStatus;
}

View file

@ -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<string | undefined | null>
* foo.filter(isNonNullable) // foo is Array<string>
*/
export const isNonNullable = <T extends unknown>(v: T): v is NonNullable<T> =>
v !== null && v !== undefined;
export const truthy = <T>(value: T): value is Truthy<T> => !!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;

View file

@ -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();
});
});
});

View file

@ -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';
};

View file

@ -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"
]
}
}

View file

@ -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<RouteProps, 'render'> & 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<CloudDefendRouteProps> = ({
id,
children,
component: Component,
disabled = false,
...cloudDefendRouteProps
}) => {
const SpyRoute = useSecuritySolutionContext()?.getSpyRouteComponent();
if (disabled) {
return null;
}
const routeProps: RouteProps = {
...cloudDefendRouteProps,
...(Component && {
render: (renderProps) => (
<TrackApplicationView viewId={id}>
{STATIC_PATH_PAGE_IDS[id] && SpyRoute && <SpyRoute pageName={id} />}
<Component {...renderProps} />
</TrackApplicationView>
),
}),
};
return <Route {...routeProps}>{children}</Route>;
};

View file

@ -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: () => <div data-test-subj="Policies">Policies</div>,
}));
jest.mock('@tanstack/react-query', () => ({
QueryClientProvider: ({ children }: QueryClientProviderProps) => <>{children}</>,
QueryClient: jest.fn(),
}));
describe('CloudDefendRouter', () => {
const originalCloudDefendPages = { ...constants.cloudDefendPages };
const mockConstants = constants as {
cloudDefendPages: Record<CloudDefendPage, CloudDefendPageNavigationItem>;
};
const securityContext: CloudDefendSecuritySolutionContext = {
getFiltersGlobalComponent: jest.fn(),
getSpyRouteComponent: () => () => <div data-test-subj="mockedSpyRoute" />,
};
let history: MemoryHistory;
const renderCloudDefendRouter = () =>
render(
<Router history={history}>
<CloudDefendRouter securitySolutionContext={securityContext} />
</Router>
);
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();
});
});
});

View file

@ -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 = (
<QueryClientProvider client={queryClient}>
<Switch>
<CloudDefendRoute {...cloudDefendPages.policies} component={Policies} />
<Route>
<Redirect to={cloudDefendPages.policies.path} />
</Route>
</Switch>
</QueryClientProvider>
);
if (securitySolutionContext) {
return (
<SecuritySolutionContext.Provider value={securitySolutionContext}>
{routerElement}
</SecuritySolutionContext.Provider>
);
}
return <>{routerElement}</>;
};
// Using a default export for usage with `React.lazy`
// eslint-disable-next-line import/no-default-export
export { CloudDefendRouter as default };

View file

@ -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);
};

View file

@ -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<SetupContextValue>({});

View file

@ -0,0 +1,13 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_224_212319)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.54808 23.8299C2.21188 23.6595 2 23.3147 2 22.9378V10.0097C2 9.26398 2.78673 8.78062 3.45192 9.11761L14.1792 14.5521C14.5154 14.7225 14.7273 15.0673 14.7273 15.4442V28.3723C14.7273 29.118 13.9405 29.6014 13.2753 29.2644L2.54808 23.8299Z" fill="#00BFB3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.4519 23.8299C29.7881 23.6595 30 23.3147 30 22.9378V10.0097C30 9.26398 29.2133 8.78062 28.5481 9.11761L17.8208 14.5521C17.4846 14.7225 17.2727 15.0673 17.2727 15.4442V28.3723C17.2727 29.118 18.0595 29.6014 18.7247 29.2644L29.4519 23.8299Z" fill="#343741"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4519 12.3621C16.1678 12.506 15.8322 12.506 15.5481 12.3621L5.33403 7.18758C4.60326 6.81737 4.60326 5.77368 5.33403 5.40347L15.5481 0.228945C15.8322 0.085021 16.1678 0.0850213 16.4519 0.228946L26.666 5.40347C27.3968 5.77368 27.3967 6.81737 26.666 7.18758L16.4519 12.3621Z" fill="#1BA9F5"/>
<path d="M20.5714 18.2857H32V26.3067C32 27.7117 31.2628 29.0137 30.058 29.7366L26.2857 32L22.5134 29.7366C21.3086 29.0137 20.5714 27.7117 20.5714 26.3067V18.2857Z" fill="#FA744E"/>
</g>
<defs>
<clipPath id="clip0_224_212319">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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<GetInfoResponse, DefaultPackagesInstallationError>(['integrations'], () =>
http.get<GetInfoResponse>(epmRouteService.getInfoPath(INTEGRATION_PACKAGE_NAME))
);
};

View file

@ -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<CloudDefendSetupStatus, unknown, CloudDefendSetupStatus>(
[getCloudDefendSetupStatusQueryKey],
() => http.get<CloudDefendSetupStatus>(STATUS_ROUTE_PATH)
);
};

View file

@ -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;

View file

@ -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<CloudDefendKibanaContext>();

View file

@ -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 };
};

View file

@ -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);
});
};

View file

@ -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<CloudDefendPage, CloudDefendPageNavigationItem> = {
policies: {
name: NAV_ITEMS_NAMES.POLICIES,
path: `${CLOUD_DEFEND_BASE_PATH}/policies`,
id: 'cloud_defend-policies',
},
};

View file

@ -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<CloudDefendPage>(['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<CloudDefendPage>(['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);
});
});

View file

@ -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<TId extends string = CloudDefendPageId> {
id: TId;
title: string;
path: string;
}
interface CloudDefendNavTab<TId extends string = CloudDefendPageId> {
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 = <TId extends string = CloudDefendPageId>(
cloudDefendPage: CloudDefendPage
): CloudDefendLinkItem<TId> => {
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 = <TId extends string = CloudDefendPageId>(
cloudDefendPage: CloudDefendPage,
basePath: string
): CloudDefendNavTab<TId> => ({
id: cloudDefendPages[cloudDefendPage].id as TId,
name: cloudDefendPages[cloudDefendPage].name,
href: `${basePath}${cloudDefendPages[cloudDefendPage].path}`,
disabled: !!cloudDefendPages[cloudDefendPage].disabled,
});

View file

@ -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';

View file

@ -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),
};
};

View file

@ -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('<CloudDefendPage />', () => {
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<typeof CloudDefendPage> = { children: null }
) => {
const mockCore = coreMock.createStart();
render(
<TestProvider
core={{
...mockCore,
application: {
...mockCore.application,
capabilities: {
...mockCore.application.capabilities,
// This is required so that the `noDataConfig` view will show the action button
navLinks: { integrations: true },
},
},
}}
>
<CloudDefendPage {...props} />
</TestProvider>
);
};
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) => <div>{isCommonError(err) && err.body.message}</div>,
});
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: () => <div>{loading}</div>,
});
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 = () => (
<NoDataPage pageTitle={pageTitle} solution={solution} docsLink={docsLink} actions={{}} />
);
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();
});
});

View file

@ -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 (
<NoDataPage
data-test-subj={testId}
css={css`
> :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 (
<FullSizeCenteredPage>
<EuiEmptyPrompt
data-test-subj={PACKAGE_NOT_INSTALLED_TEST_SUBJECT}
icon={<EuiImage size="m" margin="m" src={noDataIllustration} alt="no-data-illustration" />}
title={
<h2>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.packageNotInstalledRenderer.promptTitle"
defaultMessage="Detect container drift and block malicious behavior at the source!"
/>
</h2>
}
layout="horizontal"
color="plain"
body={
<p>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.packageNotInstalledRenderer.promptDescription"
defaultMessage="Add the Defend for containers (D4C) integration to begin. {learnMore}."
values={{
learnMore: (
<EuiLink href={docsLink}>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.packageNotInstalledRenderer.learnMoreTitle"
defaultMessage="Learn more about Defend for containers (D4C)"
/>
</EuiLink>
),
}}
/>
</p>
}
actions={
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton color="primary" fill href={addIntegrationLink}>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.packageNotInstalledRenderer.addCloudDefendmIntegrationButtonTitle"
defaultMessage="Add D4C Integration"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
</FullSizeCenteredPage>
);
};
const defaultLoadingRenderer = () => (
<LoadingState data-test-subj={LOADING_STATE_TEST_SUBJECT}>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.loadingDescription"
defaultMessage="Loading..."
/>
</LoadingState>
);
const defaultErrorRenderer = (error: unknown) => (
<FullSizeCenteredPage>
<EuiEmptyPrompt
color="danger"
iconType="alert"
data-test-subj={ERROR_STATE_TEST_SUBJECT}
title={
<h2>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.errorRenderer.errorTitle"
defaultMessage="We couldn't fetch your cloud defend data"
/>
</h2>
}
body={
isCommonError(error) ? (
<p>
<FormattedMessage
id="xpack.cloudDefend.cloudDefendPage.errorRenderer.errorDescription"
defaultMessage="{error} {statusCode}: {body}"
values={{
error: error.body.error,
statusCode: error.body.statusCode,
body: error.body.message,
}}
/>
</p>
) : undefined
}
/>
</FullSizeCenteredPage>
);
const defaultNoDataRenderer = (docsLink: string) => (
<FullSizeCenteredPage>
<NoDataPage
data-test-subj={DEFAULT_NO_DATA_TEST_SUBJECT}
pageTitle={i18n.translate('xpack.cloudDefend.cloudDefendPage.defaultNoDataConfig.pageTitle', {
defaultMessage: 'No data found',
})}
solution={i18n.translate(
'xpack.cloudDefend.cloudDefendPage.defaultNoDataConfig.solutionNameLabel',
{
defaultMessage: 'Defend for containers',
}
)}
docsLink={docsLink}
logo={'logoSecurity'}
actions={{}}
/>
</FullSizeCenteredPage>
);
const subscriptionNotAllowedRenderer = () => (
<FullSizeCenteredPage data-test-subj={SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT}>
<SubscriptionNotAllowed />
</FullSizeCenteredPage>
);
interface CloudDefendPageProps<TData, TError> {
children: React.ReactNode;
query?: UseQueryResult<TData, TError>;
loadingRender?: () => React.ReactNode;
errorRender?: (error: TError) => React.ReactNode;
noDataRenderer?: (docsLink: string) => React.ReactNode;
}
export const CloudDefendPage = <TData, TError>({
children,
query,
loadingRender = defaultLoadingRenderer,
errorRender = defaultErrorRenderer,
noDataRenderer = defaultNoDataRenderer,
}: CloudDefendPageProps<TData, TError>) => {
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()}</>;
};

View file

@ -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 }) => (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>{title}</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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) => (
<EuiFlexGroup
css={css`
// 140px is roughly the Kibana chrome with a bit of space to spare
min-height: calc(100vh - 140px);
`}
justifyContent="center"
alignItems="center"
direction="column"
{...rest}
>
{children}
</EuiFlexGroup>
);

View file

@ -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 (
<FullSizeCenteredPage data-test-subj={rest['data-test-subj']}>
<EuiLoadingSpinner size="xl" />
<EuiSpacer />
{children}
</FullSizeCenteredPage>
);
};

View file

@ -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('<PoliciesTable />', () => {
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(
<TestProvider>
<PoliciesTable
{...{
...tableProps,
policies,
totalItemCount: policies.length,
}}
/>
</TestProvider>
);
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(
<TestProvider>
<PoliciesTable
{...{
...tableProps,
policies,
totalItemCount: policies.length,
}}
/>
</TestProvider>
);
expect(screen.getByText(agentPolicy.name)).toBeInTheDocument();
});
it('renders number of agents', () => {
const item = createCloudDefendIntegrationFixture();
const policies = [item];
render(
<TestProvider>
<PoliciesTable
{...{
...tableProps,
policies,
totalItemCount: policies.length,
}}
/>
</TestProvider>
);
// TODO too loose
expect(screen.getByText(item.agent_policy.agents as number)).toBeInTheDocument();
});
it('renders created by', () => {
const item = createCloudDefendIntegrationFixture();
const policies = [item];
render(
<TestProvider>
<PoliciesTable
{...{
...tableProps,
policies,
totalItemCount: policies.length,
}}
/>
</TestProvider>
);
expect(screen.getByText(item.package_policy.created_by)).toBeInTheDocument();
});
it('renders created at', () => {
const item = createCloudDefendIntegrationFixture();
const policies = [item];
render(
<TestProvider>
<PoliciesTable
{...{
...tableProps,
policies,
totalItemCount: policies.length,
}}
/>
</TestProvider>
);
expect(screen.getByText(moment(item.package_policy.created_at).fromNow())).toBeInTheDocument();
});
});

View file

@ -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<CloudDefendPolicy>,
'loading' | 'error' | 'noItemsMessage' | 'sorting'
>,
Pagination {
policies: CloudDefendPolicy[];
setQuery(pagination: CriteriaWithPagination<CloudDefendPolicy>): 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 <EuiLink href={http.basePath.prepend([fleetBase, path].join(''))}>{name}</EuiLink>;
};
const IntegrationButtonLink = ({
packageName,
policyId,
packagePolicyId,
}: {
packageName: string;
packagePolicyId: string;
policyId: string;
}) => {
const editIntegrationLink = pagePathGetters
.edit_integration({
packagePolicyId,
policyId,
})
.join('');
return <EuiLink href={editIntegrationLink}>{packageName}</EuiLink>;
};
const POLICIES_TABLE_COLUMNS: Array<EuiBasicTableColumn<CloudDefendPolicy>> = [
{
field: 'package_policy.name',
name: i18n.translate('xpack.cloudDefend.policies.policiesTable.integrationNameColumnTitle', {
defaultMessage: 'Integration Name',
}),
render: (packageName, policy) => (
<IntegrationButtonLink
packageName={packageName}
packagePolicyId={policy.package_policy.id}
policyId={policy.package_policy.policy_id}
/>
),
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) => <AgentPolicyButtonLink name={name} id={policy.agent_policy.id} />,
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']) => (
<TimestampTableCell timestamp={timestamp} />
),
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<CloudDefendPolicy>) => {
setQuery({ page: { ...page, index: page.index + 1 }, sort });
};
return (
<EuiBasicTable
data-test-subj={rest['data-test-subj']}
items={policies}
columns={POLICIES_TABLE_COLUMNS}
itemId={(item) => [item.agent_policy.id, item.package_policy.id].join('/')}
pagination={pagination}
onChange={onChange}
tableLayout="fixed"
loading={loading}
noItemsMessage={noItemsMessage}
error={error}
sorting={sorting}
/>
);
};

View file

@ -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 (
<EuiPageSection color="danger" alignment="center">
<EuiEmptyPrompt
iconType="alert"
title={
<h2>
<FormattedMessage
id="xpack.cloudDefend.subscriptionNotAllowed.promptTitle"
defaultMessage="Upgrade for subscription features"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.cloudDefend.subscriptionNotAllowed.promptDescription"
defaultMessage="To use these cloud security features, you must {link}."
values={{
link: (
<EuiLink
href={application.getUrlForApp('management', {
path: 'stack/license_management/home',
})}
>
<FormattedMessage
id="xpack.cloudDefend.subscriptionNotAllowed.promptLinkText"
defaultMessage="start a trial or upgrade your subscription"
/>
</EuiLink>
),
}}
/>
</p>
}
/>
</EuiPageSection>
);
};

View file

@ -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<string>(DEFAULT_DATE_FORMAT);
const formatted = formatDate(timestamp, dateFormat);
return (
<EuiToolTip position="top" content={formatted}>
<span>{moment(timestamp).fromNow()}</span>
</EuiToolTip>
);
};

View file

@ -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() {

View file

@ -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';

View file

@ -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('<Policies />', () => {
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<UseQueryResult> = createReactQueryResponse()) => {
(useCloudDefendPolicies as jest.Mock).mockImplementation(() => queryResponse);
return render(
<TestProvider>
<Policies />
</TestProvider>
);
};
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()
);
});
});

View file

@ -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 (
<EuiButton
data-test-subj={TEST_SUBJ.ADD_INTEGRATION_TEST_SUBJ}
fill
iconType="plusInCircle"
href={addIntegrationLink}
>
<FormattedMessage
id="xpack.cloudDefend.policies.policiesPageHeader.addIntegrationButtonLabel"
defaultMessage="Add Integration"
/>
</EuiButton>
);
};
const EmptyState = ({ name }: { name: string }) => (
<div>
<EuiSpacer size="l" />
{
<EuiText>
<strong>
<FormattedMessage
id="xpack.cloudDefend.policies.policyEmptyState.integrationsNotFoundTitle"
defaultMessage="No policies found"
/>
{name && (
<FormattedMessage
id="xpack.cloudDefend.policies.policyEmptyState.integrationsNotFoundForNameTitle"
defaultMessage=' for "{name}"'
values={{ name }}
/>
)}
</strong>
</EuiText>
}
<EuiSpacer size="s" />
<EuiText>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.cloudDefend.policies.policyEmptyState.integrationsNotFoundWithFiltersTitle"
defaultMessage="We weren't able to find any policies with the above filters."
/>
</EuiTextColor>
</EuiText>
<EuiSpacer size="l" />
</div>
);
const TotalIntegrationsCount = ({
pageCount,
totalCount,
}: Record<'pageCount' | 'totalCount', number>) => (
<EuiText size="xs" style={{ marginLeft: 8 }}>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.cloudDefend.policies.totalIntegrationsCountMessage"
defaultMessage="Showing {pageCount} of {totalCount, plural, one {# integration} other {# integrations}}"
values={{ pageCount, totalCount }}
/>
</EuiTextColor>
</EuiText>
);
const SearchField = ({
onSearch,
isLoading,
}: Required<Pick<EuiFieldSearchProps, 'isLoading' | 'onSearch'>>) => {
const [localValue, setLocalValue] = useState('');
useDebounce(() => onSearch(localValue), SEARCH_DEBOUNCE_MS, [localValue]);
return (
<EuiFlexGroup>
<EuiFlexItem grow={true} style={{ alignItems: 'flex-end' }}>
<EuiFieldSearch
fullWidth
onSearch={setLocalValue}
isLoading={isLoading}
placeholder={i18n.translate(
'xpack.cloudDefend.policies.policySearchField.searchPlaceholder',
{ defaultMessage: 'Search integration name' }
)}
incremental
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const Policies = () => {
const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE);
const [query, setQuery] = useState<UseCloudDefendPoliciesProps>({
name: '',
page: 1,
perPage: pageSize,
sortField: 'package_policy.name',
sortOrder: 'asc',
});
const queryResult = useCloudDefendPolicies(query);
const totalItemCount = queryResult.data?.total || 0;
return (
<CloudDefendPage>
<EuiPageHeader
data-test-subj={TEST_SUBJ.POLICIES_PAGE_HEADER}
pageTitle={
<CloudDefendPageTitle
title={i18n.translate('xpack.cloudDefend.policies.policiesPageHeader', {
defaultMessage: 'Defend for containers (D4C)',
})}
/>
}
rightSideItems={[<AddIntegrationButton />]}
bottomBorder
/>
<EuiSpacer />
<SearchField
isLoading={queryResult.isFetching}
onSearch={(name) => setQuery((current) => ({ ...current, name }))}
/>
<EuiSpacer />
<TotalIntegrationsCount
pageCount={(queryResult.data?.items || []).length}
totalCount={totalItemCount}
/>
<EuiSpacer size="s" />
<PoliciesTable
policies={queryResult.data?.items || []}
data-test-subj={TEST_SUBJ.POLICIES_TABLE_DATA_TEST_SUBJ}
error={queryResult.error ? extractErrorMessage(queryResult.error) : undefined}
loading={queryResult.isFetching}
pageIndex={query.page}
pageSize={pageSize || query.perPage}
sorting={{
// @ts-expect-error - EUI types currently do not support sorting by nested fields
sort: { field: query.sortField, direction: query.sortOrder },
allowNeutralSort: false,
}}
totalItemCount={totalItemCount}
setQuery={({ page, sort }) => {
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 ? (
<EmptyState name={query.name} />
) : undefined
}
/>
</CloudDefendPage>
);
};

View file

@ -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',
};

View file

@ -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<ListResult<CloudDefendPolicy>>(POLICIES_ROUTE_PATH, { query }),
{ keepPreviousData: true }
);
};

View file

@ -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<CloudDefendPluginSetup, CloudDefendPluginStart> {
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() {}
}

View file

@ -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) => (
<Suspense fallback={<LoadingState />}>
<RouterLazy {...props} />
</Suspense>
);
export class CloudDefendPlugin
implements
Plugin<
CloudDefendPluginSetup,
CloudDefendPluginStart,
CloudDefendPluginSetupDeps,
CloudDefendPluginStartDeps
>
{
private isCloudEnabled?: boolean;
public setup(
core: CoreSetup<CloudDefendPluginStartDeps, CloudDefendPluginStart>,
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) => (
<KibanaContextProvider services={{ ...core, ...plugins }}>
<RedirectAppLinks coreStart={core}>
<div style={{ width: '100%', height: '100%' }}>
<SetupContext.Provider value={{ isCloudEnabled: this.isCloudEnabled }}>
<Router {...props} />
</SetupContext.Provider>
</div>
</RedirectAppLinks>
</KibanaContextProvider>
);
return {
getCloudDefendRouter: () => CloudDefendRouter,
};
}
public stop() {}
}

View file

@ -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<CloudDefendPolicy>;
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,
});

View file

@ -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<CloudDefendPageNavigationItem>;
export const createPageNavigationItemFixture = ({
chance = new Chance(),
name = chance.word(),
path = `/${chance.word()}`,
disabled = undefined,
id = 'cloud_defend-policies',
}: CreateNavigationItemFixtureInput = {}): CloudDefendPageNavigationItem => ({
name,
path,
disabled,
id,
});

View file

@ -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<TData = unknown, TError = unknown> {
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 = <TData = unknown, TError = unknown>({
status = 'loading',
error = undefined,
data = undefined,
}: CreateReactQueryResponseInput<TData, TError> = {}): Partial<UseQueryResult<TData, TError>> => {
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 };
};

View file

@ -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',
},
});

View file

@ -37,13 +37,13 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
interface CspAppDeps {
interface CloudDefendAppDeps {
core: CoreStart;
deps: CloudDefendPluginStartDeps;
params: AppMountParameters;
}
export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
export const TestProvider: React.FC<Partial<CloudDefendAppDeps>> = ({
core = coreMock.createStart(),
deps = {
data: dataPluginMock.createStartContract(),

View file

@ -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<CloudDefendRouterProps>;
}
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<string, string | undefined>;
}>;
}
/**
* cloud_defend/control types
*/
export enum ControlResponseAction {
alert = 'alert',
block = 'block',

View file

@ -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';

View file

@ -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<IndexStatus> => {
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';
}
};

View file

@ -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<string, GetAgentStatusResponse['results']>;
export const getAgentStatusesByAgentPolicies = async (
agentService: AgentService,
agentPolicies: AgentPolicy[] | undefined,
logger: Logger
): Promise<AgentStatusByAgentPolicyMap> => {
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<AgentPolicy[]> =>
agentPolicyService.getByIds(soClient, uniq(map(packagePolicies, 'policy_id')), {
withPackagePolicies: true,
ignoreMissing: true,
});
export const getCloudDefendPackagePolicies = (
soClient: SavedObjectsClientContract,
packagePolicyService: PackagePolicyClient,
packageName: string,
queryParams: Partial<PoliciesQueryParams>
): Promise<ListResult<PackagePolicy>> => {
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 [];
}
};

View file

@ -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(),
},
};
};

View file

@ -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<CloudDefendPluginSetup, CloudDefendPluginStart> {
private readonly logger: Logger;
private isCloudEnabled?: boolean;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
}
public setup(
core: CoreSetup<CloudDefendPluginStartDeps, CloudDefendPluginStart>,
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<NewPackagePolicy> => {
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() {}
}

View file

@ -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<SavedObjectsClientContract>;
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);
});
});
});
});

View file

@ -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<CloudDefendPolicy[]> => {
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,
});
}
}
);

View file

@ -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<CloudDefendPluginStartDeps, CloudDefendPluginStart>;
logger: Logger;
}) {
const router = core.http.createRouter<CloudDefendRequestHandlerContext>();
defineGetPoliciesRoute(router);
defineGetCloudDefendStatusRoute(router);
core.http.registerRouteHandlerContext<CloudDefendRequestHandlerContext, typeof PLUGIN_ID>(
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,
};
}
);
}

View file

@ -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<typeof createCloudDefendRequestHandlerContextMock>;
let mockPackagePolicyService: jest.Mocked<PackagePolicyClient>;
let mockAgentPolicyService: jest.Mocked<AgentPolicyServiceInterface>;
let mockAgentService: jest.Mocked<AgentService>;
let mockAgentClient: jest.Mocked<AgentClient>;
let mockPackageService: PackageService;
let mockPackageClient: jest.Mocked<PackageClient>;
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<AgentClient>;
mockPackageClient = mockPackageService.asInternalUser as jest.Mocked<PackageClient>;
});
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',
});
});
});

View file

@ -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<number> => {
// 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<CloudDefendSetupStatus> => {
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,
});
}
}
);

View file

@ -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<SecurityPluginStart['authc']['getCurrentUser']>;
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<CloudDefendRequestHandlerContext>;

View file

@ -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/**/*"]
}

View file

@ -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',

View file

@ -15,6 +15,7 @@
"alerting",
"cases",
"cloud",
"cloudDefend",
"cloudSecurityPosture",
"dashboard",
"data",

View file

@ -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<SecurityPageName>('dashboard'),
...getCloudPostureSecuritySolutionLink<SecurityPageName>('dashboard'),
features: [FEATURE.general],
},
{
@ -251,7 +252,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
],
},
{
...getSecuritySolutionLink<SecurityPageName>('findings'),
...getCloudPostureSecuritySolutionLink<SecurityPageName>('findings'),
features: [FEATURE.general],
navLinkStatus: AppNavLinkStatus.visible,
order: 9002,
@ -529,7 +530,10 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
path: RESPONSE_ACTIONS_HISTORY_PATH,
},
{
...getSecuritySolutionLink<SecurityPageName>('benchmarks'),
...getCloudPostureSecuritySolutionLink<SecurityPageName>('benchmarks'),
},
{
...getCloudDefendSecuritySolutionLink<SecurityPageName>('policies'),
},
],
},

View file

@ -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<SecurityPageName>('benchmarks', APP_PATH),
urlKey: 'administration',
},
[SecurityPageName.cloudDefendPolicies]: {
...getSecuritySolutionCloudDefendNavTab<SecurityPageName>('policies', APP_PATH),
urlKey: 'administration',
},
[SecurityPageName.entityAnalytics]: {
id: SecurityPageName.entityAnalytics,
name: i18n.ENTITY_ANALYTICS,

View file

@ -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 };
}
}

View file

@ -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<LinkItem> = {
hideTimeline: true,
capabilities: [`${SERVER_APP_ID}.show`],
};
export const manageLinks: LinkItem = {
...getSecuritySolutionLink<SecurityPageName>('policies'),
description: i18n.translate('xpack.securitySolution.appLinks.cloudDefendPoliciesDescription', {
defaultMessage: 'View drift prevention policies.',
}),
landingIcon: IconCloudDefend,
...commonLinkProperties,
};

View file

@ -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 }) => (
<SpyRoute pageName={pageName as SecurityPageName | undefined} {...rest} />
);
const cloudDefendSecuritySolutionContext: CloudDefendSecuritySolutionContext = {
getFiltersGlobalComponent: () => FiltersGlobal,
getSpyRouteComponent: () => CloudDefendSpyRoute,
};
const CloudDefend = () => {
const { cloudDefend } = useKibana().services;
const CloudDefendRouter = cloudDefend.getCloudDefendRouter();
return (
<PluginTemplateWrapper>
<SecuritySolutionPageWrapper noPadding noTimeline>
<CloudDefendRouter securitySolutionContext={cloudDefendSecuritySolutionContext} />
</SecuritySolutionPageWrapper>
</PluginTemplateWrapper>
);
};
CloudDefend.displayName = 'CloudDefend';
export const routes: SecuritySubPluginRoutes = [
{
path: CLOUD_DEFEND_BASE_PATH,
component: CloudDefend,
},
];

View file

@ -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,
],
},
];

View file

@ -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;

View file

@ -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,
};

View file

@ -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<SVGProps<SVGSVGElement>> = ({ ...props }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_327_292367)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 22.3234L12.7273 26.7447V16.0586L4 11.6373V22.3234ZM2 22.9378C2 23.3147 2.21188 23.6595 2.54807 23.8299L13.2753 29.2644C13.9405 29.6014 14.7273 29.118 14.7273 28.3723V15.4442C14.7273 15.0673 14.5154 14.7225 14.1792 14.5521L3.45192 9.11761C2.78673 8.78062 2 9.26398 2 10.0097V22.9378Z"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 10.349L7.99871 6.29552L16 2.24201L24.0013 6.29552L16 10.349ZM15.5481 12.3621C15.8322 12.506 16.1678 12.506 16.4519 12.3621L26.666 7.18758C27.3967 6.81737 27.3967 5.77368 26.666 5.40347L16.4519 0.228946C16.1678 0.0850214 15.8322 0.0850209 15.5481 0.228945L5.33402 5.40347C4.60325 5.77368 4.60325 6.81737 5.33402 7.18758L15.5481 12.3621Z"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.2727 16.0586L28 11.6373V17.0039H30V10.0097C30 9.26398 29.2133 8.78062 28.5481 9.11761L17.8208 14.5521C17.4846 14.7225 17.2727 15.0673 17.2727 15.4442V28.3723C17.2727 29.118 18.0595 29.6014 18.7247 29.2644L19.2645 28.9909V17.0039H19.2727V16.0586Z"
fill="black"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M30 20.2857H22.5714V26.3067C22.5714 27.0092 22.94 27.6602 23.5424 28.0216L26.2857 29.6676L29.029 28.0216C29.6314 27.6602 30 27.0092 30 26.3067V20.2857ZM26.2857 32L22.5134 29.7366C21.3086 29.0137 20.5714 27.7117 20.5714 26.3067V18.2857H32V26.3067C32 27.7117 31.2628 29.0137 30.058 29.7366L26.2857 32Z"
fill="#00BFB3"
/>
</g>
<defs>
<clipPath id="clip0_327_292367">
<rect width="32" height="32" fill="white" />
</clipPath>
</defs>
</svg>
);

View file

@ -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,
],
};

View file

@ -395,6 +395,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
timelines: new subPluginClasses.Timelines(),
management: new subPluginClasses.Management(),
landingPages: new subPluginClasses.LandingPages(),
cloudDefend: new subPluginClasses.CloudDefend(),
cloudSecurityPosture: new subPluginClasses.CloudSecurityPosture(),
threatIntelligence: new subPluginClasses.ThreatIntelligence(),
};
@ -422,6 +423,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
kubernetes: subPlugins.kubernetes.start(),
management: subPlugins.management.start(core, plugins),
landingPages: subPlugins.landingPages.start(),
cloudDefend: subPlugins.cloudDefend.start(),
cloudSecurityPosture: subPlugins.cloudSecurityPosture.start(),
threatIntelligence: subPlugins.threatIntelligence.start(),
};

View file

@ -33,6 +33,7 @@ import type { LicensingPluginStart, LicensingPluginSetup } from '@kbn/licensing-
import type { DashboardStart } from '@kbn/dashboard-plugin/public';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { CloudDefendPluginStart } from '@kbn/cloud-defend-plugin/public';
import type { CspClientPluginStart } from '@kbn/cloud-security-posture-plugin/public';
import type { ApmBase } from '@elastic/apm-rum';
import type {
@ -55,6 +56,7 @@ import type { Timelines } from './timelines';
import type { Management } from './management';
import type { LandingPages } from './landing_pages';
import type { CloudSecurityPosture } from './cloud_security_posture';
import type { CloudDefend } from './cloud_defend';
import type { ThreatIntelligence } from './threat_intelligence';
import type { SecuritySolutionTemplateWrapper } from './app/home/template_wrapper';
import type { Explore } from './explore';
@ -91,6 +93,7 @@ export interface StartPlugins {
dataViewFieldEditor: IndexPatternFieldEditorStart;
osquery?: OsqueryPluginStart;
security: SecurityPluginStart;
cloudDefend: CloudDefendPluginStart;
cloudSecurityPosture: CspClientPluginStart;
threatIntelligence: ThreatIntelligencePluginStart;
cloudExperiments?: CloudExperimentsPluginStart;
@ -142,6 +145,7 @@ export interface SubPlugins {
timelines: Timelines;
management: Management;
landingPages: LandingPages;
cloudDefend: CloudDefend;
cloudSecurityPosture: CloudSecurityPosture;
threatIntelligence: ThreatIntelligence;
}
@ -158,6 +162,7 @@ export interface StartedSubPlugins {
timelines: ReturnType<Timelines['start']>;
management: ReturnType<Management['start']>;
landingPages: ReturnType<LandingPages['start']>;
cloudDefend: ReturnType<CloudDefend['start']>;
cloudSecurityPosture: ReturnType<CloudSecurityPosture['start']>;
threatIntelligence: ReturnType<ThreatIntelligence['start']>;
}

View file

@ -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",