[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:
Ari Aviran 2022-02-28 19:52:51 +02:00 committed by spalger
parent 51e1f25d21
commit b95f891ea2
91 changed files with 5005 additions and 0 deletions

3
.github/CODEOWNERS vendored
View file

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

View file

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

View file

@ -121,3 +121,4 @@ pageLoadAssetSize:
expressionPartitionVis: 26338
sharedUX: 16225
ux: 20784
cloudSecurityPosture: 19109

View file

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

View 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.

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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;

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

View 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[];
}

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * 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
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/** @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}',
],
};

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

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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);
});
});

View 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} />;

View 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 { 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,
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './use_cloud_posture_stats_api';

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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));
};

View 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 { euiPaletteForStatus } from '@elastic/eui';
const [success, warning, danger] = euiPaletteForStatus(3);
export const statusColors = { success, warning, danger };

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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,
};
};

View 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 * 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,
},
};

View file

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

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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',
});

View file

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

View file

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

View 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 { i18n } from '@kbn/i18n';
export const CLOUD_SECURITY_POSTURE = i18n.translate('xpack.csp.cloudSecurityPosture', {
defaultMessage: 'Cloud Security Posture',
});

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { 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();
});
});

View file

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

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { FormattedNumber } from '@kbn/i18n-react';
export const CompactFormattedNumber = ({ number }: { number: number }) => (
<FormattedNumber
value={number}
maximumFractionDigits={1}
notation="compact"
compactDisplay="short"
/>
);

View file

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

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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>
);

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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;
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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>
);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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>
));

View 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();

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { Benchmarks } from './benchmarks';

View file

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

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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>
);
};

View file

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

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './compliance_dashboard';

View file

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

View file

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

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { 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>}
/>
);

View file

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

View file

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

View file

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

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { 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();
});
});
});

View file

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

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './findings';

View 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.
*/
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';

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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);
};

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

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { 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() {}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* 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,
};
};

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

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

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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>
);
};

View 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
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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,
};

View 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 RULE_PASSED = `passed`;
export const RULE_FAILED = `failed`;

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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';

View 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() {}
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View 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
}

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