diff --git a/CHANGELOG.md b/CHANGELOG.md index 02971f0fce..7f44ba8f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Support for Wazuh 4.9.0 - Added AngularJS dependencies [#6145](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6145) - Remove embedded discover [#6120](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6120) +- Develop logic of a new index for the fim module [#6227](https://github.com/wazuh/wazuh-dashboard-plugins/pull/6227) ## Wazuh v4.8.1 - OpenSearch Dashboards 2.10.0 - Revision 00 diff --git a/plugins/main/common/config-equivalences.js b/plugins/main/common/config-equivalences.js index fcfd359454..83aebea711 100644 --- a/plugins/main/common/config-equivalences.js +++ b/plugins/main/common/config-equivalences.js @@ -98,6 +98,8 @@ export const nameEquivalence = { 'alerts.sample.prefix': 'Sample alerts prefix', 'vulnerabilities.pattern': 'Index pattern', 'checks.vulnerabilities.pattern': 'Vulnerabilities index pattern', + 'fim.pattern': 'Index pattern', + 'checks.fim.pattern': 'Fim index pattern', }; const HEALTH_CHECK = 'Health Check'; diff --git a/plugins/main/common/constants.ts b/plugins/main/common/constants.ts index 4949a30685..46831badb9 100644 --- a/plugins/main/common/constants.ts +++ b/plugins/main/common/constants.ts @@ -51,6 +51,9 @@ export const WAZUH_STATISTICS_DEFAULT_CRON_FREQ = '0 */5 * * * *'; // Wazuh vulnerabilities export const WAZUH_VULNERABILITIES_PATTERN = 'wazuh-states-vulnerabilities'; +// Wazuh fim +export const WAZUH_FIM_PATTERN = 'wazuh-states-fim'; + // Job - Wazuh initialize export const WAZUH_PLUGIN_PLATFORM_TEMPLATE_NAME = 'wazuh-kibana'; @@ -861,6 +864,33 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { return schema.boolean(); }, }, + 'checks.fim.pattern': { + title: 'Fim index pattern', + description: + 'Enable or disable the fim index pattern health check when opening the app.', + category: SettingCategory.HEALTH_CHECK, + type: EpluginSettingType.switch, + defaultValue: true, + isConfigurableFromFile: true, + isConfigurableFromUI: true, + options: { + switch: { + values: { + disabled: { label: 'false', value: false }, + enabled: { label: 'true', value: true }, + }, + }, + }, + uiFormTransformChangedInputValue: function ( + value: boolean | string, + ): boolean { + return Boolean(value); + }, + validate: SettingsValidator.isBoolean, + validateBackend: function (schema) { + return schema.boolean(); + }, + }, 'cron.prefix': { title: 'Cron prefix', description: 'Define the index prefix of predefined jobs.', diff --git a/plugins/main/public/components/common/modules/modules-defaults.js b/plugins/main/public/components/common/modules/modules-defaults.js index 24b3b2b421..8c3fd06459 100644 --- a/plugins/main/public/components/common/modules/modules-defaults.js +++ b/plugins/main/public/components/common/modules/modules-defaults.js @@ -11,9 +11,7 @@ */ import { Dashboard } from './dashboard'; import { Events } from './events'; -import { MainFim } from '../../agents/fim'; import { MainSca } from '../../agents/sca'; -import { MainVuls } from '../../agents/vuls'; import { MainMitre } from './main-mitre'; import { ModuleMitreAttackIntelligence } from '../../overview/mitre_attack_intelligence'; import { ComplianceTable } from '../../overview/compliance-table'; @@ -21,8 +19,10 @@ import ButtonModuleExploreAgent from '../../../controllers/overview/components/o import { ButtonModuleGenerateReport } from '../modules/buttons'; import { OfficePanel } from '../../overview/office-panel'; import { GitHubPanel } from '../../overview/github-panel'; -import { DashboardVuls, InventoryVuls } from '../../overview/vulnerabilities' -import { withModuleNotForAgent, withModuleTabLoader } from '../hocs'; +import { DashboardVuls, InventoryVuls } from '../../overview/vulnerabilities'; +import { withModuleNotForAgent } from '../hocs'; +import { DashboardFim } from '../../overview/fim/dashboard/dashboard'; +import { InventoryFim } from '../../overview/fim/inventory/inventory'; const DashboardTab = { id: 'dashboard', @@ -56,12 +56,17 @@ export const ModulesDefaults = { fim: { init: 'dashboard', tabs: [ - DashboardTab, + { + id: 'dashboard', + name: 'Dashboard', + buttons: [ButtonModuleExploreAgent], + component: DashboardFim, + }, { id: 'inventory', name: 'Inventory', buttons: [ButtonModuleExploreAgent], - component: MainFim, + component: InventoryFim, }, EventsTab, ], diff --git a/plugins/main/public/components/health-check/container/__snapshots__/health-check.container.test.tsx.snap b/plugins/main/public/components/health-check/container/__snapshots__/health-check.container.test.tsx.snap index 9b6a456c8f..9e6a4d67a0 100644 --- a/plugins/main/public/components/health-check/container/__snapshots__/health-check.container.test.tsx.snap +++ b/plugins/main/public/components/health-check/container/__snapshots__/health-check.container.test.tsx.snap @@ -122,6 +122,23 @@ exports[`Health Check container should render a Health check screen 1`] = ` title="Check vulnerabilities index pattern" validationService={[Function]} /> + ({ 'checks.template': true, 'checks.fields': true, 'checks.vulnerabilities.pattern': true, + 'checks.fim.pattern': true, }, }), useRootScope: () => ({}), diff --git a/plugins/main/public/components/health-check/container/health-check.container.tsx b/plugins/main/public/components/health-check/container/health-check.container.tsx index 29c1252545..f3be74bc46 100644 --- a/plugins/main/public/components/health-check/container/health-check.container.tsx +++ b/plugins/main/public/components/health-check/container/health-check.container.tsx @@ -37,6 +37,7 @@ import { WAZUH_INDEX_TYPE_MONITORING, WAZUH_INDEX_TYPE_STATISTICS, WAZUH_INDEX_TYPE_VULNERABILITIES, + WAZUH_INDEX_TYPE_FIM, } from '../../../../common/constants'; import { compose } from 'redux'; @@ -103,6 +104,19 @@ const checks = { shouldCheck: false, canRetry: true, }, + 'fim.pattern': { + title: 'Check fim index pattern', + label: 'Fim index pattern', + validator: appConfig => + checkPatternSupportService( + appConfig.data['fim.pattern'], + WAZUH_INDEX_TYPE_FIM, + NOT_TIME_FIELD_NAME_INDEX_PATTERN, + ), + awaitFor: [], + shouldCheck: false, + canRetry: true, + }, }; function HealthCheckComponent() { diff --git a/plugins/main/public/components/overview/fim/dashboard/dashboard.tsx b/plugins/main/public/components/overview/fim/dashboard/dashboard.tsx new file mode 100644 index 0000000000..b13fd11b91 --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/dashboard.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { getPlugins } from '../../../../kibana-services'; +import { ViewMode } from '../../../../../../../src/plugins/embeddable/public'; +import { getDashboardPanels } from './dashboard_panels'; +import { I18nProvider } from '@osd/i18n/react'; +import useSearchBar from '../../../common/search-bar/use-search-bar'; +import { getDashboardFilters } from './dashboard_panels_filters'; +import './fim_filters.scss'; +import { getKPIsPanel } from './dashboard_panels_kpis'; +import { useAppConfig } from '../../../common/hooks'; + +const plugins = getPlugins(); + +const SearchBar = getPlugins().data.ui.SearchBar; + +const DashboardByRenderer = plugins.dashboard.DashboardContainerByValueRenderer; + +export const DashboardFim: React.FC = () => { + const appConfig = useAppConfig(); + const FIM_INDEX_PATTERN_ID = appConfig.data['fim.pattern']; + + const { searchBarProps } = useSearchBar({ + defaultIndexPatternID: FIM_INDEX_PATTERN_ID, + filters: [], + }); + + return ( + <> + + + +
+ +
+ + + + ); +}; diff --git a/plugins/main/public/components/overview/fim/dashboard/dashboard_panels.ts b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels.ts new file mode 100644 index 0000000000..044622307e --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels.ts @@ -0,0 +1,682 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; + +const getVisStateTopFim = (indexPatternId: string) => { + return { + id: 'most_detected_fim', + title: 'Most detected fim', + type: 'horizontal_bar', + params: { + type: 'histogram', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 200, + }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'bottom', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 75, + filter: true, + truncate: 100, + }, + title: { + text: 'Count', + }, + }, + ], + seriesParams: [ + { + show: true, + type: 'histogram', + mode: 'stacked', + data: { + label: 'Count', + id: '1', + }, + valueAxis: 'ValueAxis-1', + drawLinesBetweenPoints: true, + lineWidth: 2, + showCircles: true, + }, + ], + addTooltip: true, + addLegend: false, + legendPosition: 'right', + times: [], + addTimeMarker: false, + labels: {}, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 10, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'Vulnerability.ID', + }, + schema: 'segment', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateTopFimEndpoints = (indexPatternId: string) => { + return { + id: 'most_vulnerable_endpoints_fim', + title: 'The most vulnerable endpoints', + type: 'horizontal_bar', + params: { + type: 'histogram', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 200, + }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'bottom', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 75, + filter: true, + truncate: 100, + }, + title: { + text: 'Count', + }, + }, + ], + seriesParams: [ + { + show: true, + type: 'histogram', + mode: 'stacked', + data: { + label: 'Count', + id: '1', + }, + valueAxis: 'ValueAxis-1', + drawLinesBetweenPoints: true, + lineWidth: 2, + showCircles: true, + }, + ], + addTooltip: true, + addLegend: false, + legendPosition: 'right', + times: [], + addTimeMarker: false, + labels: {}, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + }, + uiState: { + vis: { + legendOpen: false, + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'agent.id', + orderBy: '1', + order: 'desc', + size: 10, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'package.path', + }, + schema: 'segment', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'agent.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'mm', + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateAccumulationMostDetectedFim = (indexPatternId: string) => { + return { + id: 'accumulation_most_vulnerable_fim', + title: 'Accumulation of the most detected fim', + type: 'line', + params: { + type: 'line', + grid: { + categoryLines: false, + }, + categoryAxes: [ + { + id: 'CategoryAxis-1', + type: 'category', + position: 'bottom', + show: true, + style: {}, + scale: { + type: 'linear', + }, + labels: { + show: true, + filter: true, + truncate: 100, + }, + title: {}, + }, + ], + valueAxes: [ + { + id: 'ValueAxis-1', + name: 'LeftAxis-1', + type: 'value', + position: 'left', + show: true, + style: {}, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + show: true, + rotate: 0, + filter: false, + truncate: 100, + }, + title: { + text: 'Count', + }, + }, + ], + seriesParams: [ + { + show: true, + type: 'line', + mode: 'normal', + data: { + label: 'Count', + id: '1', + }, + valueAxis: 'ValueAxis-1', + drawLinesBetweenPoints: false, + lineWidth: 2, + interpolate: 'linear', + showCircles: true, + }, + ], + addTooltip: true, + addLegend: true, + legendPosition: 'right', + times: [], + addTimeMarker: false, + labels: {}, + thresholdLine: { + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + radiusRatio: 20, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'count', + params: {}, + schema: 'radius', + }, + { + id: '4', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Others', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + schema: 'group', + }, + { + id: '3', + enabled: true, + type: 'date_histogram', + params: { + field: 'event.created', + timeRange: { + from: 'now-24h', + to: 'now', + }, + useNormalizedOpenSearchInterval: true, + scaleMetricValues: false, + interval: 'w', + // eslint-disable-next-line camelcase + drop_partials: false, + // eslint-disable-next-line camelcase + min_doc_count: 1, + // eslint-disable-next-line camelcase + extended_bounds: {}, + }, + schema: 'segment', + }, + ], + }, + }; +}; + +const getVisStateInventoryTable = (indexPatternId: string) => { + return { + id: 'inventory_table_fim', + title: 'Inventory table', + type: 'table', + params: { + perPage: 5, + showPartialRows: false, + showMetricsAtAllLevels: false, + showTotal: false, + totalFunc: 'sum', + percentageCol: '', + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: 'package.name', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'name', + }, + schema: 'bucket', + }, + { + id: '3', + enabled: true, + type: 'terms', + params: { + field: 'package.version', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'version', + }, + schema: 'bucket', + }, + { + id: '4', + enabled: true, + type: 'terms', + params: { + field: 'package.architecture', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'architecture', + }, + schema: 'bucket', + }, + { + id: '5', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.severity', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'severity', + }, + schema: 'bucket', + }, + { + id: '6', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.id', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'id', + }, + schema: 'bucket', + }, + { + id: '7', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.score.version', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'score version', + }, + schema: 'bucket', + }, + { + id: '8', + enabled: true, + type: 'terms', + params: { + field: 'vulnerability.score.base', + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: 'score base', + }, + schema: 'bucket', + }, + ], + }, + }; +}; + +export const getDashboardPanels = ( + indexPatternId: string, +): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + '6': { + gridData: { + w: 16, + h: 12, + x: 0, + y: 0, + i: '6', + }, + type: 'visualization', + explicitInput: { + id: '6', + savedVis: getVisStateTopFim(indexPatternId), + }, + }, + '7': { + gridData: { + w: 16, + h: 12, + x: 16, + y: 0, + i: '7', + }, + type: 'visualization', + explicitInput: { + id: '7', + savedVis: getVisStateTopFimEndpoints(indexPatternId), + }, + }, + '8': { + gridData: { + w: 16, + h: 12, + x: 32, + y: 0, + i: '8', + }, + type: 'visualization', + explicitInput: { + id: '8', + savedVis: getVisStateAccumulationMostDetectedFim(indexPatternId), + }, + }, + '9': { + gridData: { + w: 48, + h: 12, + x: 0, + y: 14, + i: '9', + }, + type: 'visualization', + explicitInput: { + id: '9', + savedVis: getVisStateInventoryTable(indexPatternId), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_filters.ts b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_filters.ts new file mode 100644 index 0000000000..47a6d63ebb --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_filters.ts @@ -0,0 +1,160 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; + +const getVisStateFilter = ( + id: string, + indexPatternId: string, + title: string, + label: string, + fieldName: string, +) => { + return { + id, + title, + type: 'table', + params: { + perPage: 5, + percentageCol: '', + row: true, + showMetricsAtAllLevels: false, + showPartialRows: false, + showTotal: false, + totalFunc: 'sum', + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: 'Count', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'terms', + params: { + field: fieldName, + orderBy: '1', + order: 'desc', + size: 5, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + customLabel: label, + }, + schema: 'bucket', + }, + ], + }, + }; +}; + +export const getDashboardFilters = ( + indexPatternId: string, +): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + topPackageSelector: { + gridData: { + w: 12, + h: 12, + x: 0, + y: 0, + i: 'topPackageSelector', + }, + type: 'visualization', + explicitInput: { + id: 'topPackageSelector', + savedVis: getVisStateFilter( + 'topPackageSelector', + indexPatternId, + 'Top Packages fim', + 'Package', + 'package.name', + ), + }, + }, + topOSFim: { + gridData: { + w: 12, + h: 12, + x: 12, + y: 0, + i: 'topOSFim', + }, + type: 'visualization', + explicitInput: { + id: 'topOSFim', + savedVis: getVisStateFilter( + 'topOSFim', + indexPatternId, + 'Top Operating system fim', + 'Operating system', + 'host.os.name', + ), + }, + }, + topAgentFim: { + gridData: { + w: 12, + h: 12, + x: 24, + y: 0, + i: 'topAgentFim', + }, + type: 'visualization', + explicitInput: { + id: 'topAgentFim', + savedVis: getVisStateFilter( + 'topAgentFim', + indexPatternId, + 'Agent filter', + 'Agent', + 'agent.id', + ), + }, + }, + topFim: { + gridData: { + w: 12, + h: 12, + x: 36, + y: 0, + i: 'topFim', + }, + type: 'visualization', + explicitInput: { + id: 'topFim', + savedVis: getVisStateFilter( + 'topFim', + indexPatternId, + 'Top vulnerabilities', + 'Fim', + 'fim.id', + ), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_kpis.ts b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_kpis.ts new file mode 100644 index 0000000000..99afc7f18f --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/dashboard_panels_kpis.ts @@ -0,0 +1,416 @@ +import { DashboardPanelState } from '../../../../../../../../src/plugins/dashboard/public/application'; +import { EmbeddableInput } from '../../../../../../../../src/plugins/embeddable/public'; + +const getVisStateSeverityCritical = (indexPatternId: string) => { + return { + id: 'severity_critical_fim', + title: 'Critical', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Reds', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"critical"', + language: 'kuery', + }, + label: '- Critical Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityHigh = (indexPatternId: string) => { + return { + id: 'severity_high_fim', + title: 'High', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Blues', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + uiState: { + vis: { + colors: { + 'High Severity Alerts - Count': '#38D1BA', + }, + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"high"', + language: 'kuery', + }, + label: '- High Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityMedium = (indexPatternId: string) => { + return { + id: 'severity_medium_fim', + title: 'Medium', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Yellow to Red', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: true, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"medium"', + language: 'kuery', + }, + label: '- Medium Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +const getVisStateSeverityLow = (indexPatternId: string) => { + return { + id: 'severity_low_fim', + title: 'Low', + type: 'metric', + params: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: 'Greens', + metricColorMode: 'Labels', + colorsRange: [ + { + from: 0, + to: 0, + }, + { + from: 0, + to: 0, + }, + ], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 50, + }, + }, + }, + data: { + searchSource: { + query: { + language: 'kuery', + query: '', + }, + filter: [], + index: indexPatternId, + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: indexPatternId, + }, + ], + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: { + customLabel: ' ', + }, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'filters', + params: { + filters: [ + { + input: { + query: 'vulnerability.severity:"low"', + language: 'kuery', + }, + label: '- Low Severity Alerts', + }, + ], + }, + schema: 'group', + }, + ], + }, + }; +}; + +export const getKPIsPanel = ( + indexPatternId: string, +): { + [panelId: string]: DashboardPanelState< + EmbeddableInput & { [k: string]: unknown } + >; +} => { + return { + '1': { + gridData: { + w: 12, + h: 6, + x: 0, + y: 0, + i: '1', + }, + type: 'visualization', + explicitInput: { + id: '1', + savedVis: getVisStateSeverityCritical(indexPatternId), + }, + }, + '2': { + gridData: { + w: 12, + h: 6, + x: 12, + y: 0, + i: '2', + }, + type: 'visualization', + explicitInput: { + id: '2', + savedVis: getVisStateSeverityHigh(indexPatternId), + }, + }, + '3': { + gridData: { + w: 12, + h: 6, + x: 24, + y: 0, + i: '3', + }, + type: 'visualization', + explicitInput: { + id: '3', + savedVis: getVisStateSeverityMedium(indexPatternId), + }, + }, + '4': { + gridData: { + w: 12, + h: 6, + x: 36, + y: 0, + i: '4', + }, + type: 'visualization', + explicitInput: { + id: '4', + savedVis: getVisStateSeverityLow(indexPatternId), + }, + }, + }; +}; diff --git a/plugins/main/public/components/overview/fim/dashboard/fim_filters.scss b/plugins/main/public/components/overview/fim/dashboard/fim_filters.scss new file mode 100644 index 0000000000..a836b86e3e --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/fim_filters.scss @@ -0,0 +1,6 @@ +.fim-dashboard-filters-wrapper { + .euiDataGrid__controls, + .euiDataGrid__pagination { + display: none !important; + } +} diff --git a/plugins/main/public/components/overview/fim/dashboard/index.tsx b/plugins/main/public/components/overview/fim/dashboard/index.tsx new file mode 100644 index 0000000000..b691822976 --- /dev/null +++ b/plugins/main/public/components/overview/fim/dashboard/index.tsx @@ -0,0 +1 @@ +export * from './dashboard'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/fim/inventory/config/index.ts b/plugins/main/public/components/overview/fim/inventory/config/index.ts new file mode 100644 index 0000000000..aa5490ffd8 --- /dev/null +++ b/plugins/main/public/components/overview/fim/inventory/config/index.ts @@ -0,0 +1,30 @@ +import { EuiDataGridColumn } from '@elastic/eui'; + +export const MAX_ENTRIES_PER_QUERY = 10000; + +export const inventoryTableDefaultColumns: EuiDataGridColumn[] = [ + { + id: 'package.name', + }, + { + id: 'package.version', + }, + { + id: 'package.architecture', + }, + { + id: 'fim.severity', + }, + { + id: 'fim.id', + }, + { + id: 'fim.score.version', + }, + { + id: 'fim.score.base', + }, + { + id: 'event.created', + }, +]; diff --git a/plugins/main/public/components/overview/fim/inventory/index.tsx b/plugins/main/public/components/overview/fim/inventory/index.tsx new file mode 100644 index 0000000000..ddb0742f5e --- /dev/null +++ b/plugins/main/public/components/overview/fim/inventory/index.tsx @@ -0,0 +1 @@ +export * from './inventory'; \ No newline at end of file diff --git a/plugins/main/public/components/overview/fim/inventory/inventory.scss b/plugins/main/public/components/overview/fim/inventory/inventory.scss new file mode 100644 index 0000000000..2303093ed2 --- /dev/null +++ b/plugins/main/public/components/overview/fim/inventory/inventory.scss @@ -0,0 +1,13 @@ +.fimInventoryContainer { + height: calc(100vh - 104px); +} + +.headerIsExpanded .fimInventoryContainer { + height: calc(100vh - 153px); +} + +.fimInventoryContainer .euiDataGrid--fullScreen { + height: calc(100vh - 49px); + bottom: 0; + top: auto; +} diff --git a/plugins/main/public/components/overview/fim/inventory/inventory.tsx b/plugins/main/public/components/overview/fim/inventory/inventory.tsx new file mode 100644 index 0000000000..39503b0e94 --- /dev/null +++ b/plugins/main/public/components/overview/fim/inventory/inventory.tsx @@ -0,0 +1,244 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { getPlugins } from '../../../../kibana-services'; +import useSearchBar from '../../../common/search-bar/use-search-bar'; +import { IntlProvider } from 'react-intl'; +import { + EuiDataGrid, + EuiPageTemplate, + EuiToolTip, + EuiButtonIcon, + EuiDataGridCellValueElementProps, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiButtonEmpty, +} from '@elastic/eui'; +import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { SearchResponse } from '../../../../../../../../src/core/server'; +import DocViewer from '../../vulnerabilities/doc_viewer/doc_viewer'; +import { DiscoverNoResults } from '../../vulnerabilities/common/components/no_results'; +import { LoadingSpinner } from '../../vulnerabilities/common/components/loading_spinner'; +import { useDataGrid } from '../../vulnerabilities/data_grid/use_data_grid'; +import { + MAX_ENTRIES_PER_QUERY, + inventoryTableDefaultColumns, +} from '../../vulnerabilities/dashboards/inventory/config'; +import { useDocViewer } from '../../vulnerabilities/doc_viewer/use_doc_viewer'; +import './inventory.scss'; +import { + search, + exportSearchToCSV, +} from '../../vulnerabilities/dashboards/inventory/inventory_service'; +import { + ErrorHandler, + ErrorFactory, + HttpError, +} from '../../../../react-services/error-management'; +import { withErrorBoundary } from '../../../common/hocs'; +import { HitsCounter } from '../../../../kibana-integrations/discover/application/components/hits_counter/hits_counter'; +import { formatNumWithCommas } from '../../../../kibana-integrations/discover/application/helpers'; +import { useAppConfig } from '../../../common/hooks'; + +const InventoryFimComponent = () => { + const appConfig = useAppConfig(); + const FIM_INDEX_PATTERN_ID = appConfig.data['fim.pattern']; + const { searchBarProps } = useSearchBar({ + defaultIndexPatternID: FIM_INDEX_PATTERN_ID, + }); + const { isLoading, filters, query, indexPatterns } = searchBarProps; + const SearchBar = getPlugins().data.ui.SearchBar; + const [results, setResults] = useState({} as SearchResponse); + const [inspectedHit, setInspectedHit] = useState(undefined); + const [indexPattern, setIndexPattern] = useState( + undefined, + ); + const [isSearching, setIsSearching] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + const onClickInspectDoc = useMemo( + () => (index: number) => { + const rowClicked = results.hits.hits[index]; + setInspectedHit(rowClicked); + }, + [results], + ); + + const DocViewInspectButton = ({ + rowIndex, + }: EuiDataGridCellValueElementProps) => { + const inspectHintMsg = 'Inspect document details'; + return ( + + onClickInspectDoc(rowIndex)} + iconType='inspect' + aria-label={inspectHintMsg} + /> + + ); + }; + + const dataGridProps = useDataGrid({ + ariaLabelledBy: 'Fim Inventory Table', + defaultColumns: inventoryTableDefaultColumns, + results, + indexPattern: indexPattern as IndexPattern, + DocViewInspectButton, + }); + + const { pagination, sorting, columnVisibility } = dataGridProps; + + const docViewerProps = useDocViewer({ + doc: inspectedHit, + indexPattern: indexPattern as IndexPattern, + }); + + useEffect(() => { + if (!isLoading) { + setIndexPattern(indexPatterns?.[0] as IndexPattern); + search({ + indexPattern: indexPatterns?.[0] as IndexPattern, + filters, + query, + pagination, + sorting, + }) + .then(results => { + setResults(results); + setIsSearching(false); + }) + .catch(error => { + const searchError = ErrorFactory.create(HttpError, { + error, + message: 'Error fetching fim', + }); + ErrorHandler.handleError(searchError); + setIsSearching(false); + }); + } + }, [ + JSON.stringify(searchBarProps), + JSON.stringify(pagination), + JSON.stringify(sorting), + ]); + + const timeField = indexPattern?.timeFieldName + ? indexPattern.timeFieldName + : undefined; + + const onClickExportResults = async () => { + const params = { + indexPattern: indexPatterns?.[0] as IndexPattern, + filters, + query, + fields: columnVisibility.visibleColumns, + pagination: { + pageIndex: 0, + pageSize: results.hits.total, + }, + sorting, + }; + try { + setIsExporting(true); + await exportSearchToCSV(params); + } catch (error) { + const searchError = ErrorFactory.create(HttpError, { + error, + message: 'Error downloading csv report', + }); + ErrorHandler.handleError(searchError); + } finally { + setIsExporting(false); + } + }; + + return ( + + + <> + {isLoading ? ( + + ) : ( + + )} + {isSearching ? : null} + {!isLoading && !isSearching && results?.hits?.total === 0 ? ( + + ) : null} + {!isLoading && !isSearching && results?.hits?.total > 0 ? ( + + {}} + tooltip={ + results?.hits?.total && + results?.hits?.total > MAX_ENTRIES_PER_QUERY + ? { + ariaLabel: 'Warning', + content: `The query results has exceeded the limit of 10,000 hits. To provide a better experience the table only shows the first ${formatNumWithCommas( + MAX_ENTRIES_PER_QUERY, + )} hits.`, + iconType: 'alert', + position: 'top', + } + : undefined + } + /> + + Export Formated + + + ), + }} + /> + ) : null} + {inspectedHit && ( + setInspectedHit(undefined)} size='m'> + + +

Document Details

+
+
+ + + + + + + +
+ )} + +
+
+ ); +}; + +export const InventoryFim = withErrorBoundary(InventoryFimComponent); diff --git a/plugins/main/public/components/overview/fim/inventory/inventory_service.ts b/plugins/main/public/components/overview/fim/inventory/inventory_service.ts new file mode 100644 index 0000000000..82ef36b007 --- /dev/null +++ b/plugins/main/public/components/overview/fim/inventory/inventory_service.ts @@ -0,0 +1,224 @@ +import { SearchResponse } from '../../../../../../../../src/core/server'; +import { getPlugins } from '../../../../kibana-services'; +import { + IndexPattern, + Filter, + OpenSearchQuerySortValue, +} from '../../../../../../../../src/plugins/data/public'; +import * as FileSaver from '../../../../../services/file-saver'; +import { beautifyDate } from '../../../../agents/vuls/inventory/lib'; +import { MAX_ENTRIES_PER_QUERY } from './config'; + +interface SearchParams { + indexPattern: IndexPattern; + filters?: Filter[]; + query?: any; + pagination?: { + pageIndex?: number; + pageSize?: number; + }; + fields?: string[]; + sorting?: { + columns: { + id: string; + direction: 'asc' | 'desc'; + }[]; + }; +} + +export const search = async ( + params: SearchParams, +): Promise => { + const { + indexPattern, + filters = [], + query, + pagination, + sorting, + fields, + } = params; + if (!indexPattern) { + return; + } + const data = getPlugins().data; + const searchSource = await data.search.searchSource.create(); + const fromField = + (pagination?.pageIndex || 0) * (pagination?.pageSize || 100); + const sortOrder: OpenSearchQuerySortValue[] = + sorting?.columns.map(column => { + const sortDirection = column.direction === 'asc' ? 'asc' : 'desc'; + return { [column?.id || '']: sortDirection } as OpenSearchQuerySortValue; + }) || []; + + const searchParams = searchSource + .setParent(undefined) + .setField('filter', filters) + .setField('query', query) + .setField('sort', sortOrder) + .setField('size', pagination?.pageSize) + .setField('from', fromField) + .setField('index', indexPattern); + + // add fields + if (fields && Array.isArray(fields) && fields.length > 0) { + searchParams.setField('fields', fields); + } + try { + return await searchParams.fetch(); + } catch (error) { + if (error.body) { + throw error.body; + } + throw error; + } +}; + +export const parseData = ( + resultsHits: SearchResponse['hits']['hits'], +): any[] => { + const data = resultsHits.map(hit => { + if (!hit) { + return {}; + } + const source = hit._source as object; + const data = { + ...source, + _id: hit._id, + _index: hit._index, + _type: hit._type, + _score: hit._score, + }; + return data; + }); + return data; +}; + +export const getFieldFormatted = ( + rowIndex, + columnId, + indexPattern, + rowsParsed, +) => { + const field = indexPattern.fields.find(field => field.name === columnId); + let fieldValue = null; + if (columnId.includes('.')) { + // when the column is a nested field. The column could have 2 to n levels + // get dinamically the value of the nested field + const nestedFields = columnId.split('.'); + fieldValue = rowsParsed[rowIndex]; + nestedFields.forEach(field => { + if (fieldValue) { + fieldValue = fieldValue[field]; + } + }); + } else { + fieldValue = rowsParsed[rowIndex][columnId].formatted + ? rowsParsed[rowIndex][columnId].formatted + : rowsParsed[rowIndex][columnId]; + } + + // if is date field + if (field?.type === 'date') { + // @ts-ignore + fieldValue = beautifyDate(fieldValue); + } + return fieldValue; +}; + +export const exportSearchToCSV = async ( + params: SearchParams, +): Promise => { + const DEFAULT_MAX_SIZE_PER_CALL = 1000; + const { + indexPattern, + filters = [], + query, + sorting, + fields, + pagination, + } = params; + // when the pageSize is greater than the default max size per call (10000) + // then we need to paginate the search + const mustPaginateSearch = + pagination?.pageSize && pagination?.pageSize > DEFAULT_MAX_SIZE_PER_CALL; + const pageSize = mustPaginateSearch + ? DEFAULT_MAX_SIZE_PER_CALL + : pagination?.pageSize; + const totalHits = pagination?.pageSize || DEFAULT_MAX_SIZE_PER_CALL; + let pageIndex = params.pagination?.pageIndex || 0; + let hitsCount = 0; + let allHits = []; + let searchResults; + if (mustPaginateSearch) { + // paginate the search + while (hitsCount < totalHits && hitsCount < MAX_ENTRIES_PER_QUERY) { + const searchParams = { + indexPattern, + filters, + query, + pagination: { + pageIndex, + pageSize, + }, + sorting, + fields, + }; + searchResults = await search(searchParams); + allHits = allHits.concat(searchResults.hits.hits); + hitsCount = allHits.length; + pageIndex++; + } + } else { + searchResults = await search(params); + allHits = searchResults.hits.hits; + } + + const resultsFields = fields; + const data = allHits.map(hit => { + // check if the field type is a date + const dateFields = indexPattern.fields.getByType('date'); + const dateFieldsNames = dateFields.map(field => field.name); + const flattenHit = indexPattern.flattenHit(hit); + // replace the date fields with the formatted date + dateFieldsNames.forEach(field => { + if (flattenHit[field]) { + flattenHit[field] = beautifyDate(flattenHit[field]); + } + }); + return flattenHit; + }); + + if (!resultsFields || resultsFields.length === 0) { + return; + } + + if (!data || data.length === 0) return; + + const parsedData = data + .map(row => { + const parsedRow = resultsFields?.map(field => { + const value = row[field]; + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + return `"${value}"`; + }); + return parsedRow?.join(','); + }) + .join('\n'); + + // create a csv file using blob + const blobData = new Blob([`${resultsFields?.join(',')}\n${parsedData}`], { + type: 'text/csv', + }); + + if (blobData) { + FileSaver?.saveAs( + blobData, + `fim_inventory-${new Date().toISOString()}.csv`, + ); + } +}; diff --git a/plugins/main/public/redux/reducers/appConfigReducers.ts b/plugins/main/public/redux/reducers/appConfigReducers.ts index 5f3d43fd92..a987ce70e9 100644 --- a/plugins/main/public/redux/reducers/appConfigReducers.ts +++ b/plugins/main/public/redux/reducers/appConfigReducers.ts @@ -18,12 +18,15 @@ const initialState: AppConfigState = { isLoading: false, isReady: false, hasError: false, - data: getSettingsDefault(), + data: { + 'vulnerabilities.pattern': 'wazuh-states-vulnerabilities', + 'fim.pattern': 'wazuh-states-fim', + }, }; const appConfigReducer: Reducer = ( state = initialState, - action + action, ) => { switch (action.type) { case 'UPDATE_APP_CONFIG_SET_IS_LOADING': @@ -31,22 +34,30 @@ const appConfigReducer: Reducer = ( ...state, isLoading: true, isReady: false, - hasError: false + hasError: false, }; case 'UPDATE_APP_CONFIG_SET_HAS_ERROR': - return { - ...state, - isLoading: false, - isReady: false, - hasError: true - }; + return { + ...state, + isLoading: false, + isReady: false, + hasError: true, + }; case 'UPDATE_APP_CONFIG_DATA': return { ...state, isLoading: false, isReady: true, hasError: false, - data: {...state.data, ...action.payload}, + data: { ...state.data, ...action.payload }, + }; + case 'UPDATE_FIM_PATTERN': + return { + ...state, + data: { + ...state.data, + 'fim.pattern': action.payload, + }, }; default: return state; diff --git a/plugins/main/test/cypress/cypress/fixtures/configuration.panel.text.json b/plugins/main/test/cypress/cypress/fixtures/configuration.panel.text.json index 5f23722d0f..5a4d7878de 100644 --- a/plugins/main/test/cypress/cypress/fixtures/configuration.panel.text.json +++ b/plugins/main/test/cypress/cypress/fixtures/configuration.panel.text.json @@ -99,6 +99,11 @@ "title": "Vulnerabilities index pattern", "subTitle": "Enable or disable the vulnerabilities index pattern health check when opening the app.", "label": "checks.vulnerabilities.pattern" + }, + { + "title": "Fim index pattern", + "subTitle": "Enable or disable the fim index pattern health check when opening the app.", + "label": "checks.fim.pattern" } ] }, diff --git a/plugins/wazuh-core/common/constants.ts b/plugins/wazuh-core/common/constants.ts index 4949a30685..180f6679dc 100644 --- a/plugins/wazuh-core/common/constants.ts +++ b/plugins/wazuh-core/common/constants.ts @@ -861,6 +861,33 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { return schema.boolean(); }, }, + 'checks.fim.pattern': { + title: 'Fim index pattern', + description: + 'Enable or disable the fim index pattern health check when opening the app.', + category: SettingCategory.HEALTH_CHECK, + type: EpluginSettingType.switch, + defaultValue: true, + isConfigurableFromFile: true, + isConfigurableFromUI: true, + options: { + switch: { + values: { + disabled: { label: 'false', value: false }, + enabled: { label: 'true', value: true }, + }, + }, + }, + uiFormTransformChangedInputValue: function ( + value: boolean | string, + ): boolean { + return Boolean(value); + }, + validate: SettingsValidator.isBoolean, + validateBackend: function (schema) { + return schema.boolean(); + }, + }, 'cron.prefix': { title: 'Cron prefix', description: 'Define the index prefix of predefined jobs.',