mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Security Solution] Initial version of the Cloud Security Posture app (#124816)
Co-authored-by: Ido Cohen <ido.cohen@elastic.co> Co-authored-by: Or Ouziel <or.ouziel@elastic.co> Co-authored-by: Yarden Shalom <yarden.shalom@elastic.co>
This commit is contained in:
parent
51e1f25d21
commit
b95f891ea2
91 changed files with 5005 additions and 0 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
@ -422,6 +422,9 @@ x-pack/test/security_solution_cypress @elastic/security-engineering-productivity
|
|||
# Security Asset Management
|
||||
/x-pack/plugins/osquery @elastic/security-asset-management
|
||||
|
||||
# Cloud Posture Security
|
||||
/x-pack/plugins/cloud_security_posture/ @elastic/cloud-posture-security
|
||||
|
||||
# Design (at the bottom for specificity of SASS files)
|
||||
**/*.scss @elastic/kibana-design
|
||||
#CC# /packages/kbn-ui-framework/ @elastic/kibana-design
|
||||
|
|
|
@ -384,6 +384,10 @@ The plugin exposes the static DefaultEditorController class to consume.
|
|||
|The cloud plugin adds Cloud-specific features to Kibana.
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_security_posture/README.md[cloudSecurityPosture]
|
||||
|Cloud Posture automates the identification and remediation of risks across cloud infrastructures
|
||||
|
||||
|
||||
|{kib-repo}blob/{branch}/x-pack/plugins/cross_cluster_replication/README.md[crossClusterReplication]
|
||||
|You can run a local cluster and simulate a remote cluster within a single Kibana directory.
|
||||
|
||||
|
|
|
@ -121,3 +121,4 @@ pageLoadAssetSize:
|
|||
expressionPartitionVis: 26338
|
||||
sharedUX: 16225
|
||||
ux: 20784
|
||||
cloudSecurityPosture: 19109
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"xpack.canvas": "plugins/canvas",
|
||||
"xpack.cases": "plugins/cases",
|
||||
"xpack.cloud": "plugins/cloud",
|
||||
"xpack.csp": "plugins/cloud_security_posture",
|
||||
"xpack.dashboard": "plugins/dashboard_enhanced",
|
||||
"xpack.discover": "plugins/discover_enhanced",
|
||||
"xpack.crossClusterReplication": "plugins/cross_cluster_replication",
|
||||
|
|
9
x-pack/plugins/cloud_security_posture/README.md
Executable file
9
x-pack/plugins/cloud_security_posture/README.md
Executable file
|
@ -0,0 +1,9 @@
|
|||
# Cloud Security Posture Kibana Plugin
|
||||
|
||||
Cloud Posture automates the identification and remediation of risks across cloud infrastructures
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment.
|
21
x-pack/plugins/cloud_security_posture/common/constants.ts
Normal file
21
x-pack/plugins/cloud_security_posture/common/constants.ts
Normal 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.
|
||||
*/
|
||||
|
||||
export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-k8s_cis*';
|
||||
export const CSP_FINDINGS_INDEX_NAME = 'findings';
|
||||
export const STATS_ROUTE_PATH = '/api/csp/stats';
|
||||
export const FINDINGS_ROUTE_PATH = '/api/csp/findings';
|
||||
export const AGENT_LOGS_INDEX_PATTERN = '.logs-k8s_cis.metadata*';
|
||||
|
||||
export const RULE_PASSED = `passed`;
|
||||
export const RULE_FAILED = `failed`;
|
||||
|
||||
// A mapping of in-development features to their status. These features should be hidden from users but can be easily
|
||||
// activated via a simple code change in a single location.
|
||||
export const INTERNAL_FEATURE_FLAGS = {
|
||||
benchmarks: false,
|
||||
} as const;
|
9
x-pack/plugins/cloud_security_posture/common/index.ts
Executable file
9
x-pack/plugins/cloud_security_posture/common/index.ts
Executable file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 PLUGIN_ID = 'csp';
|
||||
export const PLUGIN_NAME = 'Cloud Security';
|
33
x-pack/plugins/cloud_security_posture/common/types.ts
Normal file
33
x-pack/plugins/cloud_security_posture/common/types.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export type Evaluation = 'passed' | 'failed' | 'NA';
|
||||
/** number between 1-100 */
|
||||
export type Score = number;
|
||||
|
||||
export interface FindingsResults {
|
||||
totalFindings: number;
|
||||
totalPassed: number;
|
||||
totalFailed: number;
|
||||
}
|
||||
|
||||
export interface Stats extends FindingsResults {
|
||||
postureScore: Score;
|
||||
}
|
||||
|
||||
export interface ResourceTypeAgg extends FindingsResults {
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export interface BenchmarkStats extends Stats {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CloudPostureStats extends Stats {
|
||||
benchmarksStats: BenchmarkStats[];
|
||||
resourceTypesAggs: ResourceTypeAgg[];
|
||||
}
|
|
@ -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 * as t from 'io-ts';
|
||||
|
||||
/**
|
||||
* @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 extractErrorMessage = (e: unknown, defaultMessage = 'Unknown Error'): string => {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (t.record(t.literal('message'), t.string).is(e)) return e.message;
|
||||
|
||||
return defaultMessage; // TODO: i18n
|
||||
};
|
18
x-pack/plugins/cloud_security_posture/jest.config.js
Normal file
18
x-pack/plugins/cloud_security_posture/jest.config.js
Normal 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.
|
||||
*/
|
||||
|
||||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../..',
|
||||
roots: ['<rootDir>/x-pack/plugins/cloud_security_posture'],
|
||||
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_security_posture',
|
||||
coverageReporters: ['text', 'html'],
|
||||
collectCoverageFrom: [
|
||||
'<rootDir>/x-pack/plugins/cloud_security_posture/{common,public,server}/**/*.{ts,tsx}',
|
||||
],
|
||||
};
|
15
x-pack/plugins/cloud_security_posture/kibana.json
Executable file
15
x-pack/plugins/cloud_security_posture/kibana.json
Executable file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"id": "cloudSecurityPosture",
|
||||
"version": "1.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "cloudSecurityPosture"],
|
||||
"owner": {
|
||||
"name": "Cloud Security Posture",
|
||||
"githubTeam": "cloud-posture-security"
|
||||
},
|
||||
"description": "The cloud security posture plugin",
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["navigation", "data"],
|
||||
"requiredBundles": ["kibanaReact"]
|
||||
}
|
|
@ -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 Chance from 'chance';
|
||||
import { createNavigationItemFixture } from '../test/fixtures/navigation_item';
|
||||
import { getRoutesFromMapping } from './app';
|
||||
|
||||
const chance = new Chance();
|
||||
const DummyComponent = () => null;
|
||||
|
||||
describe('getRoutesFromMapping', () => {
|
||||
it('should map routes', () => {
|
||||
const pageId = chance.word();
|
||||
const navigationItems = { [pageId]: createNavigationItemFixture() };
|
||||
const routesMapping = { [pageId]: DummyComponent };
|
||||
const routes = getRoutesFromMapping(navigationItems, routesMapping);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({
|
||||
path: navigationItems[pageId].path,
|
||||
component: DummyComponent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not map routes where the navigation item is disabled', () => {
|
||||
const pageId = chance.word();
|
||||
const navigationItems = { [pageId]: createNavigationItemFixture({ disabled: true }) };
|
||||
const routesMapping = { [pageId]: DummyComponent };
|
||||
const routes = getRoutesFromMapping(navigationItems, routesMapping);
|
||||
|
||||
expect(routes).toHaveLength(0);
|
||||
});
|
||||
});
|
65
x-pack/plugins/cloud_security_posture/public/application/app.tsx
Executable file
65
x-pack/plugins/cloud_security_posture/public/application/app.tsx
Executable file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { Router, Redirect, Switch, Route } from 'react-router-dom';
|
||||
import type { RouteProps } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { EuiErrorBoundary } from '@elastic/eui';
|
||||
import { allNavigationItems } from '../common/navigation/constants';
|
||||
import { CspNavigationItem } from '../common/navigation/types';
|
||||
import { UnknownRoute } from '../components/unknown_route';
|
||||
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
|
||||
import type { AppMountParameters, CoreStart } from '../../../../../src/core/public';
|
||||
import type { CspClientPluginStartDeps } from '../types';
|
||||
import { pageToComponentMapping } from './constants';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export interface CspAppDeps {
|
||||
core: CoreStart;
|
||||
deps: CspClientPluginStartDeps;
|
||||
params: AppMountParameters;
|
||||
}
|
||||
|
||||
type RoutePropsWithStringPath = RouteProps & { path: string };
|
||||
|
||||
// Converts the mapping of page -> component to be of type `RouteProps` while filtering out disabled navigation items
|
||||
export const getRoutesFromMapping = <T extends string>(
|
||||
navigationItems: Record<T, CspNavigationItem>,
|
||||
componentMapping: Record<T, RouteProps['component']>
|
||||
): readonly RoutePropsWithStringPath[] =>
|
||||
Object.entries(componentMapping)
|
||||
.filter(([id, _]) => !navigationItems[id as T].disabled)
|
||||
.map<RoutePropsWithStringPath>(([id, component]) => ({
|
||||
path: navigationItems[id as T].path,
|
||||
component: component as RouteProps['component'],
|
||||
}));
|
||||
|
||||
const routes = getRoutesFromMapping(allNavigationItems, pageToComponentMapping);
|
||||
|
||||
export const CspApp = ({ core, deps, params }: CspAppDeps) => (
|
||||
<KibanaContextProvider services={{ ...deps, ...core }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EuiErrorBoundary>
|
||||
<Router history={params.history}>
|
||||
<I18nProvider>
|
||||
<Switch>
|
||||
{routes.map((route) => (
|
||||
<Route key={route.path} {...route} />
|
||||
))}
|
||||
<Route exact path="/" component={RedirectToDashboard} />
|
||||
<Route path="*" component={UnknownRoute} />
|
||||
</Switch>
|
||||
</I18nProvider>
|
||||
</Router>
|
||||
</EuiErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
|
||||
const RedirectToDashboard = () => <Redirect to={allNavigationItems.dashboard.path} />;
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
import { CspPage } from '../common/navigation/types';
|
||||
import * as pages from '../pages';
|
||||
|
||||
export const pageToComponentMapping: Record<CspPage, RouteProps['component']> = {
|
||||
findings: pages.Findings,
|
||||
dashboard: pages.ComplianceDashboard,
|
||||
benchmarks: pages.Benchmarks,
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { CspApp } from './app';
|
||||
|
||||
import type { AppMountParameters, CoreStart } from '../../../../../src/core/public';
|
||||
import type { CspClientPluginStartDeps } from '../types';
|
||||
|
||||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
deps: CspClientPluginStartDeps,
|
||||
params: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(<CspApp core={core} params={params} deps={deps} />, params.element);
|
||||
|
||||
return () => ReactDOM.unmountComponentAtNode(params.element);
|
||||
};
|
|
@ -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 * from './use_cloud_posture_stats_api';
|
|
@ -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 { useQuery } from 'react-query';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { CloudPostureStats } from '../../../common/types';
|
||||
import { STATS_ROUTE_PATH } from '../../../common/constants';
|
||||
|
||||
const getStatsKey = 'csp_dashboard_stats';
|
||||
|
||||
export const useCloudPostureStatsApi = () => {
|
||||
const { http } = useKibana().services;
|
||||
return useQuery([getStatsKey], () => http!.get<CloudPostureStats>(STATS_ROUTE_PATH));
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 { euiPaletteForStatus } from '@elastic/eui';
|
||||
|
||||
const [success, warning, danger] = euiPaletteForStatus(3);
|
||||
|
||||
export const statusColors = { success, warning, danger };
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks/dom';
|
||||
import { useUrlQuery } from './use_url_query';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import { encodeQuery } from '../navigation/query_utils';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useHistory: jest.fn(),
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useUrlQuery', () => {
|
||||
it('uses default query when no query is provided', () => {
|
||||
const defaultQuery = { foo: 1 };
|
||||
(useHistory as jest.Mock).mockReturnValue({
|
||||
push: jest.fn(),
|
||||
});
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
search: encodeQuery(defaultQuery),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUrlQuery(() => defaultQuery));
|
||||
|
||||
act(() => {
|
||||
result.current.setUrlQuery({});
|
||||
});
|
||||
|
||||
expect(result.current.urlQuery.foo).toBe(defaultQuery.foo);
|
||||
expect(useHistory().push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('merges default query, partial first query and partial second query', () => {
|
||||
const defaultQuery = { foo: 1, zoo: 2, moo: 3 };
|
||||
const first = { zoo: 3 };
|
||||
const second = { moo: 4 };
|
||||
(useHistory as jest.Mock).mockReturnValue({
|
||||
push: jest.fn(),
|
||||
});
|
||||
(useLocation as jest.Mock).mockReturnValue({
|
||||
search: encodeQuery({ ...defaultQuery, ...first, ...second }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUrlQuery(() => defaultQuery));
|
||||
|
||||
act(() => {
|
||||
result.current.setUrlQuery(first);
|
||||
});
|
||||
act(() => {
|
||||
result.current.setUrlQuery(second);
|
||||
});
|
||||
|
||||
expect(result.current.urlQuery.foo).toBe(defaultQuery.foo);
|
||||
expect(result.current.urlQuery.zoo).toBe(first.zoo);
|
||||
expect(result.current.urlQuery.moo).toBe(second.moo);
|
||||
expect(useHistory().push).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
|
@ -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 { useEffect, useCallback, useMemo } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { decodeQuery, encodeQuery } from '../navigation/query_utils';
|
||||
|
||||
/**
|
||||
* @description uses 'rison' to encode/decode a url query
|
||||
* @todo replace getDefaultQuery with schema. validate after decoded from URL, use defaultValues
|
||||
* @note shallow-merges default, current and next query
|
||||
*/
|
||||
export const useUrlQuery = <T extends object>(getDefaultQuery: () => T) => {
|
||||
const { push } = useHistory();
|
||||
const { search, key } = useLocation();
|
||||
|
||||
const urlQuery = useMemo(
|
||||
() => ({ ...getDefaultQuery(), ...decodeQuery<T>(search) }),
|
||||
[getDefaultQuery, search]
|
||||
);
|
||||
|
||||
const setUrlQuery = useCallback(
|
||||
(query: Partial<T>) =>
|
||||
push({
|
||||
search: encodeQuery({ ...getDefaultQuery(), ...urlQuery, ...query }),
|
||||
}),
|
||||
[getDefaultQuery, urlQuery, push]
|
||||
);
|
||||
|
||||
// Set initial query
|
||||
useEffect(() => {
|
||||
// TODO: condition should be if decoding failed
|
||||
if (search) return;
|
||||
|
||||
setUrlQuery(getDefaultQuery());
|
||||
}, [getDefaultQuery, search, setUrlQuery]);
|
||||
|
||||
return {
|
||||
key,
|
||||
urlQuery,
|
||||
setUrlQuery,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 * as TEXT from './translations';
|
||||
import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants';
|
||||
import type { CspPage, CspNavigationItem } from './types';
|
||||
|
||||
export const allNavigationItems: Record<CspPage, CspNavigationItem> = {
|
||||
dashboard: { name: TEXT.DASHBOARD, path: '/dashboard' },
|
||||
findings: { name: TEXT.FINDINGS, path: '/findings' },
|
||||
benchmarks: {
|
||||
name: TEXT.MY_BENCHMARKS,
|
||||
path: '/benchmarks',
|
||||
disabled: !INTERNAL_FEATURE_FLAGS.benchmarks,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { encode, decode, type RisonObject } from 'rison-node';
|
||||
import type { LocationDescriptorObject } from 'history';
|
||||
|
||||
const encodeRison = (v: RisonObject): string | undefined => {
|
||||
try {
|
||||
return encode(v);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const decodeRison = <T extends unknown>(query: string): T | undefined => {
|
||||
try {
|
||||
return decode(query) as T;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const QUERY_PARAM_KEY = 'query';
|
||||
|
||||
export const encodeQuery = (query: RisonObject): LocationDescriptorObject['search'] => {
|
||||
const risonQuery = encodeRison(query);
|
||||
if (!risonQuery) return;
|
||||
return `${QUERY_PARAM_KEY}=${risonQuery}`;
|
||||
};
|
||||
|
||||
export const decodeQuery = <T extends unknown>(search?: string): Partial<T> | undefined => {
|
||||
const risonQuery = new URLSearchParams(search).get(QUERY_PARAM_KEY);
|
||||
if (!risonQuery) return;
|
||||
return decodeRison<T>(risonQuery);
|
||||
};
|
|
@ -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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CLOUD_POSTURE = i18n.translate('xpack.csp.navigation.cloudPosture', {
|
||||
defaultMessage: 'Cloud Posture',
|
||||
});
|
||||
|
||||
export const FINDINGS = i18n.translate('xpack.csp.navigation.findings', {
|
||||
defaultMessage: 'Findings',
|
||||
});
|
||||
|
||||
export const DASHBOARD = i18n.translate('xpack.csp.navigation.dashboard', {
|
||||
defaultMessage: 'Dashboard',
|
||||
});
|
||||
|
||||
export const MY_BENCHMARKS = i18n.translate('xpack.csp.navigation.my_benchmarks', {
|
||||
defaultMessage: 'My Benchmarks',
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
export interface CspNavigationItem {
|
||||
readonly name: string;
|
||||
readonly path: string;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export type CspPage = 'dashboard' | 'findings' | 'benchmarks';
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ChromeBreadcrumb, CoreStart } from 'kibana/public';
|
||||
import { useEffect } from 'react';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import { PLUGIN_ID } from '../../../common';
|
||||
import type { CspNavigationItem } from './types';
|
||||
import { CLOUD_POSTURE } from './translations';
|
||||
|
||||
export const useCspBreadcrumbs = (breadcrumbs: CspNavigationItem[]) => {
|
||||
const {
|
||||
services: {
|
||||
chrome: { setBreadcrumbs },
|
||||
application: { getUrlForApp },
|
||||
},
|
||||
} = useKibana<CoreStart>();
|
||||
|
||||
useEffect(() => {
|
||||
const cspPath = getUrlForApp(PLUGIN_ID);
|
||||
const additionalBreadCrumbs: ChromeBreadcrumb[] = breadcrumbs.map((breadcrumb) => ({
|
||||
text: breadcrumb.name,
|
||||
path: breadcrumb.path.startsWith('/')
|
||||
? `${cspPath}${breadcrumb.path}`
|
||||
: `${cspPath}/${breadcrumb.path}`,
|
||||
}));
|
||||
|
||||
setBreadcrumbs([
|
||||
{
|
||||
text: CLOUD_POSTURE,
|
||||
href: cspPath,
|
||||
},
|
||||
...additionalBreadCrumbs,
|
||||
]);
|
||||
}, [getUrlForApp, setBreadcrumbs, breadcrumbs]);
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CLOUD_SECURITY_POSTURE = i18n.translate('xpack.csp.cloudSecurityPosture', {
|
||||
defaultMessage: 'Cloud Security Posture',
|
||||
});
|
|
@ -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 { render, screen } from '@testing-library/react';
|
||||
import { ChartPanel } from './chart_panel';
|
||||
import { CHART_PANEL_TEST_SUBJECTS } from './constants';
|
||||
import Chance from 'chance';
|
||||
|
||||
const chance = new Chance();
|
||||
const testData = chance.word();
|
||||
|
||||
const TestingChart = ({ data }: { data: string | undefined }) => {
|
||||
return <div data-test-subj={CHART_PANEL_TEST_SUBJECTS.TEST_CHART}>{data}</div>;
|
||||
};
|
||||
|
||||
describe('<ChartPanel />', () => {
|
||||
it('renders loading state', () => {
|
||||
render(
|
||||
<ChartPanel isLoading={true} isError={false}>
|
||||
<TestingChart data={testData} />
|
||||
</ChartPanel>
|
||||
);
|
||||
expect(screen.getByTestId(CHART_PANEL_TEST_SUBJECTS.LOADING)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(CHART_PANEL_TEST_SUBJECTS.TEST_CHART)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state', () => {
|
||||
render(
|
||||
<ChartPanel isLoading={false} isError={true}>
|
||||
<TestingChart data={testData} />
|
||||
</ChartPanel>
|
||||
);
|
||||
expect(screen.getByTestId(CHART_PANEL_TEST_SUBJECTS.ERROR)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(CHART_PANEL_TEST_SUBJECTS.TEST_CHART)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart component', () => {
|
||||
render(
|
||||
<ChartPanel isLoading={false} isError={false}>
|
||||
<TestingChart data={testData} />
|
||||
</ChartPanel>
|
||||
);
|
||||
expect(screen.queryByTestId(CHART_PANEL_TEST_SUBJECTS.LOADING)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(CHART_PANEL_TEST_SUBJECTS.ERROR)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId(CHART_PANEL_TEST_SUBJECTS.TEST_CHART)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 { css } from '@emotion/react';
|
||||
import {
|
||||
EuiPanel,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiLoadingChart,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
} from '@elastic/eui';
|
||||
import { CHART_PANEL_TEST_SUBJECTS } from './constants';
|
||||
|
||||
interface ChartPanelProps {
|
||||
title?: string;
|
||||
hasBorder?: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
const Loading = () => (
|
||||
<EuiFlexGroup
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
data-test-subj={CHART_PANEL_TEST_SUBJECTS.LOADING}
|
||||
>
|
||||
<EuiLoadingChart size="m" />
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
||||
const Error = () => (
|
||||
<EuiFlexGroup
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
data-test-subj={CHART_PANEL_TEST_SUBJECTS.ERROR}
|
||||
>
|
||||
<EuiText size="xs" color="subdued">
|
||||
{'Error'}
|
||||
</EuiText>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
||||
export const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
title,
|
||||
hasBorder = true,
|
||||
isLoading,
|
||||
isError,
|
||||
children,
|
||||
}) => {
|
||||
const renderChart = () => {
|
||||
if (isLoading) return <Loading />;
|
||||
if (isError) return <Error />;
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiPanel hasBorder={hasBorder} hasShadow={false} data-test-subj="chart-panel">
|
||||
<EuiFlexGroup direction="column" gutterSize="none" style={{ height: '100%' }}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{title && (
|
||||
<EuiTitle size="s" css={euiTitleStyle}>
|
||||
<h3>{title}</h3>
|
||||
</EuiTitle>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{renderChart()}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const euiTitleStyle = css`
|
||||
font-weight: 400;
|
||||
`;
|
|
@ -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 React from 'react';
|
||||
import { FormattedNumber } from '@kbn/i18n-react';
|
||||
|
||||
export const CompactFormattedNumber = ({ number }: { number: number }) => (
|
||||
<FormattedNumber
|
||||
value={number}
|
||||
maximumFractionDigits={1}
|
||||
notation="compact"
|
||||
compactDisplay="short"
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const CHART_PANEL_TEST_SUBJECTS = {
|
||||
LOADING: 'chart_is_loading',
|
||||
EMPTY: 'chart_is_empty',
|
||||
ERROR: 'chart_is_error',
|
||||
TEST_CHART: 'testing_chart',
|
||||
};
|
|
@ -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 React from 'react';
|
||||
import { EuiBadge, EuiBadgeProps } from '@elastic/eui';
|
||||
import { CSP_EVALUATION_BADGE_FAILED, CSP_EVALUATION_BADGE_PASSED } from './translations';
|
||||
|
||||
interface Props {
|
||||
type: 'passed' | 'failed';
|
||||
}
|
||||
|
||||
const getColor = (type: Props['type']): EuiBadgeProps['color'] => {
|
||||
if (type === 'passed') return 'success';
|
||||
if (type === 'failed') return 'danger';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
export const CspEvaluationBadge = ({ type }: Props) => (
|
||||
<EuiBadge color={getColor(type)}>
|
||||
{type === 'failed' ? CSP_EVALUATION_BADGE_FAILED : CSP_EVALUATION_BADGE_PASSED}
|
||||
</EuiBadge>
|
||||
);
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiBadge } from '@elastic/eui';
|
||||
import type { Score } from '../../common/types';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
interface Props {
|
||||
value: Score;
|
||||
}
|
||||
|
||||
export const CspHealthBadge = ({ value }: Props) => {
|
||||
if (value <= 65) return <EuiBadge color="danger">{TEXT.CRITICAL}</EuiBadge>;
|
||||
if (value <= 86) return <EuiBadge color="warning">{TEXT.WARNING}</EuiBadge>;
|
||||
if (value <= 100) return <EuiBadge color="success">{TEXT.HEALTHY}</EuiBadge>;
|
||||
return null;
|
||||
};
|
|
@ -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 { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
export const CspLoadingState: React.FunctionComponent = ({ children }) => (
|
||||
<EuiFlexGroup direction="column" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{children}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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, { ComponentProps } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Chance from 'chance';
|
||||
import { createNavigationItemFixture } from '../test/fixtures/navigation_item';
|
||||
import { TestProvider } from '../test/test_provider';
|
||||
import { CspPageTemplate, getSideNavItems } from './page_template';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
describe('getSideNavItems', () => {
|
||||
it('maps navigation items to side navigation items', () => {
|
||||
const navigationItem = createNavigationItemFixture();
|
||||
const id = chance.word();
|
||||
const sideNavItems = getSideNavItems({ [id]: navigationItem });
|
||||
|
||||
expect(sideNavItems).toHaveLength(1);
|
||||
expect(sideNavItems[0]).toMatchObject({
|
||||
id,
|
||||
name: navigationItem.name,
|
||||
renderItem: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not map disabled navigation items to side navigation items', () => {
|
||||
const navigationItem = createNavigationItemFixture({ disabled: true });
|
||||
const id = chance.word();
|
||||
const sideNavItems = getSideNavItems({ [id]: navigationItem });
|
||||
expect(sideNavItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<CspPageTemplate />', () => {
|
||||
const renderCspPageTemplate = (props: ComponentProps<typeof CspPageTemplate>) => {
|
||||
render(
|
||||
<TestProvider>
|
||||
<CspPageTemplate {...props} />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders children when not loading', () => {
|
||||
const children = chance.sentence();
|
||||
renderCspPageTemplate({ isLoading: false, children });
|
||||
|
||||
expect(screen.getByText(children)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render loading text when not loading', () => {
|
||||
const children = chance.sentence();
|
||||
const loadingText = chance.sentence();
|
||||
renderCspPageTemplate({ isLoading: false, loadingText, children });
|
||||
|
||||
expect(screen.queryByText(loadingText)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading text when loading is true', () => {
|
||||
const loadingText = chance.sentence();
|
||||
renderCspPageTemplate({ loadingText, isLoading: true });
|
||||
|
||||
expect(screen.getByText(loadingText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render children when loading', () => {
|
||||
const children = chance.sentence();
|
||||
renderCspPageTemplate({ isLoading: true, children });
|
||||
|
||||
expect(screen.queryByText(children)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { EuiErrorBoundary } from '@elastic/eui';
|
||||
import {
|
||||
KibanaPageTemplate,
|
||||
KibanaPageTemplateProps,
|
||||
} from '../../../../../src/plugins/kibana_react/public';
|
||||
import { allNavigationItems } from '../common/navigation/constants';
|
||||
import type { CspNavigationItem } from '../common/navigation/types';
|
||||
import { CLOUD_SECURITY_POSTURE } from '../common/translations';
|
||||
import { CspLoadingState } from './csp_loading_state';
|
||||
import { LOADING } from './translations';
|
||||
|
||||
const activeItemStyle = { fontWeight: 700 };
|
||||
|
||||
export const getSideNavItems = (
|
||||
navigationItems: Record<string, CspNavigationItem>
|
||||
): NonNullable<KibanaPageTemplateProps['solutionNav']>['items'] =>
|
||||
Object.entries(navigationItems)
|
||||
.filter(([_, navigationItem]) => !navigationItem.disabled)
|
||||
.map(([id, navigationItem]) => ({
|
||||
id,
|
||||
name: navigationItem.name,
|
||||
renderItem: () => (
|
||||
<NavLink to={navigationItem.path} activeStyle={activeItemStyle}>
|
||||
{navigationItem.name}
|
||||
</NavLink>
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps: KibanaPageTemplateProps = {
|
||||
solutionNav: {
|
||||
name: CLOUD_SECURITY_POSTURE,
|
||||
items: getSideNavItems(allNavigationItems),
|
||||
},
|
||||
restrictWidth: false,
|
||||
template: 'default',
|
||||
};
|
||||
|
||||
interface CspPageTemplateProps extends KibanaPageTemplateProps {
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export const CspPageTemplate: React.FC<CspPageTemplateProps> = ({
|
||||
children,
|
||||
isLoading,
|
||||
loadingText = LOADING,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<KibanaPageTemplate {...defaultProps} {...props}>
|
||||
<EuiErrorBoundary>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<EuiSpacer size="xxl" />
|
||||
<CspLoadingState>{loadingText}</CspLoadingState>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</EuiErrorBoundary>
|
||||
</KibanaPageTemplate>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CRITICAL = i18n.translate('xpack.csp.critical', {
|
||||
defaultMessage: 'Critical',
|
||||
});
|
||||
|
||||
export const WARNING = i18n.translate('xpack.csp.warning', {
|
||||
defaultMessage: 'Warning',
|
||||
});
|
||||
|
||||
export const HEALTHY = i18n.translate('xpack.csp.healthy', {
|
||||
defaultMessage: 'Healthy',
|
||||
});
|
||||
|
||||
export const PAGE_NOT_FOUND = i18n.translate('xpack.csp.page_not_found', {
|
||||
defaultMessage: 'Page not found',
|
||||
});
|
||||
|
||||
export const LOADING = i18n.translate('xpack.csp.loading', {
|
||||
defaultMessage: 'Loading...',
|
||||
});
|
||||
|
||||
export const CSP_EVALUATION_BADGE_FAILED = i18n.translate(
|
||||
'xpack.csp.cspEvaluationBadge.failedLabelText',
|
||||
{
|
||||
defaultMessage: 'FAILED',
|
||||
}
|
||||
);
|
||||
|
||||
export const CSP_EVALUATION_BADGE_PASSED = i18n.translate(
|
||||
'xpack.csp.cspEvaluationBadge.passedLabelText',
|
||||
{
|
||||
defaultMessage: 'PASSED',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { CspPageTemplate } from './page_template';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
export const UnknownRoute = React.memo(() => (
|
||||
<CspPageTemplate template="centeredContent">
|
||||
<EuiEmptyPrompt
|
||||
data-test-subj="unknownRoute"
|
||||
iconColor="default"
|
||||
iconType="logoElastic"
|
||||
title={<p>{TEXT.PAGE_NOT_FOUND}</p>}
|
||||
/>
|
||||
</CspPageTemplate>
|
||||
));
|
12
x-pack/plugins/cloud_security_posture/public/index.ts
Executable file
12
x-pack/plugins/cloud_security_posture/public/index.ts
Executable file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 { CspPlugin } from './plugin';
|
||||
|
||||
export type { CspClientPluginSetup, CspClientPluginStart } from './types';
|
||||
|
||||
export const plugin = () => new CspPlugin();
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { UseQueryResult } from 'react-query/types/react/types';
|
||||
import { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_benchmark_integration';
|
||||
import { createReactQueryResponse } from '../../test/fixtures/react_query';
|
||||
import { TestProvider } from '../../test/test_provider';
|
||||
import { Benchmarks, BENCHMARKS_ERROR_TEXT, BENCHMARKS_TABLE_DATA_TEST_SUBJ } from './benchmarks';
|
||||
import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } from './translations';
|
||||
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
|
||||
|
||||
jest.mock('./use_csp_benchmark_integrations');
|
||||
|
||||
describe('<Benchmarks />', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const renderBenchmarks = (
|
||||
queryResponse: Partial<UseQueryResult> = createReactQueryResponse()
|
||||
) => {
|
||||
(useCspBenchmarkIntegrations as jest.Mock).mockImplementation(() => queryResponse);
|
||||
|
||||
return render(
|
||||
<TestProvider>
|
||||
<Benchmarks />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('renders the page header', () => {
|
||||
renderBenchmarks();
|
||||
|
||||
expect(screen.getByText(BENCHMARK_INTEGRATIONS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "add integration" button', () => {
|
||||
renderBenchmarks();
|
||||
|
||||
expect(screen.getByText(ADD_A_CIS_INTEGRATION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state while loading', () => {
|
||||
renderBenchmarks(createReactQueryResponse({ status: 'loading' }));
|
||||
|
||||
expect(screen.getByText(LOADING_BENCHMARKS)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(BENCHMARKS_TABLE_DATA_TEST_SUBJ)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state while there is an error', () => {
|
||||
renderBenchmarks(createReactQueryResponse({ status: 'error', error: new Error() }));
|
||||
|
||||
expect(screen.getByText(BENCHMARKS_ERROR_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(BENCHMARKS_TABLE_DATA_TEST_SUBJ)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the benchmarks table', () => {
|
||||
renderBenchmarks(
|
||||
createReactQueryResponse({
|
||||
status: 'success',
|
||||
data: [createCspBenchmarkIntegrationFixture()],
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(BENCHMARKS_TABLE_DATA_TEST_SUBJ)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { EuiPageHeaderProps, EuiButton } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import { BenchmarksTable } from './benchmarks_table';
|
||||
import { ADD_A_CIS_INTEGRATION, BENCHMARK_INTEGRATIONS, LOADING_BENCHMARKS } from './translations';
|
||||
import { useCspBenchmarkIntegrations } from './use_csp_benchmark_integrations';
|
||||
|
||||
const PAGE_HEADER: EuiPageHeaderProps = {
|
||||
pageTitle: BENCHMARK_INTEGRATIONS,
|
||||
rightSideItems: [
|
||||
// TODO: Link this to integrations once we have one
|
||||
<EuiButton fill iconType="plusInCircle">
|
||||
{ADD_A_CIS_INTEGRATION}
|
||||
</EuiButton>,
|
||||
],
|
||||
};
|
||||
|
||||
const BENCHMARKS_BREADCRUMBS = [allNavigationItems.benchmarks];
|
||||
|
||||
export const BENCHMARKS_TABLE_DATA_TEST_SUBJ = 'cspBenchmarksTable';
|
||||
// TODO: Error state
|
||||
export const BENCHMARKS_ERROR_TEXT = 'TODO: Error state';
|
||||
const BenchmarksErrorState = () => <div>{BENCHMARKS_ERROR_TEXT}</div>;
|
||||
|
||||
export const Benchmarks = () => {
|
||||
useCspBreadcrumbs(BENCHMARKS_BREADCRUMBS);
|
||||
const query = useCspBenchmarkIntegrations();
|
||||
|
||||
return (
|
||||
<CspPageTemplate
|
||||
pageHeader={PAGE_HEADER}
|
||||
loadingText={LOADING_BENCHMARKS}
|
||||
isLoading={query.status === 'loading'}
|
||||
>
|
||||
{query.status === 'error' && <BenchmarksErrorState />}
|
||||
{query.status === 'success' && (
|
||||
<BenchmarksTable benchmarks={query.data} data-test-subj={BENCHMARKS_TABLE_DATA_TEST_SUBJ} />
|
||||
)}
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* 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 { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_benchmark_integration';
|
||||
import { BenchmarksTable } from './benchmarks_table';
|
||||
import { TABLE_COLUMN_HEADERS } from './translations';
|
||||
|
||||
describe('<BenchmarksTable />', () => {
|
||||
const chance = new Chance();
|
||||
|
||||
it('renders all column headers', () => {
|
||||
render(<BenchmarksTable benchmarks={[]} />);
|
||||
|
||||
Object.values(TABLE_COLUMN_HEADERS).forEach((columnHeader) => {
|
||||
expect(screen.getByText(columnHeader)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders integration name', () => {
|
||||
const integrationName = chance.sentence();
|
||||
const benchmarks = [
|
||||
createCspBenchmarkIntegrationFixture({ integration_name: integrationName }),
|
||||
];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(integrationName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders benchmark name', () => {
|
||||
const benchmarkName = chance.sentence();
|
||||
const benchmarks = [createCspBenchmarkIntegrationFixture({ benchmark: benchmarkName })];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(benchmarkName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders active rules', () => {
|
||||
const activeRules = chance.integer({ min: 1 });
|
||||
const totalRules = chance.integer({ min: activeRules });
|
||||
const benchmarks = [
|
||||
createCspBenchmarkIntegrationFixture({ rules: { active: activeRules, total: totalRules } }),
|
||||
];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(`${activeRules} of ${totalRules}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders agent policy name', () => {
|
||||
const agentPolicy = {
|
||||
id: chance.guid(),
|
||||
name: chance.sentence(),
|
||||
number_of_agents: chance.integer({ min: 1 }),
|
||||
};
|
||||
|
||||
const benchmarks = [createCspBenchmarkIntegrationFixture({ agent_policy: agentPolicy })];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(agentPolicy.name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number of agents', () => {
|
||||
const agentPolicy = {
|
||||
id: chance.guid(),
|
||||
name: chance.sentence(),
|
||||
number_of_agents: chance.integer({ min: 1 }),
|
||||
};
|
||||
|
||||
const benchmarks = [createCspBenchmarkIntegrationFixture({ agent_policy: agentPolicy })];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(agentPolicy.number_of_agents)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders created by', () => {
|
||||
const createdBy = chance.sentence();
|
||||
const benchmarks = [createCspBenchmarkIntegrationFixture({ created_by: createdBy })];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(createdBy)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders created at', () => {
|
||||
const createdAt = chance.date({ year: chance.integer({ min: 2015, max: 2021 }) }) as Date;
|
||||
const benchmarks = [createCspBenchmarkIntegrationFixture({ created_at: createdAt })];
|
||||
|
||||
render(<BenchmarksTable benchmarks={benchmarks} />);
|
||||
|
||||
expect(screen.getByText(moment(createdAt).fromNow())).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import { TABLE_COLUMN_HEADERS } from './translations';
|
||||
import type { CspBenchmarkIntegration } from './types';
|
||||
|
||||
interface BenchmarksTableProps {
|
||||
benchmarks: CspBenchmarkIntegration[];
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
const BENCHMARKS_TABLE_COLUMNS: Array<EuiBasicTableColumn<CspBenchmarkIntegration>> = [
|
||||
{
|
||||
field: 'integration_name',
|
||||
name: TABLE_COLUMN_HEADERS.INTEGRATION_NAME,
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
field: 'benchmark',
|
||||
name: TABLE_COLUMN_HEADERS.BENCHMARK,
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
render: (benchmarkIntegration: CspBenchmarkIntegration) =>
|
||||
`${benchmarkIntegration.rules.active} of ${benchmarkIntegration.rules.total}`,
|
||||
name: TABLE_COLUMN_HEADERS.ACTIVE_RULES,
|
||||
},
|
||||
{
|
||||
field: 'agent_policy.name',
|
||||
name: TABLE_COLUMN_HEADERS.AGENT_POLICY,
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
field: 'agent_policy.number_of_agents',
|
||||
name: TABLE_COLUMN_HEADERS.NUMBER_OF_AGENTS,
|
||||
dataType: 'number',
|
||||
},
|
||||
{
|
||||
field: 'created_by',
|
||||
name: TABLE_COLUMN_HEADERS.CREATED_BY,
|
||||
dataType: 'string',
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
name: TABLE_COLUMN_HEADERS.CREATED_AT,
|
||||
dataType: 'date',
|
||||
render: (date: CspBenchmarkIntegration['created_at']) => moment(date).fromNow(),
|
||||
},
|
||||
];
|
||||
|
||||
export const BenchmarksTable = ({ benchmarks, ...rest }: BenchmarksTableProps) => (
|
||||
<EuiBasicTable
|
||||
data-test-subj={rest['data-test-subj']}
|
||||
items={benchmarks}
|
||||
columns={BENCHMARKS_TABLE_COLUMNS}
|
||||
/>
|
||||
);
|
|
@ -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 { Benchmarks } from './benchmarks';
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const BENCHMARK_INTEGRATIONS = i18n.translate(
|
||||
'xpack.csp.benchmarks.benchmark_integrations',
|
||||
{
|
||||
defaultMessage: 'Benchmark Integrations',
|
||||
}
|
||||
);
|
||||
|
||||
export const LOADING_BENCHMARKS = i18n.translate('xpack.csp.benchmarks.loading_benchmarks', {
|
||||
defaultMessage: 'Loading your benchmarks...',
|
||||
});
|
||||
|
||||
export const ADD_A_CIS_INTEGRATION = i18n.translate('xpack.csp.benchmarks.add_a_cis_integration', {
|
||||
defaultMessage: 'Add a CIS integration',
|
||||
});
|
||||
|
||||
export const TABLE_COLUMN_HEADERS = {
|
||||
INTEGRATION_NAME: i18n.translate('xpack.csp.benchmarks.table_column_headers.integration_name', {
|
||||
defaultMessage: 'Integration Name',
|
||||
}),
|
||||
BENCHMARK: i18n.translate('xpack.csp.benchmarks.table_column_headers.benchmark', {
|
||||
defaultMessage: 'Benchmark',
|
||||
}),
|
||||
ACTIVE_RULES: i18n.translate('xpack.csp.benchmarks.table_column_headers.active_rules', {
|
||||
defaultMessage: 'Active Rules',
|
||||
}),
|
||||
AGENT_POLICY: i18n.translate('xpack.csp.benchmarks.table_column_headers.agent_policy', {
|
||||
defaultMessage: 'Agent Policy',
|
||||
}),
|
||||
NUMBER_OF_AGENTS: i18n.translate('xpack.csp.benchmarks.table_column_headers.number_of_agents', {
|
||||
defaultMessage: 'Number of Agents',
|
||||
}),
|
||||
CREATED_BY: i18n.translate('xpack.csp.benchmarks.table_column_headers.created_by', {
|
||||
defaultMessage: 'Created by',
|
||||
}),
|
||||
CREATED_AT: i18n.translate('xpack.csp.benchmarks.table_column_headers.created_at', {
|
||||
defaultMessage: 'Created at',
|
||||
}),
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
// TODO: Use interface from BE
|
||||
export interface CspBenchmarkIntegration {
|
||||
integration_name: string;
|
||||
benchmark: string;
|
||||
rules: {
|
||||
active: number;
|
||||
total: number;
|
||||
};
|
||||
agent_policy: {
|
||||
id: string;
|
||||
name: string;
|
||||
number_of_agents: number;
|
||||
};
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 'react-query';
|
||||
import { createCspBenchmarkIntegrationFixture } from '../../test/fixtures/csp_benchmark_integration';
|
||||
import { CspBenchmarkIntegration } from './types';
|
||||
|
||||
const QUERY_KEY = 'csp_benchmark_integrations';
|
||||
|
||||
const FAKE_DATA: CspBenchmarkIntegration[] = [
|
||||
createCspBenchmarkIntegrationFixture(),
|
||||
createCspBenchmarkIntegrationFixture(),
|
||||
createCspBenchmarkIntegrationFixture(),
|
||||
createCspBenchmarkIntegrationFixture(),
|
||||
createCspBenchmarkIntegrationFixture(),
|
||||
];
|
||||
|
||||
// TODO: Use data from BE
|
||||
export const useCspBenchmarkIntegrations = () => {
|
||||
return useQuery(QUERY_KEY, () => Promise.resolve(FAKE_DATA));
|
||||
};
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* 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 {
|
||||
Chart,
|
||||
ElementClickListener,
|
||||
Partition,
|
||||
PartitionElementEvent,
|
||||
PartitionLayout,
|
||||
Settings,
|
||||
} from '@elastic/charts';
|
||||
import { EuiFlexGroup, EuiText, EuiHorizontalRule, EuiFlexItem } from '@elastic/eui';
|
||||
import { statusColors } from '../../../common/constants';
|
||||
import type { Stats } from '../../../../common/types';
|
||||
import * as TEXT from '../translations';
|
||||
import { CompactFormattedNumber } from '../../../components/compact_formatted_number';
|
||||
|
||||
interface CloudPostureScoreChartProps {
|
||||
data: Stats;
|
||||
id: string;
|
||||
partitionOnElementClick: (elements: PartitionElementEvent[]) => void;
|
||||
}
|
||||
|
||||
const ScoreChart = ({
|
||||
data: { totalPassed, totalFailed },
|
||||
id,
|
||||
partitionOnElementClick,
|
||||
}: CloudPostureScoreChartProps) => {
|
||||
const data = [
|
||||
{ label: TEXT.PASSED, value: totalPassed },
|
||||
{ label: TEXT.FAILED, value: totalFailed },
|
||||
];
|
||||
|
||||
return (
|
||||
<Chart size={{ height: 80, width: 90 }}>
|
||||
<Settings
|
||||
theme={{
|
||||
partition: {
|
||||
linkLabel: { maximumSection: Infinity, maxCount: 0 },
|
||||
outerSizeRatio: 0.9,
|
||||
emptySizeRatio: 0.75,
|
||||
},
|
||||
}}
|
||||
onElementClick={partitionOnElementClick as ElementClickListener}
|
||||
/>
|
||||
<Partition
|
||||
id={id}
|
||||
data={data}
|
||||
valueGetter="percent"
|
||||
valueAccessor={(d) => d.value}
|
||||
layout={PartitionLayout.sunburst}
|
||||
layers={[
|
||||
{
|
||||
groupByRollup: (d: { label: string }) => d.label,
|
||||
shape: {
|
||||
fillColor: (d, index) =>
|
||||
d.dataName === 'Passed' ? statusColors.success : statusColors.danger,
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Chart>
|
||||
);
|
||||
};
|
||||
|
||||
const PercentageInfo = ({
|
||||
postureScore,
|
||||
totalPassed,
|
||||
totalFindings,
|
||||
}: CloudPostureScoreChartProps['data']) => {
|
||||
const percentage = `${Math.round(postureScore)}%`;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" justifyContent="center">
|
||||
<EuiText style={{ fontSize: 40, fontWeight: 'bold', lineHeight: 1 }}>{percentage}</EuiText>
|
||||
<EuiText size="xs">
|
||||
<CompactFormattedNumber number={totalPassed} />
|
||||
{'/'}
|
||||
<CompactFormattedNumber number={totalFindings} />
|
||||
{' Findings passed'}
|
||||
</EuiText>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceTrendChart = () => <div>Trend Placeholder</div>;
|
||||
|
||||
export const CloudPostureScoreChart = ({
|
||||
data,
|
||||
id,
|
||||
partitionOnElementClick,
|
||||
}: CloudPostureScoreChartProps) => (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={4}>
|
||||
<EuiFlexGroup direction="row" style={{ margin: 0 }}>
|
||||
<EuiFlexItem grow={false} style={{ justifyContent: 'flex-end' }}>
|
||||
<ScoreChart {...{ id, data, partitionOnElementClick }} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<PercentageInfo {...data} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiHorizontalRule margin="m" />
|
||||
<EuiFlexItem grow={6}>
|
||||
<ComplianceTrendChart />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiStat,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiIcon,
|
||||
EuiFlexGrid,
|
||||
EuiText,
|
||||
// EuiFlexGroup,
|
||||
} from '@elastic/eui';
|
||||
// import { Chart, Settings, LineSeries } from '@elastic/charts';
|
||||
import type { IconType, EuiStatProps } from '@elastic/eui';
|
||||
import { useCloudPostureStatsApi } from '../../../common/api';
|
||||
import { statusColors } from '../../../common/constants';
|
||||
import { Score } from '../../../../common/types';
|
||||
import * as TEXT from '../translations';
|
||||
import { NO_DATA_TO_DISPLAY } from '../translations';
|
||||
|
||||
// type Trend = Array<[time: number, value: number]>;
|
||||
|
||||
// TODO: this is the warning color hash listen in EUI's docs. need to find where to import it from.
|
||||
|
||||
const getTitleColor = (value: Score): EuiStatProps['titleColor'] => {
|
||||
if (value <= 65) return 'danger';
|
||||
if (value <= 95) return statusColors.warning;
|
||||
if (value <= 100) return 'success';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getScoreIcon = (value: Score): IconType => {
|
||||
if (value <= 65) return 'alert';
|
||||
if (value <= 86) return 'alert';
|
||||
if (value <= 100) return 'check';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
// TODO: make score trend check for length, cases for less than 2 or more than 5 should be handled
|
||||
// const getScoreTrendPercentage = (scoreTrend: Trend) => {
|
||||
// const beforeLast = scoreTrend[scoreTrend.length - 2][1];
|
||||
// const last = scoreTrend[scoreTrend.length - 1][1];
|
||||
//
|
||||
// return Number((last - beforeLast).toFixed(1));
|
||||
// };
|
||||
|
||||
const placeholder = (
|
||||
<EuiText size="xs" color="subdued">
|
||||
{NO_DATA_TO_DISPLAY}
|
||||
</EuiText>
|
||||
);
|
||||
|
||||
export const ComplianceStats = () => {
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
// TODO: add error/loading state
|
||||
if (!getStats.isSuccess) return null;
|
||||
const { postureScore, benchmarksStats: benchmarks } = getStats.data;
|
||||
|
||||
// TODO: in case we dont have a full length trend we will need to handle the sparkline chart alone. not rendering anything is just a temporary solution
|
||||
if (!benchmarks || !postureScore) return null;
|
||||
|
||||
// TODO: mock data, needs BE
|
||||
// const scoreTrend = [
|
||||
// [0, 0],
|
||||
// [1, 10],
|
||||
// [2, 100],
|
||||
// [3, 50],
|
||||
// [4, postureScore],
|
||||
// ] as Trend;
|
||||
//
|
||||
// const scoreChange = getScoreTrendPercentage(scoreTrend);
|
||||
// const isPositiveChange = scoreChange > 0;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: postureScore,
|
||||
description: TEXT.POSTURE_SCORE,
|
||||
titleColor: getTitleColor(postureScore),
|
||||
iconType: getScoreIcon(postureScore),
|
||||
},
|
||||
{
|
||||
// TODO: remove placeholder for the commented out component, needs BE
|
||||
title: placeholder,
|
||||
description: TEXT.POSTURE_SCORE_TREND,
|
||||
},
|
||||
// {
|
||||
// title: (
|
||||
// <span>
|
||||
// <EuiIcon size="xl" type={isPositiveChange ? 'sortUp' : 'sortDown'} />
|
||||
// {`${scoreChange}%`}
|
||||
// </span>
|
||||
// ),
|
||||
// description: 'Posture Score Trend',
|
||||
// titleColor: isPositiveChange ? 'success' : 'danger',
|
||||
// renderBody: (
|
||||
// <>
|
||||
// <Chart size={{ height: 30 }}>
|
||||
// <Settings
|
||||
// showLegend={false}
|
||||
// tooltip="none"
|
||||
// theme={{
|
||||
// lineSeriesStyle: {
|
||||
// point: {
|
||||
// visible: false,
|
||||
// },
|
||||
// },
|
||||
// }}
|
||||
// />
|
||||
// <LineSeries
|
||||
// id="posture-score-trend-sparkline"
|
||||
// data={scoreTrend}
|
||||
// xAccessor={0}
|
||||
// yAccessors={[1]}
|
||||
// color={isPositiveChange ? statusColors.success : statusColors.danger}
|
||||
// />
|
||||
// </Chart>
|
||||
// </>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
// TODO: this should count only ACTIVE benchmarks. needs BE
|
||||
title: benchmarks.length,
|
||||
description: TEXT.ACTIVE_FRAMEWORKS,
|
||||
},
|
||||
{
|
||||
// TODO: should be relatively simple to return from BE. needs BE
|
||||
title: placeholder,
|
||||
description: TEXT.TOTAL_RESOURCES,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<EuiFlexGrid columns={2}>
|
||||
{stats.map((s) => (
|
||||
<EuiFlexItem style={{ height: '45%' }}>
|
||||
<EuiPanel hasShadow={false} hasBorder>
|
||||
<EuiStat
|
||||
title={s.title}
|
||||
description={s.description}
|
||||
textAlign="left"
|
||||
titleColor={s.titleColor}
|
||||
>
|
||||
{
|
||||
// s.renderBody ||
|
||||
<EuiIcon type={s.iconType || 'empty'} color={s.titleColor} />
|
||||
}
|
||||
</EuiStat>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 {
|
||||
Chart,
|
||||
Settings,
|
||||
Axis,
|
||||
timeFormatter,
|
||||
niceTimeFormatByDay,
|
||||
AreaSeries,
|
||||
} from '@elastic/charts';
|
||||
|
||||
export const ComplianceTrendChart = () => (
|
||||
<Chart size={{ height: 200 }}>
|
||||
<Settings showLegend={false} legendPosition="right" />
|
||||
<AreaSeries
|
||||
id="compliance_score"
|
||||
// TODO: no api for this chart yet, using empty state for now. needs BE
|
||||
data={[]}
|
||||
xScaleType="time"
|
||||
xAccessor={0}
|
||||
yAccessors={[1]}
|
||||
/>
|
||||
<Axis id="bottom-axis" position="bottom" tickFormat={timeFormatter(niceTimeFormatByDay(1))} />
|
||||
<Axis ticks={4} id="left-axis" position="left" showGridLines domain={{ min: 0, max: 100 }} />
|
||||
</Chart>
|
||||
);
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 { getTop5Risks, RisksTableProps } from './risks_table';
|
||||
|
||||
const podsAgg = {
|
||||
resourceType: 'pods',
|
||||
totalFindings: 2,
|
||||
totalPassed: 1,
|
||||
totalFailed: 1,
|
||||
};
|
||||
|
||||
const etcdAgg = {
|
||||
resourceType: 'etcd',
|
||||
totalFindings: 5,
|
||||
totalPassed: 0,
|
||||
totalFailed: 5,
|
||||
};
|
||||
|
||||
const clusterAgg = {
|
||||
resourceType: 'cluster',
|
||||
totalFindings: 2,
|
||||
totalPassed: 2,
|
||||
totalFailed: 0,
|
||||
};
|
||||
|
||||
const systemAgg = {
|
||||
resourceType: 'system',
|
||||
totalFindings: 10,
|
||||
totalPassed: 6,
|
||||
totalFailed: 4,
|
||||
};
|
||||
|
||||
const apiAgg = {
|
||||
resourceType: 'api',
|
||||
totalFindings: 19100,
|
||||
totalPassed: 2100,
|
||||
totalFailed: 17000,
|
||||
};
|
||||
|
||||
const serverAgg = {
|
||||
resourceType: 'server',
|
||||
totalFindings: 7,
|
||||
totalPassed: 4,
|
||||
totalFailed: 3,
|
||||
};
|
||||
|
||||
const mockData: RisksTableProps['data'] = [
|
||||
podsAgg,
|
||||
etcdAgg,
|
||||
clusterAgg,
|
||||
systemAgg,
|
||||
apiAgg,
|
||||
serverAgg,
|
||||
];
|
||||
|
||||
describe('getTop5Risks', () => {
|
||||
it('returns sorted by failed findings', () => {
|
||||
expect(getTop5Risks([systemAgg, etcdAgg, apiAgg])).toEqual([apiAgg, etcdAgg, systemAgg]);
|
||||
});
|
||||
|
||||
it('return array filtered with failed findings only', () => {
|
||||
expect(getTop5Risks([systemAgg, clusterAgg, apiAgg])).toEqual([apiAgg, systemAgg]);
|
||||
});
|
||||
|
||||
it('return sorted and filtered array with no more then 5 elements', () => {
|
||||
expect(getTop5Risks(mockData)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
EuiBasicTable,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLink,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import type { Query } from '@kbn/es-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { CloudPostureStats, ResourceTypeAgg } from '../../../../common/types';
|
||||
import { allNavigationItems } from '../../../common/navigation/constants';
|
||||
import { encodeQuery } from '../../../common/navigation/query_utils';
|
||||
import { CompactFormattedNumber } from '../../../components/compact_formatted_number';
|
||||
import * as TEXT from '../translations';
|
||||
import { RULE_FAILED } from '../../../../common/constants';
|
||||
|
||||
// TODO: remove this option after we get data from the beat
|
||||
const useMockData: boolean = false;
|
||||
const mock = [
|
||||
{
|
||||
resourceType: 'pods',
|
||||
totalFindings: 2,
|
||||
totalPassed: 1,
|
||||
totalFailed: 1,
|
||||
},
|
||||
{
|
||||
resourceType: 'etcd',
|
||||
totalFindings: 5,
|
||||
totalPassed: 0,
|
||||
totalFailed: 5,
|
||||
},
|
||||
{
|
||||
resourceType: 'cluster',
|
||||
totalFindings: 2,
|
||||
totalPassed: 2,
|
||||
totalFailed: 0,
|
||||
},
|
||||
{
|
||||
resourceType: 'system',
|
||||
totalFindings: 10,
|
||||
totalPassed: 6,
|
||||
totalFailed: 4,
|
||||
},
|
||||
{
|
||||
resourceType: 'api',
|
||||
totalFindings: 19100,
|
||||
totalPassed: 2100,
|
||||
totalFailed: 17000,
|
||||
},
|
||||
{
|
||||
resourceType: 'server',
|
||||
totalFindings: 7,
|
||||
totalPassed: 4,
|
||||
totalFailed: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export interface RisksTableProps {
|
||||
data: CloudPostureStats['resourceTypesAggs'];
|
||||
}
|
||||
|
||||
const maxRisks = 5;
|
||||
|
||||
export const getTop5Risks = (resourceTypesAggs: CloudPostureStats['resourceTypesAggs']) => {
|
||||
const filtered = resourceTypesAggs.filter((x) => x.totalFailed > 0);
|
||||
const sorted = filtered.slice().sort((first, second) => second.totalFailed - first.totalFailed);
|
||||
|
||||
return sorted.slice(0, maxRisks);
|
||||
};
|
||||
|
||||
const getFailedFindingsQuery = (): Query => ({
|
||||
language: 'kuery',
|
||||
query: `result.evaluation : "${RULE_FAILED}" `,
|
||||
});
|
||||
|
||||
const getResourceTypeFailedFindingsQuery = (resourceType: string): Query => ({
|
||||
language: 'kuery',
|
||||
query: `resource.type : "${resourceType}" and result.evaluation : "${RULE_FAILED}" `,
|
||||
});
|
||||
|
||||
export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => {
|
||||
const { push } = useHistory();
|
||||
|
||||
const handleCellClick = useCallback(
|
||||
(resourceType: ResourceTypeAgg['resourceType']) =>
|
||||
push({
|
||||
pathname: allNavigationItems.findings.path,
|
||||
search: encodeQuery(getResourceTypeFailedFindingsQuery(resourceType)),
|
||||
}),
|
||||
[push]
|
||||
);
|
||||
|
||||
const handleViewAllClick = useCallback(
|
||||
() =>
|
||||
push({
|
||||
pathname: allNavigationItems.findings.path,
|
||||
search: encodeQuery(getFailedFindingsQuery()),
|
||||
}),
|
||||
[push]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: 'resourceType',
|
||||
name: TEXT.RESOURCE_TYPE,
|
||||
render: (resourceType: ResourceTypeAgg['resourceType']) => (
|
||||
<EuiLink onClick={() => handleCellClick(resourceType)}>{resourceType}</EuiLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'totalFailed',
|
||||
name: TEXT.FAILED_FINDINGS,
|
||||
render: (totalFailed: ResourceTypeAgg['totalFailed'], resource: ResourceTypeAgg) => (
|
||||
<>
|
||||
<EuiText size="s" color="danger">
|
||||
<CompactFormattedNumber number={resource.totalFailed} />
|
||||
</EuiText>
|
||||
<EuiText size="s">
|
||||
{'/'}
|
||||
<CompactFormattedNumber number={resource.totalFindings} />
|
||||
</EuiText>
|
||||
</>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleCellClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" justifyContent="spaceBetween" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiBasicTable<ResourceTypeAgg>
|
||||
rowHeader="resourceType"
|
||||
items={useMockData ? getTop5Risks(mock) : getTop5Risks(resourceTypesAggs)}
|
||||
columns={columns}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup justifyContent="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty onClick={handleViewAllClick} iconType="search">
|
||||
{TEXT.VIEW_ALL_FAILED_FINDINGS}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Axis, BarSeries, Chart, Settings } from '@elastic/charts';
|
||||
import { statusColors } from '../../../common/constants';
|
||||
|
||||
// soon to be deprecated
|
||||
export const ScorePerAccountChart = () => {
|
||||
return (
|
||||
<Chart size={{ height: 200 }}>
|
||||
<Settings theme={theme} rotation={90} showLegend={false} />
|
||||
<Axis id="left" position="left" />
|
||||
<BarSeries
|
||||
displayValueSettings={{
|
||||
showValueLabel: true,
|
||||
valueFormatter: (v) => `${Number(v * 100).toFixed(0)}%`,
|
||||
}}
|
||||
id="bars"
|
||||
data={[]}
|
||||
xAccessor={'resource'}
|
||||
yAccessors={['value']}
|
||||
splitSeriesAccessors={['evaluation']}
|
||||
stackAccessors={['evaluation']}
|
||||
stackMode="percentage"
|
||||
/>
|
||||
</Chart>
|
||||
);
|
||||
};
|
||||
|
||||
const theme = {
|
||||
colors: { vizColors: [statusColors.success, statusColors.danger] },
|
||||
barSeriesStyle: {
|
||||
displayValue: {
|
||||
fontSize: 14,
|
||||
fill: { color: 'white', borderColor: 'blue', borderWidth: 0 },
|
||||
offsetX: 5,
|
||||
offsetY: -5,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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 React from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { SummarySection } from './dashboard_sections/summary_section';
|
||||
import { BenchmarksSection } from './dashboard_sections/benchmarks_section';
|
||||
import { useCloudPostureStatsApi } from '../../common/api';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
const CompliancePage = () => {
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
if (getStats.isLoading) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummarySection />
|
||||
<EuiSpacer />
|
||||
<BenchmarksSection />
|
||||
<EuiSpacer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplianceDashboard = () => {
|
||||
useCspBreadcrumbs([allNavigationItems.dashboard]);
|
||||
|
||||
return (
|
||||
<CspPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: TEXT.CLOUD_POSTURE,
|
||||
}}
|
||||
>
|
||||
<CompliancePage />
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiFlexGrid,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiIcon,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiDescriptionList,
|
||||
} from '@elastic/eui';
|
||||
import { EuiIconType } from '@elastic/eui/src/components/icon/icon';
|
||||
import { Query } from '@kbn/es-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { PartitionElementEvent } from '@elastic/charts';
|
||||
import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart';
|
||||
import { ComplianceTrendChart } from '../compliance_charts/compliance_trend_chart';
|
||||
import { useCloudPostureStatsApi } from '../../../common/api/use_cloud_posture_stats_api';
|
||||
import { CspHealthBadge } from '../../../components/csp_health_badge';
|
||||
import { ChartPanel } from '../../../components/chart_panel';
|
||||
import * as TEXT from '../translations';
|
||||
import { allNavigationItems } from '../../../common/navigation/constants';
|
||||
import { encodeQuery } from '../../../common/navigation/query_utils';
|
||||
import { Evaluation } from '../../../../common/types';
|
||||
|
||||
const logoMap: ReadonlyMap<string, EuiIconType> = new Map([['CIS Kubernetes', 'logoKubernetes']]);
|
||||
|
||||
const getBenchmarkLogo = (benchmarkName: string): EuiIconType => {
|
||||
return logoMap.get(benchmarkName) ?? 'logoElastic';
|
||||
};
|
||||
|
||||
const getBenchmarkEvaluationQuery = (name: string, evaluation: Evaluation): Query => ({
|
||||
language: 'kuery',
|
||||
query: `rule.benchmark : "${name}" and result.evaluation : "${evaluation}"`,
|
||||
});
|
||||
|
||||
export const BenchmarksSection = () => {
|
||||
const history = useHistory();
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
const benchmarks = getStats.isSuccess && getStats.data.benchmarksStats;
|
||||
if (!benchmarks) return null;
|
||||
|
||||
const handleElementClick = (name: string, elements: PartitionElementEvent[]) => {
|
||||
const [element] = elements;
|
||||
const [layerValue] = element;
|
||||
const rollupValue = layerValue[0].groupByRollup as Evaluation;
|
||||
|
||||
history.push({
|
||||
pathname: allNavigationItems.findings.path,
|
||||
search: encodeQuery(getBenchmarkEvaluationQuery(name, rollupValue)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{benchmarks.map((benchmark) => (
|
||||
<EuiPanel hasBorder hasShadow={false}>
|
||||
<EuiFlexGrid columns={4}>
|
||||
<EuiFlexItem
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexBasis: '20%',
|
||||
borderRight: `1px solid lightgray`,
|
||||
}}
|
||||
>
|
||||
<EuiIcon type={getBenchmarkLogo(benchmark.name)} size="xxl" />
|
||||
<EuiSpacer />
|
||||
<EuiTitle size={'s'}>
|
||||
<h3>{benchmark.name}</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem style={{ flexBasis: '20%' }}>
|
||||
<EuiDescriptionList
|
||||
listItems={[
|
||||
{
|
||||
// TODO: this shows the failed/passed ratio and not the calculated score. needs product
|
||||
title: TEXT.COMPLIANCE_SCORE,
|
||||
description: (
|
||||
<ChartPanel
|
||||
hasBorder={false}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<CloudPostureScoreChart
|
||||
id={`${benchmark.name}_score_chart`}
|
||||
data={benchmark}
|
||||
partitionOnElementClick={(elements) =>
|
||||
handleElementClick(benchmark.name, elements)
|
||||
}
|
||||
/>
|
||||
</ChartPanel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem style={{ flexBasis: '40%' }}>
|
||||
<EuiDescriptionList
|
||||
listItems={[
|
||||
{
|
||||
title: TEXT.COMPLIANCE_TREND,
|
||||
description: (
|
||||
<ChartPanel
|
||||
hasBorder={false}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
{/* TODO: no api for this chart yet, using empty state for now. needs BE */}
|
||||
<ComplianceTrendChart />
|
||||
</ChartPanel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem style={{ flexBasis: '10%' }}>
|
||||
<EuiDescriptionList
|
||||
listItems={[
|
||||
{
|
||||
title: TEXT.POSTURE_SCORE,
|
||||
// TODO: temporary until the type for this are fixed and the score is no longer optional (right now can fail if score equals 0).
|
||||
description: benchmark.postureScore || 'error',
|
||||
},
|
||||
{
|
||||
title: TEXT.STATUS,
|
||||
description:
|
||||
benchmark.postureScore !== undefined ? (
|
||||
<CspHealthBadge value={benchmark.postureScore} />
|
||||
) : (
|
||||
TEXT.ERROR
|
||||
),
|
||||
},
|
||||
{
|
||||
title: TEXT.TOTAL_FAILURES,
|
||||
description: benchmark.totalFailed || TEXT.ERROR,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
</EuiPanel>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* 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 { EuiFlexGrid, EuiFlexItem } from '@elastic/eui';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { PartitionElementEvent } from '@elastic/charts';
|
||||
import { Query } from '@kbn/es-query';
|
||||
import { ScorePerAccountChart } from '../compliance_charts/score_per_account_chart';
|
||||
import { ChartPanel } from '../../../components/chart_panel';
|
||||
import { useCloudPostureStatsApi } from '../../../common/api';
|
||||
import * as TEXT from '../translations';
|
||||
import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart';
|
||||
import { allNavigationItems } from '../../../common/navigation/constants';
|
||||
import { encodeQuery } from '../../../common/navigation/query_utils';
|
||||
import { Evaluation } from '../../../../common/types';
|
||||
import { RisksTable } from '../compliance_charts/risks_table';
|
||||
|
||||
const getEvaluationQuery = (evaluation: Evaluation): Query => ({
|
||||
language: 'kuery',
|
||||
query: `"result.evaluation : "${evaluation}"`,
|
||||
});
|
||||
|
||||
const defaultHeight = 360;
|
||||
|
||||
// TODO: limit this to desktop media queries only
|
||||
const summarySectionWrapperStyle = {
|
||||
height: defaultHeight,
|
||||
};
|
||||
|
||||
export const SummarySection = () => {
|
||||
const history = useHistory();
|
||||
const getStats = useCloudPostureStatsApi();
|
||||
if (!getStats.isSuccess) return null;
|
||||
|
||||
const handleElementClick = (elements: PartitionElementEvent[]) => {
|
||||
const [element] = elements;
|
||||
const [layerValue] = element;
|
||||
const rollupValue = layerValue[0].groupByRollup as Evaluation;
|
||||
|
||||
history.push({
|
||||
pathname: allNavigationItems.findings.path,
|
||||
search: encodeQuery(getEvaluationQuery(rollupValue)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGrid columns={3} style={summarySectionWrapperStyle}>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel
|
||||
title={TEXT.CLOUD_POSTURE_SCORE}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
<CloudPostureScoreChart
|
||||
id="cloud_posture_score_chart"
|
||||
data={getStats.data}
|
||||
partitionOnElementClick={handleElementClick}
|
||||
/>
|
||||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel title={TEXT.RISKS} isLoading={getStats.isLoading} isError={getStats.isError}>
|
||||
<RisksTable data={getStats.data.resourceTypesAggs} />
|
||||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ChartPanel
|
||||
title={TEXT.SCORE_PER_CLUSTER_CHART_TITLE}
|
||||
isLoading={getStats.isLoading}
|
||||
isError={getStats.isError}
|
||||
>
|
||||
{/* TODO: no api for this chart yet, using empty state for now. needs BE */}
|
||||
<ScorePerAccountChart />
|
||||
</ChartPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
};
|
|
@ -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 * from './compliance_dashboard';
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CLOUD_POSTURE = i18n.translate('xpack.csp.cloud_posture', {
|
||||
defaultMessage: 'Cloud Posture',
|
||||
});
|
||||
|
||||
export const CLOUD_POSTURE_SCORE = i18n.translate('xpack.csp.cloud_posture_score', {
|
||||
defaultMessage: 'Cloud Posture Score',
|
||||
});
|
||||
|
||||
export const RISKS = i18n.translate('xpack.csp.risks', {
|
||||
defaultMessage: 'Risks',
|
||||
});
|
||||
|
||||
export const SCORE_PER_CLUSTER_CHART_TITLE = i18n.translate(
|
||||
'xpack.csp.score_per_cluster_chart_title',
|
||||
{
|
||||
defaultMessage: 'Score Per Account / Cluster',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMPLIANCE_SCORE = i18n.translate('xpack.csp.compliance_score', {
|
||||
defaultMessage: 'Compliance Score',
|
||||
});
|
||||
|
||||
export const COMPLIANCE_TREND = i18n.translate('xpack.csp.compliance_trend', {
|
||||
defaultMessage: 'Compliance Trend',
|
||||
});
|
||||
|
||||
export const POSTURE_SCORE = i18n.translate('xpack.csp.posture_score', {
|
||||
defaultMessage: 'Posture Score',
|
||||
});
|
||||
|
||||
export const STATUS = i18n.translate('xpack.csp.status', {
|
||||
defaultMessage: 'STATUS',
|
||||
});
|
||||
|
||||
export const TOTAL_FAILURES = i18n.translate('xpack.csp.total_failures', {
|
||||
defaultMessage: 'Total Failures',
|
||||
});
|
||||
|
||||
export const ERROR = i18n.translate('xpack.csp.error', {
|
||||
defaultMessage: 'Error',
|
||||
});
|
||||
|
||||
export const POSTURE_SCORE_TREND = i18n.translate('xpack.csp.posture_score_trend', {
|
||||
defaultMessage: 'Posture Score Trend',
|
||||
});
|
||||
|
||||
export const ACTIVE_FRAMEWORKS = i18n.translate('xpack.csp.active_frameworks', {
|
||||
defaultMessage: 'Active Frameworks',
|
||||
});
|
||||
|
||||
export const TOTAL_RESOURCES = i18n.translate('xpack.csp.total_resources', {
|
||||
defaultMessage: 'Total Resources',
|
||||
});
|
||||
|
||||
export const PASSED = i18n.translate('xpack.csp.passed', {
|
||||
defaultMessage: 'Passed',
|
||||
});
|
||||
|
||||
export const FAILED = i18n.translate('xpack.csp.failed', {
|
||||
defaultMessage: 'Failed',
|
||||
});
|
||||
|
||||
export const VIEW_ALL_FAILED_FINDINGS = i18n.translate('xpack.csp.view_all_failed_findings', {
|
||||
defaultMessage: 'View all failed findings',
|
||||
});
|
||||
|
||||
export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', {
|
||||
defaultMessage: 'Resource Type',
|
||||
});
|
||||
|
||||
export const FAILED_FINDINGS = i18n.translate('xpack.csp.failed_findings', {
|
||||
defaultMessage: 'Failed Findings',
|
||||
});
|
||||
|
||||
export const NO_DATA_TO_DISPLAY = i18n.translate('xpack.csp.complianceDashboard.noDataLabel', {
|
||||
defaultMessage: 'No data to display',
|
||||
});
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 { UseQueryResult } from 'react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Findings } from './findings';
|
||||
import { MISSING_KUBEBEAT } from './translations';
|
||||
import { TestProvider } from '../../test/test_provider';
|
||||
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
|
||||
import { createStubDataView } from '../../../../../../src/plugins/data_views/public/data_views/data_view.stub';
|
||||
import { useKubebeatDataView } from './utils';
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import type { DataView } from '../../../../../../src/plugins/data/common';
|
||||
|
||||
jest.mock('./utils');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const Wrapper = ({ data = dataPluginMock.createStartContract() }) => (
|
||||
<TestProvider deps={{ data }}>
|
||||
<Findings />
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
describe('<Findings />', () => {
|
||||
it("renders the error state component when 'kubebeat' DataView doesn't exists", async () => {
|
||||
(useKubebeatDataView as jest.Mock).mockReturnValue({
|
||||
status: 'success',
|
||||
} as UseQueryResult<DataView>);
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error state component when 'kubebeat' request status is 'error'", async () => {
|
||||
(useKubebeatDataView as jest.Mock).mockReturnValue({
|
||||
status: 'error',
|
||||
} as UseQueryResult<DataView>);
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
expect(await screen.findByText(MISSING_KUBEBEAT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the success state component when 'kubebeat' DataView exists and request status is 'success'", async () => {
|
||||
const data = dataPluginMock.createStartContract();
|
||||
const source = await data.search.searchSource.create();
|
||||
|
||||
(source.fetch$ as jest.Mock).mockReturnValue({
|
||||
toPromise: () => Promise.resolve({ rawResponse: { hits: { hits: [] } } }),
|
||||
});
|
||||
|
||||
(useKubebeatDataView as jest.Mock).mockReturnValue({
|
||||
status: 'success',
|
||||
data: createStubDataView({
|
||||
spec: {
|
||||
id: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
},
|
||||
}),
|
||||
} as UseQueryResult<DataView>);
|
||||
|
||||
render(<Wrapper data={data} />);
|
||||
|
||||
expect(await screen.findByTestId(TEST_SUBJECTS.FINDINGS_CONTAINER)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -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 { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import type { EuiPageHeaderProps } from '@elastic/eui';
|
||||
import { allNavigationItems } from '../../common/navigation/constants';
|
||||
import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs';
|
||||
import { FindingsContainer } from './findings_container';
|
||||
import { CspPageTemplate } from '../../components/page_template';
|
||||
import { useKubebeatDataView } from './utils';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { FINDINGS, MISSING_KUBEBEAT } from './translations';
|
||||
|
||||
const pageHeader: EuiPageHeaderProps = {
|
||||
pageTitle: FINDINGS,
|
||||
};
|
||||
|
||||
export const Findings = () => {
|
||||
const dataView = useKubebeatDataView();
|
||||
useCspBreadcrumbs([allNavigationItems.findings]);
|
||||
|
||||
return (
|
||||
<CspPageTemplate pageHeader={pageHeader}>
|
||||
{dataView.status === 'loading' && <LoadingPrompt />}
|
||||
{(dataView.status === 'error' || (dataView.status !== 'loading' && !dataView.data)) && (
|
||||
<ErrorPrompt />
|
||||
)}
|
||||
{dataView.status === 'success' && dataView.data && (
|
||||
<FindingsContainer dataView={dataView.data} />
|
||||
)}
|
||||
</CspPageTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingPrompt = () => <EuiEmptyPrompt icon={<EuiLoadingSpinner size="xl" />} />;
|
||||
|
||||
// TODO: follow https://elastic.github.io/eui/#/display/empty-prompt/guidelines
|
||||
const ErrorPrompt = () => (
|
||||
<EuiEmptyPrompt
|
||||
data-test-subj={TEST_SUBJECTS.FINDINGS_MISSING_INDEX}
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
// TODO: account for when we have a dataview without an index
|
||||
title={<h2>{MISSING_KUBEBEAT}</h2>}
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import { FindingsTable } from './findings_table';
|
||||
import { FindingsSearchBar } from './findings_search_bar';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import type { DataView } from '../../../../../../src/plugins/data/common';
|
||||
import { SortDirection } from '../../../../../../src/plugins/data/common';
|
||||
import { useUrlQuery } from '../../common/hooks/use_url_query';
|
||||
import { useFindings, type CspFindingsRequest } from './use_findings';
|
||||
|
||||
// TODO: define this as a schema with default values
|
||||
// need to get Query and DateRange schema
|
||||
const getDefaultQuery = (): CspFindingsRequest => ({
|
||||
query: { language: 'kuery', query: '' },
|
||||
filters: [],
|
||||
dateRange: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
sort: [{ ['@timestamp']: SortDirection.desc }],
|
||||
from: 0,
|
||||
size: 10,
|
||||
});
|
||||
|
||||
export const FindingsContainer = ({ dataView }: { dataView: DataView }) => {
|
||||
const { urlQuery: findingsQuery, setUrlQuery, key } = useUrlQuery(getDefaultQuery);
|
||||
const findingsResult = useFindings(dataView, findingsQuery, key);
|
||||
|
||||
return (
|
||||
<div data-test-subj={TEST_SUBJECTS.FINDINGS_CONTAINER}>
|
||||
<FindingsSearchBar
|
||||
dataView={dataView}
|
||||
setQuery={setUrlQuery}
|
||||
{...findingsQuery}
|
||||
{...findingsResult}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<FindingsTable setQuery={setUrlQuery} {...findingsQuery} {...findingsResult} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
EuiCode,
|
||||
EuiDescriptionList,
|
||||
EuiTextColor,
|
||||
EuiFlyout,
|
||||
EuiFlyoutHeader,
|
||||
EuiTitle,
|
||||
EuiFlyoutBody,
|
||||
EuiBadge,
|
||||
EuiTabs,
|
||||
EuiTab,
|
||||
EuiFlexGrid,
|
||||
EuiCard,
|
||||
PropsOf,
|
||||
} from '@elastic/eui';
|
||||
import { assertNever } from '@kbn/std';
|
||||
import type { CspFinding } from './types';
|
||||
import { CspEvaluationBadge } from '../../components/csp_evaluation_badge';
|
||||
import * as TEXT from './translations';
|
||||
|
||||
const tabs = ['result', 'rule', 'resource'] as const;
|
||||
|
||||
type FindingsTab = typeof tabs[number];
|
||||
|
||||
type EuiListItemsProps = NonNullable<PropsOf<typeof EuiDescriptionList>['listItems']>[number];
|
||||
|
||||
interface Card {
|
||||
title: string;
|
||||
listItems: Array<[EuiListItemsProps['title'], EuiListItemsProps['description']]>;
|
||||
}
|
||||
|
||||
interface FindingFlyoutProps {
|
||||
onClose(): void;
|
||||
findings: CspFinding;
|
||||
}
|
||||
|
||||
export const FindingsRuleFlyout = ({ onClose, findings }: FindingFlyoutProps) => {
|
||||
const [tab, setTab] = useState<FindingsTab>('result');
|
||||
return (
|
||||
<EuiFlyout onClose={onClose}>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle size="l">
|
||||
<EuiTextColor color="primary">
|
||||
<h2>{TEXT.FINDINGS}</h2>
|
||||
</EuiTextColor>
|
||||
</EuiTitle>
|
||||
<EuiSpacer />
|
||||
<EuiTabs>
|
||||
{tabs.map((v) => (
|
||||
<EuiTab
|
||||
key={v}
|
||||
isSelected={tab === v}
|
||||
onClick={() => setTab(v)}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{v}
|
||||
</EuiTab>
|
||||
))}
|
||||
</EuiTabs>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<FindingsTab tab={tab} findings={findings} />
|
||||
</EuiFlyoutBody>
|
||||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
||||
const Cards = ({ data }: { data: Card[] }) => (
|
||||
<EuiFlexGrid direction="column" gutterSize={'l'}>
|
||||
{data.map((card) => (
|
||||
<EuiFlexItem key={card.title} style={{ display: 'block' }}>
|
||||
<EuiCard textAlign="left" title={card.title}>
|
||||
<EuiDescriptionList
|
||||
compressed={false}
|
||||
type="column"
|
||||
listItems={card.listItems.map((v) => ({ title: v[0], description: v[1] }))}
|
||||
/>
|
||||
</EuiCard>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
|
||||
const FindingsTab = ({ tab, findings }: { findings: CspFinding; tab: FindingsTab }) => {
|
||||
switch (tab) {
|
||||
case 'result':
|
||||
return <Cards data={getResultCards(findings)} />;
|
||||
case 'rule':
|
||||
return <Cards data={getRuleCards(findings)} />;
|
||||
case 'resource':
|
||||
return <Cards data={getResourceCards(findings)} />;
|
||||
default:
|
||||
assertNever(tab);
|
||||
}
|
||||
};
|
||||
|
||||
const getResourceCards = ({ resource }: CspFinding): Card[] => [
|
||||
{
|
||||
title: TEXT.RESOURCE,
|
||||
listItems: [
|
||||
[TEXT.FILENAME, <EuiCode>{resource.filename}</EuiCode>],
|
||||
[TEXT.MODE, resource.mode],
|
||||
[TEXT.PATH, <EuiCode>{resource.path}</EuiCode>],
|
||||
[TEXT.TYPE, resource.type],
|
||||
[TEXT.UID, resource.uid],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getRuleCards = ({ rule }: CspFinding): Card[] => [
|
||||
{
|
||||
title: TEXT.RULE,
|
||||
listItems: [
|
||||
[TEXT.BENCHMARK, rule.benchmark],
|
||||
[TEXT.NAME, rule.name],
|
||||
[TEXT.DESCRIPTION, rule.description],
|
||||
[TEXT.REMEDIATION, <EuiCode>{rule.remediation}</EuiCode>],
|
||||
[
|
||||
TEXT.TAGS,
|
||||
rule.tags.map((t) => (
|
||||
<EuiBadge key={t} color="default">
|
||||
{t}
|
||||
</EuiBadge>
|
||||
)),
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getResultCards = ({ result, agent, host, ...rest }: CspFinding): Card[] => [
|
||||
{
|
||||
title: TEXT.RESULT,
|
||||
listItems: [
|
||||
[TEXT.EVALUATION, <CspEvaluationBadge type={result.evaluation} />],
|
||||
[TEXT.EVIDENCE, <EuiCode>{JSON.stringify(result.evidence, null, 2)}</EuiCode>],
|
||||
[TEXT.TIMESTAMP, rest['@timestamp']],
|
||||
result.evaluation === 'failed' && [TEXT.REMEDIATION, rest.rule.remediation],
|
||||
].filter(Boolean) as Card['listItems'],
|
||||
},
|
||||
{
|
||||
title: TEXT.AGENT,
|
||||
listItems: [
|
||||
[TEXT.NAME, agent.name],
|
||||
[TEXT.ID, agent.id],
|
||||
[TEXT.TYPE, agent.type],
|
||||
[TEXT.VERSION, agent.version],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: TEXT.HOST,
|
||||
listItems: [
|
||||
[TEXT.ARCHITECTURE, host.architecture],
|
||||
[TEXT.CONTAINERIZED, host.containerized ? 'true' : 'false'],
|
||||
[TEXT.HOSTNAME, host.hostname],
|
||||
[TEXT.ID, host.id],
|
||||
[TEXT.IP, host.ip.join(',')],
|
||||
[TEXT.MAC, host.mac.join(',')],
|
||||
[TEXT.NAME, host.name],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: TEXT.OS,
|
||||
listItems: [
|
||||
[TEXT.CODENAME, host.os.codename],
|
||||
[TEXT.FAMILY, host.os.family],
|
||||
[TEXT.KERNEL, host.os.kernel],
|
||||
[TEXT.NAME, host.os.name],
|
||||
[TEXT.PLATFORM, host.os.platform],
|
||||
[TEXT.TYPE, host.os.type],
|
||||
[TEXT.VERSION, host.os.version],
|
||||
],
|
||||
},
|
||||
];
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import type { CspFindingsRequest, CspFindingsResponse } from './use_findings';
|
||||
import type { CspClientPluginStartDeps } from '../../types';
|
||||
import { PLUGIN_NAME } from '../../../common';
|
||||
import type { DataView } from '../../../../../../src/plugins/data/common';
|
||||
|
||||
type SearchBarQueryProps = Pick<CspFindingsRequest, 'query' | 'filters' | 'dateRange'>;
|
||||
|
||||
interface BaseFindingsSearchBarProps extends SearchBarQueryProps {
|
||||
setQuery(v: Partial<SearchBarQueryProps>): void;
|
||||
}
|
||||
|
||||
type FindingsSearchBarProps = CspFindingsResponse & BaseFindingsSearchBarProps;
|
||||
|
||||
export const FindingsSearchBar = ({
|
||||
dataView,
|
||||
dateRange,
|
||||
query,
|
||||
filters,
|
||||
status,
|
||||
setQuery,
|
||||
}: FindingsSearchBarProps & { dataView: DataView }) => {
|
||||
const {
|
||||
data: {
|
||||
ui: { SearchBar },
|
||||
},
|
||||
} = useKibana<CspClientPluginStartDeps>().services;
|
||||
|
||||
return (
|
||||
<SearchBar
|
||||
appName={PLUGIN_NAME}
|
||||
dataTestSubj={TEST_SUBJECTS.FINDINGS_SEARCH_BAR}
|
||||
showFilterBar={true}
|
||||
showDatePicker={true}
|
||||
showQueryBar={true}
|
||||
showQueryInput={true}
|
||||
showSaveQuery={false}
|
||||
isLoading={status === 'loading'}
|
||||
indexPatterns={[dataView]}
|
||||
dateRangeFrom={dateRange.from}
|
||||
dateRangeTo={dateRange.to}
|
||||
query={query}
|
||||
filters={filters}
|
||||
onQuerySubmit={setQuery}
|
||||
// @ts-expect-error onFiltersUpdated is a valid prop on SearchBar
|
||||
onFiltersUpdated={(value: Filter[]) => setQuery({ filters: value })}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 { render, screen } from '@testing-library/react';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import { FindingsTable } from './findings_table';
|
||||
import type { PropsOf } from '@elastic/eui';
|
||||
import Chance from 'chance';
|
||||
import type { CspFinding } from './types';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
const getFakeFindings = (): CspFinding & { id: string } => ({
|
||||
id: chance.word(),
|
||||
result: {
|
||||
evaluation: chance.weighted(['passed', 'failed'], [0.5, 0.5]),
|
||||
evidence: {
|
||||
filemode: chance.word(),
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
name: chance.word(),
|
||||
description: chance.paragraph(),
|
||||
impact: chance.word(),
|
||||
remediation: chance.word(),
|
||||
benchmark: {
|
||||
name: 'CIS Kubernetes',
|
||||
version: '1.6.0',
|
||||
},
|
||||
tags: [],
|
||||
},
|
||||
agent: {
|
||||
id: chance.string(),
|
||||
name: chance.string(),
|
||||
type: chance.string(),
|
||||
version: chance.string(),
|
||||
},
|
||||
resource: {
|
||||
filename: chance.string(),
|
||||
type: chance.string(),
|
||||
path: chance.string(),
|
||||
uid: chance.string(),
|
||||
mode: chance.string(),
|
||||
},
|
||||
run_id: chance.string(),
|
||||
host: {} as any,
|
||||
ecs: {} as any,
|
||||
'@timestamp': new Date().toISOString(),
|
||||
});
|
||||
|
||||
type TableProps = PropsOf<typeof FindingsTable>;
|
||||
|
||||
describe('<FindingsTable />', () => {
|
||||
it('renders the zero state when status success and data has a length of zero ', async () => {
|
||||
const props: TableProps = {
|
||||
status: 'success',
|
||||
data: { data: [], total: 0 },
|
||||
error: null,
|
||||
sort: [],
|
||||
from: 1,
|
||||
size: 10,
|
||||
setQuery: jest.fn,
|
||||
};
|
||||
|
||||
render(<FindingsTable {...props} />);
|
||||
|
||||
expect(screen.getByTestId(TEST_SUBJECTS.FINDINGS_TABLE_ZERO_STATE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the table with provided items', () => {
|
||||
const data = Array.from({ length: 10 }, getFakeFindings);
|
||||
|
||||
const props: TableProps = {
|
||||
status: 'success',
|
||||
data: { data, total: 10 },
|
||||
error: null,
|
||||
sort: [],
|
||||
from: 0,
|
||||
size: 10,
|
||||
setQuery: jest.fn,
|
||||
};
|
||||
|
||||
render(<FindingsTable {...props} />);
|
||||
|
||||
data.forEach((item) => {
|
||||
expect(screen.getByText(item.rule.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
type Criteria,
|
||||
EuiToolTip,
|
||||
EuiLink,
|
||||
EuiTableFieldDataColumnType,
|
||||
EuiEmptyPrompt,
|
||||
EuiBasicTable,
|
||||
PropsOf,
|
||||
EuiBasicTableProps,
|
||||
} from '@elastic/eui';
|
||||
import moment from 'moment';
|
||||
import { extractErrorMessage } from '../../../common/utils/helpers';
|
||||
import * as TEST_SUBJECTS from './test_subjects';
|
||||
import * as TEXT from './translations';
|
||||
import type { CspFinding } from './types';
|
||||
import { CspEvaluationBadge } from '../../components/csp_evaluation_badge';
|
||||
import type { CspFindingsRequest, CspFindingsResponse } from './use_findings';
|
||||
import { SortDirection } from '../../../../../../src/plugins/data/common';
|
||||
import { FindingsRuleFlyout } from './findings_flyout';
|
||||
|
||||
type TableQueryProps = Pick<CspFindingsRequest, 'sort' | 'from' | 'size'>;
|
||||
|
||||
interface BaseFindingsTableProps extends TableQueryProps {
|
||||
setQuery(query: Partial<TableQueryProps>): void;
|
||||
}
|
||||
|
||||
type FindingsTableProps = CspFindingsResponse & BaseFindingsTableProps;
|
||||
|
||||
const FindingsTableComponent = ({
|
||||
setQuery,
|
||||
from,
|
||||
size,
|
||||
sort = [],
|
||||
error,
|
||||
...props
|
||||
}: FindingsTableProps) => {
|
||||
const [selectedFinding, setSelectedFinding] = useState<CspFinding>();
|
||||
|
||||
const pagination = useMemo(
|
||||
() =>
|
||||
getEuiPaginationFromEsSearchSource({
|
||||
from,
|
||||
size,
|
||||
total: props.status === 'success' ? props.data.total : 0,
|
||||
}),
|
||||
[from, size, props]
|
||||
);
|
||||
|
||||
const sorting = useMemo(() => getEuiSortFromEsSearchSource(sort), [sort]);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(item: CspFinding, column: EuiTableFieldDataColumnType<CspFinding>) => ({
|
||||
onClick: column.field === 'rule.name' ? () => setSelectedFinding(item) : undefined,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const onTableChange = useCallback(
|
||||
(params: Criteria<CspFinding>) => {
|
||||
setQuery(getEsSearchQueryFromEuiTableParams(params));
|
||||
},
|
||||
[setQuery]
|
||||
);
|
||||
|
||||
// Show "zero state"
|
||||
if (props.status === 'success' && !props.data.data.length)
|
||||
// TODO: use our own logo
|
||||
return (
|
||||
<EuiEmptyPrompt
|
||||
iconType="logoKibana"
|
||||
title={<h2>{TEXT.NO_FINDINGS}</h2>}
|
||||
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE_ZERO_STATE}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiBasicTable
|
||||
data-test-subj={TEST_SUBJECTS.FINDINGS_TABLE}
|
||||
loading={props.status === 'loading'}
|
||||
error={error ? extractErrorMessage(error) : undefined}
|
||||
items={props.data?.data || []}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
sorting={sorting}
|
||||
onChange={onTableChange}
|
||||
cellProps={getCellProps}
|
||||
/>
|
||||
{selectedFinding && (
|
||||
<FindingsRuleFlyout
|
||||
findings={selectedFinding}
|
||||
onClose={() => setSelectedFinding(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getEuiPaginationFromEsSearchSource = ({
|
||||
from: pageIndex,
|
||||
size: pageSize,
|
||||
total,
|
||||
}: Pick<FindingsTableProps, 'from' | 'size'> & {
|
||||
total: number;
|
||||
}): EuiBasicTableProps<CspFinding>['pagination'] => ({
|
||||
pageSize,
|
||||
pageIndex: Math.ceil(pageIndex / pageSize),
|
||||
totalItemCount: total,
|
||||
pageSizeOptions: [10, 25, 100],
|
||||
hidePerPageOptions: false,
|
||||
});
|
||||
|
||||
const getEuiSortFromEsSearchSource = (
|
||||
sort: TableQueryProps['sort']
|
||||
): EuiBasicTableProps<CspFinding>['sorting'] => {
|
||||
if (!sort.length) return;
|
||||
|
||||
const entry = Object.entries(sort[0])?.[0];
|
||||
if (!entry) return;
|
||||
|
||||
const [field, direction] = entry;
|
||||
return { sort: { field: field as keyof CspFinding, direction: direction as SortDirection } };
|
||||
};
|
||||
|
||||
const getEsSearchQueryFromEuiTableParams = ({
|
||||
page,
|
||||
sort,
|
||||
}: Criteria<CspFinding>): Partial<TableQueryProps> => ({
|
||||
...(!!page && { from: page.index * page.size, size: page.size }),
|
||||
sort: sort ? [{ [sort.field]: SortDirection[sort.direction] }] : undefined,
|
||||
});
|
||||
|
||||
const timestampRenderer = (timestamp: string) => (
|
||||
<EuiToolTip position="top" content={timestamp}>
|
||||
<span>{moment.duration(moment().diff(timestamp)).humanize()}</span>
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
||||
const resourceFilenameRenderer = (filename: string) => (
|
||||
<EuiToolTip position="top" content={filename}>
|
||||
<span>{filename}</span>
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
||||
const ruleNameRenderer = (name: string) => (
|
||||
<EuiToolTip position="top" content={name}>
|
||||
<EuiLink>{name}</EuiLink>
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
||||
const resultEvaluationRenderer = (type: PropsOf<typeof CspEvaluationBadge>['type']) => (
|
||||
<CspEvaluationBadge type={type} />
|
||||
);
|
||||
|
||||
const columns: Array<EuiTableFieldDataColumnType<CspFinding>> = [
|
||||
{
|
||||
field: 'resource.filename',
|
||||
name: TEXT.RESOURCE,
|
||||
truncateText: true,
|
||||
width: '15%',
|
||||
sortable: true,
|
||||
render: resourceFilenameRenderer,
|
||||
},
|
||||
{
|
||||
field: 'rule.name',
|
||||
name: TEXT.RULE_NAME,
|
||||
truncateText: true,
|
||||
render: ruleNameRenderer,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'result.evaluation',
|
||||
name: TEXT.EVALUATION,
|
||||
width: '100px',
|
||||
render: resultEvaluationRenderer,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: '@timestamp',
|
||||
width: '100px',
|
||||
name: TEXT.TIMESTAMP,
|
||||
truncateText: true,
|
||||
render: timestampRenderer,
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const FindingsTable = React.memo(FindingsTableComponent);
|
|
@ -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 * from './findings';
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 FINDINGS_SEARCH_BAR = 'findings_search_bar';
|
||||
export const FINDINGS_TABLE = 'findings_table';
|
||||
export const FINDINGS_CONTAINER = 'findings_container';
|
||||
export const FINDINGS_MISSING_INDEX = 'findings_page_missing_dataview';
|
||||
export const FINDINGS_TABLE_ZERO_STATE = 'findings_table_zero_state';
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NAME = i18n.translate('xpack.csp.name', {
|
||||
defaultMessage: 'Name',
|
||||
});
|
||||
|
||||
export const SEARCH_FAILED = i18n.translate('xpack.csp.search_failed', {
|
||||
defaultMessage: 'Search failed',
|
||||
});
|
||||
|
||||
export const TAGS = i18n.translate('xpack.csp.tags', {
|
||||
defaultMessage: 'Tags',
|
||||
});
|
||||
|
||||
export const RULE_NAME = i18n.translate('xpack.csp.rule_name', {
|
||||
defaultMessage: 'Rule Name',
|
||||
});
|
||||
|
||||
export const OS = i18n.translate('xpack.csp.os', {
|
||||
defaultMessage: 'OS',
|
||||
});
|
||||
|
||||
export const FINDINGS = i18n.translate('xpack.csp.findings', {
|
||||
defaultMessage: 'Findings',
|
||||
});
|
||||
|
||||
export const MISSING_KUBEBEAT = i18n.translate('xpack.csp.kubebeatDataViewIsMissing', {
|
||||
defaultMessage: 'Kubebeat DataView is missing',
|
||||
});
|
||||
|
||||
export const RESOURCE = i18n.translate('xpack.csp.resource', {
|
||||
defaultMessage: 'Resource',
|
||||
});
|
||||
|
||||
export const FILENAME = i18n.translate('xpack.csp.filename', {
|
||||
defaultMessage: 'Filename',
|
||||
});
|
||||
|
||||
export const MODE = i18n.translate('xpack.csp.mode', {
|
||||
defaultMessage: 'Mode',
|
||||
});
|
||||
|
||||
export const TYPE = i18n.translate('xpack.csp.type', {
|
||||
defaultMessage: 'Type',
|
||||
});
|
||||
|
||||
export const PATH = i18n.translate('xpack.csp.path', {
|
||||
defaultMessage: 'Path',
|
||||
});
|
||||
|
||||
export const UID = i18n.translate('xpack.csp.uid', {
|
||||
defaultMessage: 'UID',
|
||||
});
|
||||
|
||||
export const GID = i18n.translate('xpack.csp.gid', {
|
||||
defaultMessage: 'GID',
|
||||
});
|
||||
|
||||
export const RULE = i18n.translate('xpack.csp.rule', {
|
||||
defaultMessage: 'Rule',
|
||||
});
|
||||
|
||||
export const DESCRIPTION = i18n.translate('xpack.csp.description', {
|
||||
defaultMessage: 'Description',
|
||||
});
|
||||
|
||||
export const REMEDIATION = i18n.translate('xpack.csp.remediation', {
|
||||
defaultMessage: 'Remediation',
|
||||
});
|
||||
|
||||
export const BENCHMARK = i18n.translate('xpack.csp.benchmark', {
|
||||
defaultMessage: 'Benchmark',
|
||||
});
|
||||
|
||||
export const RESULT = i18n.translate('xpack.csp.result', {
|
||||
defaultMessage: 'Result',
|
||||
});
|
||||
|
||||
export const EVALUATION = i18n.translate('xpack.csp.evaluation', {
|
||||
defaultMessage: 'Evaluation',
|
||||
});
|
||||
|
||||
export const EVIDENCE = i18n.translate('xpack.csp.evidence', {
|
||||
defaultMessage: 'Evidence',
|
||||
});
|
||||
|
||||
export const TIMESTAMP = i18n.translate('xpack.csp.timestamp', {
|
||||
defaultMessage: 'Timestamp',
|
||||
});
|
||||
|
||||
export const AGENT = i18n.translate('xpack.csp.agent', {
|
||||
defaultMessage: 'Agent',
|
||||
});
|
||||
|
||||
export const VERSION = i18n.translate('xpack.csp.version', {
|
||||
defaultMessage: 'Version',
|
||||
});
|
||||
|
||||
export const ID = i18n.translate('xpack.csp.id', {
|
||||
defaultMessage: 'ID',
|
||||
});
|
||||
|
||||
export const HOST = i18n.translate('xpack.csp.host', {
|
||||
defaultMessage: 'HOST',
|
||||
});
|
||||
|
||||
export const ARCHITECTURE = i18n.translate('xpack.csp.architecture', {
|
||||
defaultMessage: 'Architecture',
|
||||
});
|
||||
|
||||
export const CONTAINERIZED = i18n.translate('xpack.csp.containerized', {
|
||||
defaultMessage: 'Containerized',
|
||||
});
|
||||
|
||||
export const HOSTNAME = i18n.translate('xpack.csp.hostname', {
|
||||
defaultMessage: 'Hostname',
|
||||
});
|
||||
|
||||
export const MAC = i18n.translate('xpack.csp.mac', {
|
||||
defaultMessage: 'Mac',
|
||||
});
|
||||
|
||||
export const IP = i18n.translate('xpack.csp.ip', {
|
||||
defaultMessage: 'IP',
|
||||
});
|
||||
|
||||
export const CODENAME = i18n.translate('xpack.csp.codename', {
|
||||
defaultMessage: 'Codename',
|
||||
});
|
||||
|
||||
export const FAMILY = i18n.translate('xpack.csp.family', {
|
||||
defaultMessage: 'Family',
|
||||
});
|
||||
|
||||
export const KERNEL = i18n.translate('xpack.csp.kernel', {
|
||||
defaultMessage: 'Kernel',
|
||||
});
|
||||
|
||||
export const PLATFORM = i18n.translate('xpack.csp.platform', {
|
||||
defaultMessage: 'Platform',
|
||||
});
|
||||
|
||||
export const NO_FINDINGS = i18n.translate('xpack.csp.thereAreNoFindings', {
|
||||
defaultMessage: 'There are no Findings',
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TODO: this needs to be defined in a versioned schema
|
||||
export interface CspFinding {
|
||||
'@timestamp': string;
|
||||
run_id: string;
|
||||
result: CspFindingResult;
|
||||
resource: CspFindingResource;
|
||||
rule: CspRule;
|
||||
host: CspFindingHost;
|
||||
agent: CspFindingAgent;
|
||||
ecs: {
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CspRule {
|
||||
benchmark: { name: string; version: string };
|
||||
description: string;
|
||||
impact: string;
|
||||
name: string;
|
||||
remediation: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface CspFindingResult {
|
||||
evaluation: 'passed' | 'failed';
|
||||
evidence: {
|
||||
filemode: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CspFindingResource {
|
||||
uid: string;
|
||||
filename: string;
|
||||
// gid: string;
|
||||
mode: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CspFindingHost {
|
||||
id: string;
|
||||
containerized: boolean;
|
||||
ip: string[];
|
||||
mac: string[];
|
||||
name: string;
|
||||
hostname: string;
|
||||
architecture: string;
|
||||
os: {
|
||||
kernel: string;
|
||||
codename: string;
|
||||
type: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
family: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CspFindingAgent {
|
||||
version: string;
|
||||
// ephemeral_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* 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 { Filter } from '@kbn/es-query';
|
||||
import { type UseQueryResult, useQuery } from 'react-query';
|
||||
import type { AggregationsAggregate, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { number } from 'io-ts';
|
||||
import { extractErrorMessage, isNonNullable } from '../../../common/utils/helpers';
|
||||
import type {
|
||||
DataView,
|
||||
EsQuerySortValue,
|
||||
IKibanaSearchResponse,
|
||||
SerializedSearchSourceFields,
|
||||
TimeRange,
|
||||
} from '../../../../../../src/plugins/data/common';
|
||||
import type { CspClientPluginStartDeps } from '../../types';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
import * as TEXT from './translations';
|
||||
import type { CoreStart } from '../../../../../../src/core/public';
|
||||
import type { CspFinding } from './types';
|
||||
|
||||
interface CspFindings {
|
||||
data: CspFinding[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CspFindingsRequest
|
||||
extends Required<Pick<SerializedSearchSourceFields, 'sort' | 'size' | 'from' | 'query'>> {
|
||||
filters: Filter[];
|
||||
dateRange: TimeRange;
|
||||
}
|
||||
|
||||
type ResponseProps = 'data' | 'error' | 'status';
|
||||
type Result = UseQueryResult<CspFindings, unknown>;
|
||||
|
||||
// TODO: use distributive Pick
|
||||
export type CspFindingsResponse =
|
||||
| Pick<Extract<Result, { status: 'success' }>, ResponseProps>
|
||||
| Pick<Extract<Result, { status: 'error' }>, ResponseProps>
|
||||
| Pick<Extract<Result, { status: 'idle' }>, ResponseProps>
|
||||
| Pick<Extract<Result, { status: 'loading' }>, ResponseProps>;
|
||||
|
||||
const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']);
|
||||
|
||||
// NOTE: .keyword comes from the mapping we defined for the Findings index
|
||||
const getSortKey = (key: string): string =>
|
||||
FIELDS_WITHOUT_KEYWORD_MAPPING.has(key) ? key : `${key}.keyword`;
|
||||
|
||||
/**
|
||||
* @description utility to transform a column header key to its field mapping for sorting
|
||||
* @example Adds '.keyword' to every property we sort on except values of `FIELDS_WITHOUT_KEYWORD_MAPPING`
|
||||
* @todo find alternative
|
||||
* @note we choose the keyword 'keyword' in the field mapping
|
||||
*/
|
||||
const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[] =>
|
||||
sort.slice().reduce<EsQuerySortValue[]>((acc, cur) => {
|
||||
const entry = Object.entries(cur)[0];
|
||||
if (!entry) return acc;
|
||||
|
||||
const [k, v] = entry;
|
||||
acc.push({ [getSortKey(k)]: v });
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const showResponseErrorToast =
|
||||
({ toasts: { addDanger } }: CoreStart['notifications']) =>
|
||||
(error: unknown): void => {
|
||||
addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED));
|
||||
};
|
||||
|
||||
const extractFindings = ({
|
||||
rawResponse: { hits },
|
||||
}: IKibanaSearchResponse<
|
||||
SearchResponse<CspFinding, Record<string, AggregationsAggregate>>
|
||||
>): CspFindings => ({
|
||||
// TODO: use 'fields' instead of '_source' ?
|
||||
data: hits.hits.map((hit) => hit._source!),
|
||||
total: number.is(hits.total) ? hits.total : 0,
|
||||
});
|
||||
|
||||
const createFindingsSearchSource = (
|
||||
{
|
||||
query,
|
||||
dateRange,
|
||||
dataView,
|
||||
filters,
|
||||
...rest
|
||||
}: Omit<CspFindingsRequest, 'queryKey'> & {
|
||||
dataView: DataView;
|
||||
},
|
||||
queryService: CspClientPluginStartDeps['data']['query']
|
||||
): SerializedSearchSourceFields => {
|
||||
if (query) queryService.queryString.setQuery(query);
|
||||
const timeFilter = queryService.timefilter.timefilter.createFilter(dataView, dateRange);
|
||||
queryService.filterManager.setFilters([...filters, timeFilter].filter(isNonNullable));
|
||||
|
||||
return {
|
||||
...rest,
|
||||
sort: mapEsQuerySortKey(rest.sort),
|
||||
filter: queryService.filterManager.getFilters(),
|
||||
query: queryService.queryString.getQuery(),
|
||||
index: dataView.id, // TODO: constant
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description a react-query#mutation wrapper on the data plugin searchSource
|
||||
* @todo use 'searchAfter'. currently limited to 10k docs. see https://github.com/elastic/kibana/issues/116776
|
||||
*/
|
||||
export const useFindings = (
|
||||
dataView: DataView,
|
||||
searchProps: CspFindingsRequest,
|
||||
urlKey?: string // Needed when URL query (searchProps) didn't change (now-15) but require a refetch
|
||||
): CspFindingsResponse => {
|
||||
const {
|
||||
notifications,
|
||||
data: { query, search },
|
||||
} = useKibana<CspClientPluginStartDeps>().services;
|
||||
|
||||
return useQuery(
|
||||
['csp_findings', { searchProps, urlKey }],
|
||||
async () => {
|
||||
const source = await search.searchSource.create(
|
||||
createFindingsSearchSource({ ...searchProps, dataView }, query)
|
||||
);
|
||||
|
||||
const response = await source.fetch$().toPromise();
|
||||
|
||||
return response;
|
||||
},
|
||||
{
|
||||
cacheTime: 0,
|
||||
onError: showResponseErrorToast(notifications!),
|
||||
select: extractFindings,
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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 { useQuery } from 'react-query';
|
||||
import type { CspClientPluginStartDeps } from '../../types';
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
|
||||
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
/**
|
||||
* TODO: use perfected kibana data views
|
||||
*/
|
||||
export const useKubebeatDataView = () => {
|
||||
const {
|
||||
data: { dataViews },
|
||||
} = useKibana<CspClientPluginStartDeps>().services;
|
||||
|
||||
// TODO: check if index exists
|
||||
// if not, no point in creating a data view
|
||||
// const check = () => http?.get(`/kubebeat`);
|
||||
|
||||
// TODO: use `dataViews.get(ID)`
|
||||
const findDataView = async () => (await dataViews.find(CSP_KUBEBEAT_INDEX_PATTERN))?.[0];
|
||||
|
||||
return useQuery(['kubebeat_dataview'], findDataView);
|
||||
};
|
10
x-pack/plugins/cloud_security_posture/public/pages/index.ts
Normal file
10
x-pack/plugins/cloud_security_posture/public/pages/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { Findings } from './findings';
|
||||
export * from './compliance_dashboard';
|
||||
export { Benchmarks } from './benchmarks';
|
54
x-pack/plugins/cloud_security_posture/public/plugin.ts
Executable file
54
x-pack/plugins/cloud_security_posture/public/plugin.ts
Executable 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 { AppMountParameters, CoreSetup, CoreStart, Plugin } from '../../../../src/core/public';
|
||||
import type {
|
||||
CspClientPluginSetup,
|
||||
CspClientPluginStart,
|
||||
CspClientPluginSetupDeps,
|
||||
CspClientPluginStartDeps,
|
||||
} from './types';
|
||||
import { PLUGIN_NAME, PLUGIN_ID } from '../common';
|
||||
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public';
|
||||
|
||||
export class CspPlugin
|
||||
implements
|
||||
Plugin<
|
||||
CspClientPluginSetup,
|
||||
CspClientPluginStart,
|
||||
CspClientPluginSetupDeps,
|
||||
CspClientPluginStartDeps
|
||||
>
|
||||
{
|
||||
public setup(
|
||||
core: CoreSetup<CspClientPluginStartDeps, CspClientPluginStart>,
|
||||
plugins: CspClientPluginSetupDeps
|
||||
): CspClientPluginSetup {
|
||||
// Register an application into the side navigation menu
|
||||
core.application.register({
|
||||
id: PLUGIN_ID,
|
||||
title: PLUGIN_NAME,
|
||||
category: DEFAULT_APP_CATEGORIES.security,
|
||||
async mount(params: AppMountParameters) {
|
||||
// Load application bundle
|
||||
const { renderApp } = await import('./application/index');
|
||||
// Get start services as specified in kibana.json
|
||||
const [coreStart, depsStart] = await core.getStartServices();
|
||||
// Render the application
|
||||
return renderApp(coreStart, depsStart, params);
|
||||
},
|
||||
});
|
||||
|
||||
// Return methods that should be available to other plugins
|
||||
return {};
|
||||
}
|
||||
public start(core: CoreStart, plugins: CspClientPluginStartDeps): CspClientPluginStart {
|
||||
return {};
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
48
x-pack/plugins/cloud_security_posture/public/test/fixtures/csp_benchmark_integration.ts
vendored
Normal file
48
x-pack/plugins/cloud_security_posture/public/test/fixtures/csp_benchmark_integration.ts
vendored
Normal 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
import Chance from 'chance';
|
||||
import type { CspBenchmarkIntegration } from '../../pages/benchmarks/types';
|
||||
|
||||
type CreateCspBenchmarkIntegrationFixtureInput = {
|
||||
chance?: Chance.Chance;
|
||||
} & Partial<CspBenchmarkIntegration>;
|
||||
|
||||
export const createCspBenchmarkIntegrationFixture = ({
|
||||
chance = new Chance(),
|
||||
integration_name = chance.sentence(),
|
||||
benchmark = chance.sentence(),
|
||||
rules = undefined,
|
||||
agent_policy = {
|
||||
id: chance.guid(),
|
||||
name: chance.sentence(),
|
||||
number_of_agents: chance.integer({ min: 1 }),
|
||||
},
|
||||
created_by = chance.sentence(),
|
||||
created_at = chance.date({ year: 2021 }) as Date,
|
||||
}: CreateCspBenchmarkIntegrationFixtureInput = {}): CspBenchmarkIntegration => {
|
||||
let outputRules: CspBenchmarkIntegration['rules'] | undefined = rules;
|
||||
if (!outputRules) {
|
||||
const activeRules = chance.integer({ min: 1 });
|
||||
const totalRules = chance.integer({ min: activeRules });
|
||||
outputRules = {
|
||||
active: activeRules,
|
||||
total: totalRules,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
integration_name,
|
||||
benchmark,
|
||||
rules: outputRules,
|
||||
agent_policy,
|
||||
created_by,
|
||||
created_at,
|
||||
};
|
||||
};
|
20
x-pack/plugins/cloud_security_posture/public/test/fixtures/navigation_item.ts
vendored
Normal file
20
x-pack/plugins/cloud_security_posture/public/test/fixtures/navigation_item.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 { CspNavigationItem } from '../../common/navigation/types';
|
||||
|
||||
type CreateNavigationItemFixtureInput = { chance?: Chance.Chance } & Partial<CspNavigationItem>;
|
||||
export const createNavigationItemFixture = ({
|
||||
chance = new Chance(),
|
||||
name = chance.word(),
|
||||
path = `/${chance.word()}`,
|
||||
disabled = undefined,
|
||||
}: CreateNavigationItemFixtureInput = {}): CspNavigationItem => ({
|
||||
name,
|
||||
path,
|
||||
disabled,
|
||||
});
|
33
x-pack/plugins/cloud_security_posture/public/test/fixtures/react_query.ts
vendored
Normal file
33
x-pack/plugins/cloud_security_posture/public/test/fixtures/react_query.ts
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { UseQueryResult } from 'react-query/types/react/types';
|
||||
|
||||
interface CreateReactQueryResponseInput<TData = unknown, TError = unknown> {
|
||||
status?: UseQueryResult['status'];
|
||||
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 };
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return { status, error };
|
||||
}
|
||||
|
||||
return { status };
|
||||
};
|
36
x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx
Executable file
36
x-pack/plugins/cloud_security_posture/public/test/test_provider.tsx
Executable 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 React, { useMemo } from 'react';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { Router, Switch, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { coreMock } from '../../../../../src/core/public/mocks';
|
||||
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
|
||||
import type { CspAppDeps } from '../application/app';
|
||||
|
||||
export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
|
||||
core = coreMock.createStart(),
|
||||
deps = {},
|
||||
params = coreMock.createAppMountParameters(),
|
||||
children,
|
||||
} = {}) => {
|
||||
const queryClient = useMemo(() => new QueryClient(), []);
|
||||
return (
|
||||
<KibanaContextProvider services={{ ...deps, ...core }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router history={params.history}>
|
||||
<I18nProvider>
|
||||
<Switch>
|
||||
<Route path="*" render={() => <>{children}</>} />
|
||||
</Switch>
|
||||
</I18nProvider>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
};
|
30
x-pack/plugins/cloud_security_posture/public/types.ts
Executable file
30
x-pack/plugins/cloud_security_posture/public/types.ts
Executable file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 {
|
||||
DataPublicPluginSetup,
|
||||
DataPublicPluginStart,
|
||||
} from '../../../../src/plugins/data/public';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface CspClientPluginSetup {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface CspClientPluginStart {}
|
||||
|
||||
export interface CspClientPluginSetupDeps {
|
||||
// required
|
||||
data: DataPublicPluginSetup;
|
||||
|
||||
// optional
|
||||
}
|
||||
|
||||
export interface CspClientPluginStartDeps {
|
||||
// required
|
||||
data: DataPublicPluginStart;
|
||||
|
||||
// optional
|
||||
}
|
19
x-pack/plugins/cloud_security_posture/server/config.ts
Normal file
19
x-pack/plugins/cloud_security_posture/server/config.ts
Normal 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 { schema, type TypeOf } from '@kbn/config-schema';
|
||||
import type { PluginConfigDescriptor } from 'kibana/server';
|
||||
|
||||
const configSchema = schema.object({
|
||||
enabled: schema.boolean({ defaultValue: false }),
|
||||
});
|
||||
|
||||
type CloudSecurityPostureConfig = TypeOf<typeof configSchema>;
|
||||
|
||||
export const config: PluginConfigDescriptor<CloudSecurityPostureConfig> = {
|
||||
schema: configSchema,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 RULE_PASSED = `passed`;
|
||||
export const RULE_FAILED = `failed`;
|
16
x-pack/plugins/cloud_security_posture/server/index.ts
Executable file
16
x-pack/plugins/cloud_security_posture/server/index.ts
Executable 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 type { PluginInitializerContext } from '../../../../src/core/server';
|
||||
import { CspPlugin } from './plugin';
|
||||
|
||||
export type { CspServerPluginSetup, CspServerPluginStart } from './types';
|
||||
|
||||
export const plugin = (initializerContext: PluginInitializerContext) =>
|
||||
new CspPlugin(initializerContext);
|
||||
|
||||
export { config } from './config';
|
53
x-pack/plugins/cloud_security_posture/server/plugin.ts
Executable file
53
x-pack/plugins/cloud_security_posture/server/plugin.ts
Executable file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 {
|
||||
PluginInitializerContext,
|
||||
CoreSetup,
|
||||
CoreStart,
|
||||
Plugin,
|
||||
Logger,
|
||||
} from '../../../../src/core/server';
|
||||
import type {
|
||||
CspServerPluginSetup,
|
||||
CspServerPluginStart,
|
||||
CspServerPluginSetupDeps,
|
||||
CspServerPluginStartDeps,
|
||||
} from './types';
|
||||
import { defineRoutes } from './routes';
|
||||
|
||||
export class CspPlugin
|
||||
implements
|
||||
Plugin<
|
||||
CspServerPluginSetup,
|
||||
CspServerPluginStart,
|
||||
CspServerPluginSetupDeps,
|
||||
CspServerPluginStartDeps
|
||||
>
|
||||
{
|
||||
private readonly logger: Logger;
|
||||
constructor(initializerContext: PluginInitializerContext) {
|
||||
this.logger = initializerContext.logger.get();
|
||||
}
|
||||
|
||||
public setup(
|
||||
core: CoreSetup<CspServerPluginStartDeps, CspServerPluginStart>,
|
||||
plugins: CspServerPluginSetupDeps
|
||||
): CspServerPluginSetup {
|
||||
const router = core.http.createRouter();
|
||||
|
||||
// Register server side APIs
|
||||
defineRoutes(router, this.logger);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, plugins: CspServerPluginStartDeps): CspServerPluginStart {
|
||||
return {};
|
||||
}
|
||||
public stop() {}
|
||||
}
|
|
@ -0,0 +1,303 @@
|
|||
/*
|
||||
* 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 {
|
||||
elasticsearchClientMock,
|
||||
ElasticsearchClientMock,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from 'src/core/server/elasticsearch/client/mocks';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { KibanaRequest } from 'src/core/server/http/router/request';
|
||||
import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks';
|
||||
import {
|
||||
defineFindingsIndexRoute,
|
||||
findingsInputSchema,
|
||||
DEFAULT_FINDINGS_PER_PAGE,
|
||||
} from './findings';
|
||||
|
||||
export const getMockCspContext = (mockEsClient: ElasticsearchClientMock): KibanaRequest => {
|
||||
return {
|
||||
core: {
|
||||
elasticsearch: {
|
||||
client: { asCurrentUser: mockEsClient },
|
||||
},
|
||||
},
|
||||
} as unknown as KibanaRequest;
|
||||
};
|
||||
|
||||
describe('findings API', () => {
|
||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = loggingSystemMock.createLogger();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('validate the API route path', async () => {
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
|
||||
const [config, _] = router.get.mock.calls[0];
|
||||
|
||||
expect(config.path).toEqual('/api/csp/findings');
|
||||
});
|
||||
|
||||
describe('test input schema', () => {
|
||||
it('expect to find default values', async () => {
|
||||
const validatedQuery = findingsInputSchema.validate({});
|
||||
|
||||
expect(validatedQuery).toMatchObject({
|
||||
page: 1,
|
||||
per_page: DEFAULT_FINDINGS_PER_PAGE,
|
||||
sort_order: expect.stringMatching('desc'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when page field is not a positive integer', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ page: -2 });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when per_page field is not a positive integer', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ per_page: -2 });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when latest_run is not a boolean', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ latest_cycle: 'some string' }); // expects to get boolean
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when latest_run is a boolean', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ latest_cycle: true });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when sort_field is not string', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_field: true });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when sort_field is a string', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_field: 'field1' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when sort_order is not `asc` or `desc`', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_order: 'Other Direction' });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when `asc` is input for sort_order field', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_order: 'asc' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when `desc` is input for sort_order field', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_order: 'desc' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when fields is not string', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ fields: ['field1', 'field2'] });
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw when fields is a string', async () => {
|
||||
expect(() => {
|
||||
findingsInputSchema.validate({ sort_field: 'field1, field2' });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('test query building', () => {
|
||||
it('takes cycle_id and validate the filter was built right', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: { latest_cycle: true },
|
||||
});
|
||||
|
||||
mockEsClient.search.mockResolvedValueOnce(
|
||||
// @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
aggregations: {
|
||||
group: {
|
||||
buckets: [
|
||||
{
|
||||
group_docs: {
|
||||
hits: {
|
||||
hits: [{ fields: { 'run_id.keyword': ['randomId1'] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(mockEsClient.search).toHaveBeenCalledTimes(2);
|
||||
|
||||
const handlerArgs = mockEsClient.search.mock.calls[1][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
query: {
|
||||
bool: {
|
||||
filter: [{ term: { 'run_id.keyword': 'randomId1' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('validate that default sort is timestamp desc', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
sort_order: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
const handlerArgs = mockEsClient.search.mock.calls[0][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
sort: [{ '@timestamp': { order: 'desc' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should build sort request by `sort_field` and `sort_order` - asc', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
sort_field: 'agent.id',
|
||||
sort_order: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
const handlerArgs = mockEsClient.search.mock.calls[0][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
sort: [{ 'agent.id': 'asc' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should build sort request by `sort_field` and `sort_order` - desc', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
sort_field: 'agent.id',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
const handlerArgs = mockEsClient.search.mock.calls[0][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
sort: [{ 'agent.id': 'desc' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('takes `page_number` and `per_page` validate that the requested selected page was called', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
per_page: 10,
|
||||
page: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
|
||||
const handlerArgs = mockEsClient.search.mock.calls[0][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
from: 20,
|
||||
size: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format request by fields filter', async () => {
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
const router = httpServiceMock.createRouter();
|
||||
defineFindingsIndexRoute(router, logger);
|
||||
const [_, handler] = router.get.mock.calls[0];
|
||||
|
||||
const mockContext = getMockCspContext(mockEsClient);
|
||||
const mockResponse = httpServerMock.createResponseFactory();
|
||||
const mockRequest = httpServerMock.createKibanaRequest({
|
||||
query: {
|
||||
fields: 'field1,field2,field3',
|
||||
},
|
||||
});
|
||||
|
||||
const [context, req, res] = [mockContext, mockRequest, mockResponse];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
const handlerArgs = mockEsClient.search.mock.calls[0][0];
|
||||
|
||||
expect(handlerArgs).toMatchObject({
|
||||
_source: ['field1', 'field2', 'field3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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 { IRouter, Logger } from 'src/core/server';
|
||||
import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { schema as rt, TypeOf } from '@kbn/config-schema';
|
||||
import type { SortOrder } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import { getLatestCycleIds } from './get_latest_cycle_ids';
|
||||
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants';
|
||||
|
||||
type FindingsQuerySchema = TypeOf<typeof findingsInputSchema>;
|
||||
|
||||
export const DEFAULT_FINDINGS_PER_PAGE = 20;
|
||||
|
||||
export interface FindingsOptions {
|
||||
size: number;
|
||||
from?: number;
|
||||
page?: number;
|
||||
sortField?: string;
|
||||
sortOrder?: SortOrder;
|
||||
fields?: string[];
|
||||
}
|
||||
|
||||
const getPointerForFirstDoc = (page: number, perPage: number): number =>
|
||||
page <= 1 ? 0 : page * perPage - perPage;
|
||||
|
||||
const getSort = (sortField: string | undefined, sortOrder: string) =>
|
||||
sortField
|
||||
? { sort: [{ [sortField]: sortOrder }] }
|
||||
: { sort: [{ '@timestamp': { order: sortOrder } }] };
|
||||
|
||||
const getSearchFields = (fields: string | undefined) =>
|
||||
fields ? { _source: fields.split(',') } : {};
|
||||
|
||||
const getFindingsEsQuery = (
|
||||
query: QueryDslQueryContainer,
|
||||
options: FindingsOptions
|
||||
): SearchRequest => {
|
||||
return {
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
query,
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
const buildQueryRequest = (latestCycleIds?: string[]): QueryDslQueryContainer => {
|
||||
let filterPart: QueryDslQueryContainer = { match_all: {} };
|
||||
if (!!latestCycleIds) {
|
||||
const filter = latestCycleIds.map((latestCycleId) => ({
|
||||
term: { 'run_id.keyword': latestCycleId },
|
||||
}));
|
||||
filterPart = { bool: { filter } };
|
||||
}
|
||||
|
||||
return {
|
||||
...filterPart,
|
||||
};
|
||||
};
|
||||
|
||||
const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions => ({
|
||||
size: queryParams.per_page,
|
||||
from: getPointerForFirstDoc(queryParams.page, queryParams.per_page),
|
||||
...getSort(queryParams.sort_field, queryParams.sort_order),
|
||||
...getSearchFields(queryParams.fields),
|
||||
});
|
||||
|
||||
export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void =>
|
||||
router.get(
|
||||
{
|
||||
path: FINDINGS_ROUTE_PATH,
|
||||
validate: { query: findingsInputSchema },
|
||||
},
|
||||
async (context, request, response) => {
|
||||
try {
|
||||
const esClient = context.core.elasticsearch.client.asCurrentUser;
|
||||
const options = buildOptionsRequest(request.query);
|
||||
|
||||
const latestCycleIds =
|
||||
request.query.latest_cycle === true
|
||||
? await getLatestCycleIds(esClient, logger)
|
||||
: undefined;
|
||||
|
||||
const query = buildQueryRequest(latestCycleIds);
|
||||
const esQuery = getFindingsEsQuery(query, options);
|
||||
|
||||
const findings = await esClient.search(esQuery, { meta: true });
|
||||
const hits = findings.body.hits.hits;
|
||||
|
||||
return response.ok({ body: hits });
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
return response.customError({
|
||||
body: { message: error.message },
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const findingsInputSchema = rt.object({
|
||||
/**
|
||||
* The page of objects to return
|
||||
*/
|
||||
page: rt.number({ defaultValue: 1, min: 1 }),
|
||||
/**
|
||||
* The number of objects to include in each page
|
||||
*/
|
||||
per_page: rt.number({ defaultValue: DEFAULT_FINDINGS_PER_PAGE, min: 0 }),
|
||||
/**
|
||||
* Boolean flag to indicate for receiving only the latest findings
|
||||
*/
|
||||
latest_cycle: rt.maybe(rt.boolean()),
|
||||
/**
|
||||
* The field to use for sorting the found objects.
|
||||
*/
|
||||
sort_field: rt.maybe(rt.string()),
|
||||
/**
|
||||
* The order to sort by
|
||||
*/
|
||||
sort_order: rt.oneOf([rt.literal('asc'), rt.literal('desc')], { defaultValue: 'desc' }),
|
||||
/**
|
||||
* The fields in the entity to return in the response
|
||||
*/
|
||||
fields: rt.maybe(rt.string()),
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* 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 {
|
||||
elasticsearchClientMock,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from 'src/core/server/elasticsearch/client/mocks';
|
||||
import { loggingSystemMock } from 'src/core/server/mocks';
|
||||
import { getLatestCycleIds } from './get_latest_cycle_ids';
|
||||
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
describe('get latest cycle ids', () => {
|
||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = loggingSystemMock.createLogger();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('expect to find empty bucket', async () => {
|
||||
mockEsClient.search.mockResolvedValueOnce(
|
||||
// @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
aggregations: {
|
||||
group: {
|
||||
buckets: [{}],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const response = await getLatestCycleIds(mockEsClient, logger);
|
||||
expect(response).toEqual(undefined);
|
||||
});
|
||||
|
||||
it('expect to find 1 cycle id', async () => {
|
||||
mockEsClient.search.mockResolvedValueOnce(
|
||||
// @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
aggregations: {
|
||||
group: {
|
||||
buckets: [
|
||||
{
|
||||
group_docs: {
|
||||
hits: {
|
||||
hits: [{ fields: { 'run_id.keyword': ['randomId1'] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const response = await getLatestCycleIds(mockEsClient, logger);
|
||||
expect(response).toEqual(expect.arrayContaining(['randomId1']));
|
||||
});
|
||||
|
||||
it('expect to find multiple cycle ids', async () => {
|
||||
mockEsClient.search.mockResolvedValueOnce(
|
||||
// @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({
|
||||
aggregations: {
|
||||
group: {
|
||||
buckets: [
|
||||
{
|
||||
group_docs: {
|
||||
hits: {
|
||||
hits: [{ fields: { 'run_id.keyword': ['randomId1'] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
group_docs: {
|
||||
hits: {
|
||||
hits: [{ fields: { 'run_id.keyword': ['randomId2'] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
group_docs: {
|
||||
hits: {
|
||||
hits: [{ fields: { 'run_id.keyword': ['randomId3'] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const response = await getLatestCycleIds(mockEsClient, logger);
|
||||
expect(response).toEqual(expect.arrayContaining(['randomId1', 'randomId2', 'randomId3']));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { Logger } from 'src/core/server';
|
||||
import { AggregationsFiltersAggregate, SearchRequest } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { ElasticsearchClient } from 'src/core/server';
|
||||
import { AGENT_LOGS_INDEX_PATTERN } from '../../../common/constants';
|
||||
|
||||
const getAgentLogsEsQuery = (): SearchRequest => ({
|
||||
index: AGENT_LOGS_INDEX_PATTERN,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [{ term: { 'status.keyword': 'end' } }],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
group: {
|
||||
terms: { field: 'agent.id.keyword' },
|
||||
aggs: {
|
||||
group_docs: {
|
||||
top_hits: {
|
||||
size: 1,
|
||||
sort: [{ '@timestamp': { order: 'desc' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: ['run_id.keyword', 'agent.id.keyword'],
|
||||
_source: false,
|
||||
});
|
||||
|
||||
const getCycleId = (v: any): string => v.group_docs.hits.hits?.[0]?.fields['run_id.keyword'][0];
|
||||
|
||||
export const getLatestCycleIds = async (
|
||||
esClient: ElasticsearchClient,
|
||||
logger: Logger
|
||||
): Promise<string[] | undefined> => {
|
||||
try {
|
||||
const agentLogs = await esClient.search(getAgentLogsEsQuery(), { meta: true });
|
||||
const aggregations = agentLogs.body.aggregations;
|
||||
if (!aggregations) {
|
||||
return;
|
||||
}
|
||||
const buckets = (aggregations.group as Record<string, AggregationsFiltersAggregate>).buckets;
|
||||
if (!Array.isArray(buckets)) {
|
||||
return;
|
||||
}
|
||||
return buckets.map(getCycleId);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch cycle_ids');
|
||||
return;
|
||||
}
|
||||
};
|
15
x-pack/plugins/cloud_security_posture/server/routes/index.ts
Executable file
15
x-pack/plugins/cloud_security_posture/server/routes/index.ts
Executable file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { IRouter, Logger } from '../../../../../src/core/server';
|
||||
import { defineGetStatsRoute } from './stats/stats';
|
||||
import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings';
|
||||
|
||||
export function defineRoutes(router: IRouter, logger: Logger) {
|
||||
defineGetStatsRoute(router, logger);
|
||||
defineGetFindingsIndexRoute(router, logger);
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* 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 {
|
||||
elasticsearchClientMock,
|
||||
ElasticsearchClientMock,
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from 'src/core/server/elasticsearch/client/mocks';
|
||||
|
||||
import {
|
||||
getBenchmarks,
|
||||
getAllFindingsStats,
|
||||
roundScore,
|
||||
getBenchmarksStats,
|
||||
getResourceTypesAggs,
|
||||
} from './stats';
|
||||
|
||||
export const mockCountResultOnce = async (mockEsClient: ElasticsearchClientMock, count: number) => {
|
||||
mockEsClient.count.mockReturnValueOnce(
|
||||
// @ts-expect-error @elast ic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise({ count })
|
||||
);
|
||||
};
|
||||
|
||||
export const mockSearchResultOnce = async (
|
||||
mockEsClient: ElasticsearchClientMock,
|
||||
returnedMock: object
|
||||
) => {
|
||||
mockEsClient.search.mockReturnValueOnce(
|
||||
// @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(returnedMock)
|
||||
);
|
||||
};
|
||||
|
||||
const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser;
|
||||
|
||||
const resourceTypeAggsMockData = {
|
||||
aggregations: {
|
||||
resource_types: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'pods',
|
||||
doc_count: 3,
|
||||
bucket_evaluation: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'passed',
|
||||
doc_count: 1,
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
doc_count: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'etcd',
|
||||
doc_count: 4,
|
||||
bucket_evaluation: {
|
||||
buckets: [
|
||||
// there is only one bucket here, in cases where aggs can't find an evaluation we count that as 0.
|
||||
{
|
||||
key: 'failed',
|
||||
doc_count: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('testing round score', () => {
|
||||
it('take decimal and expect the roundScore will return it with one digit after the dot ', async () => {
|
||||
const score = roundScore(0.85245);
|
||||
expect(score).toEqual(85.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('general cloud posture score', () => {
|
||||
it('expect to valid score from getAllFindingsStats', async () => {
|
||||
mockCountResultOnce(mockEsClient, 10); // total findings
|
||||
mockCountResultOnce(mockEsClient, 3); // pass findings
|
||||
mockCountResultOnce(mockEsClient, 7); // fail findings
|
||||
|
||||
const generalScore = await getAllFindingsStats(mockEsClient, 'randomCycleId');
|
||||
expect(generalScore).toEqual({
|
||||
name: 'general',
|
||||
postureScore: 30,
|
||||
totalFailed: 7,
|
||||
totalFindings: 10,
|
||||
totalPassed: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("getAllFindingsStats throws when cycleId doesn't exists", async () => {
|
||||
try {
|
||||
await getAllFindingsStats(mockEsClient, 'randomCycleId');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.message).toEqual('missing stats');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('get benchmarks list', () => {
|
||||
it('getBenchmarks - takes aggregated data and expect unique benchmarks array', async () => {
|
||||
const returnedMock = {
|
||||
aggregations: {
|
||||
benchmarks: {
|
||||
buckets: [
|
||||
{ key: 'CIS Kubernetes', doc_count: 248514 },
|
||||
{ key: 'GDPR', doc_count: 248514 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
mockSearchResultOnce(mockEsClient, returnedMock);
|
||||
const benchmarks = await getBenchmarks(mockEsClient);
|
||||
expect(benchmarks).toEqual(['CIS Kubernetes', 'GDPR']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('score per benchmark, testing getBenchmarksStats', () => {
|
||||
it('get data for only one benchmark and check', async () => {
|
||||
mockCountResultOnce(mockEsClient, 10); // total findings
|
||||
mockCountResultOnce(mockEsClient, 3); // pass findings
|
||||
mockCountResultOnce(mockEsClient, 7); // fail findings
|
||||
const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [
|
||||
'CIS Benchmark',
|
||||
]);
|
||||
expect(benchmarkScore).toEqual([
|
||||
{
|
||||
name: 'CIS Benchmark',
|
||||
postureScore: 30,
|
||||
totalFailed: 7,
|
||||
totalFindings: 10,
|
||||
totalPassed: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('get data two benchmarks and check', async () => {
|
||||
mockCountResultOnce(mockEsClient, 10); // total findings
|
||||
mockCountResultOnce(mockEsClient, 3); // pass findings
|
||||
mockCountResultOnce(mockEsClient, 7); // fail findings
|
||||
mockCountResultOnce(mockEsClient, 100);
|
||||
mockCountResultOnce(mockEsClient, 50);
|
||||
mockCountResultOnce(mockEsClient, 50);
|
||||
const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [
|
||||
'CIS Benchmark',
|
||||
'GDPR',
|
||||
]);
|
||||
expect(benchmarkScore).toEqual([
|
||||
{
|
||||
name: 'CIS Benchmark',
|
||||
postureScore: 30,
|
||||
totalFailed: 7,
|
||||
totalFindings: 10,
|
||||
totalPassed: 3,
|
||||
},
|
||||
{
|
||||
name: 'GDPR',
|
||||
postureScore: 50,
|
||||
totalFailed: 50,
|
||||
totalFindings: 100,
|
||||
totalPassed: 50,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourceTypesAggs', () => {
|
||||
it('get all resources types aggregations', async () => {
|
||||
await mockSearchResultOnce(mockEsClient, resourceTypeAggsMockData);
|
||||
const resourceTypeAggs = await getResourceTypesAggs(mockEsClient, 'RandomCycleId');
|
||||
expect(resourceTypeAggs).toEqual([
|
||||
{
|
||||
resourceType: 'pods',
|
||||
totalFindings: 3,
|
||||
totalPassed: 1,
|
||||
totalFailed: 2,
|
||||
},
|
||||
{
|
||||
resourceType: 'etcd',
|
||||
totalFindings: 4,
|
||||
totalPassed: 0,
|
||||
totalFailed: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient, IRouter, Logger } from 'src/core/server';
|
||||
import type { AggregationsMultiBucketAggregateBase } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { number, UnknownRecord } from 'io-ts';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
|
||||
import type { BenchmarkStats, CloudPostureStats, Evaluation, Score } from '../../../common/types';
|
||||
import {
|
||||
getBenchmarksQuery,
|
||||
getFindingsEsQuery,
|
||||
getLatestFindingQuery,
|
||||
getRisksEsQuery,
|
||||
} from './stats_queries';
|
||||
import { RULE_FAILED, RULE_PASSED } from '../../constants';
|
||||
import { STATS_ROUTE_PATH } from '../../../common/constants';
|
||||
|
||||
// TODO: use a schema decoder
|
||||
function assertBenchmarkStats(v: unknown): asserts v is BenchmarkStats {
|
||||
if (
|
||||
!UnknownRecord.is(v) ||
|
||||
!number.is(v.totalFindings) ||
|
||||
!number.is(v.totalPassed) ||
|
||||
!number.is(v.totalFailed) ||
|
||||
!number.is(v.postureScore)
|
||||
) {
|
||||
throw new Error('missing stats');
|
||||
}
|
||||
}
|
||||
|
||||
interface LastCycle {
|
||||
cycle_id: string;
|
||||
}
|
||||
|
||||
interface GroupFilename {
|
||||
// TODO find the 'key', 'doc_count' interface
|
||||
key: string;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
interface ResourceTypeBucket {
|
||||
resource_types: AggregationsMultiBucketAggregateBase<{
|
||||
key: string;
|
||||
doc_count: number;
|
||||
bucket_evaluation: AggregationsMultiBucketAggregateBase<ResourceTypeEvaluationBucket>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResourceTypeEvaluationBucket {
|
||||
key: Evaluation;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param value value is [0, 1] range
|
||||
*/
|
||||
export const roundScore = (value: number): Score => Number((value * 100).toFixed(1));
|
||||
|
||||
const calculatePostureScore = (total: number, passed: number, failed: number): Score | undefined =>
|
||||
passed + failed === 0 || total === undefined ? undefined : roundScore(passed / (passed + failed));
|
||||
|
||||
const getLatestCycleId = async (esClient: ElasticsearchClient) => {
|
||||
const latestFinding = await esClient.search<LastCycle>(getLatestFindingQuery(), { meta: true });
|
||||
const lastCycle = latestFinding.body.hits.hits[0];
|
||||
|
||||
if (lastCycle?._source?.cycle_id === undefined) {
|
||||
throw new Error('cycle id is missing');
|
||||
}
|
||||
return lastCycle?._source?.cycle_id;
|
||||
};
|
||||
|
||||
export const getBenchmarks = async (esClient: ElasticsearchClient) => {
|
||||
const queryResult = await esClient.search<
|
||||
{},
|
||||
{ benchmarks: AggregationsMultiBucketAggregateBase<Pick<GroupFilename, 'key'>> }
|
||||
>(getBenchmarksQuery(), { meta: true });
|
||||
const benchmarksBuckets = queryResult.body.aggregations?.benchmarks;
|
||||
|
||||
if (!benchmarksBuckets || !Array.isArray(benchmarksBuckets?.buckets)) {
|
||||
throw new Error('missing buckets');
|
||||
}
|
||||
|
||||
return benchmarksBuckets.buckets.map((e) => e.key);
|
||||
};
|
||||
|
||||
export const getAllFindingsStats = async (
|
||||
esClient: ElasticsearchClient,
|
||||
cycleId: string
|
||||
): Promise<BenchmarkStats> => {
|
||||
const [findings, passedFindings, failedFindings] = await Promise.all([
|
||||
esClient.count(getFindingsEsQuery(cycleId), { meta: true }),
|
||||
esClient.count(getFindingsEsQuery(cycleId, RULE_PASSED), { meta: true }),
|
||||
esClient.count(getFindingsEsQuery(cycleId, RULE_FAILED), { meta: true }),
|
||||
]);
|
||||
|
||||
const totalFindings = findings.body.count;
|
||||
const totalPassed = passedFindings.body.count;
|
||||
const totalFailed = failedFindings.body.count;
|
||||
const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed);
|
||||
const stats = {
|
||||
name: 'general',
|
||||
postureScore,
|
||||
totalFindings,
|
||||
totalPassed,
|
||||
totalFailed,
|
||||
};
|
||||
|
||||
assertBenchmarkStats(stats);
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export const getBenchmarksStats = async (
|
||||
esClient: ElasticsearchClient,
|
||||
cycleId: string,
|
||||
benchmarks: string[]
|
||||
): Promise<BenchmarkStats[]> => {
|
||||
const benchmarkPromises = benchmarks.map((benchmark) => {
|
||||
const benchmarkFindings = esClient.count(getFindingsEsQuery(cycleId, undefined, benchmark), {
|
||||
meta: true,
|
||||
});
|
||||
const benchmarkPassedFindings = esClient.count(
|
||||
getFindingsEsQuery(cycleId, RULE_PASSED, benchmark),
|
||||
{ meta: true }
|
||||
);
|
||||
const benchmarkFailedFindings = esClient.count(
|
||||
getFindingsEsQuery(cycleId, RULE_FAILED, benchmark),
|
||||
{ meta: true }
|
||||
);
|
||||
|
||||
return Promise.all([benchmarkFindings, benchmarkPassedFindings, benchmarkFailedFindings]).then(
|
||||
([benchmarkFindingsResult, benchmarkPassedFindingsResult, benchmarkFailedFindingsResult]) => {
|
||||
const totalFindings = benchmarkFindingsResult.body.count;
|
||||
const totalPassed = benchmarkPassedFindingsResult.body.count;
|
||||
const totalFailed = benchmarkFailedFindingsResult.body.count;
|
||||
const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed);
|
||||
const stats = {
|
||||
name: benchmark,
|
||||
postureScore,
|
||||
totalFindings,
|
||||
totalPassed,
|
||||
totalFailed,
|
||||
};
|
||||
|
||||
assertBenchmarkStats(stats);
|
||||
return stats;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return Promise.all(benchmarkPromises);
|
||||
};
|
||||
|
||||
export const getResourceTypesAggs = async (
|
||||
esClient: ElasticsearchClient,
|
||||
cycleId: string
|
||||
): Promise<CloudPostureStats['resourceTypesAggs']> => {
|
||||
const resourceTypesQueryResult = await esClient.search<unknown, ResourceTypeBucket>(
|
||||
getRisksEsQuery(cycleId),
|
||||
{ meta: true }
|
||||
);
|
||||
|
||||
const resourceTypesAggs = resourceTypesQueryResult.body.aggregations?.resource_types.buckets;
|
||||
if (!Array.isArray(resourceTypesAggs)) throw new Error('missing resources types buckets');
|
||||
|
||||
return resourceTypesAggs.map((bucket) => {
|
||||
const evalBuckets = bucket.bucket_evaluation.buckets;
|
||||
if (!Array.isArray(evalBuckets)) throw new Error('missing resources types evaluations buckets');
|
||||
|
||||
const failedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_FAILED);
|
||||
const passedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_PASSED);
|
||||
|
||||
return {
|
||||
resourceType: bucket.key,
|
||||
totalFindings: bucket.doc_count,
|
||||
totalFailed: failedBucket?.doc_count || 0,
|
||||
totalPassed: passedBucket?.doc_count || 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const defineGetStatsRoute = (router: IRouter, logger: Logger): void =>
|
||||
router.get(
|
||||
{
|
||||
path: STATS_ROUTE_PATH,
|
||||
validate: false,
|
||||
},
|
||||
async (context, _, response) => {
|
||||
try {
|
||||
const esClient = context.core.elasticsearch.client.asCurrentUser;
|
||||
const [benchmarks, latestCycleID] = await Promise.all([
|
||||
getBenchmarks(esClient),
|
||||
getLatestCycleId(esClient),
|
||||
]);
|
||||
|
||||
// TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html
|
||||
const [allFindingsStats, benchmarksStats, resourceTypesAggs] = await Promise.all([
|
||||
getAllFindingsStats(esClient, latestCycleID),
|
||||
getBenchmarksStats(esClient, latestCycleID, benchmarks),
|
||||
getResourceTypesAggs(esClient, latestCycleID),
|
||||
]);
|
||||
|
||||
const body: CloudPostureStats = {
|
||||
...allFindingsStats,
|
||||
benchmarksStats,
|
||||
resourceTypesAggs,
|
||||
};
|
||||
|
||||
return response.ok({
|
||||
body,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
|
||||
return response.customError({
|
||||
body: { message: error.message },
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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 {
|
||||
SearchRequest,
|
||||
CountRequest,
|
||||
QueryDslQueryContainer,
|
||||
} from '@elastic/elasticsearch/lib/api/types';
|
||||
|
||||
import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants';
|
||||
import { Evaluation } from '../../../common/types';
|
||||
|
||||
export const getFindingsEsQuery = (
|
||||
cycleId: string,
|
||||
evaluationResult?: string,
|
||||
benchmark?: string
|
||||
): CountRequest => {
|
||||
const filter: QueryDslQueryContainer[] = [{ term: { 'cycle_id.keyword': cycleId } }];
|
||||
|
||||
if (benchmark) {
|
||||
filter.push({ term: { 'rule.benchmark.keyword': benchmark } });
|
||||
}
|
||||
|
||||
if (evaluationResult) {
|
||||
filter.push({ term: { 'result.evaluation.keyword': evaluationResult } });
|
||||
}
|
||||
|
||||
return {
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
query: {
|
||||
bool: { filter },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getResourcesEvaluationEsQuery = (
|
||||
cycleId: string,
|
||||
evaluation: Evaluation,
|
||||
size: number,
|
||||
resources?: string[]
|
||||
): SearchRequest => {
|
||||
const query: QueryDslQueryContainer = {
|
||||
bool: {
|
||||
filter: [
|
||||
{ term: { 'cycle_id.keyword': cycleId } },
|
||||
{ term: { 'result.evaluation.keyword': evaluation } },
|
||||
],
|
||||
},
|
||||
};
|
||||
if (resources) {
|
||||
query.bool!.must = { terms: { 'resource.filename.keyword': resources } };
|
||||
}
|
||||
return {
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
size,
|
||||
query,
|
||||
aggs: {
|
||||
group: {
|
||||
terms: { field: 'resource.filename.keyword' },
|
||||
},
|
||||
},
|
||||
sort: 'resource.filename.keyword',
|
||||
};
|
||||
};
|
||||
|
||||
export const getBenchmarksQuery = (): SearchRequest => ({
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
size: 0,
|
||||
aggs: {
|
||||
benchmarks: {
|
||||
terms: { field: 'rule.benchmark.keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getLatestFindingQuery = (): SearchRequest => ({
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
size: 1,
|
||||
/* @ts-expect-error TS2322 - missing SearchSortContainer */
|
||||
sort: { '@timestamp': 'desc' },
|
||||
query: {
|
||||
match_all: {},
|
||||
},
|
||||
});
|
||||
|
||||
export const getRisksEsQuery = (cycleId: string): SearchRequest => ({
|
||||
index: CSP_KUBEBEAT_INDEX_PATTERN,
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [{ term: { 'cycle_id.keyword': cycleId } }],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
resource_types: {
|
||||
terms: {
|
||||
field: 'resource.type.keyword',
|
||||
},
|
||||
aggs: {
|
||||
bucket_evaluation: {
|
||||
terms: {
|
||||
field: 'result.evaluation.keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
30
x-pack/plugins/cloud_security_posture/server/types.ts
Normal file
30
x-pack/plugins/cloud_security_posture/server/types.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 {
|
||||
PluginSetup as DataPluginSetup,
|
||||
PluginStart as DataPluginStart,
|
||||
} from '../../../../src/plugins/data/server';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface CspServerPluginSetup {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface CspServerPluginStart {}
|
||||
|
||||
export interface CspServerPluginSetupDeps {
|
||||
// required
|
||||
data: DataPluginSetup;
|
||||
|
||||
// optional
|
||||
}
|
||||
|
||||
export interface CspServerPluginStartDeps {
|
||||
// required
|
||||
data: DataPluginStart;
|
||||
|
||||
// optional
|
||||
}
|
24
x-pack/plugins/cloud_security_posture/tsconfig.json
Executable file
24
x-pack/plugins/cloud_security_posture/tsconfig.json
Executable file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./target/types",
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": [
|
||||
"common/**/*",
|
||||
"public/**/*",
|
||||
"server/**/*",
|
||||
"scripts/**/*",
|
||||
// have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636
|
||||
"server/**/*.json",
|
||||
"public/**/*.json",
|
||||
"../../../typings/**/*"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../src/core/tsconfig.json" },
|
||||
{ "path": "../../../src/plugins/data/tsconfig.json" },
|
||||
{ "path": "../../../src/plugins/navigation/tsconfig.json" }
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue