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