diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml
index c0205e8af28f1..7f84f8e38d2e6 100644
--- a/.buildkite/ftr_configs.yml
+++ b/.buildkite/ftr_configs.yml
@@ -133,6 +133,7 @@ enabled:
- x-pack/test/cases_api_integration/security_and_spaces/config_trial.ts
- x-pack/test/cases_api_integration/security_and_spaces/config_no_public_base_url.ts
- x-pack/test/cases_api_integration/spaces_only/config.ts
+ - x-pack/test/cloud_security_posture_functional/config.ts
- x-pack/test/detection_engine_api_integration/basic/config.ts
- x-pack/test/detection_engine_api_integration/security_and_spaces/group1/config.ts
- x-pack/test/detection_engine_api_integration/security_and_spaces/group2/config.ts
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 253dd86879dda..1945c26864c18 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -662,6 +662,8 @@ x-pack/test/threat_intelligence_cypress @elastic/protections-experience
/x-pack/plugins/cloud_security_posture/ @elastic/kibana-cloud-security-posture
/x-pack/plugins/security_solution/public/cloud_security_posture @elastic/kibana-cloud-security-posture
/x-pack/test/api_integration/apis/cloud_security_posture/ @elastic/kibana-cloud-security-posture
+/x-pack/test/cloud_security_posture_functional/ @elastic/kibana-cloud-security-posture
+
# Security Solution onboarding tour
/x-pack/plugins/security_solution/public/common/components/guided_onboarding @elastic/security-threat-hunting-explore
diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts
index f75289bfa020b..644eeababe834 100644
--- a/x-pack/plugins/cloud_security_posture/common/types.ts
+++ b/x-pack/plugins/cloud_security_posture/common/types.ts
@@ -62,6 +62,7 @@ interface BaseCspSetupStatus {
latestPackageVersion: string;
installedPackagePolicies: number;
healthyAgents: number;
+ isPluginInitialized: boolean;
}
interface CspSetupNotInstalledStatus extends BaseCspSetupStatus {
diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx
index 50cb233902017..e279d78ac0b7c 100644
--- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx
+++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx
@@ -239,7 +239,9 @@ const FilterableCell: React.FC<{
}
`}
>
-
{children}
+
+ {children}
+
{
agentService: createMockAgentService(),
packagePolicyService: createPackagePolicyServiceMock(),
packageService: createMockPackageService(),
+ isPluginInitialized: () => false,
},
};
};
diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts
index 59cbbec37cab3..465825e46a80e 100755
--- a/x-pack/plugins/cloud_security_posture/server/plugin.ts
+++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts
@@ -62,6 +62,13 @@ export class CspPlugin
private readonly logger: Logger;
private isCloudEnabled?: boolean;
+ /**
+ * CSP is initialized when the Fleet package is installed.
+ * either directly after installation, or
+ * when the plugin is started and a package is present.
+ */
+ #isInitialized: boolean = false;
+
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
}
@@ -75,6 +82,7 @@ export class CspPlugin
setupRoutes({
core,
logger: this.logger,
+ isPluginInitialized: () => this.#isInitialized,
});
const coreStartServices = core.getStartServices();
@@ -162,12 +170,16 @@ export class CspPlugin
public stop() {}
+ /**
+ * Initialization is idempotent and required for (re)creating indices and transforms.
+ */
async initialize(core: CoreStart, taskManager: TaskManagerStartContract): Promise {
this.logger.debug('initialize');
const esClient = core.elasticsearch.client.asInternalUser;
await initializeCspIndices(esClient, this.logger);
await initializeCspTransforms(esClient, this.logger);
await scheduleFindingsStatsTask(taskManager, this.logger);
+ this.#isInitialized = true;
}
async uninstallResources(taskManager: TaskManagerStartContract, logger: Logger): Promise {
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts b/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts
index ed31e5fd6bf7a..b2247ae84b55c 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts
@@ -24,9 +24,11 @@ import { defineGetCspStatusRoute } from './status/status';
export function setupRoutes({
core,
logger,
+ isPluginInitialized,
}: {
core: CoreSetup;
logger: Logger;
+ isPluginInitialized(): boolean;
}) {
const router = core.http.createRouter();
defineGetComplianceDashboardRoute(router);
@@ -57,6 +59,7 @@ export function setupRoutes({
agentService: fleet.agentService,
packagePolicyService: fleet.packagePolicyService,
packageService: fleet.packageService,
+ isPluginInitialized,
};
}
);
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts
index d3e37b7c4b6c7..59d59e3ea7bcf 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts
@@ -119,6 +119,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 0,
healthyAgents: 0,
installedPackageVersion: undefined,
+ isPluginInitialized: false,
});
});
@@ -159,6 +160,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 3,
healthyAgents: 0,
installedPackageVersion: '0.0.14',
+ isPluginInitialized: false,
});
});
@@ -208,6 +210,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 3,
healthyAgents: 1,
installedPackageVersion: '0.0.14',
+ isPluginInitialized: false,
});
});
@@ -245,6 +248,7 @@ describe('CspSetupStatus route', () => {
latestPackageVersion: '0.0.14',
installedPackagePolicies: 0,
healthyAgents: 0,
+ isPluginInitialized: false,
});
});
@@ -295,6 +299,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 1,
healthyAgents: 0,
installedPackageVersion: '0.0.14',
+ isPluginInitialized: false,
});
});
@@ -352,6 +357,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 1,
healthyAgents: 1,
installedPackageVersion: '0.0.14',
+ isPluginInitialized: false,
});
});
@@ -408,6 +414,7 @@ describe('CspSetupStatus route', () => {
installedPackagePolicies: 1,
healthyAgents: 1,
installedPackageVersion: '0.0.14',
+ isPluginInitialized: false,
});
});
});
diff --git a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts
index 64899a925acff..60dcd69fe5981 100644
--- a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts
+++ b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts
@@ -10,6 +10,7 @@ import type { SavedObjectsClientContract } from '@kbn/core/server';
import type { AgentPolicyServiceInterface, AgentService } from '@kbn/fleet-plugin/server';
import moment from 'moment';
import { PackagePolicy } from '@kbn/fleet-plugin/common';
+import { schema } from '@kbn/config-schema';
import { CLOUD_SECURITY_POSTURE_PACKAGE_NAME, STATUS_ROUTE_PATH } from '../../../common/constants';
import type { CspApiRequestHandlerContext, CspRouter } from '../../types';
import type { CspSetupStatus, CspStatusCode } from '../../../common/types';
@@ -73,6 +74,7 @@ const getCspStatus = async ({
packagePolicyService,
agentPolicyService,
agentService,
+ isPluginInitialized,
}: CspApiRequestHandlerContext): Promise => {
const [hasFindings, installation, latestCspPackage, installedPackagePolicies] = await Promise.all(
[
@@ -109,6 +111,7 @@ const getCspStatus = async ({
latestPackageVersion: latestCspPackageVersion,
healthyAgents,
installedPackagePolicies: installedPackagePoliciesTotal,
+ isPluginInitialized: isPluginInitialized(),
};
return {
@@ -117,21 +120,37 @@ const getCspStatus = async ({
healthyAgents,
installedPackagePolicies: installedPackagePoliciesTotal,
installedPackageVersion: installation?.install_version,
+ isPluginInitialized: isPluginInitialized(),
};
};
+export const statusQueryParamsSchema = schema.object({
+ /**
+ * CSP Plugin initialization includes creating indices/transforms/tasks.
+ * Prior to this initialization, the plugin is not ready to index findings.
+ */
+ check: schema.oneOf([schema.literal('all'), schema.literal('init')], { defaultValue: 'all' }),
+});
+
export const defineGetCspStatusRoute = (router: CspRouter): void =>
router.get(
{
path: STATUS_ROUTE_PATH,
- validate: false,
+ validate: { query: statusQueryParamsSchema },
options: {
tags: ['access:cloud-security-posture-read'],
},
},
- async (context, _, response) => {
+ async (context, request, response) => {
const cspContext = await context.csp;
try {
+ if (request.query.check === 'init') {
+ return response.ok({
+ body: {
+ isPluginInitialized: cspContext.isPluginInitialized(),
+ },
+ });
+ }
const status = await getCspStatus(cspContext);
return response.ok({
body: status,
diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts
index ba69b8402bde0..503a529f4a681 100644
--- a/x-pack/plugins/cloud_security_posture/server/types.ts
+++ b/x-pack/plugins/cloud_security_posture/server/types.ts
@@ -72,6 +72,7 @@ export interface CspApiRequestHandlerContext {
agentService: AgentService;
packagePolicyService: PackagePolicyClient;
packageService: PackageService;
+ isPluginInitialized(): boolean;
}
export type CspRequestHandlerContext = CustomRequestHandlerContext<{
diff --git a/x-pack/test/cloud_security_posture_functional/config.ts b/x-pack/test/cloud_security_posture_functional/config.ts
new file mode 100644
index 0000000000000..99aa5cf8893ea
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/config.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor 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 { resolve } from 'path';
+import type { FtrConfigProviderContext } from '@kbn/test';
+import { pageObjects } from './page_objects';
+import { getPreConfiguredFleetPackages, getPreConfiguredAgentPolicies } from './helpers';
+
+export default async function ({ readConfigFile }: FtrConfigProviderContext) {
+ const xpackFunctionalConfig = await readConfigFile(
+ require.resolve('../functional/config.base.js')
+ );
+
+ return {
+ ...xpackFunctionalConfig.getAll(),
+ pageObjects,
+ testFiles: [resolve(__dirname, './pages')],
+ junit: {
+ reportName: 'X-Pack Cloud Security Posture Functional Tests',
+ },
+ kbnTestServer: {
+ ...xpackFunctionalConfig.get('kbnTestServer'),
+ serverArgs: [
+ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'),
+ ...getPreConfiguredFleetPackages(),
+ ...getPreConfiguredAgentPolicies(),
+ ],
+ },
+ };
+}
diff --git a/x-pack/test/cloud_security_posture_functional/ftr_provider_context.d.ts b/x-pack/test/cloud_security_posture_functional/ftr_provider_context.d.ts
new file mode 100644
index 0000000000000..368a4b380602d
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/ftr_provider_context.d.ts
@@ -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.
+ */
+
+import { GenericFtrProviderContext } from '@kbn/test';
+
+import { pageObjects } from './page_objects';
+import { services } from '../functional/services';
+
+export type FtrProviderContext = GenericFtrProviderContext;
diff --git a/x-pack/test/cloud_security_posture_functional/helpers.ts b/x-pack/test/cloud_security_posture_functional/helpers.ts
new file mode 100644
index 0000000000000..4c7f0ef633594
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/helpers.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+const CLOUD_SECURITY_POSTURE_PACKAGE_NAME = 'cloud_security_posture';
+
+/**
+ * flags to load kibana with fleet pre-configured to have 'cloud_security_posture' integration installed
+ */
+export const getPreConfiguredFleetPackages = () => [
+ `--xpack.fleet.packages.0.name=${CLOUD_SECURITY_POSTURE_PACKAGE_NAME}`,
+ `--xpack.fleet.packages.0.version=latest`,
+];
+
+/**
+ * flags to load kibana with pre-configured agent policy with a 'cloud_security_posture' package policy
+ */
+export const getPreConfiguredAgentPolicies = () => [
+ `--xpack.fleet.agentPolicies.0.id=agent-policy-csp`,
+ `--xpack.fleet.agentPolicies.0.name=example-agent-policy-csp`,
+ `--xpack.fleet.agentPolicies.0.package_policies.0.id=integration-policy-csp`,
+ `--xpack.fleet.agentPolicies.0.package_policies.0.name=example-integration-csp`,
+ `--xpack.fleet.agentPolicies.0.package_policies.0.package.name=${CLOUD_SECURITY_POSTURE_PACKAGE_NAME}`,
+];
diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts
new file mode 100644
index 0000000000000..19154cc27bcc8
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts
@@ -0,0 +1,127 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor 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 expect from '@kbn/expect';
+import type { FtrProviderContext } from '../ftr_provider_context';
+
+// Defined in CSP plugin
+const STATUS_API_PATH = '/internal/cloud_security_posture/status?check=init';
+const FINDINGS_INDEX = 'logs-cloud_security_posture.findings_latest-default';
+const FINDINGS_ROUTE = 'cloud_security_posture/findings';
+const FINDINGS_TABLE_TESTID = 'findings_table';
+const getFilterValueSelector = (columnIndex: number) =>
+ `tbody tr td:nth-child(${columnIndex + 1}) div[data-test-subj="filter_cell_value"]`;
+
+// Defined in Security Solution plugin
+const SECURITY_SOLUTION_APP_NAME = 'securitySolution';
+
+export function FindingsPageProvider({ getService, getPageObjects }: FtrProviderContext) {
+ const testSubjects = getService('testSubjects');
+ const PageObjects = getPageObjects(['common']);
+ const retry = getService('retry');
+ const es = getService('es');
+ const supertest = getService('supertest');
+ const log = getService('log');
+
+ /**
+ * required before indexing findings
+ */
+ const waitForPluginInitialized = (): Promise =>
+ retry.try(async () => {
+ log.debug('Check CSP plugin is initialized');
+ const response = await supertest.get(STATUS_API_PATH).expect(200);
+ expect(response.body).to.eql({ isPluginInitialized: true });
+ log.debug('CSP plugin is initialized');
+ });
+
+ const index = {
+ remove: () => es.indices.delete({ index: FINDINGS_INDEX, ignore_unavailable: true }),
+ add: async (findingsMock: T[]) => {
+ await waitForPluginInitialized();
+ await Promise.all(
+ findingsMock.map((finding) =>
+ es.index({
+ index: FINDINGS_INDEX,
+ body: finding,
+ })
+ )
+ );
+ },
+ };
+
+ const table = {
+ getTableElement: () => testSubjects.find(FINDINGS_TABLE_TESTID),
+
+ getColumnIndex: async (columnName: string) => {
+ const tableElement = await table.getTableElement();
+ const headers = await tableElement.findAllByCssSelector('thead tr :is(th,td)');
+ const headerIndexes = await Promise.all(headers.map((header) => header.getVisibleText()));
+ const columnIndex = headerIndexes.findIndex((i) => i === columnName);
+ expect(columnIndex).to.be.greaterThan(-1);
+ return [columnIndex, headers[columnIndex]] as [
+ number,
+ Awaited>
+ ];
+ },
+
+ getFilterColumnValues: async (columnName: string) => {
+ const tableElement = await table.getTableElement();
+ const [columnIndex] = await table.getColumnIndex(columnName);
+ const columnCells = await tableElement.findAllByCssSelector(
+ getFilterValueSelector(columnIndex)
+ );
+
+ return await Promise.all(columnCells.map((h) => h.getVisibleText()));
+ },
+
+ assertColumnSort: async (columnName: string, direction: 'asc' | 'desc') => {
+ const values = (await table.getFilterColumnValues(columnName)).filter(Boolean);
+ expect(values).to.not.be.empty();
+ const sorted = values
+ .slice()
+ .sort((a, b) => (direction === 'asc' ? a.localeCompare(b) : b.localeCompare(a)));
+ values.every((value, i) => expect(value).to.be(sorted[i]));
+ },
+
+ toggleColumnSortOrFail: async (columnName: string, direction: 'asc' | 'desc') => {
+ const getColumnElement = async () => (await table.getColumnIndex(columnName))[1];
+ const element = await getColumnElement();
+ const currentSort = await element.getAttribute('aria-sort');
+ if (currentSort === 'none') {
+ // a click is needed to focus on Eui column header
+ await element.click();
+
+ // default is ascending
+ if (direction === 'desc') {
+ const nonStaleElement = await getColumnElement();
+ await nonStaleElement.click();
+ }
+ }
+ if (
+ (currentSort === 'ascending' && direction === 'desc') ||
+ (currentSort === 'descending' && direction === 'asc')
+ ) {
+ // Without getting the element again, the click throws an error (stale element reference)
+ const nonStaleElement = await getColumnElement();
+ await nonStaleElement.click();
+ }
+ await table.assertColumnSort(columnName, direction);
+ },
+ };
+
+ const navigateToFindingsPage = async () => {
+ await PageObjects.common.navigateToUrl(SECURITY_SOLUTION_APP_NAME, FINDINGS_ROUTE, {
+ shouldUseHashForSubUrl: false,
+ });
+ };
+
+ return {
+ navigateToFindingsPage,
+ table,
+ index,
+ };
+}
diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/index.ts b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts
new file mode 100644
index 0000000000000..e5738873edc51
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts
@@ -0,0 +1,14 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects';
+import { FindingsPageProvider } from './findings_page';
+
+export const pageObjects = {
+ ...xpackFunctionalPageObjects,
+ findings: FindingsPageProvider,
+};
diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts
new file mode 100644
index 0000000000000..a76b815d75461
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts
@@ -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 type { FtrProviderContext } from '../ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ getPageObjects, getService }: FtrProviderContext) {
+ const pageObjects = getPageObjects(['common', 'findings']);
+ const FINDINGS_SIZE = 2;
+ const findingsMock = Array.from({ length: FINDINGS_SIZE }, (_, id) => {
+ return {
+ resource: { id, name: `Resource ${id}` },
+ result: { evaluation: 'passed' },
+ rule: {
+ name: `Rule ${id}`,
+ section: 'Kubelet',
+ tags: ['Kubernetes'],
+ type: 'process',
+ },
+ };
+ });
+
+ describe('Findings Page', () => {
+ before(async () => {
+ await pageObjects.findings.index.add(findingsMock);
+ await pageObjects.findings.navigateToFindingsPage();
+ });
+
+ after(async () => {
+ await pageObjects.findings.index.remove();
+ });
+
+ describe('Sort', () => {
+ it('Sorts by rule name', async () => {
+ await pageObjects.findings.table.toggleColumnSortOrFail('Rule', 'asc');
+ });
+
+ it('Sorts by resource name', async () => {
+ await pageObjects.findings.table.toggleColumnSortOrFail('Resource Name', 'desc');
+ });
+ });
+ });
+}
diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts
new file mode 100644
index 0000000000000..80e96b8b17ce9
--- /dev/null
+++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts
@@ -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 { FtrProviderContext } from '../ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ loadTestFile }: FtrProviderContext) {
+ describe('Cloud Security Posture', function () {
+ loadTestFile(require.resolve('./findings'));
+ });
+}