diff --git a/.eslintrc.js b/.eslintrc.js index 28118e1374..16133261ff 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -53,14 +53,20 @@ module.exports = { message: "Don't use arrow functions in class properties. Use a function instead.", }, + { + selector: + 'MemberExpression > LogicalExpression[operator="||"]:has(Identifier):has(ObjectExpression[properties.length=0])', + message: 'Use optional chaining operator instead (?.).', + }, + { + selector: + 'MemberExpression > LogicalExpression[operator="||"]:has(Identifier):has(ArrayExpression[elements.length=0])', + message: 'Use optional chaining operator instead (?.).', + }, ], 'prefer-arrow-callback': 'error', 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], - 'arrow-body-style': [ - 'error', - 'as-needed', - { requireReturnForObjectLiteral: true }, - ], + 'arrow-body-style': ['error', 'as-needed'], 'no-unreachable': 'error', 'no-fallthrough': [ 'error', @@ -82,7 +88,7 @@ module.exports = { 'block-scoped-var': 'error', 'default-case': 'error', 'default-case-last': 'error', - 'default-param-last': 'error', + 'default-param-last': 'off', eqeqeq: ['error', 'always'], 'no-var': 'error', /* -------------------------------------------------------------------------- */ @@ -163,6 +169,7 @@ module.exports = { /* -------------------------------------------------------------------------- */ /* unicorn */ /* -------------------------------------------------------------------------- */ + 'unicorn/prefer-module': 'off', 'unicorn/prefer-ternary': 'off', // https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/custom-error-definition.md 'unicorn/custom-error-definition': 'error', @@ -203,6 +210,32 @@ module.exports = { /* -------------------------------------------------------------------------- */ /* @typescript-eslint */ /* -------------------------------------------------------------------------- */ + '@typescript-eslint/no-dynamic-delete': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + // Whether to check all, some, or no arguments. + args: 'after-used', + // Regular expressions of argument names to not check for usage. + argsIgnorePattern: '^_', + // Whether to check catch block arguments. + caughtErrors: 'all', + // Regular expressions of catch block argument names to not check for usage. + caughtErrorsIgnorePattern: '^_', + // Regular expressions of destructured array variable names to not check for usage. + destructuredArrayIgnorePattern: '^_', + // Whether to ignore classes with at least one static initialization block. + ignoreClassWithStaticInitBlock: false, + // Whether to ignore sibling properties in `...` destructurings. + ignoreRestSiblings: false, + // Whether to report variables that match any of the valid ignore pattern options if they have been used. + reportUsedIgnorePattern: true, + // Whether to check all variables or only locally-declared variables. + vars: 'all', + // Regular expressions of variable names to not check for usage. + varsIgnorePattern: '[iI]gnored$', + }, + ], '@typescript-eslint/prefer-readonly': 'error', '@typescript-eslint/no-extraneous-class': 'off', '@typescript-eslint/method-signature-style': ['error', 'property'], @@ -210,12 +243,10 @@ module.exports = { '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/naming-convention': [ 'error', - { selector: 'default', format: ['camelCase'] }, - { selector: 'import', format: ['camelCase', 'PascalCase'] }, { - selector: 'variable', - types: ['function'], + selector: 'default', format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', }, { selector: ['objectLiteralProperty', 'typeProperty'], @@ -232,7 +263,12 @@ module.exports = { { selector: ['variable'], modifiers: ['global'], - types: ['number', 'string'], + format: ['UPPER_CASE', 'camelCase', 'PascalCase'], + }, + { + selector: ['variable'], + modifiers: ['global'], + types: ['boolean', 'number', 'string'], format: ['UPPER_CASE'], }, { @@ -316,5 +352,29 @@ module.exports = { ], }, }, + { + // constants files + files: ['plugins/**/constants.{ts,js}'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + modifiers: ['global'], + format: ['UPPER_CASE'], + }, + ], + }, + }, + { + files: ['plugins/**/*.test.{js,jsx,ts,tsx}'], + rules: { + '@typescript-eslint/no-empty-function': [ + 'error', + { allow: ['arrowFunctions'] }, + ], + '@typescript-eslint/no-unused-vars': 'off', + }, + }, ], }; diff --git a/CHANGELOG.md b/CHANGELOG.md index ace26016b6..f227ebda87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the Wazuh app project will be documented in this file. - Support for Wazuh 5.0.0 - Added creation of report definition when creating dashboard by reference and the button to reset the report [#7091](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7091) +- Added a frontend http client to core plugin [#7000](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7000) - Added an initilization service to core plugin to run the initilization tasks related to user scope [#7145](https://github.com/wazuh/wazuh-dashboard-plugins/pull/7145) ### Removed diff --git a/plugins/main/public/components/search-bar/query-language/index.ts b/plugins/main/public/components/search-bar/query-language/index.ts index 5a897d1d34..abb9fcf231 100644 --- a/plugins/main/public/components/search-bar/query-language/index.ts +++ b/plugins/main/public/components/search-bar/query-language/index.ts @@ -1,32 +1,58 @@ +import { suggestItem } from '../../wz-search-bar'; import { AQL } from './aql'; import { WQL } from './wql'; -type SearchBarQueryLanguage = { - description: string; - documentationLink?: string; +interface SearchBarProps { + suggestions: suggestItem[]; + onItemClick: (currentInput: string) => (item: suggestItem) => void; + prepend?: React.ReactNode; + disableFocusTrap?: boolean; + isInvalid?: boolean; + onKeyPress?: (event: React.KeyboardEvent) => void; +} + +interface SearchBarQueryLanguage { id: string; label: string; + description: string; + documentationLink?: string; getConfiguration?: () => any; - run: (input: string | undefined, params: any) => Promise<{ - searchBarProps: any, + run: ( + input: string | undefined, + params: any, + ) => Promise<{ + filterButtons?: React.ReactElement | null; + searchBarProps: SearchBarProps; output: { - language: string, - unifiedQuery: string, - query: string - } + language: string; + unifiedQuery?: string; + apiQuery?: { + q: string; + }; + query: string; + }; }>; - transformInput: (unifiedQuery: string, options: {configuration: any, parameters: any}) => string; -}; + transformInput?: ( + unifiedQuery: string, + options: { configuration: any; parameters: any }, + ) => string; + transformUQLToQL?: (unifiedQuery: string) => string; +} // Register the query languages -export const searchBarQueryLanguages: { - [key: string]: SearchBarQueryLanguage; -} = [AQL, WQL].reduce((accum, item) => { - if (accum[item.id]) { - throw new Error(`Query language with id: ${item.id} already registered.`); +function initializeSearchBarQueryLanguages() { + const languages = [AQL, WQL]; + const result: Record = {}; + + for (const item of languages) { + if (result[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + + result[item.id] = item; } - return { - ...accum, - [item.id]: item, - }; -}, {}); + + return result; +} + +export const searchBarQueryLanguages = initializeSearchBarQueryLanguages(); diff --git a/plugins/main/server/controllers/wazuh-api.ts b/plugins/main/server/controllers/wazuh-api.ts index a5c3772c8f..f0016b62b1 100644 --- a/plugins/main/server/controllers/wazuh-api.ts +++ b/plugins/main/server/controllers/wazuh-api.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ /* * Wazuh app - Class for Wazuh-API functions * Copyright (C) 2015-2022 Wazuh, Inc. @@ -11,30 +12,29 @@ */ // Require some libraries -import { ErrorResponse } from '../lib/error-response'; +import fs from 'node:fs'; +import path from 'node:path'; import { Parser } from 'json2csv'; -import { KeyEquivalence } from '../../common/csv-key-equivalence'; -import { ApiErrorEquivalence } from '../lib/api-errors-equivalence'; -import apiRequestList from '../../common/api-info/endpoints'; -import { HTTP_STATUS_CODES } from '../../common/constants'; -import { addJobToQueue } from '../start/queue'; import jwtDecode from 'jwt-decode'; import { OpenSearchDashboardsRequest, RequestHandlerContext, OpenSearchDashboardsResponseFactory, } from 'src/core/server'; +import { ErrorResponse } from '../lib/error-response'; +import { KeyEquivalence } from '../../common/csv-key-equivalence'; +import { ApiErrorEquivalence } from '../lib/api-errors-equivalence'; +import apiRequestList from '../../common/api-info/endpoints'; +import { HTTP_STATUS_CODES } from '../../common/constants'; +import { addJobToQueue } from '../start/queue'; import { getCookieValueByName } from '../lib/cookie'; import { version as pluginVersion, revision as pluginRevision, } from '../../package.json'; -import fs from 'fs'; -import path from 'path'; +import { WAZUH_ERROR_DAEMONS_NOT_READY } from '../../../wazuh-core/common/constants'; export class WazuhApiCtrl { - constructor() {} - async getToken( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, @@ -46,12 +46,13 @@ export class WazuhApiCtrl { request, context, ); + if ( !force && request.headers.cookie && username === decodeURIComponent( - getCookieValueByName(request.headers.cookie, 'wz-user'), + getCookieValueByName(request.headers.cookie, 'wz-user') as string, ) && idHost === getCookieValueByName(request.headers.cookie, 'wz-api') ) { @@ -59,11 +60,13 @@ export class WazuhApiCtrl { request.headers.cookie, 'wz-token', ); + if (wzToken) { try { // if the current token is not a valid jwt token we ask for a new one const decodedToken = jwtDecode(wzToken); const expirationTime = decodedToken.exp - Date.now() / 1000; + if (wzToken && expirationTime > 0) { return response.ok({ body: { token: wzToken }, @@ -76,22 +79,17 @@ export class WazuhApiCtrl { } } } - let token; - if (context.wazuh_core.manageHosts.isEnabledAuthWithRunAs(idHost)) { - token = await context.wazuh.api.client.asCurrentUser.authenticate( - idHost, - ); - } else { - token = await context.wazuh.api.client.asInternalUser.authenticate( - idHost, - ); - } + const token = + await context.wazuh.api.client.asCurrentUser.authenticate(idHost); let textSecure = ''; + if (context.wazuh.server.info.protocol === 'https') { textSecure = ';Secure'; } + const encodedUser = encodeURIComponent(username); + return response.ok({ headers: { 'set-cookie': [ @@ -104,9 +102,11 @@ export class WazuhApiCtrl { }); } catch (error) { const errorMessage = `Error getting the authorization token: ${ - ((error.response || {}).data || {}).detail || error.message || error + error.response?.data?.detail || error.message || error }`; + context.wazuh.logger.error(errorMessage); + return ErrorResponse( errorMessage, 3000, @@ -131,11 +131,14 @@ export class WazuhApiCtrl { try { // Get config from configuration const id = request.body.id; + context.wazuh.logger.debug(`Getting server API host by ID: ${id}`); + const apiHostData = await context.wazuh_core.manageHosts.get(id, { excludePassword: true, }); const api = { ...apiHostData }; + context.wazuh.logger.debug( `Server API host data: ${JSON.stringify(api)}`, ); @@ -154,7 +157,7 @@ export class WazuhApiCtrl { // Look for socket-related errors if (this.checkResponseIsDown(context, responseManagerInfo)) { return ErrorResponse( - `ERROR3099 - ${ + `${WAZUH_ERROR_DAEMONS_NOT_READY} - ${ responseManagerInfo.data.detail || 'Server not ready yet' }`, 3099, @@ -170,6 +173,7 @@ export class WazuhApiCtrl { ) { // Clear and update cluster information before being sent back to frontend delete api.cluster_info; + const responseAgents = await context.wazuh.api.client.asInternalUser.request( 'GET', @@ -181,7 +185,6 @@ export class WazuhApiCtrl { if (responseAgents.status === HTTP_STATUS_CODES.OK) { const managerName = responseAgents.data.data.affected_items[0].manager; - const responseClusterStatus = await context.wazuh.api.client.asInternalUser.request( 'GET', @@ -189,6 +192,7 @@ export class WazuhApiCtrl { {}, { apiHostID: id }, ); + if (responseClusterStatus.status === HTTP_STATUS_CODES.OK) { if (responseClusterStatus.data.data.enabled === 'yes') { const responseClusterLocalInfo = @@ -198,9 +202,11 @@ export class WazuhApiCtrl { {}, { apiHostID: id }, ); + if (responseClusterLocalInfo.status === HTTP_STATUS_CODES.OK) { const clusterEnabled = responseClusterStatus.data.data.enabled === 'yes'; + api.cluster_info = { status: clusterEnabled ? 'enabled' : 'disabled', manager: managerName, @@ -270,10 +276,10 @@ export class WazuhApiCtrl { } else { try { const apis = await context.wazuh_core.manageHosts.get(); + for (const api of apis) { try { const { id } = api; - const responseManagerInfo = await context.wazuh.api.client.asInternalUser.request( 'GET', @@ -284,7 +290,7 @@ export class WazuhApiCtrl { if (this.checkResponseIsDown(context, responseManagerInfo)) { return ErrorResponse( - `ERROR3099 - ${ + `${WAZUH_ERROR_DAEMONS_NOT_READY} - ${ response.data.detail || 'Server not ready yet' }`, 3099, @@ -292,15 +298,20 @@ export class WazuhApiCtrl { response, ); } + if (responseManagerInfo.status === HTTP_STATUS_CODES.OK) { request.body.id = id; request.body.idChanged = id; + return await this.checkStoredAPI(context, request, response); } - } catch (error) {} // eslint-disable-line + } catch { + /* empty */ + } } } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse( error.message || error, 3020, @@ -308,7 +319,9 @@ export class WazuhApiCtrl { response, ); } + context.wazuh.logger.error(error.message || error); + return ErrorResponse( error.message || error, 3002, @@ -360,19 +373,20 @@ export class WazuhApiCtrl { response: OpenSearchDashboardsResponseFactory, ) { try { - let apiAvailable = null; // const notValid = this.validateCheckApiParams(request.body); // if (notValid) return ErrorResponse(notValid, 3003, HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, response); context.wazuh.logger.debug(`${request.body.id} is valid`); + // Check if a Wazuh API id is given (already stored API) const data = await context.wazuh_core.manageHosts.get(request.body.id, { excludePassword: true, }); - if (data) { - apiAvailable = data; - } else { + + if (!data) { const errorMessage = `The server API host entry with ID ${request.body.id} was not found`; + context.wazuh.logger.debug(errorMessage); + return ErrorResponse( errorMessage, 3029, @@ -380,11 +394,15 @@ export class WazuhApiCtrl { response, ); } + const options = { apiHostID: request.body.id }; + if (request.body.forceRefresh) { options['forceRefresh'] = request.body.forceRefresh; } + let responseManagerInfo; + try { responseManagerInfo = await context.wazuh.api.client.asInternalUser.request( @@ -395,7 +413,7 @@ export class WazuhApiCtrl { ); } catch (error) { return ErrorResponse( - `ERROR3099 - ${ + `${WAZUH_ERROR_DAEMONS_NOT_READY} - ${ error.response?.data?.detail || 'Server not ready yet' }`, 3099, @@ -403,13 +421,16 @@ export class WazuhApiCtrl { response, ); } + context.wazuh.logger.debug(`${request.body.id} credentials are valid`); + if ( responseManagerInfo.status === HTTP_STATUS_CODES.OK && responseManagerInfo.data ) { const result = await context.wazuh_core.manageHosts.getRegistryDataByHost(data); + return response.ok({ body: result, }); @@ -429,6 +450,7 @@ export class WazuhApiCtrl { response, ); } + if ( error && error.response && @@ -442,6 +464,7 @@ export class WazuhApiCtrl { response, ); } + if (error.code === 'EPROTO') { return ErrorResponse( 'Wrong protocol being used to connect to the API', @@ -450,6 +473,7 @@ export class WazuhApiCtrl { response, ); } + return ErrorResponse( error.message || error, 3005, @@ -463,16 +487,18 @@ export class WazuhApiCtrl { if (response.status !== HTTP_STATUS_CODES.OK) { // Avoid "Error communicating with socket" like errors const socketErrorCodes = [1013, 1014, 1017, 1018, 1019]; - const status = (response.data || {}).status || 1; + const status = response.data?.status || 1; const isDown = socketErrorCodes.includes(status); - isDown && + if (isDown) { context.wazuh.logger.error( 'Server API is online but the server is not ready yet', ); + } return isDown; } + return false; } @@ -490,26 +516,22 @@ export class WazuhApiCtrl { {}, { apiHostID: api.id }, ); - - const daemons = - ((((response || {}).data || {}).data || {}).affected_items || [])[0] || - {}; - + const daemons = response?.data?.data?.affected_items?.[0] || {}; const isCluster = - ((api || {}).cluster_info || {}).status === 'enabled' && - typeof daemons['wazuh-clusterd'] !== 'undefined'; - const wazuhdbExists = typeof daemons['wazuh-db'] !== 'undefined'; - + api?.cluster_info?.status === 'enabled' && + daemons['wazuh-clusterd'] !== undefined; + const wazuhdbExists = daemons['wazuh-db'] !== undefined; const execd = daemons['wazuh-execd'] === 'running'; const modulesd = daemons['wazuh-modulesd'] === 'running'; const wazuhdb = wazuhdbExists ? daemons['wazuh-db'] === 'running' : true; const clusterd = isCluster ? daemons['wazuh-clusterd'] === 'running' : true; - const isValid = execd && modulesd && wazuhdb && clusterd; - isValid && context.wazuh.logger.debug('Wazuh is ready'); + if (isValid) { + context.wazuh.logger.debug('Wazuh is ready'); + } if (path === '/ping') { return { isValid }; @@ -520,13 +542,13 @@ export class WazuhApiCtrl { } } catch (error) { context.wazuh.logger.error(error.message || error); - return Promise.reject(error); + + throw error; } } - sleep(timeMs) { - // eslint-disable-next-line - return new Promise((resolve, reject) => { + sleep(timeMs: number) { + return new Promise(resolve => { setTimeout(resolve, timeMs); }); } @@ -541,16 +563,19 @@ export class WazuhApiCtrl { * @returns {Object} API response or ErrorResponse */ async makeRequest(context, method, path, data, id, response) { - const devTools = !!(data || {}).devTools; + const devTools = !!data?.devTools; + try { let api; + try { api = await context.wazuh_core.manageHosts.get(id, { excludePassword: true, }); - } catch (error) { + } catch { context.wazuh.logger.error('Could not get host credentials'); - //Can not get credentials from wazuh-hosts + + // Can not get credentials from wazuh-hosts return ErrorResponse( 'Could not get host credentials', 3011, @@ -576,30 +601,23 @@ export class WazuhApiCtrl { }; // Set content type application/xml if needed - if ( - typeof (data || {}).body === 'string' && - (data || {}).origin === 'xmleditor' - ) { + if (typeof data?.body === 'string' && data?.origin === 'xmleditor') { data.headers['content-type'] = 'application/xml'; delete data.origin; } - if ( - typeof (data || {}).body === 'string' && - (data || {}).origin === 'json' - ) { + if (typeof data?.body === 'string' && data?.origin === 'json') { data.headers['content-type'] = 'application/json'; delete data.origin; } - if ( - typeof (data || {}).body === 'string' && - (data || {}).origin === 'raw' - ) { + if (typeof data?.body === 'string' && data?.origin === 'raw') { data.headers['content-type'] = 'application/octet-stream'; delete data.origin; } - const delay = (data || {}).delay || 0; + + const delay = data?.delay || 0; + if (delay) { // Remove the delay parameter that is used to add the sever API request to the queue job. // This assumes the delay parameter is not used as part of the server API request. If it @@ -625,6 +643,7 @@ export class WazuhApiCtrl { } }, }); + return response.ok({ body: { error: 0, message: 'Success' }, }); @@ -633,15 +652,18 @@ export class WazuhApiCtrl { if (path === '/ping') { try { const check = await this.checkDaemons(context, api, path); + return check; } catch (error) { - const isDown = (error || {}).code === 'ECONNREFUSED'; + const isDown = error?.code === 'ECONNREFUSED'; + if (!isDown) { context.wazuh.logger.error( 'Server API is online but the server is not ready yet', ); + return ErrorResponse( - `ERROR3099 - ${error.message || 'Server not ready yet'}`, + `${WAZUH_ERROR_DAEMONS_NOT_READY} - ${error.message || 'Server not ready yet'}`, 3099, HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, response, @@ -660,15 +682,18 @@ export class WazuhApiCtrl { options, ); const responseIsDown = this.checkResponseIsDown(context, responseToken); + if (responseIsDown) { return ErrorResponse( - `ERROR3099 - ${response.body.message || 'Server not ready yet'}`, + `${WAZUH_ERROR_DAEMONS_NOT_READY} - ${response.body.message || 'Server not ready yet'}`, 3099, HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR, response, ); } - let responseBody = (responseToken || {}).data || {}; + + let responseBody = responseToken?.data || {}; + if (!responseBody) { responseBody = typeof responseBody === 'string' && @@ -678,8 +703,9 @@ export class WazuhApiCtrl { : false; response.data = responseBody; } + const responseError = - response.status !== HTTP_STATUS_CODES.OK ? response.status : false; + response.status === HTTP_STATUS_CODES.OK ? false : response.status; if (!responseError && responseBody) { return response.ok({ @@ -692,6 +718,7 @@ export class WazuhApiCtrl { body: response.data, }); } + throw responseError && responseBody.detail ? { message: responseBody.detail, code: responseError } : new Error('Unexpected error fetching data from the API'); @@ -708,16 +735,20 @@ export class WazuhApiCtrl { response, ); } - const errorMsg = (error.response || {}).data || error.message; + + const errorMsg = error.response?.data || error.message; + context.wazuh.logger.error(errorMsg || error); + if (devTools) { return response.ok({ body: { error: '3013', message: errorMsg || error }, }); } else { - if ((error || {}).code && ApiErrorEquivalence[error.code]) { + if (error?.code && ApiErrorEquivalence[error.code]) { error.message = ApiErrorEquivalence[error.code]; } + return ErrorResponse( errorMsg.detail || error, error.code ? `API error: ${error.code}` : 3013, @@ -741,6 +772,7 @@ export class WazuhApiCtrl { response: OpenSearchDashboardsResponseFactory, ) { const idApi = getCookieValueByName(request.headers.cookie, 'wz-api'); + if (idApi !== request.body.id) { // if the current token belongs to a different API id, we relogin to obtain a new token return ErrorResponse( @@ -750,6 +782,7 @@ export class WazuhApiCtrl { response, ); } + if (!request.body.method) { return ErrorResponse( 'Missing param: method', @@ -757,9 +790,10 @@ export class WazuhApiCtrl { HTTP_STATUS_CODES.BAD_REQUEST, response, ); - } else if (!request.body.method.match(/^(?:GET|PUT|POST|DELETE)$/)) { + } else if (!/^(?:GET|PUT|POST|DELETE)$/.test(request.body.method)) { context.wazuh.logger.error('Request method is not valid.'); - //Method is not a valid HTTP request method + + // Method is not a valid HTTP request method return ErrorResponse( 'Request method is not valid.', 3015, @@ -773,16 +807,7 @@ export class WazuhApiCtrl { HTTP_STATUS_CODES.BAD_REQUEST, response, ); - } else if (!request.body.path.startsWith('/')) { - context.wazuh.logger.error('Request path is not valid.'); - //Path doesn't start with '/' - return ErrorResponse( - 'Request path is not valid.', - 3015, - HTTP_STATUS_CODES.BAD_REQUEST, - response, - ); - } else { + } else if (request.body.path.startsWith('/')) { return this.makeRequest( context, request.body.method, @@ -791,6 +816,16 @@ export class WazuhApiCtrl { request.body.id, response, ); + } else { + context.wazuh.logger.error('Request path is not valid.'); + + // Path doesn't start with '/' + return ErrorResponse( + 'Request path is not valid.', + 3015, + HTTP_STATUS_CODES.BAD_REQUEST, + response, + ); } } @@ -807,68 +842,76 @@ export class WazuhApiCtrl { response: OpenSearchDashboardsResponseFactory, ) { try { - if (!request.body || !request.body.path) + if (!request.body || !request.body.path) { throw new Error('Field path is required'); - if (!request.body.id) throw new Error('Field id is required'); + } - const filters = Array.isArray(((request || {}).body || {}).filters) + if (!request.body.id) { + throw new Error('Field id is required'); + } + + const filters = Array.isArray(request?.body?.filters) ? request.body.filters : []; - let tmpPath = request.body.path; if (tmpPath && typeof tmpPath === 'string') { - tmpPath = tmpPath[0] === '/' ? tmpPath.substr(1) : tmpPath; + tmpPath = tmpPath[0] === '/' ? tmpPath.slice(1) : tmpPath; } - if (!tmpPath) throw new Error('An error occurred parsing path field'); + if (!tmpPath) { + throw new Error('An error occurred parsing path field'); + } context.wazuh.logger.debug(`Report ${tmpPath}`); + // Real limit, regardless the user query const params = { limit: 500 }; - if (filters.length) { + if (filters.length > 0) { for (const filter of filters) { - if (!filter.name || !filter.value) continue; + if (!filter.name || !filter.value) { + continue; + } + params[filter.name] = filter.value; } } let itemsArray = []; - const output = await context.wazuh.api.client.asCurrentUser.request( 'GET', `/${tmpPath}`, { params: params }, { apiHostID: request.body.id }, ); - const isList = request.body.path.includes('/lists') && request.body.filters && - request.body.filters.length && + request.body.filters.length > 0 && request.body.filters.find(filter => filter._isCDBList); - - const totalItems = (((output || {}).data || {}).data || {}) - .total_affected_items; + const totalItems = output?.data?.data?.total_affected_items; if (totalItems && !isList) { params.offset = 0; itemsArray.push(...output.data.data.affected_items); + while (itemsArray.length < totalItems && params.offset < totalItems) { params.offset += params.limit; + const tmpData = await context.wazuh.api.client.asCurrentUser.request( 'GET', `/${tmpPath}`, { params: params }, { apiHostID: request.body.id }, ); + itemsArray.push(...tmpData.data.data.affected_items); } } if (totalItems) { - const { path, filters } = request.body; + const { path, filters: filtersIgnored } = request.body; const isArrayOfLists = path.includes('/lists') && !isList; const isAgents = path.includes('/agents') && !path.includes('groups'); const isAgentsOfGroup = path.startsWith('/agents/groups/'); @@ -905,16 +948,19 @@ export class WazuhApiCtrl { if (isArrayOfLists) { const flatLists = []; + for (const list of itemsArray) { - const { relative_dirname, items } = list; + const { relative_dirname: relativeDirname, items } = list; + flatLists.push( ...items.map(item => ({ - relative_dirname, + relative_dirname: relativeDirname, key: item.key, value: item.value, })), ); } + fields = ['relative_dirname', 'key', 'value']; itemsArray = [...flatLists]; } @@ -923,13 +969,15 @@ export class WazuhApiCtrl { fields = ['key', 'value']; itemsArray = output.data.data.affected_items[0].items; } + fields = fields.map(item => ({ value: item, default: '-' })); const json2csvParser = new Parser({ fields }); - let csv = json2csvParser.parse(itemsArray); + for (const field of fields) { const { value } = field; + if (csv.includes(value)) { csv = csv.replace(value, KeyEquivalence[value] || value); } @@ -957,6 +1005,7 @@ export class WazuhApiCtrl { } } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse( error.message || error, 3034, @@ -972,7 +1021,7 @@ export class WazuhApiCtrl { request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, ) { - //Read a static JSON until the api call has implemented + // Read a static JSON until the api call has implemented return response.ok({ body: apiRequestList, }); @@ -1003,6 +1052,7 @@ export class WazuhApiCtrl { }); } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse( `Could not get data from wazuh-version registry due to ${ error.message || error @@ -1028,12 +1078,12 @@ export class WazuhApiCtrl { ) { try { const apiHostID = getCookieValueByName(request.headers.cookie, 'wz-api'); + if (!request.params || !apiHostID || !request.params.agent) { throw new Error('Agent ID and API ID are required'); } const { agent } = request.params; - const data = await Promise.all([ context.wazuh.api.client.asInternalUser.request( 'GET', @@ -1048,19 +1098,17 @@ export class WazuhApiCtrl { { apiHostID }, ), ]); - - const result = data.map(item => (item.data || {}).data || []); + const result = data.map(item => item.data?.data || []); const [hardwareResponse, osResponse] = result; - // Fill syscollector object const syscollector = { hardware: typeof hardwareResponse === 'object' && - Object.keys(hardwareResponse).length + Object.keys(hardwareResponse).length > 0 ? { ...hardwareResponse.affected_items[0] } : false, os: - typeof osResponse === 'object' && Object.keys(osResponse).length + typeof osResponse === 'object' && Object.keys(osResponse).length > 0 ? { ...osResponse.affected_items[0] } : false, }; @@ -1070,6 +1118,7 @@ export class WazuhApiCtrl { }); } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse( error.message || error, 3035, @@ -1091,17 +1140,16 @@ export class WazuhApiCtrl { response: OpenSearchDashboardsResponseFactory, ) { try { - const APP_LOGO = 'customization.logo.app'; - const HEALTHCHECK_LOGO = 'customization.logo.healthcheck'; - + const appLogo = 'customization.logo.app'; + const healthcheckLogo = 'customization.logo.healthcheck'; const logos = { - [APP_LOGO]: + [appLogo]: await context.wazuh_core.configuration.getCustomizationSetting( - APP_LOGO, + appLogo, ), - [HEALTHCHECK_LOGO]: + [healthcheckLogo]: await context.wazuh_core.configuration.getCustomizationSetting( - HEALTHCHECK_LOGO, + healthcheckLogo, ), }; @@ -1110,6 +1158,7 @@ export class WazuhApiCtrl { }); } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse( error.message || error, 3035, @@ -1118,6 +1167,7 @@ export class WazuhApiCtrl { ); } } + async getAppDashboards( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, @@ -1138,6 +1188,7 @@ export class WazuhApiCtrl { }); } catch (error) { context.wazuh.logger.error(error.message || error); + return ErrorResponse(error.message || error, 5030, 500, response); } } diff --git a/plugins/wazuh-core/common/constants.ts b/plugins/wazuh-core/common/constants.ts index e4ad9aa1ab..726ac2c535 100644 --- a/plugins/wazuh-core/common/constants.ts +++ b/plugins/wazuh-core/common/constants.ts @@ -9,13 +9,13 @@ * * Find more information about this on the LICENSE file. */ -import path from 'path'; +import path from 'node:path'; import { version } from '../package.json'; // import { validate as validateNodeCronInterval } from 'node-cron'; import { SettingsValidator } from '../common/services/settings-validator'; // Plugin -export const PLUGIN_VERSION = version; + export const PLUGIN_VERSION_SHORT = version.split('.').splice(0, 2).join('.'); // Index patterns - Wazuh alerts @@ -101,7 +101,7 @@ export const WAZUH_SECURITY_PLUGINS = [ ]; // App configuration -export const WAZUH_CONFIGURATION_CACHE_TIME = 10000; // time in ms; +export const WAZUH_CONFIGURATION_CACHE_TIME = 10_000; // time in ms; // Reserved ids for Users/Role mapping export const WAZUH_API_RESERVED_ID_LOWER_THAN = 100; @@ -109,6 +109,7 @@ export const WAZUH_API_RESERVED_WUI_SECURITY_RULES = [1, 2]; // Wazuh data path const WAZUH_DATA_PLUGIN_PLATFORM_BASE_PATH = 'data'; + export const WAZUH_DATA_PLUGIN_PLATFORM_BASE_ABSOLUTE_PATH = path.join( __dirname, '../../../', @@ -148,7 +149,9 @@ export const WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH = path.join( export const WAZUH_QUEUE_CRON_FREQ = '*/15 * * * * *'; // Every 15 seconds // Wazuh errors -export const WAZUH_ERROR_DAEMONS_NOT_READY = 'ERROR3099'; +export enum WAZUH_ERROR_CODES { + DAEMONS_NOT_READY = 3099, +} // Agents export enum WAZUH_AGENTS_OS_TYPE { @@ -234,7 +237,7 @@ export const WAZUH_LINK_SLACK = 'https://wazuh.com/community/join-us-on-slack'; export const HEALTH_CHECK = 'health-check'; // Health check -export const HEALTH_CHECK_REDIRECTION_TIME = 300; //ms +export const HEALTH_CHECK_REDIRECTION_TIME = 300; // ms // Plugin platform settings // Default timeFilter set by the app @@ -246,7 +249,7 @@ export const PLUGIN_PLATFORM_SETTING_NAME_TIME_FILTER = 'timepicker:timeDefaults'; // Default maxBuckets set by the app -export const WAZUH_PLUGIN_PLATFORM_SETTING_MAX_BUCKETS = 200000; +export const WAZUH_PLUGIN_PLATFORM_SETTING_MAX_BUCKETS = 200_000; export const PLUGIN_PLATFORM_SETTING_NAME_MAX_BUCKETS = 'timeline:max_buckets'; // Default metaFields set by the app @@ -386,7 +389,7 @@ export const NOT_TIME_FIELD_NAME_INDEX_PATTERN = 'not_time_field_name_index_pattern'; // Customization -export const CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES = 1048576; +export const CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES = 1_048_576; // Plugin settings export enum SettingCategory { @@ -400,23 +403,27 @@ export enum SettingCategory { API_CONNECTION, } -type TPluginSettingOptionsTextArea = { +interface TPluginSettingOptionsArrayOf { + arrayOf: Record; +} + +interface TPluginSettingOptionsTextArea { maxRows?: number; minRows?: number; maxLength?: number; -}; +} -type TPluginSettingOptionsSelect = { +interface TPluginSettingOptionsSelect { select: { text: string; value: any }[]; -}; +} -type TPluginSettingOptionsEditor = { +interface TPluginSettingOptionsEditor { editor: { language: string; }; -}; +} -type TPluginSettingOptionsFile = { +interface TPluginSettingOptionsFile { file: { type: 'image'; extensions?: string[]; @@ -437,24 +444,24 @@ type TPluginSettingOptionsFile = { resolveStaticURL: (filename: string) => string; }; }; -}; +} -type TPluginSettingOptionsNumber = { +interface TPluginSettingOptionsNumber { number: { min?: number; max?: number; integer?: boolean; }; -}; +} -type TPluginSettingOptionsSwitch = { +interface TPluginSettingOptionsSwitch { switch: { values: { disabled: { label?: string; value: any }; enabled: { label?: string; value: any }; }; }; -}; +} export enum EpluginSettingType { text = 'text', @@ -469,7 +476,7 @@ export enum EpluginSettingType { custom = 'custom', } -export type TPluginSetting = { +export interface TPluginSetting { // Define the text displayed in the UI. title: string; // Description. @@ -509,7 +516,8 @@ export type TPluginSetting = { | TPluginSettingOptionsNumber | TPluginSettingOptionsSelect | TPluginSettingOptionsSwitch - | TPluginSettingOptionsTextArea; + | TPluginSettingOptionsTextArea + | TPluginSettingOptionsArrayOf; // Transform the input value. The result is saved in the form global state of Settings/Configuration uiFormTransformChangedInputValue?: (value: any) => any; // Transform the configuration value or default as initial value for the input in Settings/Configuration @@ -520,68 +528,8 @@ export type TPluginSetting = { validateUIForm?: (value: any) => string | undefined; // Validate function creator to validate the setting in the backend. validate?: (value: unknown) => string | undefined; -}; - -export type TPluginSettingWithKey = TPluginSetting & { key: TPluginSettingKey }; -export type TPluginSettingCategory = { - title: string; - description?: string; - documentationLink?: string; - renderOrder?: number; -}; - -export const PLUGIN_SETTINGS_CATEGORIES: { - [category: number]: TPluginSettingCategory; -} = { - [SettingCategory.HEALTH_CHECK]: { - title: 'Health check', - description: "Checks will be executed by the app's Healthcheck.", - renderOrder: SettingCategory.HEALTH_CHECK, - }, - [SettingCategory.GENERAL]: { - title: 'General', - description: - 'Basic app settings related to alerts index pattern, hide the manager alerts in the dashboards, logs level and more.', - renderOrder: SettingCategory.GENERAL, - }, - [SettingCategory.SECURITY]: { - title: 'Security', - description: 'Application security options such as unauthorized roles.', - renderOrder: SettingCategory.SECURITY, - }, - [SettingCategory.MONITORING]: { - title: 'Task:Monitoring', - description: - 'Options related to the agent status monitoring job and its storage in indexes.', - renderOrder: SettingCategory.MONITORING, - }, - [SettingCategory.STATISTICS]: { - title: 'Task:Statistics', - description: - 'Options related to the daemons manager monitoring job and their storage in indexes.', - renderOrder: SettingCategory.STATISTICS, - }, - [SettingCategory.VULNERABILITIES]: { - title: 'Vulnerabilities', - description: - 'Options related to the agent vulnerabilities monitoring job and its storage in indexes.', - renderOrder: SettingCategory.VULNERABILITIES, - }, - [SettingCategory.CUSTOMIZATION]: { - title: 'Custom branding', - description: - 'If you want to use custom branding elements such as logos, you can do so by editing the settings below.', - documentationLink: 'user-manual/wazuh-dashboard/white-labeling.html', - renderOrder: SettingCategory.CUSTOMIZATION, - }, - [SettingCategory.API_CONNECTION]: { - title: 'API connections', - description: 'Options related to the API connections.', - renderOrder: SettingCategory.API_CONNECTION, - }, -}; - -export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { +} +export const PLUGIN_SETTINGS: Record = { 'alerts.sample.prefix': { title: 'Sample alerts prefix', description: @@ -597,7 +545,7 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { isConfigurableFromSettings: true, requiresRunningHealthCheck: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, // Validation: https://github.com/elastic/elasticsearch/blob/v7.10.2/docs/reference/indices/create-index.asciidoc validate: SettingsValidator.compose( @@ -639,15 +587,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.fields': { title: 'Known fields', @@ -670,15 +616,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.maxBuckets': { title: 'Set max buckets to 200000', @@ -701,15 +645,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.metaFields': { title: 'Remove meta fields', @@ -732,15 +674,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.pattern': { title: 'Index pattern', @@ -763,15 +703,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.setup': { title: 'API version', @@ -794,15 +732,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.template': { title: 'Index template', @@ -825,15 +761,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'checks.timeFilter': { title: 'Set time filter to 24h', @@ -856,15 +790,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'configuration.ui_api_editable': { title: 'Configuration UI editable', @@ -888,15 +820,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'cron.prefix': { title: 'Cron prefix', @@ -911,7 +841,7 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { defaultValue: WAZUH_STATISTICS_DEFAULT_PREFIX, isConfigurableFromSettings: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, // Validation: https://github.com/elastic/elasticsearch/blob/v7.10.2/docs/reference/indices/create-index.asciidoc validate: SettingsValidator.compose( @@ -959,12 +889,14 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { ): any { try { return JSON.parse(value); - } catch (error) { + } catch { return value; } }, validateUIForm: function (value) { - return SettingsValidator.json(this.validate)(value); + return SettingsValidator.json( + this.validate as (value: unknown) => string | undefined, + )(value); }, validate: SettingsValidator.compose( SettingsValidator.array( @@ -1010,11 +942,11 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { isConfigurableFromSettings: true, requiresRunningHealthCheck: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: function (value) { return SettingsValidator.literal( - this.options.select.map(({ value }) => value), + this.options?.select.map(({ value }) => value), )(value); }, }, @@ -1075,24 +1007,16 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function ( - value: number, - ): string { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'cron.statistics.index.shards': { title: 'Index shards', @@ -1114,22 +1038,16 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function (value: number) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'cron.statistics.interval': { title: 'Interval', @@ -1171,15 +1089,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'customization.enabled': { title: 'Status', @@ -1202,15 +1118,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'customization.logo.app': { title: 'App main logo', @@ -1251,11 +1165,11 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { validateUIForm: function (value) { return SettingsValidator.compose( SettingsValidator.filePickerFileSize({ - ...this.options.file.size, + ...(this.options as TPluginSettingOptionsFile)?.file.size, meaningfulUnit: true, }), SettingsValidator.filePickerSupportedExtensions( - this.options.file.extensions, + (this.options as TPluginSettingOptionsFile)?.file.extensions ?? [], ), )(value); }, @@ -1299,11 +1213,11 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { validateUIForm: function (value) { return SettingsValidator.compose( SettingsValidator.filePickerFileSize({ - ...this.options.file.size, + ...(this.options as TPluginSettingOptionsFile)?.file.size, meaningfulUnit: true, }), SettingsValidator.filePickerSupportedExtensions( - this.options.file.extensions, + (this.options as TPluginSettingOptionsFile)?.file.extensions ?? [], ), )(value); }, @@ -1346,11 +1260,11 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { validateUIForm: function (value) { return SettingsValidator.compose( SettingsValidator.filePickerFileSize({ - ...this.options.file.size, + ...(this.options as TPluginSettingOptionsFile)?.file.size, meaningfulUnit: true, }), SettingsValidator.filePickerSupportedExtensions( - this.options.file.extensions, + (this.options as TPluginSettingOptionsFile)?.file.extensions ?? [], ), )(value); }, @@ -1370,14 +1284,14 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { isConfigurableFromSettings: true, options: { maxRows: 2, maxLength: 50 }, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: function (value) { return SettingsValidator.compose( SettingsValidator.isString, SettingsValidator.multipleLinesString({ - maxRows: this.options?.maxRows, - maxLength: this.options?.maxLength, + maxRows: (this.options as TPluginSettingOptionsTextArea)?.maxRows, + maxLength: (this.options as TPluginSettingOptionsTextArea)?.maxLength, }), )(value); }, @@ -1397,14 +1311,14 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { isConfigurableFromSettings: true, options: { maxRows: 3, maxLength: 40 }, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: function (value) { return SettingsValidator.compose( SettingsValidator.isString, SettingsValidator.multipleLinesString({ - maxRows: this.options?.maxRows, - maxLength: this.options?.maxLength, + maxRows: (this.options as TPluginSettingOptionsTextArea)?.maxRows, + maxLength: (this.options as TPluginSettingOptionsTextArea)?.maxLength, }), )(value); }, @@ -1423,7 +1337,7 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { defaultValue: '', isConfigurableFromSettings: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: SettingsValidator.compose( SettingsValidator.isString, @@ -1444,7 +1358,7 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { defaultValue: '', isConfigurableFromSettings: false, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: SettingsValidator.compose( SettingsValidator.isString, @@ -1472,15 +1386,13 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = { }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, hosts: { title: 'Server hosts', @@ -1520,12 +1432,12 @@ hosts: username: wazuh-wui password: wazuh-wui run_as: false`, - transformFrom: value => { - return value.map(hostData => { + transformFrom: value => + value.map((hostData: Record) => { const key = Object.keys(hostData)?.[0]; + return { ...hostData[key], id: key }; - }); - }, + }), }, }, options: { @@ -1536,7 +1448,7 @@ hosts: type: EpluginSettingType.text, defaultValue: 'default', isConfigurableFromSettings: true, - validateUIForm: function (value) { + validateUIForm: function (value: string) { return this.validate(value); }, validate: SettingsValidator.compose( @@ -1550,7 +1462,7 @@ hosts: type: EpluginSettingType.text, defaultValue: 'https://localhost', isConfigurableFromSettings: true, - validateUIForm: function (value) { + validateUIForm: function (value: string) { return this.validate(value); }, validate: SettingsValidator.compose( @@ -1562,31 +1474,23 @@ hosts: title: 'Port', description: 'Port', type: EpluginSettingType.number, - defaultValue: 55000, + defaultValue: 55_000, isConfigurableFromSettings: true, options: { number: { min: 0, - max: 65535, + max: 65_535, integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function ( - value: number, - ) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, - validateUIForm: function (value) { + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, + validateUIForm: function (value: number) { return this.validate( this.uiFormTransformInputValueToConfigurationValue(value), ); }, - validate: function (value) { + validate: function (value: number) { return SettingsValidator.number(this.options.number)(value); }, }, @@ -1596,7 +1500,7 @@ hosts: type: EpluginSettingType.text, defaultValue: 'wazuh-wui', isConfigurableFromSettings: true, - validateUIForm: function (value) { + validateUIForm: function (value: string) { return this.validate(value); }, validate: SettingsValidator.compose( @@ -1610,7 +1514,7 @@ hosts: type: EpluginSettingType.password, defaultValue: 'wazuh-wui', isConfigurableFromSettings: true, - validateUIForm: function (value) { + validateUIForm: function (value: string) { return this.validate(value); }, validate: SettingsValidator.compose( @@ -1632,12 +1536,8 @@ hosts: }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, - validateUIForm: function (value) { + uiFormTransformChangedInputValue: Boolean, + validateUIForm: function (value: string) { return this.validate(value); }, validate: SettingsValidator.isBoolean, @@ -1645,11 +1545,7 @@ hosts: }, }, isConfigurableFromSettings: false, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, // TODO: add validation // validate: SettingsValidator.isBoolean, // validate: function (schema) { @@ -1682,13 +1578,15 @@ hosts: ): any { try { return JSON.parse(value); - } catch (error) { + } catch { return value; } }, // Validation: https://github.com/elastic/elasticsearch/blob/v7.10.2/docs/reference/indices/create-index.asciidoc validateUIForm: function (value) { - return SettingsValidator.json(this.validate)(value); + return SettingsValidator.json( + this.validate as (value: unknown) => string | undefined, + )(value); }, validate: SettingsValidator.compose( SettingsValidator.array( @@ -1734,15 +1632,13 @@ hosts: }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'wazuh.updates.disabled': { title: 'Check updates', @@ -1764,12 +1660,10 @@ hosts: }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, - validate: SettingsValidator.isBoolean, + uiFormTransformChangedInputValue: Boolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, pattern: { title: 'Index pattern', @@ -1787,7 +1681,7 @@ hosts: requiresRunningHealthCheck: true, // Validation: https://github.com/elastic/elasticsearch/blob/v7.10.2/docs/reference/indices/create-index.asciidoc validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: SettingsValidator.compose( SettingsValidator.isString, @@ -1819,7 +1713,7 @@ hosts: 'Maximum time, in milliseconds, the app will wait for an API response when making requests to it. It will be ignored if the value is set under 1500 milliseconds.', category: SettingCategory.GENERAL, type: EpluginSettingType.number, - defaultValue: 20000, + defaultValue: 20_000, isConfigurableFromSettings: true, options: { number: { @@ -1827,22 +1721,16 @@ hosts: integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function (value: number) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'wazuh.monitoring.creation': { title: 'Index creation', @@ -1879,11 +1767,11 @@ hosts: isConfigurableFromSettings: true, requiresRunningHealthCheck: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: function (value) { return SettingsValidator.literal( - this.options.select.map(({ value }) => value), + this.options?.select.map(({ value }) => value), )(value); }, }, @@ -1909,15 +1797,13 @@ hosts: }, }, }, - uiFormTransformChangedInputValue: function ( - value: boolean | string, - ): boolean { - return Boolean(value); - }, + uiFormTransformChangedInputValue: Boolean, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, - validate: SettingsValidator.isBoolean, + validate: SettingsValidator.isBoolean as ( + value: unknown, + ) => string | undefined, }, 'wazuh.monitoring.frequency': { title: 'Frequency', @@ -1939,22 +1825,16 @@ hosts: integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function (value: number) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'wazuh.monitoring.pattern': { title: 'Index pattern', @@ -1970,7 +1850,7 @@ hosts: isConfigurableFromSettings: true, requiresRunningHealthCheck: true, validateUIForm: function (value) { - return this.validate(value); + return this.validate?.(value); }, validate: SettingsValidator.compose( SettingsValidator.isString, @@ -2011,22 +1891,16 @@ hosts: integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function (value: number) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'wazuh.monitoring.shards': { title: 'Index shards', @@ -2048,22 +1922,16 @@ hosts: integer: true, }, }, - uiFormTransformConfigurationValueToInputValue: function (value: number) { - return String(value); - }, - uiFormTransformInputValueToConfigurationValue: function ( - value: string, - ): number { - return Number(value); - }, + uiFormTransformConfigurationValueToInputValue: String, + uiFormTransformInputValueToConfigurationValue: Number, validateUIForm: function (value) { - return this.validate( - this.uiFormTransformInputValueToConfigurationValue(value), + return this.validate?.( + this.uiFormTransformInputValueToConfigurationValue?.(value), ); }, - validate: function (value) { - return SettingsValidator.number(this.options.number)(value); - }, + validate: function (value: number) { + return SettingsValidator.number(this.options?.number)(value); + } as (value: unknown) => string | undefined, }, 'vulnerabilities.pattern': { title: 'Index pattern', @@ -2101,8 +1969,66 @@ hosts: ), }, }; - export type TPluginSettingKey = keyof typeof PLUGIN_SETTINGS; +export type TPluginSettingWithKey = TPluginSetting & { key: TPluginSettingKey }; +export interface TPluginSettingCategory { + title: string; + description?: string; + documentationLink?: string; + renderOrder?: number; +} + +export const PLUGIN_SETTINGS_CATEGORIES: Record< + number, + TPluginSettingCategory +> = { + [SettingCategory.HEALTH_CHECK]: { + title: 'Health check', + description: "Checks will be executed by the app's Healthcheck.", + renderOrder: SettingCategory.HEALTH_CHECK, + }, + [SettingCategory.GENERAL]: { + title: 'General', + description: + 'Basic app settings related to alerts index pattern, hide the manager alerts in the dashboards, logs level and more.', + renderOrder: SettingCategory.GENERAL, + }, + [SettingCategory.SECURITY]: { + title: 'Security', + description: 'Application security options such as unauthorized roles.', + renderOrder: SettingCategory.SECURITY, + }, + [SettingCategory.MONITORING]: { + title: 'Task:Monitoring', + description: + 'Options related to the agent status monitoring job and its storage in indexes.', + renderOrder: SettingCategory.MONITORING, + }, + [SettingCategory.STATISTICS]: { + title: 'Task:Statistics', + description: + 'Options related to the daemons manager monitoring job and their storage in indexes.', + renderOrder: SettingCategory.STATISTICS, + }, + [SettingCategory.VULNERABILITIES]: { + title: 'Vulnerabilities', + description: + 'Options related to the agent vulnerabilities monitoring job and its storage in indexes.', + renderOrder: SettingCategory.VULNERABILITIES, + }, + [SettingCategory.CUSTOMIZATION]: { + title: 'Custom branding', + description: + 'If you want to use custom branding elements such as logos, you can do so by editing the settings below.', + documentationLink: 'user-manual/wazuh-dashboard/white-labeling.html', + renderOrder: SettingCategory.CUSTOMIZATION, + }, + [SettingCategory.API_CONNECTION]: { + title: 'API connections', + description: 'Options related to the API connections.', + renderOrder: SettingCategory.API_CONNECTION, + }, +}; export enum HTTP_STATUS_CODES { CONTINUE = 100, @@ -2192,3 +2118,5 @@ export const WAZUH_ROLE_ADMINISTRATOR_ID = 1; // ID used to refer the createOsdUrlStateStorage state export const OSD_URL_STATE_STORAGE_ID = 'state:storeInSessionStorage'; + +export { version as PLUGIN_VERSION } from '../package.json'; diff --git a/plugins/wazuh-core/common/services/configuration.ts b/plugins/wazuh-core/common/services/configuration.ts index fcb642e2bb..f1d305918c 100644 --- a/plugins/wazuh-core/common/services/configuration.ts +++ b/plugins/wazuh-core/common/services/configuration.ts @@ -1,37 +1,38 @@ import { cloneDeep } from 'lodash'; +import { EpluginSettingType } from '../constants'; import { formatLabelValuePair } from './settings'; import { formatBytes } from './file-size'; -export interface ILogger { - debug(message: string): void; - info(message: string): void; - warn(message: string): void; - error(message: string): void; +export interface Logger { + debug: (message: string) => void; + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; } -type TConfigurationSettingOptionsPassword = { +interface TConfigurationSettingOptionsPassword { password: { dual?: 'text' | 'password' | 'dual'; }; -}; +} -type TConfigurationSettingOptionsTextArea = { +interface TConfigurationSettingOptionsTextArea { maxRows?: number; minRows?: number; maxLength?: number; -}; +} -type TConfigurationSettingOptionsSelect = { +interface TConfigurationSettingOptionsSelect { select: { text: string; value: any }[]; -}; +} -type TConfigurationSettingOptionsEditor = { +interface TConfigurationSettingOptionsEditor { editor: { language: string; }; -}; +} -type TConfigurationSettingOptionsFile = { +interface TConfigurationSettingOptionsFile { file: { type: 'image'; extensions?: string[]; @@ -52,37 +53,26 @@ type TConfigurationSettingOptionsFile = { resolveStaticURL: (filename: string) => string; }; }; -}; +} -type TConfigurationSettingOptionsNumber = { +interface TConfigurationSettingOptionsNumber { number: { min?: number; max?: number; integer?: boolean; }; -}; +} -type TConfigurationSettingOptionsSwitch = { +interface TConfigurationSettingOptionsSwitch { switch: { values: { disabled: { label?: string; value: any }; enabled: { label?: string; value: any }; }; }; -}; - -export enum EpluginSettingType { - text = 'text', - password = 'password', - textarea = 'textarea', - switch = 'switch', - number = 'number', - editor = 'editor', - select = 'select', - filepicker = 'filepicker', } -export type TConfigurationSetting = { +export interface TConfigurationSetting { // Define the text displayed in the UI. title: string; // Description. @@ -133,19 +123,19 @@ export type TConfigurationSetting = { validateUIForm?: (value: any) => string | undefined; // Validate function creator to validate the setting in the backend. validate?: (schema: any) => (value: unknown) => string | undefined; -}; +} export type TConfigurationSettingWithKey = TConfigurationSetting & { key: string; }; -export type TConfigurationSettingCategory = { +export interface TConfigurationSettingCategory { title: string; description?: string; documentationLink?: string; renderOrder?: number; -}; +} +type TConfigurationSettings = Record; -type TConfigurationSettings = { [key: string]: any }; export interface IConfigurationStore { setup: () => Promise; start: () => Promise; @@ -153,70 +143,77 @@ export interface IConfigurationStore { get: (...settings: string[]) => Promise; set: (settings: TConfigurationSettings) => Promise; clear: (...settings: string[]) => Promise; + // eslint-disable-next-line no-use-before-define setConfiguration: (configuration: IConfiguration) => void; } export interface IConfiguration { - setStore(store: IConfigurationStore): void; - setup(): Promise; - start(): Promise; - stop(): Promise; - register(id: string, value: any): void; - get(...settings: string[]): Promise; - set(settings: TConfigurationSettings): Promise; - clear(...settings: string[]): Promise; - reset(...settings: string[]): Promise; - _settings: Map< - string, - { - [key: string]: TConfigurationSetting; - } - >; - getSettingValue(settingKey: string, value?: any): any; - getSettingValueIfNotSet(settingKey: string, value?: any): any; + setStore: (store: IConfigurationStore) => void; + setup: () => Promise; + start: () => Promise; + stop: () => Promise; + register: (id: string, value: any) => void; + get: (...settings: string[]) => Promise; + set: (settings: TConfigurationSettings) => Promise; + clear: (...settings: string[]) => Promise; + reset: (...settings: string[]) => Promise; + _settings: Map>; + getSettingValue: (settingKey: string, value?: any) => any; + getSettingValueIfNotSet: (settingKey: string, value?: any) => any; } export class Configuration implements IConfiguration { store: IConfigurationStore | null = null; - _settings: Map; - _categories: Map; - constructor(private logger: ILogger, store: IConfigurationStore) { + _settings: Map>; + _categories: Map>; + + constructor( + private readonly logger: Logger, + store: IConfigurationStore, + ) { this._settings = new Map(); this._categories = new Map(); this.setStore(store); } + setStore(store: IConfigurationStore) { this.store = store; this.store.setConfiguration(this); } + async setup(dependencies: any = {}) { return this.store.setup(dependencies); } + async start(dependencies: any = {}) { return this.store.start(dependencies); } + async stop(dependencies: any = {}) { return this.store.stop(dependencies); } + /** * Register a setting * @param id * @param value */ register(id: string, value: any) { - if (!this._settings.has(id)) { + if (this._settings.has(id)) { + const message = `Setting ${id} exists`; + + this.logger.error(message); + throw new Error(message); + } else { // Enhance the setting const enhancedValue = value; + // Enhance the description enhancedValue._description = value.description; enhancedValue.description = this.enhanceSettingDescription(value); // Register the setting this._settings.set(id, enhancedValue); this.logger.debug(`Registered ${id}`); - } else { - const message = `Setting ${id} exists`; - this.logger.error(message); - throw new Error(message); } } @@ -245,20 +242,25 @@ export class Configuration implements IConfiguration { this.logger.debug( `Getting value for [${settingKey}]: stored [${JSON.stringify(value)}]`, ); + if (!this._settings.has(settingKey)) { throw new Error(`${settingKey} is not registered`); } - if (typeof value !== 'undefined') { + + if (value !== undefined) { return value; } + const setting = this._settings.get(settingKey); const finalValue = - typeof setting.defaultValueIfNotSet !== 'undefined' - ? setting.defaultValueIfNotSet - : setting.defaultValue; + setting.defaultValueIfNotSet === undefined + ? setting.defaultValue + : setting.defaultValueIfNotSet; + this.logger.debug( `Value for [${settingKey}]: [${JSON.stringify(finalValue)}]`, ); + return finalValue; } @@ -273,19 +275,25 @@ export class Configuration implements IConfiguration { this.logger.debug( `Getting value for [${settingKey}]: stored [${JSON.stringify(value)}]`, ); + if (!this._settings.has(settingKey)) { throw new Error(`${settingKey} is not registered`); } - if (typeof value !== 'undefined') { + + if (value !== undefined) { return value; } + const setting = this._settings.get(settingKey); const finalValue = setting.defaultValue; + this.logger.debug( `Value for [${settingKey}]: [${JSON.stringify(finalValue)}]`, ); + return finalValue; } + /** * Get the value for all settings or a subset of them * @param rest @@ -293,57 +301,60 @@ export class Configuration implements IConfiguration { */ async get(...settings: string[]) { this.logger.debug( - settings.length + settings.length > 0 ? `Getting settings [${settings.join(',')}]` : 'Getting settings', ); + const stored = await this.store.get(...settings); + this.logger.debug(`configuration stored: ${JSON.stringify({ stored })}`); const result = settings && settings.length === 1 ? this.getSettingValue(settings[0], stored[settings[0]]) - : (settings.length > 1 - ? settings - : Array.from(this._settings.keys()) - ).reduce( - (accum, key) => ({ - ...accum, - [key]: this.getSettingValue(key, stored[key]), - }), - {}, + : Object.fromEntries( + (settings.length > 1 ? settings : [...this._settings.keys()]).map( + key => [key, this.getSettingValue(key, stored[key])], + ), ); // Clone the result. This avoids the object reference can be changed when managing the result. return cloneDeep(result); } + /** * Set a the value for a subset of settings * @param settings * @returns */ - async set(settings: { [key: string]: any }) { + async set(settings: Record) { const settingsAreRegistered = Object.entries(settings) .map(([key]) => this._settings.has(key) ? null : `${key} is not registered`, ) - .filter(value => value); - if (settingsAreRegistered.length) { + .filter(Boolean); + + if (settingsAreRegistered.length > 0) { throw new Error(`${settingsAreRegistered.join(', ')} are not registered`); } const validationErrors = Object.entries(settings) .map(([key, value]) => { const validationError = this._settings.get(key)?.validate?.(value); + return validationError ? `setting [${key}]: ${validationError}` : undefined; }) - .filter(value => value); - if (validationErrors.length) { + .filter(Boolean); + + if (validationErrors.length > 0) { throw new Error(`Validation errors: ${validationErrors.join('\n')}`); } + const responseStore = await this.store.set(settings); + return { requirements: this.checkRequirementsOnUpdatedSettings( Object.keys(responseStore), @@ -358,10 +369,13 @@ export class Configuration implements IConfiguration { * @returns */ async clear(...settings: string[]) { - if (settings.length) { + if (settings.length > 0) { this.logger.debug(`Clean settings: ${settings.join(', ')}`); + const responseStore = await this.store.clear(...settings); + this.logger.info('Settings were cleared'); + return { requirements: this.checkRequirementsOnUpdatedSettings( Object.keys(responseStore), @@ -369,7 +383,7 @@ export class Configuration implements IConfiguration { update: responseStore, }; } else { - return await this.clear(...Array.from(this._settings.keys())); + return await this.clear(...this._settings.keys()); } } @@ -379,16 +393,19 @@ export class Configuration implements IConfiguration { * @returns */ async reset(...settings: string[]) { - if (settings.length) { + if (settings.length > 0) { this.logger.debug(`Reset settings: ${settings.join(', ')}`); - const updatedSettings = settings.reduce((accum, settingKey: string) => { - return { - ...accum, - [settingKey]: this.getSettingValue(settingKey), - }; - }, {}); + + const updatedSettings = Object.fromEntries( + settings.map((settingKey: string) => [ + settingKey, + this.getSettingValue(settingKey), + ]), + ); const responseStore = await this.store.set(updatedSettings); + this.logger.info('Settings were reset'); + return { requirements: this.checkRequirementsOnUpdatedSettings( Object.keys(responseStore), @@ -405,6 +422,7 @@ export class Configuration implements IConfiguration { this.logger.error(`Registered category [${id}]`); throw new Error(`Category exists [${id}]`); } + this._categories.set(id, rest); this.logger.debug(`Registered category [${id}]`); } @@ -412,7 +430,7 @@ export class Configuration implements IConfiguration { getUniqueCategories() { return [ ...new Set( - Array.from(this._settings.entries()) + [...this._settings.entries()] .filter( ([, { isConfigurableFromSettings }]) => isConfigurableFromSettings, ) @@ -426,11 +444,14 @@ export class Configuration implements IConfiguration { } else if (categoryA.title < categoryB.title) { return -1; } + return 0; }); } + private enhanceSettingDescription(setting: TConfigurationSetting) { const { description, options } = setting; + return [ description, ...(options?.select @@ -473,57 +494,57 @@ export class Configuration implements IConfiguration { ] : []), // File size - ...(options?.file?.size && - typeof options.file.size.minBytes !== 'undefined' + ...(options?.file?.size && options.file.size.minBytes !== undefined ? [`Minimum file size: ${formatBytes(options.file.size.minBytes)}.`] : []), - ...(options?.file?.size && - typeof options.file.size.maxBytes !== 'undefined' + ...(options?.file?.size && options.file.size.maxBytes !== undefined ? [`Maximum file size: ${formatBytes(options.file.size.maxBytes)}.`] : []), // Multi line text - ...(options?.maxRows && typeof options.maxRows !== 'undefined' + ...(options?.maxRows && options.maxRows !== undefined ? [`Maximum amount of lines: ${options.maxRows}.`] : []), - ...(options?.minRows && typeof options.minRows !== 'undefined' + ...(options?.minRows && options.minRows !== undefined ? [`Minimum amount of lines: ${options.minRows}.`] : []), - ...(options?.maxLength && typeof options.maxLength !== 'undefined' + ...(options?.maxLength && options.maxLength !== undefined ? [`Maximum lines length is ${options.maxLength} characters.`] : []), ].join(' '); } + groupSettingsByCategory( - _settings: string[] | null = null, + settings: string[] | null = null, filterFunction: | ((setting: TConfigurationSettingWithKey) => boolean) | null = null, ) { - const settings = ( - _settings && Array.isArray(_settings) - ? Array.from(this._settings.entries()).filter(([key]) => - _settings.includes(key), + const settingsMapped = ( + settings && Array.isArray(settings) + ? [...this._settings.entries()].filter(([key]) => + settings.includes(key), ) - : Array.from(this._settings.entries()) + : [...this._settings.entries()] ).map(([key, value]) => ({ ...value, key, })); - const settingsSortedByCategories = ( - filterFunction ? settings.filter(filterFunction) : settings - ) - .sort((settingA, settingB) => settingA.key?.localeCompare?.(settingB.key)) - .reduce( - (accum, pluginSettingConfiguration) => ({ - ...accum, - [pluginSettingConfiguration.category]: [ - ...(accum[pluginSettingConfiguration.category] || []), - { ...pluginSettingConfiguration }, - ], - }), - {}, - ); + filterFunction + ? settingsMapped.filter(element => filterFunction(element)) + : settingsMapped + ).sort((settingA, settingB) => settingA.key?.localeCompare?.(settingB.key)); + const result: any = {}; + + for (const pluginSettingConfiguration of settingsSortedByCategories) { + const category = pluginSettingConfiguration.category; + + if (!result[category]) { + result[category] = []; + } + + result[category].push({ ...pluginSettingConfiguration }); + } return Object.entries(settingsSortedByCategories) .map(([category, settings]) => ({ diff --git a/plugins/wazuh-core/common/services/settings-validator.ts b/plugins/wazuh-core/common/services/settings-validator.ts index f0392e314a..15503d6330 100644 --- a/plugins/wazuh-core/common/services/settings-validator.ts +++ b/plugins/wazuh-core/common/services/settings-validator.ts @@ -1,47 +1,48 @@ -import path from 'path'; +import path from 'node:path'; import { formatBytes } from './file-size'; -export class SettingsValidator { +export const SettingsValidator = { /** * Create a function that is a composition of the input validations * @param functions SettingsValidator functions to compose * @returns composed validation */ - static compose(...functions) { - return function composedValidation(value) { - for (const fn of functions) { - const result = fn(value); + compose(...functions: ((value: any) => string | undefined)[]) { + return function composedValidation(value: any) { + for (const callback of functions) { + const result = callback(value); + if (typeof result === 'string' && result.length > 0) { return result; } } }; - } + }, /** * Check the value is a string * @param value * @returns */ - static isString(value: unknown): string | undefined { + isString(value: unknown): string | undefined { return typeof value === 'string' ? undefined : 'Value is not a string.'; - } + }, /** * Check the string has no spaces * @param value * @returns */ - static hasNoSpaces(value: string): string | undefined { + hasNoSpaces(value: string): string | undefined { return /^\S*$/.test(value) ? undefined : 'No whitespaces allowed.'; - } + }, /** * Check the string has no empty * @param value * @returns */ - static isNotEmptyString(value: string): string | undefined { + isNotEmptyString(value: string): string | undefined { if (typeof value === 'string') { if (value.length === 0) { return 'Value can not be empty.'; @@ -49,39 +50,43 @@ export class SettingsValidator { return undefined; } } - } + }, /** * Check the number of string lines is limited * @param options * @returns */ - static multipleLinesString( + multipleLinesString( options: { minRows?: number; maxRows?: number; maxLength?: number } = {}, ) { return function (value: string) { const lines = value.split(/\r\n|\r|\n/).length; + const maxLength = options.maxLength; + if ( - typeof options.maxLength !== 'undefined' && - value.split('\n').some(line => line.length > options.maxLength) + maxLength !== undefined && + value.split('\n').some(line => line.length > maxLength) ) { - return `The maximum length of a line is ${options.maxLength} characters.`; + return `The maximum length of a line is ${maxLength} characters.`; } - if (typeof options.minRows !== 'undefined' && lines < options.minRows) { + + if (options.minRows !== undefined && lines < options.minRows) { return `The string should have more or ${options.minRows} line/s.`; } - if (typeof options.maxRows !== 'undefined' && lines > options.maxRows) { + + if (options.maxRows !== undefined && lines > options.maxRows) { return `The string should have less or equal to ${options.maxRows} line/s.`; } }; - } + }, /** * Creates a function that checks the string does not contain some characters * @param invalidCharacters * @returns */ - static hasNotInvalidCharacters(...invalidCharacters: string[]) { + hasNotInvalidCharacters(...invalidCharacters: string[]) { return function (value: string): string | undefined { return invalidCharacters.some(invalidCharacter => value.includes(invalidCharacter), @@ -91,14 +96,14 @@ export class SettingsValidator { )}.` : undefined; }; - } + }, /** * Creates a function that checks the string does not start with a substring * @param invalidStartingCharacters * @returns */ - static noStartsWithString(...invalidStartingCharacters: string[]) { + noStartsWithString(...invalidStartingCharacters: string[]) { return function (value: string): string | undefined { return invalidStartingCharacters.some(invalidStartingCharacter => value.startsWith(invalidStartingCharacter), @@ -106,49 +111,47 @@ export class SettingsValidator { ? `It can't start with: ${invalidStartingCharacters.join(', ')}.` : undefined; }; - } + }, /** * Creates a function that checks the string is not equals to some values * @param invalidLiterals * @returns */ - static noLiteralString(...invalidLiterals: string[]) { + noLiteralString(...invalidLiterals: string[]) { return function (value: string): string | undefined { - return invalidLiterals.some(invalidLiteral => value === invalidLiteral) + return invalidLiterals.includes(value) ? `It can't be: ${invalidLiterals.join(', ')}.` : undefined; }; - } + }, /** * Check the value is a boolean * @param value * @returns */ - static isBoolean(value: string): string | undefined { + isBoolean(value: string): string | undefined { return typeof value === 'boolean' ? undefined : 'It should be a boolean. Allowed values: true or false.'; - } + }, /** * Check the value is a number * @param value * @returns */ - static isNumber(value: string): string | undefined { + isNumber(value: string): string | undefined { return typeof value === 'number' ? undefined : 'Value is not a number.'; - } + }, /** * Check the value is a number between some optional limits * @param options * @returns */ - static number( - options: { min?: number; max?: number; integer?: boolean } = {}, - ) { + number(options: { min?: number; max?: number; integer?: boolean } = {}) { return function (value: number) { if (typeof value !== 'number') { return 'Value is not a number.'; @@ -158,130 +161,119 @@ export class SettingsValidator { return 'Number should be an integer.'; } - if (typeof options.min !== 'undefined' && value < options.min) { + if (options.min !== undefined && value < options.min) { return `Value should be greater or equal than ${options.min}.`; } - if (typeof options.max !== 'undefined' && value > options.max) { + + if (options.max !== undefined && value > options.max) { return `Value should be lower or equal than ${options.max}.`; } }; - } + }, /** * Creates a function that checks if the value is a json * @param validateParsed Optional parameter to validate the parsed object * @returns */ - static json(validateParsed: (object: any) => string | undefined) { + json(validateParsed: (object: any) => string | undefined) { return function (value: string) { let jsonObject; + // Try to parse the string as JSON try { jsonObject = JSON.parse(value); - } catch (error) { + } catch { return "Value can't be parsed. There is some error."; } return validateParsed ? validateParsed(jsonObject) : undefined; }; - } + }, /** * Creates a function that checks is the value is an array and optionally validates each element * @param validationElement Optional function to validate each element of the array * @returns */ - static array(validationElement: (json: any) => string | undefined) { + array(validationElement: (json: any) => string | undefined) { return function (value: unknown[]) { // Check the JSON is an array if (!Array.isArray(value)) { return 'Value is not a valid list.'; } + let index: number; + return validationElement - ? value.reduce((accum, elementValue) => { - if (accum) { - return accum; - } - - const resultValidationElement = validationElement(elementValue); - if (resultValidationElement) { - return resultValidationElement; - } - - return accum; - }, undefined) + ? (index = value.findIndex(element => validationElement(element))) >= 0 + ? validationElement(value[index]) + : undefined : undefined; }; - } + }, /** * Creates a function that checks if the value is equal to list of values * @param literals Array of values to compare * @returns */ - static literal(literals: unknown[]) { + literal(literals: unknown[]) { return function (value: any): string | undefined { return literals.includes(value) ? undefined : `Invalid value. Allowed values: ${literals.map(String).join(', ')}.`; }; - } + }, // FilePicker - static filePickerSupportedExtensions = + filePickerSupportedExtensions: (extensions: string[]) => (options: { name: string }) => { - if ( - typeof options === 'undefined' || - typeof options.name === 'undefined' - ) { + if (options === undefined || options.name === undefined) { return; } + if (!extensions.includes(path.extname(options.name))) { return `File extension is invalid. Allowed file extensions: ${extensions.join( ', ', )}.`; } - }; + }, /** * filePickerFileSize * @param options */ - static filePickerFileSize = + filePickerFileSize: (options: { maxBytes?: number; minBytes?: number; meaningfulUnit?: boolean; }) => (value: { size: number }) => { - if (typeof value === 'undefined' || typeof value.size === 'undefined') { + if (value === undefined || value.size === undefined) { return; } - if ( - typeof options.minBytes !== 'undefined' && - value.size <= options.minBytes - ) { + + if (options.minBytes !== undefined && value.size <= options.minBytes) { return `File size should be greater or equal than ${ options.meaningfulUnit ? formatBytes(options.minBytes) : `${options.minBytes} bytes` }.`; } - if ( - typeof options.maxBytes !== 'undefined' && - value.size >= options.maxBytes - ) { + + if (options.maxBytes !== undefined && value.size >= options.maxBytes) { return `File size should be lower or equal than ${ options.meaningfulUnit ? formatBytes(options.maxBytes) : `${options.maxBytes} bytes` }.`; } - }; + }, - //IPv4: This is a set of four numbers, for example, 192.158.1.38. Each number in the set can range from 0 to 255. Therefore, the full range of IP addresses goes from 0.0.0.0 to 255.255.255.255 - //IPv6: This is a set or eight hexadecimal expressions, each from 0000 to FFFF. 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + // IPv4: This is a set of four numbers, for example, 192.158.1.38. Each number in the set can range from 0 to 255. Therefore, the full range of IP addresses goes from 0.0.0.0 to 255.255.255.255 + // IPv6: This is a set or eight hexadecimal expressions, each from 0000 to FFFF. 2001:0db8:85a3:0000:0000:8a2e:0370:7334 // FQDN: Maximum of 63 characters per label. // Can only contain numbers, letters and hyphens (-) @@ -292,10 +284,10 @@ export class SettingsValidator { // Hostname: Maximum of 63 characters per label. Same rules as FQDN apply. - static serverAddressHostnameFQDNIPv4IPv6(value: string) { + serverAddressHostnameFQDNIPv4IPv6(value: string) { const isFQDNOrHostname = - /^(?!-)(?!.*--)[a-zA-Z0-9áéíóúüñ-]{0,62}[a-zA-Z0-9áéíóúüñ](?:\.[a-zA-Z0-9áéíóúüñ-]{0,62}[a-zA-Z0-9áéíóúüñ]){0,}$/; - const isIPv6 = /^(?:[0-9a-fA-F]{4}:){7}[0-9a-fA-F]{4}$/; + /^(?!-)(?!.*--)[\dA-Za-záéíñóúü-]{0,62}[\dA-Za-záéíñóúü](?:\.[\dA-Za-záéíñóúü-]{0,62}[\dA-Za-záéíñóúü])*$/; + const isIPv6 = /^(?:[\dA-Fa-f]{4}:){7}[\dA-Fa-f]{4}$/; if ( value.length > 255 || @@ -303,6 +295,5 @@ export class SettingsValidator { ) { return 'It should be a valid hostname, FQDN, IPv4 or uncompressed IPv6'; } - return undefined; - } -} + }, +}; diff --git a/plugins/wazuh-core/common/types.ts b/plugins/wazuh-core/common/types.ts index f22c311bac..e818c55850 100644 --- a/plugins/wazuh-core/common/types.ts +++ b/plugins/wazuh-core/common/types.ts @@ -1,11 +1,3 @@ -export interface AvailableUpdates { - apiId: string; - last_check?: Date | string | undefined; - mayor: Update[]; - minor: Update[]; - patch: Update[]; -} - export interface Update { description: string; published_date: string; @@ -17,3 +9,13 @@ export interface Update { tag: string; title: string; } + +export interface AvailableUpdates { + apiId: string; + last_check?: Date | string | undefined; + mayor: Update[]; + minor: Update[]; + patch: Update[]; +} + +export type OmitStrict = Pick>; diff --git a/plugins/wazuh-core/public/components/index.ts b/plugins/wazuh-core/public/components/index.ts new file mode 100644 index 0000000000..564af2e44e --- /dev/null +++ b/plugins/wazuh-core/public/components/index.ts @@ -0,0 +1,2 @@ +export * from './table-data'; +export * from './search-bar'; diff --git a/plugins/wazuh-core/public/components/search-bar/README.md b/plugins/wazuh-core/public/components/search-bar/README.md new file mode 100644 index 0000000000..42d0f92bb0 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/README.md @@ -0,0 +1,203 @@ +# Component + +The `SearchBar` component is a base component of a search bar. + +It is designed to be extensible through the self-contained query language implementations. This means +the behavior of the search bar depends on the business logic of each query language. For example, a +query language can display suggestions according to the user input or prepend some buttons to the search bar. + +It is based on a custom `EuiSuggest` component defined in `public/components/eui-suggest/suggest.js`. So the +abilities are restricted by this one. + +## Features + +- Supports multiple query languages. +- Switch the selected query language. +- Self-contained query language implementation and ability to interact with the search bar component. +- React to external changes to set the new input. This enables to change the input from external components. + +# Usage + +Basic usage: + +```tsx + { + switch (field) { + case 'configSum': + return [{ label: 'configSum1' }, { label: 'configSum2' }]; + break; + case 'dateAdd': + return [{ label: 'dateAdd1' }, { label: 'dateAdd2' }]; + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map(status => ({ + label: status, + })); + break; + default: + return []; + break; + } + }, + }, + }, + ]} + // Handler fired when the input handler changes. Optional. + onChange={onChange} + // Handler fired when the user press the Enter key or custom implementations. Required. + onSearch={onSearch} + // Used to define the internal input. Optional. + // This could be used to change the input text from the external components. + // Use the UQL (Unified Query Language) syntax. + input='' + // Define the default mode. Optional. If not defined, it will use the first one mode. + defaultMode='' +> +``` + +# Query languages + +The built-in query languages are: + +- AQL: API Query Language. Based on https://documentation.wazuh.com/current/user-manual/api/queries.html. + +## How to add a new query language + +### Definition + +The language expects to take the interface: + +```ts +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: ( + input: string | undefined, + params: any, + ) => Promise<{ + searchBarProps: any; + output: { + language: string; + apiQuery: string; + query: string; + }; + }>; + transformInput: ( + unifiedQuery: string, + options: { configuration: any; parameters: any }, + ) => string; +}; +``` + +where: + +- `description`: is the description of the query language. This is displayed in a query language popover + on the right side of the search bar. Required. +- `documentationLink`: URL to the documentation link. Optional. +- `id`: identification of the query language. +- `label`: name +- `getConfiguration`: method that returns the configuration of the language. This allows custom behavior. +- `run`: method that returns: + - `searchBarProps`: properties to be passed to the search bar component. This allows the + customization the properties that will used by the base search bar component and the output used when searching + - `output`: + - `language`: query language ID + - `apiQuery`: API query. + - `query`: current query in the specified language +- `transformInput`: method that transforms the UQL (Unified Query Language) to the specific query + language. This is used when receives a external input in the Unified Query Language, the returned + value is converted to the specific query language to set the new input text of the search bar + component. + +Create a new file located in `public/components/search-bar/query-language` and define the expected interface; + +### Register + +Go to `public/components/search-bar/query-language/index.ts` and add the new query language: + +```ts +import { AQL } from './aql'; + +// Import the custom query language +import { CustomQL } from './custom'; + +// [...] + +// Register the query languages +export const searchBarQueryLanguages: { + [key: string]: SearchBarQueryLanguage; +} = [ + AQL, + CustomQL, // Add the new custom query language +].reduce((accum, item) => { + if (accum[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + return { + ...accum, + [item.id]: item, + }; +}, {}); +``` + +## Unified Query Language - UQL + +This is an unified syntax used by the search bar component that provides a way to communicate +with the different query language implementations. + +The input and output parameters of the search bar component must use this syntax. + +This is used in: + +- input: + - `input` component property +- output: + - `onChange` component handler + - `onSearch` component handler + +Its syntax is equal to Wazuh API Query Language +https://wazuh.com/./user-manual/api/queries.html + +> The AQL query language is a implementation of this syntax. diff --git a/plugins/wazuh-core/public/components/search-bar/__snapshots__/index.test.tsx.snap b/plugins/wazuh-core/public/components/search-bar/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..5602512bd0 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/__snapshots__/index.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly the initial render 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/index.js b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/index.js new file mode 100644 index 0000000000..3d3a15b048 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/index.js @@ -0,0 +1,3 @@ +export { EuiSuggestInput } from './suggest-input'; + +export { EuiSuggest } from './suggest'; diff --git a/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest-input.js b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest-input.js new file mode 100644 index 0000000000..963caecd09 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest-input.js @@ -0,0 +1,144 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + EuiFieldText, + EuiToolTip, + EuiIcon, + EuiInputPopover, +} from '@elastic/eui'; + +const statusMap = { + unsaved: { + icon: 'dot', + color: 'accent', + tooltip: 'Changes have not been saved.', + }, + saved: { + icon: 'checkInCircleFilled', + color: 'secondary', + tooltip: 'Saved.', + }, + unchanged: { + icon: '', + color: 'secondary', + }, +}; + +export class EuiSuggestInput extends Component { + state = { + value: '', + isPopoverOpen: false, + }; + + onFieldChange(event) { + this.setState({ + value: event.target.value, + isPopoverOpen: event.target.value === '' ? false : true, + }); + this.props.sendValue(event.target.value); + } + + render() { + const { + className, + status, + append, + tooltipContent, + suggestions, + onPopoverFocus, + isPopoverOpen, + onClosePopover, + disableFocusTrap = false, + ...rest + } = this.props; + let icon; + let color; + + if (statusMap[status]) { + icon = statusMap[status].icon; + color = statusMap[status].color; + } + + const classes = classNames('euiSuggestInput', className); + // EuiFieldText's append accepts an array of elements so start by creating an empty array + const appendArray = []; + const statusElement = (status === 'saved' || status === 'unsaved') && ( + + + + ); + + // Push the status element to the array if it is not undefined + if (statusElement) { + appendArray.push(statusElement); + } + + // Check to see if consumer passed an append item and if so, add it to the array + if (append) { + appendArray.push(append); + } + + const customInput = ( + + ); + + return ( +
+ +
{suggestions}
+
+
+ ); + } +} + +EuiSuggestInput.propTypes = { + className: PropTypes.string, + /** + * Status of the current query 'unsaved', 'saved', 'unchanged' or 'loading'. + */ + status: PropTypes.oneOf(['unsaved', 'saved', 'unchanged', 'loading']), + tooltipContent: PropTypes.string, + /** + * Element to be appended to the input bar. + */ + append: PropTypes.node, + /** + * List of suggestions to display using 'suggestItem'. + */ + suggestions: PropTypes.array, + isOpen: PropTypes.bool, + onClosePopover: PropTypes.func, + onPopoverFocus: PropTypes.func, + isPopoverOpen: PropTypes.bool, + disableFocusTrap: PropTypes.bool, + sendValue: PropTypes.func, +}; + +EuiSuggestInput.defaultProps = { + status: 'unchanged', +}; diff --git a/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest.js b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest.js new file mode 100644 index 0000000000..b65029fffb --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { EuiSuggestItem } from '@elastic/eui'; +import { EuiSuggestInput } from './suggest-input'; + +export class EuiSuggest extends Component { + state = { + value: '', + status: 'unsaved', + }; + + getValue(val) { + this.setState({ + value: val, + }); + } + + onChange(event) { + this.props.onInputChange(event.target.value); + } + + render() { + const { + onItemClick, + status, + append, + tooltipContent, + suggestions, + ...rest + } = this.props; + const suggestionList = suggestions.map((item, index) => ( + onItemClick(item) : null} + description={item.description} + /> + )); + const suggestInput = ( + + ); + + return
{suggestInput}
; + } +} + +EuiSuggest.propTypes = { + className: PropTypes.string, + /** + * Status of the current query 'notYetSaved', 'saved', 'unchanged' or 'loading'. + */ + status: PropTypes.oneOf(['unsaved', 'saved', 'unchanged', 'loading']), + tooltipContent: PropTypes.string, + /** + * Element to be appended to the input bar (e.g. hashtag popover). + */ + append: PropTypes.node, + /** + * List of suggestions to display using 'suggestItem'. + */ + suggestions: PropTypes.array, + /** + * Handler for click on a suggestItem. + */ + onItemClick: PropTypes.func, + onInputChange: PropTypes.func, + isOpen: PropTypes.bool, + onClosePopover: PropTypes.func, + onPopoverFocus: PropTypes.func, +}; + +EuiSuggestInput.defaultProps = { + status: 'unchanged', +}; diff --git a/plugins/wazuh-core/public/components/search-bar/index.test.tsx b/plugins/wazuh-core/public/components/search-bar/index.test.tsx new file mode 100644 index 0000000000..4708cb28e5 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/index.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SearchBar } from './index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: 'wql', + input: '', + modes: [ + { + id: 'aql', + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { field }) { + return []; + }, + }, + }, + { + id: 'wql', + implicitQuery: { + query: 'id!=000', + conjunction: ';', + }, + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { field }) { + return []; + }, + }, + }, + ], + onChange: () => {}, + onSearch: () => {}, + }; + + it('Renders correctly the initial render', async () => { + const wrapper = render(); + + /* This test causes a warning about act. This is intentional, because the test pretends to get + the first rendering of the component that doesn't have the component properties coming of the + selected query language */ + expect(wrapper.container).toMatchSnapshot(); + }); +}); diff --git a/plugins/wazuh-core/public/components/search-bar/index.tsx b/plugins/wazuh-core/public/components/search-bar/index.tsx new file mode 100644 index 0000000000..c07a711b0c --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/index.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + EuiButtonEmpty, + EuiFormRow, + EuiLink, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSelect, + EuiText, + EuiFlexGroup, + EuiFlexItem, + // EuiSuggest, +} from '@elastic/eui'; +import { isEqual } from 'lodash'; +import { SEARCH_BAR_DEBOUNCE_UPDATE_TIME } from '../../../common/constants'; +import { EuiSuggest } from './components/eui-suggest'; +import { searchBarQueryLanguages } from './query-language'; +import { ISearchBarModeWQL } from './query-language/wql'; + +export interface SearchBarProps { + defaultMode?: string; + modes: ISearchBarModeWQL[]; + onChange?: (params: any) => void; + onSearch: (params: any) => void; + buttonsRender?: () => React.ReactNode; + input?: string; +} + +export const SearchBar = ({ + defaultMode, + modes, + onChange, + onSearch, + ...rest +}: SearchBarProps) => { + // Query language ID and configuration + const [queryLanguage, setQueryLanguage] = useState<{ + id: string; + configuration: any; + }>({ + id: defaultMode || modes[0].id, + configuration: + searchBarQueryLanguages[ + defaultMode || modes[0].id + ]?.getConfiguration?.() || {}, + }); + // Popover query language is open + const [isOpenPopoverQueryLanguage, setIsOpenPopoverQueryLanguage] = + useState(false); + // Input field + const [input, setInput] = useState(rest.input || ''); + // Query language output of run method + const [queryLanguageOutputRun, setQueryLanguageOutputRun] = useState({ + searchBarProps: { suggestions: [] }, + output: undefined, + }); + // Cache the previous output + const queryLanguageOutputRunPreviousOutput = useRef( + queryLanguageOutputRun.output, + ); + // Controls when the suggestion popover is open/close + const [isOpenSuggestionPopover, setIsOpenSuggestionPopover] = + useState(false); + // Reference to the input + const inputRef = useRef(); + // Debounce update timer + const debounceUpdateSearchBarTimer = useRef(); + + // Handler when searching + const _onSearch = (output: any) => { + // TODO: fix when searching + onSearch(output); + setIsOpenSuggestionPopover(false); + }; + + // Handler on change the input field text + const onChangeInput = (event: React.ChangeEvent) => + setInput(event.target.value); + + // Handler when pressing a key + const onKeyPressHandler = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + _onSearch(queryLanguageOutputRun.output); + } + }; + + const selectedQueryLanguageParameters = modes.find( + ({ id }) => id === queryLanguage.id, + ); + + useEffect(() => { + // React to external changes and set the internal input text. Use the `transformInput` of + // the query language in use + if ( + rest.input && + searchBarQueryLanguages[queryLanguage.id]?.transformInput + ) { + setInput( + searchBarQueryLanguages[queryLanguage.id]?.transformInput?.( + rest.input, + { + configuration: queryLanguage.configuration, + parameters: selectedQueryLanguageParameters, + }, + ), + ); + } + }, [rest.input]); + + useEffect(() => { + (async () => { + // Set the query language output + if (debounceUpdateSearchBarTimer.current) { + clearTimeout(debounceUpdateSearchBarTimer.current); + } + + // Debounce the updating of the search bar state + debounceUpdateSearchBarTimer.current = setTimeout(async () => { + const queryLanguageOutput = await searchBarQueryLanguages[ + queryLanguage.id + ].run(input, { + onSearch: _onSearch, + setInput, + closeSuggestionPopover: () => setIsOpenSuggestionPopover(false), + openSuggestionPopover: () => setIsOpenSuggestionPopover(true), + setQueryLanguageConfiguration: (configuration: any) => + setQueryLanguage(state => ({ + ...state, + configuration: + configuration?.(state.configuration) || configuration, + })), + setQueryLanguageOutput: setQueryLanguageOutputRun, + inputRef, + queryLanguage: { + configuration: queryLanguage.configuration, + parameters: selectedQueryLanguageParameters, + }, + }); + + queryLanguageOutputRunPreviousOutput.current = { + ...queryLanguageOutputRun.output, + }; + setQueryLanguageOutputRun(queryLanguageOutput); + }, SEARCH_BAR_DEBOUNCE_UPDATE_TIME); + })(); + }, [input, queryLanguage, selectedQueryLanguageParameters?.options]); + + useEffect(() => { + if ( + onChange && + // Ensure the previous output is different to the new one + !isEqual( + queryLanguageOutputRun.output, + queryLanguageOutputRunPreviousOutput.current, + ) + ) { + onChange(queryLanguageOutputRun.output); + } + }, [queryLanguageOutputRun.output]); + + const onQueryLanguagePopoverSwitch = () => + setIsOpenPopoverQueryLanguage(state => !state); + const searchBar = ( + <> + {}} /* This method is run by EuiSuggest when there is a change in + a div wrapper of the input and should be defined. Defining this + property prevents an error. */ + suggestions={[]} + isPopoverOpen={ + queryLanguageOutputRun?.searchBarProps?.suggestions?.length > 0 && + isOpenSuggestionPopover + } + onClosePopover={() => setIsOpenSuggestionPopover(false)} + onPopoverFocus={() => setIsOpenSuggestionPopover(true)} + placeholder={'Search'} + append={ + + {searchBarQueryLanguages[queryLanguage.id].label} + + } + isOpen={isOpenPopoverQueryLanguage} + closePopover={onQueryLanguagePopoverSwitch} + > + SYNTAX OPTIONS +
+ + {searchBarQueryLanguages[queryLanguage.id].description} + + {searchBarQueryLanguages[queryLanguage.id].documentationLink && ( + <> + +
+ + Documentation + +
+ + )} + {modes?.length > 1 && ( + <> + + + ({ + value: id, + text: searchBarQueryLanguages[id].label, + }))} + value={queryLanguage.id} + onChange={( + event: React.ChangeEvent, + ) => { + const queryLanguageID: string = event.target.value; + + setQueryLanguage({ + id: queryLanguageID, + configuration: + searchBarQueryLanguages[ + queryLanguageID + ]?.getConfiguration?.() || {}, + }); + setInput(''); + }} + aria-label='query-language-selector' + /> + + + )} +
+
+ } + {...queryLanguageOutputRun.searchBarProps} + {...(queryLanguageOutputRun.searchBarProps?.onItemClick + ? { + onItemClick: + queryLanguageOutputRun.searchBarProps?.onItemClick(input), + } + : {})} + /> + + ); + + return rest.buttonsRender || queryLanguageOutputRun.filterButtons ? ( + + {searchBar} + {rest.buttonsRender && ( + {rest.buttonsRender()} + )} + {queryLanguageOutputRun.filterButtons && ( + + {queryLanguageOutputRun.filterButtons} + + )} + + ) : ( + searchBar + ); +}; diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap b/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap new file mode 100644 index 0000000000..3f9bd54fc2 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/aql.test.tsx.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap b/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap new file mode 100644 index 0000000000..8d16ea7342 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/__snapshots__/wql.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBar component Renders correctly to match the snapshot of query language 1`] = ` +
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/aql.md b/plugins/wazuh-core/public/components/search-bar/query-language/aql.md new file mode 100644 index 0000000000..d052a2f4b1 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/aql.md @@ -0,0 +1,204 @@ +**WARNING: The search bar was changed and this language needs some adaptations to work.** + +# Query Language - AQL + +AQL (API Query Language) is a query language based in the `q` query parameters of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +## Features + +- Suggestions for `fields` (configurable), `operators` and `values` (configurable) +- Support implicit query + +# Language syntax + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +# Developer notes + +## Options + +- `implicitQuery`: add an implicit query that is added to the user input. Optional. + Use UQL (Unified Query Language). + This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. + +```ts +// language options +// ID is not equal to 000 and . This is defined in UQL that is transformed internally to the specific query language. +implicitQuery: 'id!=000;'; +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + return [ + { label: 'configSum', description: 'Config sum' }, + { label: 'dateAdd', description: 'Date add' }, + { label: 'id', description: 'ID' }, + { label: 'ip', description: 'IP address' }, + { label: 'group', description: 'Group' }, + { label: 'group_config_status', description: 'Synced configuration status' }, + { label: 'lastKeepAline', description: 'Date add' }, + { label: 'manager', description: 'Manager' }, + { label: 'mergedSum', description: 'Merged sum' }, + { label: 'name', description: 'Agent name' }, + { label: 'node_name', description: 'Node name' }, + { label: 'os.platform', description: 'Operating system platform' }, + { label: 'status', description: 'Status' }, + { label: 'version', description: 'Version' }, + ]; + } + ``` + + - `value`: method that returns the suggestion for the values + + ```ts + // language options + value: async (currentValue, { previousField }) => { + switch (previousField) { + case 'configSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'dateAdd': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'id': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'ip': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'group': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'group_config_status': + return [AGENT_SYNCED_STATUS.SYNCED, AGENT_SYNCED_STATUS.NOT_SYNCED].map( + status => ({ + type: 'value', + label: status, + }), + ); + break; + case 'lastKeepAline': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'manager': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'mergedSum': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'node_name': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'os.platform': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + case 'status': + return UI_ORDER_AGENT_STATUS.map(status => ({ + type: 'value', + label: status, + })); + break; + case 'version': + return await getAgentFilterValuesMapToSearchBarSuggestion( + previousField, + currentValue, + { q: 'id!=000' }, + ); + break; + default: + return []; + break; + } + }; + ``` + +## Language workflow + +```mermaid +graph TD; + user_input[User input]-->tokenizer; + subgraph tokenizer + tokenize_regex[Wazuh API `q` regular expression] + end + + tokenizer-->tokens; + + tokens-->searchBarProps; + subgraph searchBarProps; + searchBarProps_suggestions-->searchBarProps_suggestions_get_last_token_with_value[Get last token with value] + searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{implicitQuery} + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion onclick handler]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + end + + tokens-->output; + subgraph output[output]; + output_result[implicitFilter + user input] + end + + output-->output_search_bar[Output] +``` diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/aql.test.tsx b/plugins/wazuh-core/public/components/search-bar/query-language/aql.test.tsx new file mode 100644 index 0000000000..92fb705926 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/aql.test.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; +import { AQL, getSuggestions, tokenizer } from './aql'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: AQL.id, + input: '', + modes: [ + { + id: AQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(_currentValue) { + return []; + }, + value(_currentValue, { previousField: _previousField }) { + return []; + }, + }, + }, + ], + onChange: () => {}, + onSearch: () => {}, + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render(); + + await waitFor(() => { + const elementImplicitQuery = wrapper.container.querySelector( + '.euiCodeBlock__code', + ); + + expect(elementImplicitQuery?.innerHTML).toEqual('id!=000;'); + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); + +describe('Query language - AQL', () => { + // Tokenize the input + it.each` + input | tokens + ${''} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'f'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'f' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with spaces<'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with spaces<' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value with (parenthesis)'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value with (parenthesis)' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!='} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'field=value;field2!=value2'} | ${[{ type: 'operator_group', value: undefined }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '!=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'('} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2);'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2='} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=value2)'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'value2' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + ${'(field>2;field2=custom value())'} | ${[{ type: 'operator_group', value: '(' }, { type: 'field', value: 'field' }, { type: 'operator_compare', value: '>' }, { type: 'value', value: '2' }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: ';' }, { type: 'operator_group', value: undefined }, { type: 'field', value: 'field2' }, { type: 'operator_compare', value: '=' }, { type: 'value', value: 'custom value()' }, { type: 'operator_group', value: ')' }, { type: 'conjunction', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'field', value: undefined }, { type: 'operator_compare', value: undefined }, { type: 'value', value: undefined }, { type: 'operator_group', value: undefined }, { type: 'conjunction', value: undefined }]} + `(`Tokenizer API input $input`, ({ input, tokens }) => { + expect(tokenizer(input)).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value;'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'field=value;field2'} | ${[{ description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field=value;field2='} | ${[{ label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { label: '190.0.0.1', type: 'value' }, { label: '190.0.0.2', type: 'value' }]} + ${'field=value;field2=127'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: '127.0.0.1', type: 'value' }, { label: '127.0.0.2', type: 'value' }, { description: 'and', label: ';', type: 'conjunction' }, { description: 'or', label: ',', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer(input), { + id: 'aql', + suggestions: { + field(_currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { previousField }) { + switch (previousField) { + case 'field': { + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + } + + case 'field2': { + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + } + + default: { + return []; + } + } + }, + }, + }), + ).toEqual(suggestions); + }); + + // When a suggestion is clicked, change the input text + it.each` + AQL | clikedSuggestion | changedInput + ${''} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'field'} + ${'field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field2'} + ${'field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'field='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'field=value'} + ${'field='} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!=' }} | ${'field!='} + ${'field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'field=value2'} + ${'field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: ';' }} | ${'field=value;'} + ${'field=value;'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field=value;field2'} + ${'field=value;field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'field=value;field2>'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces' }} | ${'field=with spaces'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces' }} | ${'field=with "spaces'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value' }} | ${'field="value'} + ${''} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '(' }} | ${'('} + ${'('} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'(field'} + ${'(field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field2'} + ${'(field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'(field='} + ${'(field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'(field=value'} + ${'(field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value2'} + ${'(field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: ',' }} | ${'(field=value,'} + ${'(field=value,'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field=value,field2'} + ${'(field=value,field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value,field2>'} + ${'(field=value,field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~' }} | ${'(field=value,field2~'} + ${'(field=value,field2>'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3' }} | ${'(field=value,field2>value3'} + ${'(field=value,field2>value2'} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')' }} | ${'(field=value,field2>value2)'} + `( + 'click suggestion - AQL "$AQL" => "$changedInput"', + async ({ AQL: currentInput, clikedSuggestion, changedInput }) => { + // Mock input + let input = currentInput; + const qlOutput = await AQL.run(input, { + setInput: (value: string): void => { + input = value; + }, + queryLanguage: { + parameters: { + implicitQuery: '', + suggestions: { + field: () => [], + value: () => [], + }, + }, + }, + }); + + qlOutput.searchBarProps.onItemClick('')(clikedSuggestion); + expect(input).toEqual(changedInput); + }, + ); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | AQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value;'} + ${'field=value;field2'} | ${'field=value;field2'} + ${'field="'} | ${'field="'} + ${'field=with spaces'} | ${'field=with spaces'} + ${'field=with "spaces'} | ${'field=with "spaces'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value,'} + ${'(field=value,field2'} | ${'(field=value,field2'} + ${'(field=value,field2>'} | ${'(field=value,field2>'} + ${'(field=value,field2>value2'} | ${'(field=value,field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value,field2>value2)'} + `( + 'Transform the external input UQL to QL - UQL $UQL => $AQL', + async ({ UQL: uql, AQL: changedInput }) => { + expect(AQL.transformUQLToQL(uql)).toEqual(changedInput); + }, + ); +}); diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/aql.tsx b/plugins/wazuh-core/public/components/search-bar/query-language/aql.tsx new file mode 100644 index 0000000000..bf45485016 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/aql.tsx @@ -0,0 +1,586 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; +import { OmitStrict } from '../../../../common/types'; +import { + CONJUNCTION, + Conjunction, + GROUP_OPERATOR_BOUNDARY, + ICON_TYPE, + OPERATOR_COMPARE, + OPERATOR_GROUP, + OperatorCompare, + OperatorGroup, +} from './constants'; + +const AQL_ID = 'aql'; +const QUERY_TOKEN_KEYS = { + FIELD: 'field', + OPERATOR_COMPARE: 'operator_compare', + OPERATOR_GROUP: 'operator_group', + VALUE: 'value', + CONJUNCTION: 'conjunction', + FUNCTION_SEARCH: 'function_search', +} as const; + +type TokenTypeEnum = (typeof QUERY_TOKEN_KEYS)[keyof OmitStrict< + typeof QUERY_TOKEN_KEYS, + 'FUNCTION_SEARCH' +>]; + +interface TokenDescriptor { + type: TokenTypeEnum; + value: OperatorCompare | OperatorGroup | Conjunction; +} +type TokenList = TokenDescriptor[]; + +/* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: +??? + +Implemented schema: +?????? +*/ + +// Language definition +export const LANGUAGE = { + // Tokens + tokens: { + [QUERY_TOKEN_KEYS.OPERATOR_COMPARE]: { + literal: { + [OPERATOR_COMPARE.EQUALITY]: 'equality', + [OPERATOR_COMPARE.NOT_EQUALITY]: 'not equality', + [OPERATOR_COMPARE.BIGGER]: 'bigger', + [OPERATOR_COMPARE.SMALLER]: 'smaller', + [OPERATOR_COMPARE.LIKE_AS]: 'like as', + }, + }, + [QUERY_TOKEN_KEYS.CONJUNCTION]: { + literal: { + [CONJUNCTION.AND]: 'and', + [CONJUNCTION.OR]: 'or', + }, + }, + [QUERY_TOKEN_KEYS.OPERATOR_GROUP]: { + literal: { + [OPERATOR_GROUP.OPEN]: 'open group', + [OPERATOR_GROUP.CLOSE]: 'close group', + }, + }, + }, +} as const; + +const OPERATORS = Object.keys( + LANGUAGE.tokens.operator_compare.literal, +) as OperatorCompare[]; +const CONJUNCTIONS = Object.keys( + LANGUAGE.tokens.conjunction.literal, +) as Conjunction[]; +// Suggestion mapper by language token type +const SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE = { + [QUERY_TOKEN_KEYS.FIELD]: { iconType: ICON_TYPE.KQL_FIELD, color: 'tint4' }, + [QUERY_TOKEN_KEYS.OPERATOR_COMPARE]: { + iconType: ICON_TYPE.KQL_OPERAND, + color: 'tint1', + }, + [QUERY_TOKEN_KEYS.VALUE]: { iconType: ICON_TYPE.KQL_VALUE, color: 'tint0' }, + [QUERY_TOKEN_KEYS.CONJUNCTION]: { + iconType: ICON_TYPE.KQL_SELECTOR, + color: 'tint3', + }, + [QUERY_TOKEN_KEYS.OPERATOR_GROUP]: { + iconType: ICON_TYPE.TOKEN_DENSE_VECTOR, + color: 'tint3', + }, + [QUERY_TOKEN_KEYS.FUNCTION_SEARCH]: { + iconType: ICON_TYPE.SEARCH, + color: 'tint5', + }, +} as const; + +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: TokenTypeEnum) { + return function ({ ...params }) { + return { + type, + ...params, + }; + }; +} + +const mapSuggestionCreatorField = mapSuggestionCreator(QUERY_TOKEN_KEYS.FIELD); +const mapSuggestionCreatorValue = mapSuggestionCreator(QUERY_TOKEN_KEYS.VALUE); + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): TokenList { + // API regular expression + // https://github.com/wazuh/wazuh/blob/v4.4.0-rc1/framework/wazuh/core/utils.py#L1242-L1257 + // self.query_regex = re.compile( + // # A ( character. + // r"(\()?" + + // # Field name: name of the field to look on DB. + // r"([\w.]+)" + + // # Operator: looks for '=', '!=', '<', '>' or '~'. + // rf"([{''.join(self.query_operators.keys())}]{{1,2}})" + + // # Value: A string. + // r"((?:(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*" + // r"(?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]+)" + // r"(?:\((?:\[[\[\]\w _\-.,:?\\/'\"=@%<>{}]*]|[\[\]\w _\-.:?\\/'\"=@%<>{}]*)\))*)+)" + + // # A ) character. + // r"(\))?" + + // # Separator: looks for ';', ',' or nothing. + // rf"([{''.join(self.query_separators.keys())}])?" + // ) + + const re = new RegExp( + // The following regular expression is based in API one but was modified to use named groups + // and added the optional operator to allow matching the entities when the query is not + // completed. This helps to tokenize the query and manage when the input is not completed. + // A ( character. + String.raw`(?<${GROUP_OPERATOR_BOUNDARY.OPEN}>\()?` + + // Field name: name of the field to look on DB. + String.raw`(?<${QUERY_TOKEN_KEYS.FIELD}>[\w.]+)?` + // Added an optional find + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?<${QUERY_TOKEN_KEYS.OPERATOR_COMPARE}>[${Object.keys( + LANGUAGE.tokens.operator_compare.literal, + )}]{1,2})?` + // Added an optional find + // Value: A string. + String.raw`(?<${QUERY_TOKEN_KEYS.VALUE}>(?:(?:\((?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|[\[\]\w _\-.:?\/'"=@%<>{}]*)\))*` + + String.raw`(?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|[\[\]\w _\-.:?\\/'"=@%<>{}]+)` + + String.raw`(?:\((?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|[\[\]\w _\-.:?\\/'"=@%<>{}]*)\))*)+)?` + // Added an optional find + // A ) character. + String.raw`(?<${GROUP_OPERATOR_BOUNDARY.CLOSE}>\))?` + + `(?<${QUERY_TOKEN_KEYS.CONJUNCTION}>[${CONJUNCTIONS}])?`, + 'g', + ); + + return [...input.matchAll(re)].flatMap(({ groups }) => + Object.entries(groups || {}).map(([key, value]) => ({ + type: key.startsWith(QUERY_TOKEN_KEYS.OPERATOR_GROUP) + ? QUERY_TOKEN_KEYS.OPERATOR_GROUP + : key, + value, + })), + ) as TokenList; +} + +interface QLOptionSuggestionEntityItem { + description?: string; + label?: string; +} + +type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem & { + type: TokenTypeEnum; +}; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string; color: string }; +}; + +type QLOptionSuggestionHandler = ( + currentValue?: string | undefined, + options?: { previousField: string; previousOperatorCompare: string }, +) => Promise; + +interface OptionsQL { + suggestions: { + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; + }; +} + +/** + * Get the last token with value + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValue(tokens: TokenList): TokenDescriptor | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = [...tokens]; + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find(({ value }) => value); + + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenWithValueByType( + tokens: TokenList, + tokenType: TokenTypeEnum, +): TokenDescriptor | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = [...tokens]; + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type === tokenType && value, + ); + + return tokenFound; +} + +const getValueSuggestions = async ( + tokens: TokenDescriptor[], + options: OptionsQL, + suggestionValue?: string, +) => { + const previousField = ( + getLastTokenWithValueByType( + tokens, + QUERY_TOKEN_KEYS.FIELD, + ) as TokenDescriptor + ).value; + const previousOperatorCompare = ( + getLastTokenWithValueByType( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + ) as TokenDescriptor + ).value; + const suggestions = await options.suggestions.value(suggestionValue, { + previousField, + previousOperatorCompare, + }); + + return suggestions.map(element => mapSuggestionCreatorValue(element)); +}; + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions( + tokens: TokenList, + options: OptionsQL, +): Promise { + if (tokens.length === 0) { + return []; + } + + const suggestions = await options.suggestions.field(); + // Get last token + const lastToken = getLastTokenWithValue(tokens); + + // If it can't get a token with value, then returns fields and open operator group + if (!lastToken?.type) { + return [ + // fields + ...suggestions.map(element => mapSuggestionCreatorField(element)), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.OPEN, + description: + LANGUAGE.tokens.operator_group.literal[OPERATOR_GROUP.OPEN], + }, + ]; + } + + switch (lastToken.type) { + case QUERY_TOKEN_KEYS.FIELD: { + return [ + // fields that starts with the input but is not equals + ...suggestions + .filter( + ({ label }) => + label?.startsWith(lastToken.value) && label !== lastToken.value, + ) + .map(element => mapSuggestionCreatorField(element)), + // operators if the input field is exact + ...(suggestions.some(({ label }) => label === lastToken.value) + ? OPERATORS.map(operator => ({ + type: QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + label: operator, + description: LANGUAGE.tokens.operator_compare.literal[operator], + })) + : []), + ]; + } + + case QUERY_TOKEN_KEYS.OPERATOR_COMPARE: { + const getOperatorSuggestions = async (lastToken: TokenDescriptor) => { + const compareOperatorSuggestions = OPERATORS.filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ).map(operator => ({ + type: QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + label: operator, + description: LANGUAGE.tokens.operator_compare.literal[operator], + })); + + return compareOperatorSuggestions; + }; + + return [ + ...(await getOperatorSuggestions(lastToken)), + ...(OPERATORS.includes(lastToken.value as OperatorCompare) + ? await getValueSuggestions(tokens, options) + : []), + ]; + } + + case QUERY_TOKEN_KEYS.VALUE: { + return [ + ...(lastToken.value + ? [ + { + type: QUERY_TOKEN_KEYS.FUNCTION_SEARCH, + label: 'Search', + description: 'run the search query', + }, + ] + : []), + ...(await getValueSuggestions(tokens, options, lastToken.value)), + ...Object.entries(LANGUAGE.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description, + }), + ), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.CLOSE, + description: + LANGUAGE.tokens.operator_group.literal[OPERATOR_GROUP.CLOSE], + }, + ]; + } + + case QUERY_TOKEN_KEYS.CONJUNCTION: { + return [ + ...CONJUNCTIONS.filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ).map(conjunction => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description: LANGUAGE.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(CONJUNCTIONS.includes(lastToken.value as Conjunction) + ? suggestions.map(element => mapSuggestionCreatorField(element)) + : []), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.OPEN, + description: + LANGUAGE.tokens.operator_group.literal[OPERATOR_GROUP.OPEN], + }, + ]; + } + + case QUERY_TOKEN_KEYS.OPERATOR_GROUP: { + if (lastToken.value === OPERATOR_GROUP.OPEN) { + return ( + // fields + suggestions.map(element => mapSuggestionCreatorField(element)) + ); + } else if (lastToken.value === OPERATOR_GROUP.CLOSE) { + return ( + // conjunction + CONJUNCTIONS.map(conjunction => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description: LANGUAGE.tokens.conjunction.literal[conjunction], + })) + ); + } + + break; + } + + default: { + return []; + } + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem( + suggestion: QLOptionSuggestionEntityItemTyped, +): SuggestItem { + const { type, ...rest } = suggestion; + + return { + type: { ...SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE[type] }, + ...rest, + }; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @returns + */ +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[], +): SuggestItem[] { + return suggestions.map(element => + transformSuggestionToEuiSuggestItem(element), + ); +} + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: { implicitQuery?: string } = {}) { + const unifiedQuery = `${options?.implicitQuery ?? ''}${ + options?.implicitQuery ? `(${input})` : input + }`; + + return { + language: AQL_ID, + query: unifiedQuery, + unifiedQuery, + }; +} + +export const AQL = { + id: AQL_ID, + label: 'AQL', + description: 'API Query Language (AQL) allows to do queries.', + documentationLink: webDocumentationLink('user-manual/api/queries.html'), + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input: string, params: any) { + // Get the tokens from the input + const tokens: TokenList = tokenizer(input); + + return { + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToEuiSuggestItem( + await getSuggestions(tokens, params.queryLanguage.parameters), + ), + // Handler to manage when clicking in a suggestion item + onItemClick: (currentInput: string) => item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === ICON_TYPE.SEARCH) { + // Execute the search action + params.onSearch( + getOutput(currentInput, params.queryLanguage.parameters), + ); + } else { + // When the clicked item has another iconType + const lastToken = getLastTokenWithValue(tokens); + + // if the clicked suggestion is of same type of last token + if ( + lastToken && + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE[lastToken.type] + .iconType === item.type.iconType + ) { + // replace the value of last token + lastToken.value = item.label; + } else { + // add a new token of the selected type and value + const type = Object.entries( + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE, + ).find( + ([, { iconType }]) => iconType === item.type.iconType, + )?.[0] as TokenTypeEnum; + + tokens.push({ + type, + value: item.label, + }); + } + + // Change the input + params.setInput( + tokens + .filter(({ value }) => value) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join(''), + ); + } + }, + prepend: params.queryLanguage.parameters.implicitQuery ? ( + + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: + !state.isOpenPopoverImplicitFilter, + })) + } + iconType='filter' + > + + {params.queryLanguage.parameters.implicitQuery} + + + } + isOpen={ + params.queryLanguage.configuration.isOpenPopoverImplicitFilter + } + closePopover={() => + params.setQueryLanguageConfiguration(state => ({ + ...state, + isOpenPopoverImplicitFilter: false, + })) + } + > + + Implicit query:{' '} + {params.queryLanguage.parameters.implicitQuery} + + This query is added to the input. + + ) : null, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true, + }, + output: getOutput(input, params.queryLanguage.parameters), + }; + }, + transformUQLToQL(unifiedQuery: string): string { + return unifiedQuery; + }, +}; diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/constants.ts b/plugins/wazuh-core/public/components/search-bar/query-language/constants.ts new file mode 100644 index 0000000000..ae0f805b24 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/constants.ts @@ -0,0 +1,39 @@ +export const OPERATOR_COMPARE = { + EQUALITY: '=', + NOT_EQUALITY: '!=', + BIGGER: '>', + SMALLER: '<', + LIKE_AS: '~', +} as const; +export type OperatorCompare = + (typeof OPERATOR_COMPARE)[keyof typeof OPERATOR_COMPARE]; + +export const OPERATOR_GROUP = { + OPEN: '(', + CLOSE: ')', +} as const; + +export type OperatorGroup = + (typeof OPERATOR_GROUP)[keyof typeof OPERATOR_GROUP]; + +export const CONJUNCTION = { + AND: ';', + OR: ',', +} as const; + +export type Conjunction = (typeof CONJUNCTION)[keyof typeof CONJUNCTION]; + +export const GROUP_OPERATOR_BOUNDARY = { + OPEN: 'operator_group_open', + CLOSE: 'operator_group_close', +}; + +export const ICON_TYPE = { + KQL_FIELD: 'kqlField', + KQL_OPERAND: 'kqlOperand', + KQL_VALUE: 'kqlValue', + KQL_SELECTOR: 'kqlSelector', + TOKEN_DENSE_VECTOR: 'tokenDenseVector', + SEARCH: 'search', + ALERT: 'alert', +} as const; diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/index.ts b/plugins/wazuh-core/public/components/search-bar/query-language/index.ts new file mode 100644 index 0000000000..ff29f3b232 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/index.ts @@ -0,0 +1,64 @@ +import React from 'react'; +import { AQL } from './aql'; +import { WQL } from './wql'; + +export interface SuggestItem { + type: { iconType: string; color: string }; + label: string; + description?: string; +} + +interface SearchBarProps { + suggestions: SuggestItem[]; + onItemClick: (currentInput: string) => (item: SuggestItem) => void; + prepend?: React.ReactNode; + disableFocusTrap?: boolean; + isInvalid?: boolean; + onKeyPress?: (event: React.KeyboardEvent) => void; +} + +interface SearchBarQueryLanguage { + id: string; + label: string; + description: string; + documentationLink?: string; + getConfiguration?: () => any; + run: ( + input: string | undefined, + params: any, + ) => Promise<{ + filterButtons?: React.ReactElement | null; + searchBarProps: SearchBarProps; + output: { + language: string; + unifiedQuery?: string; + apiQuery?: { + q: string; + }; + query: string; + }; + }>; + transformInput?: ( + unifiedQuery: string, + options: { configuration: any; parameters: any }, + ) => string; + transformUQLToQL?: (unifiedQuery: string) => string; +} + +// Register the query languages +function initializeSearchBarQueryLanguages() { + const languages = [AQL, WQL]; + const result: Record = {}; + + for (const item of languages) { + if (result[item.id]) { + throw new Error(`Query language with id: ${item.id} already registered.`); + } + + result[item.id] = item; + } + + return result; +} + +export const searchBarQueryLanguages = initializeSearchBarQueryLanguages(); diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/wql.md b/plugins/wazuh-core/public/components/search-bar/query-language/wql.md new file mode 100644 index 0000000000..f29230e564 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/wql.md @@ -0,0 +1,272 @@ +# Query Language - WQL + +WQL (Wazuh Query Language) is a query language based in the `q` query parameter of the Wazuh API +endpoints. + +Documentation: https://wazuh.com/./user-manual/api/queries.html + +The implementation is adapted to work with the search bar component defined +`public/components/search-bar/index.tsx`. + +# Language syntax + +It supports 2 modes: + +- `explicit`: define the field, operator and value +- `search term`: use a term to search in the available fields + +Theses modes can not be combined. + +`explicit` mode is enabled when it finds a field and operator tokens. + +## Mode: explicit + +### Schema + +``` +???????????? +``` + +### Fields + +Regular expression: /[\\w.]+/ + +Examples: + +``` +field +field.custom +``` + +### Operators + +#### Compare + +- `=` equal to +- `!=` not equal to +- `>` bigger +- `<` smaller +- `~` like + +#### Group + +- `(` open +- `)` close + +#### Conjunction (logical) + +- `and` intersection +- `or` union + +#### Values + +- Value without spaces can be literal +- Value with spaces should be wrapped by `"`. The `"` can be escaped using `\"`. + +Examples: + +``` +value_without_whitespace +"value with whitespaces" +"value with whitespaces and escaped \"quotes\"" +``` + +### Notes + +- The tokens can be separated by whitespaces. + +### Examples + +- Simple query + +``` +id=001 +id = 001 +``` + +- Complex query (logical operator) + +``` +status=active and os.platform~linux +status = active and os.platform ~ linux +``` + +``` +status!=never_connected and ip~240 or os.platform~linux +status != never_connected and ip ~ 240 or os.platform ~ linux +``` + +- Complex query (logical operators and group operator) + +``` +(status!=never_connected and ip~240) or id=001 +( status != never_connected and ip ~ 240 ) or id = 001 +``` + +## Mode: search term + +Search the term in the available fields. + +This mode is used when there is no a `field` and `operator` according to the regular expression +of the **explicit** mode. + +### Examples: + +``` +linux +``` + +If the available fields are `id` and `ip`, then the input will be translated under the hood to the +following UQL syntax: + +``` +id~linux,ip~linux +``` + +## Developer notes + +## Features + +- Support suggestions for each token entity. `fields` and `values` are customizable. +- Support implicit query. +- Support for search term mode. It enables to search a term in multiple fields. + The query is built under the hoods. This mode requires there are `field` and `operator_compare`. + +### Implicit query + +This a query that can't be added, edited or removed by the user. It is added to the user input. + +### Search term mode + +This mode enables to search in multiple fields using a search term. The fields to use must be defined. + +Use an union expression of each field with the like as operation `~`. + +The user input is transformed to something as: + +``` +field1~user_input,field2~user_input,field3~user_input +``` + +## Options + +- `options`: options + + - `implicitQuery`: add an implicit query that is added to the user input. Optional. + This can't be changed by the user. If this is defined, will be displayed as a prepend of the search bar. - `query`: query string in UQL (Unified Query Language) + Use UQL (Unified Query Language). - `conjunction`: query string of the conjunction in UQL (Unified Query Language) + - `searchTermFields`: define the fields used to build the query for the search term mode + - `filterButtons`: define a list of buttons to filter in the search bar + +```ts +// language options +options: { + // ID is not equal to 000 and . This is defined in UQL that is transformed internally to + // the specific query language. + implicitQuery: { + query: 'id!=000', + conjunction: ';' + } + searchTermFields: ['id', 'ip'] + filterButtons: [ + {id: 'status-active', input: 'status=active', label: 'Active'} + ] +} +``` + +- `suggestions`: define the suggestion handlers. This is required. + + - `field`: method that returns the suggestions for the fields + + ```ts + // language options + field(currentValue) { + // static or async fetching is allowed + return [ + { label: 'field1', description: 'Description' }, + { label: 'field2', description: 'Description' } + ]; + } + ``` + + - `value`: method that returns the suggestion for the values + + ```ts + // language options + value: async (currentValue, { field }) => { + // static or async fetching is allowed + // async fetching data + // const response = await fetchData(); + return [{ label: 'value1' }, { label: 'value2' }]; + }; + ``` + +- `validate`: define validation methods for the field types. Optional + + - `value`: method to validate the value token + + ```ts + validate: { + value: (token, { field, operator_compare }) => { + if (field === 'field1') { + const value = token.formattedValue || token.value; + return /\d+/ + ? undefined + : `Invalid value for field ${field}, only digits are supported: "${value}"`; + } + }; + } + ``` + +## Language workflow + +```mermaid +graph TD; + user_input[User input]-->ql_run; + ql_run-->filterButtons[filterButtons]; + ql_run-->tokenizer-->tokens; + tokens-->searchBarProps; + tokens-->output; + + subgraph tokenizer + tokenize_regex[Query language regular expression: decomposition and extract quoted values] + end + + subgraph searchBarProps; + searchBarProps_suggestions[suggestions]-->searchBarProps_suggestions_input_isvalid{Input is valid} + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_success[Yes] + searchBarProps_suggestions_input_isvalid{Is input valid?}-->searchBarProps_suggestions_input_isvalid_fail[No] + searchBarProps_suggestions_input_isvalid_success[Yes]--->searchBarProps_suggestions_get_last_token_with_value[Get last token with value]-->searchBarProps_suggestions__result[Suggestions] + searchBarProps_suggestions__result[Suggestions]-->EuiSuggestItem + searchBarProps_suggestions_input_isvalid_fail[No]-->searchBarProps_suggestions_invalid[Invalid with error message] + searchBarProps_suggestions_invalid[Invalid with error message]-->EuiSuggestItem + searchBarProps_prepend[prepend]-->searchBarProps_prepend_implicitQuery{options.implicitQuery} + searchBarProps_prepend_implicitQuery{options.implicitQuery}-->searchBarProps_prepend_implicitQuery_yes[Yes]-->EuiButton + searchBarProps_prepend_implicitQuery{options.implicitQuery}-->searchBarProps_prepend_implicitQuery_no[No]-->null + searchBarProps_disableFocusTrap:true[disableFocusTrap = true] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_search[Search suggestion] + searchBarProps_onItemClick_suggestion_search[Search suggestion]-->searchBarProps_onItemClick_suggestion_search_run[Run search] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_edit_current_token[Edit current token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_add_new_token[Add new token]-->searchBarProps_onItemClick_build_input[Build input] + searchBarProps_onItemClick[onItemClickSuggestion]-->searchBarProps_onItemClick_suggestion_error[Error] + searchBarProps_isInvalid[isInvalid]-->searchBarProps_validate_input[validate input] + end + + subgraph output[output]; + output_input_options_implicitFilter[options.implicitFilter]-->output_input_options_result["{apiQuery: { q }, error, language: 'wql', query}"] + output_input_user_input_QL[User input in QL]-->output_input_user_input_UQL[User input in UQL]-->output_input_options_result["{apiQuery: { q }, error, language: 'wql', query}"] + end + + subgraph filterButtons; + filterButtons_optional{options.filterButtons}-->filterButtons_optional_yes[Yes]-->filterButtons_optional_yes_component[Render fitter button] + filterButtons_optional{options.filterButtons}-->filterButtons_optional_no[No]-->filterButtons_optional_no_null[null] + end +``` + +## Notes + +- The value that contains the following characters: `!`, `~` are not supported by the AQL and this + could cause problems when do the request to the API. +- The value with spaces are wrapped with `"`. If the value contains the `\"` sequence this is + replaced by `"`. This could cause a problem with values that are intended to have the mentioned + sequence. diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/wql.test.tsx b/plugins/wazuh-core/public/components/search-bar/query-language/wql.test.tsx new file mode 100644 index 0000000000..c53e437aad --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/wql.test.tsx @@ -0,0 +1,467 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; +import { + getSuggestions, + tokenizer, + transformSpecificQLToUnifiedQL, + WQL, +} from './wql'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: WQL.id, + input: '', + modes: [ + { + id: WQL.id, + options: { + implicitQuery: { + query: 'id!=000', + conjunction: ';', + }, + }, + suggestions: { + field(_currentValue) { + return []; + }, + value(_currentValue, { field: _field }) { + return []; + }, + }, + }, + ], + onChange: () => {}, + onSearch: () => {}, + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render(); + + await waitFor(() => { + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); + +// Tokenize the input +function tokenCreator({ type, value, formattedValue }) { + return { type, value, ...(formattedValue ? { formattedValue } : {}) }; +} + +describe('Query language - WQL', () => { + const t = { + opGroup: (value?) => tokenCreator({ type: 'operator_group', value }), + opCompare: (value?) => tokenCreator({ type: 'operator_compare', value }), + field: (value?) => tokenCreator({ type: 'field', value }), + value: (value?, formattedValue?) => + tokenCreator({ + type: 'value', + value, + formattedValue: formattedValue ?? value, + }), + whitespace: (value?) => tokenCreator({ type: 'whitespace', value }), + conjunction: (value?) => tokenCreator({ type: 'conjunction', value }), + }; + // Token undefined + const tu = { + opGroup: tokenCreator({ type: 'operator_group', value: undefined }), + opCompare: tokenCreator({ type: 'operator_compare', value: undefined }), + whitespace: tokenCreator({ type: 'whitespace', value: undefined }), + field: tokenCreator({ type: 'field', value: undefined }), + value: tokenCreator({ + type: 'value', + value: undefined, + formattedValue: undefined, + }), + conjunction: tokenCreator({ type: 'conjunction', value: undefined }), + }; + const tuBlankSerie = [ + tu.opGroup, + tu.whitespace, + tu.field, + tu.whitespace, + tu.opCompare, + tu.whitespace, + tu.value, + tu.whitespace, + tu.opGroup, + tu.whitespace, + tu.conjunction, + tu.whitespace, + ]; + + it.each` + input | tokens + ${''} | ${tuBlankSerie} + ${'f'} | ${[tu.opGroup, tu.whitespace, t.field('f'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=or'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('or'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueand'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueand'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=valueor'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('valueor'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value!='), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value>'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value>'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value<'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value<'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value~'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value~'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value and value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value and value2"', 'value and value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value or value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value or value2"', 'value or value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value = value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value = value2"', 'value = value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value != value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value != value2"', 'value != value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value > value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value > value2"', 'value > value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value < value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value < value2"', 'value < value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field="value ~ value2"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('"value ~ value2"', 'value ~ value2'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie] /** ~ character is not supported as value in the q query parameter */} + ${'field=value and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + ${'field=value and '} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), ...tuBlankSerie]} + ${'field=value and field2'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, tu.opCompare, tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!='} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, tu.value, tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!="value"'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('"value"', 'value'), tu.whitespace, tu.opGroup, tu.whitespace, tu.conjunction, tu.whitespace, ...tuBlankSerie]} + ${'field=value and field2!=value2 and'} | ${[tu.opGroup, tu.whitespace, t.field('field'), tu.whitespace, t.opCompare('='), tu.whitespace, t.value('value'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.field('field2'), tu.whitespace, t.opCompare('!='), tu.whitespace, t.value('value2'), t.whitespace(' '), tu.opGroup, tu.whitespace, t.conjunction('and'), tu.whitespace, ...tuBlankSerie]} + `(`Tokenizer API input $input`, ({ input, tokens }) => { + expect(tokenizer(input)).toEqual(tokens); + }); + + // Get suggestions + it.each` + input | suggestions + ${''} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + ${'w'} | ${[]} + ${'f'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }]} + ${'field'} | ${[{ description: 'Field2', label: 'field2', type: 'field' }, { description: 'equality', label: '=', type: 'operator_compare' }, { description: 'not equality', label: '!=', type: 'operator_compare' }, { description: 'bigger', label: '>', type: 'operator_compare' }, { description: 'smaller', label: '<', type: 'operator_compare' }, { description: 'like as', label: '~', type: 'operator_compare' }]} + ${'field='} | ${[{ label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }]} + ${'field=v'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value'} | ${[{ description: 'run the search query', label: 'Search', type: 'function_search' }, { label: 'value', type: 'value' }, { label: 'value2', type: 'value' }, { label: 'value3', type: 'value' }, { label: 'value4', type: 'value' }, { description: 'and', label: 'and', type: 'conjunction' }, { description: 'or', label: 'or', type: 'conjunction' }, { description: 'close group', label: ')', type: 'operator_group' }]} + ${'field=value and'} | ${[{ description: 'Field', label: 'field', type: 'field' }, { description: 'Field2', label: 'field2', type: 'field' }, { description: 'open group', label: '(', type: 'operator_group' }]} + `('Get suggestion from the input: $input', async ({ input, suggestions }) => { + expect( + await getSuggestions(tokenizer(input), { + id: 'aql', + suggestions: { + field(_currentValue) { + return [ + { label: 'field', description: 'Field' }, + { label: 'field2', description: 'Field2' }, + ].map(({ label, description }) => ({ + type: 'field', + label, + description, + })); + }, + value(currentValue = '', { field }) { + switch (field) { + case 'field': { + return ['value', 'value2', 'value3', 'value4'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + } + + case 'field2': { + return ['127.0.0.1', '127.0.0.2', '190.0.0.1', '190.0.0.2'] + .filter(value => value.startsWith(currentValue)) + .map(value => ({ type: 'value', label: value })); + } + + default: { + return []; + } + } + }, + }, + }), + ).toEqual(suggestions); + }); + + // Transform specific query language to UQL (Unified Query Language) + it.each` + WQL | UQL + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=value'} | ${'field=value'} + ${'field=value()'} | ${'field=value()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field="custom value"'} | ${'field=custom value'} + ${'field="custom value()"'} | ${'field=custom value()'} + ${'field="value and value2"'} | ${'field=value and value2'} + ${'field="value or value2"'} | ${'field=value or value2'} + ${'field="value = value2"'} | ${'field=value = value2'} + ${'field="value != value2"'} | ${'field=value != value2'} + ${'field="value > value2"'} | ${'field=value > value2'} + ${'field="value < value2"'} | ${'field=value < value2'} + ${'field="value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} + ${String.raw`field="custom \"value"`} | ${'field=custom "value'} + ${String.raw`field="custom \"value\""`} | ${'field=custom "value"'} + ${'field=value and'} | ${'field=value;'} + ${'field="custom value" and'} | ${'field=custom value;'} + ${'(field=value'} | ${'(field=value'} + ${'(field=value)'} | ${'(field=value)'} + ${'(field=value) and'} | ${'(field=value);'} + ${'(field=value) and field2'} | ${'(field=value);field2'} + ${'(field=value) and field2>'} | ${'(field=value);field2>'} + ${'(field=value) and field2>"wrappedcommas"'} | ${'(field=value);field2>wrappedcommas'} + ${'(field=value) and field2>"value with spaces"'} | ${'(field=value);field2>value with spaces'} + ${'field ='} | ${'field='} + ${'field = value'} | ${'field=value'} + ${'field = value()'} | ${'field=value()'} + ${'field = valueand'} | ${'field=valueand'} + ${'field = valueor'} | ${'field=valueor'} + ${'field = value='} | ${'field=value='} + ${'field = value!='} | ${'field=value!='} + ${'field = value>'} | ${'field=value>'} + ${'field = value<'} | ${'field=value<'} + ${'field = value~'} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field = "custom value"'} | ${'field=custom value'} + ${'field = "custom value()"'} | ${'field=custom value()'} + ${'field = "value and value2"'} | ${'field=value and value2'} + ${'field = "value or value2"'} | ${'field=value or value2'} + ${'field = "value = value2"'} | ${'field=value = value2'} + ${'field = "value != value2"'} | ${'field=value != value2'} + ${'field = "value > value2"'} | ${'field=value > value2'} + ${'field = "value < value2"'} | ${'field=value < value2'} + ${'field = "value ~ value2"'} | ${'field=value ~ value2' /** ~ character is not supported as value in the q query parameter */} + ${'field = value or'} | ${'field=value,'} + ${'field = value or field2'} | ${'field=value,field2'} + ${'field = value or field2 <'} | ${'field=value,field2<'} + ${'field = value or field2 < value2'} | ${'field=value,field2 "custom value" '} | ${'(field=value);field2>custom value'} + `('transformSpecificQLToUnifiedQL - WQL $WQL TO UQL $UQL', ({ WQL, UQL }) => { + expect(transformSpecificQLToUnifiedQL(WQL)).toEqual(UQL); + }); + + // When a suggestion is clicked, change the input text + it.each` + WQL | clikedSuggestion | changedInput + ${''} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'field'} + ${'field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field2'} + ${'field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'field='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'field=value'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value()' }} | ${'field=value()'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueand' }} | ${'field=valueand'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'valueor' }} | ${'field=valueor'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value=' }} | ${'field=value='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value!=' }} | ${'field=value!='} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value>' }} | ${'field=value>'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value<' }} | ${'field=value<'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value~' }} | ${'field=value~' /** ~ character is not supported as value in the q query parameter */} + ${'field='} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '!=' }} | ${'field!='} + ${'field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'field=value2'} + ${'field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'field=value and '} + ${'field=value and'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'field=value or'} + ${'field=value and'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field=value and field2'} + ${'field=value and '} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'field=value or '} + ${'field=value and '} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'field=value and field2'} + ${'field=value and field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'field=value and field2>'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with spaces' }} | ${'field="with spaces"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with "spaces' }} | ${String.raw`field="with \"spaces"`} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with value()' }} | ${'field="with value()"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with and value' }} | ${'field="with and value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with or value' }} | ${'field="with or value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with = value' }} | ${'field="with = value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with != value' }} | ${'field="with != value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with > value' }} | ${'field="with > value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with < value' }} | ${'field="with < value"'} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'with ~ value' }} | ${'field="with ~ value"' /** ~ character is not supported as value in the q query parameter */} + ${'field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: '"value' }} | ${String.raw`field="\"value"`} + ${'field="with spaces"'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'field=value'} + ${'field="with spaces"'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'other spaces' }} | ${'field="other spaces"'} + ${''} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: '(' }} | ${'('} + ${'('} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field' }} | ${'(field'} + ${'(field'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field2'} + ${'(field'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '=' }} | ${'(field='} + ${'(field='} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value' }} | ${'(field=value'} + ${'(field=value'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value2'} + ${'(field=value'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'or' }} | ${'(field=value or '} + ${'(field=value or'} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'(field=value and'} + ${'(field=value or'} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field=value or field2'} + ${'(field=value or '} | ${{ type: { iconType: 'kqlSelector', color: 'tint3' }, label: 'and' }} | ${'(field=value and '} + ${'(field=value or '} | ${{ type: { iconType: 'kqlField', color: 'tint4' }, label: 'field2' }} | ${'(field=value or field2'} + ${'(field=value or field2'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '>' }} | ${'(field=value or field2>'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlOperand', color: 'tint1' }, label: '~' }} | ${'(field=value or field2~'} + ${'(field=value or field2>'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value2' }} | ${'(field=value or field2>value2'} + ${'(field=value or field2>value2'} | ${{ type: { iconType: 'kqlValue', color: 'tint0' }, label: 'value3' }} | ${'(field=value or field2>value3'} + ${'(field=value or field2>value2'} | ${{ type: { iconType: 'tokenDenseVector', color: 'tint3' }, label: ')' }} | ${'(field=value or field2>value2 )'} + `( + 'click suggestion - WQL "$WQL" => "$changedInput"', + async ({ WQL: currentInput, clikedSuggestion, changedInput }) => { + // Mock input + let input = currentInput; + const qlOutput = await WQL.run(input, { + setInput: (value: string): void => { + input = value; + }, + queryLanguage: { + parameters: { + options: {}, + suggestions: { + field: () => [], + value: () => [], + }, + }, + }, + }); + + qlOutput.searchBarProps.onItemClick('')(clikedSuggestion); + expect(input).toEqual(changedInput); + }, + ); + + // Transform the external input in UQL (Unified Query Language) to QL + it.each` + UQL | WQL + ${''} | ${''} + ${'field'} | ${'field'} + ${'field='} | ${'field='} + ${'field=()'} | ${'field=()'} + ${'field=valueand'} | ${'field=valueand'} + ${'field=valueor'} | ${'field=valueor'} + ${'field=value='} | ${'field=value='} + ${'field=value!='} | ${'field=value!='} + ${'field=value>'} | ${'field=value>'} + ${'field=value<'} | ${'field=value<'} + ${'field=value~'} | ${'field=value~'} + ${'field!='} | ${'field!='} + ${'field>'} | ${'field>'} + ${'field<'} | ${'field<'} + ${'field~'} | ${'field~'} + ${'field=value'} | ${'field=value'} + ${'field=value;'} | ${'field=value and '} + ${'field=value;field2'} | ${'field=value and field2'} + ${'field="'} | ${String.raw`field="\""`} + ${'field=with spaces'} | ${'field="with spaces"'} + ${'field=with "spaces'} | ${String.raw`field="with \"spaces"`} + ${'field=value ()'} | ${'field="value ()"'} + ${'field=with and value'} | ${'field="with and value"'} + ${'field=with or value'} | ${'field="with or value"'} + ${'field=with = value'} | ${'field="with = value"'} + ${'field=with > value'} | ${'field="with > value"'} + ${'field=with < value'} | ${'field="with < value"'} + ${'('} | ${'('} + ${'(field'} | ${'(field'} + ${'(field='} | ${'(field='} + ${'(field=value'} | ${'(field=value'} + ${'(field=value,'} | ${'(field=value or '} + ${'(field=value,field2'} | ${'(field=value or field2'} + ${'(field=value,field2>'} | ${'(field=value or field2>'} + ${'(field=value,field2>value2'} | ${'(field=value or field2>value2'} + ${'(field=value,field2>value2)'} | ${'(field=value or field2>value2)'} + ${'implicit=value;'} | ${''} + ${'implicit=value;field'} | ${'field'} + `( + 'Transform the external input UQL to QL - UQL $UQL => $WQL', + async ({ UQL, WQL: changedInput }) => { + expect( + WQL.transformInput(UQL, { + parameters: { + options: { + implicitQuery: { + query: 'implicit=value', + conjunction: ';', + }, + }, + }, + }), + ).toEqual(changedInput); + }, + ); + + /* The ! and ~ characters can't be part of a value that contains examples. The tests doesn't + include these cases. + + Value examples: + - with != value + - with ~ value + */ + + // Validate the tokens + // Some examples of value tokens are based on this API test: https://github.com/wazuh/wazuh/blob/813595cf58d753c1066c3e7c2018dbb4708df088/framework/wazuh/core/tests/test_utils.py#L987-L1050 + it.each` + WQL | validationError + ${''} | ${undefined} + ${'field1'} | ${undefined} + ${'field2'} | ${undefined} + ${'field1='} | ${['The value for field "field1" is missing.']} + ${'field2='} | ${['The value for field "field2" is missing.']} + ${'field='} | ${['"field" is not a valid field.']} + ${'custom='} | ${['"custom" is not a valid field.']} + ${'field1=value'} | ${undefined} + ${'field_not_number=1'} | ${['Numbers are not valid for field_not_number']} + ${'field_not_number=value1'} | ${['Numbers are not valid for field_not_number']} + ${'field2=value'} | ${undefined} + ${'field=value'} | ${['"field" is not a valid field.']} + ${'custom=value'} | ${['"custom" is not a valid field.']} + ${'field1=value!test'} | ${['"value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value&test'} | ${['"value&test" is not a valid value. Invalid characters found: &']} + ${'field1=value!value&test'} | ${['"value!value&test" is not a valid value. Invalid characters found: !&']} + ${'field1=value!value!test'} | ${['"value!value!test" is not a valid value. Invalid characters found: !']} + ${'field1=value!value!t$&st'} | ${['"value!value!t$&st" is not a valid value. Invalid characters found: !$&']} + ${'field1=value,'} | ${['"value," is not a valid value.']} + ${'field1="Mozilla Firefox 53.0 (x64 en-US)"'} | ${undefined} + ${String.raw`field1="[\"https://example-link@<>=,%?\"]"`} | ${undefined} + ${'field1=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field2=value and'} | ${['There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field=value and'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'custom=value and'} | ${['"custom" is not a valid field.', 'There is no whitespace after conjunction "and".', 'There is no sentence after conjunction "and".']} + ${'field1=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field2=value and '} | ${['There is no sentence after conjunction "and".']} + ${'field=value and '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'custom=value and '} | ${['"custom" is not a valid field.', 'There is no sentence after conjunction "and".']} + ${'field1=value and field2'} | ${['The operator for field "field2" is missing.']} + ${'field2=value and field1'} | ${['The operator for field "field1" is missing.']} + ${'field1=value and field'} | ${['"field" is not a valid field.']} + ${'field2=value and field'} | ${['"field" is not a valid field.']} + ${'field=value and custom'} | ${['"field" is not a valid field.', '"custom" is not a valid field.']} + ${'('} | ${undefined} + ${'(field'} | ${undefined} + ${'(field='} | ${['"field" is not a valid field.']} + ${'(field=value'} | ${['"field" is not a valid field.']} + ${'(field=value or'} | ${['"field" is not a valid field.', 'There is no whitespace after conjunction "or".', 'There is no sentence after conjunction "or".']} + ${'(field=value or '} | ${['"field" is not a valid field.', 'There is no sentence after conjunction "or".']} + ${'(field=value or field2'} | ${['"field" is not a valid field.', 'The operator for field "field2" is missing.']} + ${'(field=value or field2>'} | ${['"field" is not a valid field.', 'The value for field "field2" is missing.']} + ${'(field=value or field2>value2'} | ${['"field" is not a valid field.']} + `( + 'validate the tokens - WQL $WQL => $validationError', + async ({ WQL: currentInput, validationError }) => { + const qlOutput = await WQL.run(currentInput, { + queryLanguage: { + parameters: { + options: {}, + suggestions: { + field: () => + ['field1', 'field2', 'field_not_number'].map(label => ({ + label, + })), + value: () => [], + }, + validate: { + value: (token, { field, operator_compare: operatorCompare }) => { + if (field === 'field_not_number') { + const value = token.formattedValue || token.value; + + return /\d/.test(value) + ? `Numbers are not valid for ${field}` + : undefined; + } + }, + }, + }, + }, + }); + + expect(qlOutput.output.error).toEqual(validationError); + }, + ); +}); diff --git a/plugins/wazuh-core/public/components/search-bar/query-language/wql.tsx b/plugins/wazuh-core/public/components/search-bar/query-language/wql.tsx new file mode 100644 index 0000000000..6ad016092e --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/wql.tsx @@ -0,0 +1,1285 @@ +/* eslint-disable unicorn/no-await-expression-member */ +import React from 'react'; +import { EuiButtonGroup } from '@elastic/eui'; +import { SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT } from '../../../../common/constants'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; +import { OmitStrict } from '../../../../common/types'; +import { tokenizer as tokenizerUQL } from './aql'; +import { + CONJUNCTION as CONJUNCTION_UQL, + GROUP_OPERATOR_BOUNDARY, + ICON_TYPE, + OPERATOR_COMPARE, + OPERATOR_GROUP, +} from './constants'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +const WQL_ID = 'wql'; +const QUERY_TOKEN_KEYS = { + FIELD: 'field', + OPERATOR_COMPARE: 'operator_compare', + OPERATOR_GROUP: 'operator_group', + VALUE: 'value', + CONJUNCTION: 'conjunction', + FUNCTION_SEARCH: 'function_search', + WHITESPACE: 'whitespace', + VALIDATION_ERROR: 'validation_error', +} as const; + +type TokenTypeEnum = (typeof QUERY_TOKEN_KEYS)[keyof OmitStrict< + typeof QUERY_TOKEN_KEYS, + 'FUNCTION_SEARCH' | 'VALIDATION_ERROR' +>]; +export const CONJUNCTION_WQL = { + AND: 'and', + OR: 'or', +} as const; + +export type Conjunction = + (typeof CONJUNCTION_WQL)[keyof typeof CONJUNCTION_WQL]; + +interface TokenDescriptor { + type: TokenTypeEnum; + value: string; + formattedValue?: string; +} +type TokenList = TokenDescriptor[]; + +enum MODE { + PREVIOUS = 'previous', + NEXT = 'next', +} + +/* API Query Language +Define the API Query Language to use in the search bar. +It is based in the language used by the q query parameter. +https://documentation.wazuh.com/current/user-manual/api/queries.html + +Use the regular expression of API with some modifications to allow the decomposition of +input in entities that doesn't compose a valid query. It allows get not-completed queries. + +API schema: +??? + +Implemented schema: +???????????? +*/ + +// Language definition +const language = { + // Tokens + tokens: { + [QUERY_TOKEN_KEYS.OPERATOR_COMPARE]: { + literal: { + [OPERATOR_COMPARE.EQUALITY]: 'equality', + [OPERATOR_COMPARE.NOT_EQUALITY]: 'not equality', + [OPERATOR_COMPARE.BIGGER]: 'bigger', + [OPERATOR_COMPARE.SMALLER]: 'smaller', + [OPERATOR_COMPARE.LIKE_AS]: 'like as', + }, + }, + conjunction: { + literal: { + [CONJUNCTION_WQL.AND]: 'and', + [CONJUNCTION_WQL.OR]: 'or', + }, + }, + [QUERY_TOKEN_KEYS.OPERATOR_GROUP]: { + literal: { + [OPERATOR_GROUP.OPEN]: 'open group', + [OPERATOR_GROUP.CLOSE]: 'close group', + }, + }, + }, + equivalencesToUQL: { + conjunction: { + literal: { + and: CONJUNCTION_UQL.AND, + or: CONJUNCTION_UQL.OR, + }, + }, + }, +}; +// Suggestion mapper by language token type +const SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE = { + [QUERY_TOKEN_KEYS.FIELD]: { iconType: ICON_TYPE.KQL_FIELD, color: 'tint4' }, + [QUERY_TOKEN_KEYS.OPERATOR_COMPARE]: { + iconType: ICON_TYPE.KQL_OPERAND, + color: 'tint1', + }, + [QUERY_TOKEN_KEYS.VALUE]: { iconType: ICON_TYPE.KQL_VALUE, color: 'tint0' }, + [QUERY_TOKEN_KEYS.CONJUNCTION]: { + iconType: ICON_TYPE.KQL_SELECTOR, + color: 'tint3', + }, + [QUERY_TOKEN_KEYS.OPERATOR_GROUP]: { + iconType: ICON_TYPE.TOKEN_DENSE_VECTOR, + color: 'tint3', + }, + [QUERY_TOKEN_KEYS.FUNCTION_SEARCH]: { + iconType: ICON_TYPE.SEARCH, + color: 'tint5', + }, + [QUERY_TOKEN_KEYS.VALIDATION_ERROR]: { + iconType: ICON_TYPE.ALERT, + color: 'tint2', + }, +}; + +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: TokenTypeEnum) { + return function ({ label, ...params }) { + return { + type, + ...params, + /* WORKAROUND: ensure the label is a string. If it is not a string, an warning is + displayed in the console related to prop types + */ + ...(label === undefined ? {} : { label: String(label) }), + }; + }; +} + +const mapSuggestionCreatorField = mapSuggestionCreator(QUERY_TOKEN_KEYS.FIELD); +const mapSuggestionCreatorValue = mapSuggestionCreator(QUERY_TOKEN_KEYS.VALUE); + +/** + * Transform the conjunction to the query language syntax + * @param conjunction + * @returns + */ +function transformQLConjunction(conjunction: string): string { + // If the value has a whitespace or comma, then + return conjunction === language.equivalencesToUQL.conjunction.literal['and'] + ? ` ${language.tokens.conjunction.literal[CONJUNCTION_WQL.AND]} ` + : ` ${language.tokens.conjunction.literal[CONJUNCTION_WQL.OR]} `; +} + +/** + * Transform the value to the query language syntax + * @param value + * @returns + */ +function transformQLValue(value: string): string { + // If the value has a whitespace or comma, then + return /[\s"|]/.test(value) + ? // Escape the commas (") => (\") and wraps the string with commas ("") + `"${value.replace(/"/, String.raw`\"`)}"` + : // Raw value + value; +} + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): TokenList { + const re = new RegExp( + // A ( character. + String.raw`(?<${GROUP_OPERATOR_BOUNDARY.OPEN}>\()?` + + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_1>\s+)?` + + // Field name: name of the field to look on DB. + String.raw`(?<${QUERY_TOKEN_KEYS.FIELD}>[\w.]+)?` + // Added an optional find + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_2>\s+)?` + + // Operator: looks for '=', '!=', '<', '>' or '~'. + // This seems to be a bug because is not searching the literal valid operators. + // I guess the operator is validated after the regular expression matches + `(?<${QUERY_TOKEN_KEYS.OPERATOR_COMPARE}>[${Object.keys( + language.tokens.operator_compare.literal, + )}]{1,2})?` + // Added an optional find + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_3>\s+)?` + + // Value: A string. + // Simple value + // Quoted ", "value, "value", "escaped \"quote" + // Escape quoted string with escaping quotes: https://stackoverflow.com/questions/249791/regex-for-quoted-string-with-escaping-quotes + String.raw`(?<${QUERY_TOKEN_KEYS.VALUE}>(?:(?:[^"\s]+|(?:"(?:[^"\\]|\\")*")|(?:"(?:[^"\\]|\\")*)|")))?` + + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_4>\s+)?` + + // A ) character. + String.raw`(?<${GROUP_OPERATOR_BOUNDARY.CLOSE}>\))?` + + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_5>\s+)?` + + `(?<${QUERY_TOKEN_KEYS.CONJUNCTION}>${Object.keys( + language.tokens.conjunction.literal, + ).join('|')})?` + + // Whitespace + String.raw`(?<${QUERY_TOKEN_KEYS.WHITESPACE}_6>\s+)?`, + 'g', + ); + + return [...input.matchAll(re)].flatMap(({ groups }) => + Object.entries(groups).map(([key, value]) => ({ + type: key.startsWith(QUERY_TOKEN_KEYS.OPERATOR_GROUP) // Transform operator_group group match + ? QUERY_TOKEN_KEYS.OPERATOR_GROUP + : key.startsWith(QUERY_TOKEN_KEYS.WHITESPACE) // Transform whitespace group match + ? QUERY_TOKEN_KEYS.WHITESPACE + : key, + value, + ...(key === QUERY_TOKEN_KEYS.VALUE && + (value && /^"([\S\s]+)"$/.test(value) + ? { formattedValue: value.match(/^"([\S\s]+)"$/)[1] } + : { formattedValue: value })), + })), + ); +} + +interface QLOptionSuggestionEntityItem { + description?: string; + label: string; +} + +type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem & { + type: (typeof QUERY_TOKEN_KEYS)[keyof OmitStrict< + typeof QUERY_TOKEN_KEYS, + 'WHITESPACE' | 'VALIDATION_ERROR' + >]; +}; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string; color: string }; +}; + +type QLOptionSuggestionHandler = ( + currentValue: string | undefined, + { field, operatorCompare }: { field: string; operatorCompare: string }, +) => Promise; + +interface OptionsQLImplicitQuery { + query: string; + conjunction: string; +} +interface OptionsQL { + options?: { + implicitQuery?: OptionsQLImplicitQuery; + searchTermFields?: string[]; + filterButtons: { id: string; label: string; input: string }[]; + }; + suggestions: { + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; + }; + validate?: { + value?: Record< + string, + ( + token: TokenDescriptor, + nearTokens: { field: string; operator: string }, + ) => string | undefined + >; + }; +} + +export interface ISearchBarModeWQL extends OptionsQL { + id: typeof WQL_ID; +} + +/** + * Get the last token with value + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenDefined(tokens: TokenList): TokenDescriptor | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = [...tokens]; + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type !== QUERY_TOKEN_KEYS.WHITESPACE && value, + ); + + return tokenFound; +} + +/** + * Get the last token with value by type + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenDefinedByType( + tokens: TokenList, + tokenType: TokenTypeEnum, +): TokenDescriptor | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = [...tokens]; + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type === tokenType && value, + ); + + return tokenFound; +} + +/** + * Get the token that is near to a token position of the token type. + * @param tokens + * @param tokenReferencePosition + * @param tokenType + * @param mode + * @returns + */ +function getTokenNearTo( + tokens: TokenList, + tokenType: TokenTypeEnum, + mode: MODE = MODE.PREVIOUS, + options: { + tokenReferencePosition?: number; + tokenFoundShouldHaveValue?: boolean; + } = {}, +): TokenDescriptor | undefined { + const shallowCopyTokens = [...tokens]; + const computedShallowCopyTokens = + mode === MODE.PREVIOUS + ? shallowCopyTokens + .slice( + 0, + options?.tokenReferencePosition || (tokens.length as number), + ) + .reverse() + : shallowCopyTokens.slice(options?.tokenReferencePosition || 0); + + return computedShallowCopyTokens.find( + ({ type, value }) => + type === tokenType && (options?.tokenFoundShouldHaveValue ? value : true), + ); +} + +/** + * It returns the regular expression that validate the token of type value + * @returns The regular expression + */ +function getTokenValueRegularExpression() { + return new RegExp( + // Value: A string. + String.raw`^(?<${QUERY_TOKEN_KEYS.VALUE}>(?:(?:\((?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|[\[\]\w _\-.:?\/'"=@%<>{}]*)\))*` + + String.raw`(?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|^[\[\]\w _\-.:?\\/'"=@%<>{}]+)` + + String.raw`(?:\((?:\[[\[\]\w _\-.,:?\\/'"=@%<>{}]*]|[\[\]\w _\-.:?\\/'"=@%<>{}]*)\))*)+)$`, + ); +} + +/** + * It filters the values that matche the validation regular expression and returns the first items + * defined by SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT constant. + * @param suggestions Suggestions provided by the suggestions.value method of each instance of the + * search bar + * @returns + */ +function filterTokenValueSuggestion( + suggestions: QLOptionSuggestionEntityItemTyped[], +) { + return suggestions + ? suggestions + .filter(({ label }: QLOptionSuggestionEntityItemTyped) => { + const re = getTokenValueRegularExpression(); + + return re.test(label); + }) + .slice(0, SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT) + : []; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions( + tokens: TokenList, + options: OptionsQL, +): Promise { + if (tokens.length === 0) { + return []; + } + + // Get last token + const lastToken = getLastTokenDefined(tokens); + + // If it can't get a token with value, then returns fields and open operator group + if (!lastToken?.type) { + return [ + // Search function + { + type: QUERY_TOKEN_KEYS.FUNCTION_SEARCH, + label: 'Search', + description: 'run the search query', + }, + // fields + ...(await options.suggestions.field()).map((element, index, array) => + mapSuggestionCreatorField(element, index, array), + ), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.OPEN, + description: + language.tokens.operator_group.literal[OPERATOR_GROUP.OPEN], + }, + ]; + } + + switch (lastToken.type) { + case QUERY_TOKEN_KEYS.FIELD: { + return [ + // fields that starts with the input but is not equals + ...(await options.suggestions.field()) + .filter( + ({ label }) => + label.startsWith(lastToken.value) && label !== lastToken.value, + ) + .map((element, index, array) => + mapSuggestionCreatorField(element, index, array), + ), + // operators if the input field is exact + ...((await options.suggestions.field()).some( + ({ label }) => label === lastToken.value, + ) + ? Object.keys(language.tokens.operator_compare.literal).map( + operator => ({ + type: QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + label: operator, + description: language.tokens.operator_compare.literal[operator], + }), + ) + : []), + ]; + } + + case QUERY_TOKEN_KEYS.OPERATOR_COMPARE: { + const field = getLastTokenDefinedByType( + tokens, + QUERY_TOKEN_KEYS.FIELD, + )?.value; + const operatorCompare = getLastTokenDefinedByType( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + )?.value; + + // If there is no a previous field, then no return suggestions because it would be an syntax + // error + if (!field) { + return []; + } + + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).includes( + lastToken.value, + ) + ? /* + WORKAROUND: When getting suggestions for the distinct values for any field, the API + could reply some values that doesn't match the expected regular expression. If the + value is invalid, a validation message is displayed and avoid the search can be run. + The goal of this filter is that the suggested values can be used to search. This + causes some values could not be displayed as suggestions. + */ + filterTokenValueSuggestion( + await options.suggestions.value(undefined, { + field, + operatorCompare, + }), + ).map((element, index, array) => + mapSuggestionCreatorValue(element, index, array), + ) + : []), + ]; + } + + case QUERY_TOKEN_KEYS.VALUE: { + const field = getLastTokenDefinedByType( + tokens, + QUERY_TOKEN_KEYS.FIELD, + )?.value; + const operatorCompare = getLastTokenDefinedByType( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + )?.value; + + /* If there is no a previous field or operator_compare, then no return suggestions because + it would be an syntax error */ + if (!field || !operatorCompare) { + return []; + } + + return [ + ...(lastToken.formattedValue + ? [ + { + type: QUERY_TOKEN_KEYS.FUNCTION_SEARCH, + label: 'Search', + description: 'run the search query', + }, + ] + : []), + /* + WORKAROUND: When getting suggestions for the distinct values for any field, the API + could reply some values that doesn't match the expected regular expression. If the + value is invalid, a validation message is displayed and avoid the search can be run. + The goal of this filter is that the suggested values can be used to search. This + causes some values could not be displayed as suggestions. + */ + ...filterTokenValueSuggestion( + await options.suggestions.value(lastToken.formattedValue, { + field, + operatorCompare, + }), + ).map((element, index, array) => + mapSuggestionCreatorValue(element, index, array), + ), + ...Object.entries(language.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description, + }), + ), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.CLOSE, + description: + language.tokens.operator_group.literal[OPERATOR_GROUP.CLOSE], + }, + ]; + } + + case QUERY_TOKEN_KEYS.CONJUNCTION: { + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).includes( + lastToken.value, + ) + ? (await options.suggestions.field()).map((element, index, array) => + mapSuggestionCreatorField(element, index, array), + ) + : []), + { + type: QUERY_TOKEN_KEYS.OPERATOR_GROUP, + label: OPERATOR_GROUP.OPEN, + description: + language.tokens.operator_group.literal[OPERATOR_GROUP.OPEN], + }, + ]; + } + + case QUERY_TOKEN_KEYS.OPERATOR_GROUP: { + if (lastToken.value === OPERATOR_GROUP.OPEN) { + return ( + // fields + (await options.suggestions.field()).map(element => + mapSuggestionCreatorField(element), + ) + ); + } else if (lastToken.value === OPERATOR_GROUP.CLOSE) { + return ( + // conjunction + Object.keys(language.tokens.conjunction.literal).map(conjunction => ({ + type: QUERY_TOKEN_KEYS.CONJUNCTION, + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })) + ); + } + + break; + } + + default: { + return []; + } + } + + return []; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param param0 + * @returns + */ +export function transformSuggestionToEuiSuggestItem( + suggestion: QLOptionSuggestionEntityItemTyped, +): SuggestItem { + const { type, ...rest } = suggestion; + + return { + type: { ...SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE[type] }, + ...rest, + }; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @returns + */ +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[], +): SuggestItem[] { + return suggestions.map(element => + transformSuggestionToEuiSuggestItem(element), + ); +} + +/** + * Transform the UQL (Unified Query Language) to QL + * @param input + * @returns + */ +export function transformUQLToQL(input: string) { + const tokens = tokenizerUQL(input); + + return tokens + .filter(({ value }) => value) + .map(({ type, value }) => { + switch (type) { + case QUERY_TOKEN_KEYS.CONJUNCTION: { + return transformQLConjunction(value); + } + + case QUERY_TOKEN_KEYS.VALUE: { + return transformQLValue(value); + } + + default: { + return value; + } + } + }) + .join(''); +} + +export function shouldUseSearchTerm(tokens: TokenList): boolean { + return !( + tokens.some( + ({ type, value }) => type === QUERY_TOKEN_KEYS.OPERATOR_COMPARE && value, + ) && + tokens.some(({ type, value }) => type === QUERY_TOKEN_KEYS.FIELD && value) + ); +} + +export function transformToSearchTerm( + searchTermFields: string[], + input: string, +): string { + return searchTermFields + .map(searchTermField => `${searchTermField}~${input}`) + .join(','); +} + +/** + * Transform the input in QL to UQL (Unified Query Language) + * @param input + * @returns + */ +export function transformSpecificQLToUnifiedQL( + input: string, + searchTermFields: string[], +) { + const tokens = tokenizer(input); + + if (input && searchTermFields && shouldUseSearchTerm(tokens)) { + return transformToSearchTerm(searchTermFields, input); + } + + return tokens + .filter( + ({ type, value, formattedValue }) => + type !== QUERY_TOKEN_KEYS.WHITESPACE && (formattedValue ?? value), + ) + .map(({ type, value, formattedValue }) => { + switch (type) { + case QUERY_TOKEN_KEYS.VALUE: { + // If the value is wrapped with ", then replace the escaped double quotation mark (\") + // by double quotation marks (") + // WARN: This could cause a problem with value that contains this sequence \" + const extractedValue = + formattedValue === value + ? formattedValue + : formattedValue?.replaceAll(String.raw`\"`, '"'); + + return extractedValue || value; + } + + case QUERY_TOKEN_KEYS.CONJUNCTION: { + return value === CONJUNCTION_WQL.AND + ? language.equivalencesToUQL.conjunction.literal['and'] + : language.equivalencesToUQL.conjunction.literal['or']; + } + + default: { + return value; + } + } + }) + .join(''); +} + +/** + * Get the output from the input + * @param input + * @returns + */ +function getOutput(input: string, options: OptionsQL) { + // Implicit query + const implicitQueryAsUQL = options?.options?.implicitQuery?.query ?? ''; + const implicitQueryAsQL = transformUQLToQL(implicitQueryAsUQL); + // Implicit query conjunction + const implicitQueryConjunctionAsUQL = + options?.options?.implicitQuery?.conjunction ?? ''; + const implicitQueryConjunctionAsQL = transformUQLToQL( + implicitQueryConjunctionAsUQL, + ); + // User input query + const inputQueryAsQL = input; + const inputQueryAsUQL = transformSpecificQLToUnifiedQL( + inputQueryAsQL, + options?.options?.searchTermFields ?? [], + ); + + return { + language: WQL_ID, + apiQuery: { + q: [ + implicitQueryAsUQL, + implicitQueryAsUQL && inputQueryAsUQL + ? implicitQueryConjunctionAsUQL + : '', + implicitQueryAsUQL && inputQueryAsUQL + ? `(${inputQueryAsUQL})` + : inputQueryAsUQL, + ].join(''), + }, + query: [ + implicitQueryAsQL, + implicitQueryAsQL && inputQueryAsQL ? implicitQueryConjunctionAsQL : '', + implicitQueryAsQL && inputQueryAsQL + ? `(${inputQueryAsQL})` + : inputQueryAsQL, + ].join(''), + }; +} + +/** + * Validate the token value + * @param token + * @returns + */ +function validateTokenValue(token: TokenDescriptor): string | undefined { + const re = getTokenValueRegularExpression(); + const value = token.formattedValue ?? token.value; + const match = value.match(re); + + if (match?.groups?.value === value) { + return undefined; + } + + const invalidCharacters: string[] = [...token.value] + .filter((value, index, array) => array.indexOf(value) === index) + .filter( + character => + !new RegExp(String.raw`[\[\]\w _\-.,:?\\/'"=@%<>{}\(\)]`).test( + character, + ), + ); + + return [ + `"${value}" is not a valid value.`, + ...(invalidCharacters.length > 0 + ? [`Invalid characters found: ${invalidCharacters.join('')}`] + : []), + ].join(' '); +} + +type ITokenValidator = ( + tokenValue: TokenDescriptor, + proximityTokens: any, +) => string | undefined; + +/** + * Validate the tokens while the user is building the query + * @param tokens + * @param validate + * @returns + */ +function validatePartial( + tokens: TokenList, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string { + // Ensure is not in search term mode + if (!shouldUseSearchTerm(tokens)) { + return ( + tokens + .map((token: TokenDescriptor, index) => { + if (token.value) { + if (token.type === QUERY_TOKEN_KEYS.FIELD) { + // Ensure there is a operator next to field to check if the fields is valid or not. + // This allows the user can type the field token and get the suggestions for the field. + const tokenOperatorNearToField = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + MODE.NEXT, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + + return tokenOperatorNearToField + ? validate.field(token) + : undefined; + } + + // Check if the value is allowed + if (token.type === QUERY_TOKEN_KEYS.VALUE) { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.FIELD, + MODE.PREVIOUS, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + MODE.PREVIOUS, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + + return ( + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined) + ); + } + } + }) + .filter(t => t !== undefined) + .join('\n') || undefined + ); + } +} + +/** + * Validate the tokens if they are a valid syntax + * @param tokens + * @param validate + * @returns + */ +function validate( + tokens: TokenList, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string[] { + if (!shouldUseSearchTerm(tokens)) { + const errors = tokens + .map((token: TokenDescriptor, index) => { + const errors = []; + + if (token.value) { + if (token.type === QUERY_TOKEN_KEYS.FIELD) { + const tokenOperatorNearToField = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + MODE.NEXT, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenValueNearToField = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.VALUE, + MODE.NEXT, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + + if (validate.field(token)) { + errors.push(`"${token.value}" is not a valid field.`); + } else if (!tokenOperatorNearToField) { + errors.push( + `The operator for field "${token.value}" is missing.`, + ); + } else if (!tokenValueNearToField) { + errors.push(`The value for field "${token.value}" is missing.`); + } + } + + // Check if the value is allowed + if (token.type === QUERY_TOKEN_KEYS.VALUE) { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.FIELD, + MODE.PREVIOUS, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.OPERATOR_COMPARE, + MODE.PREVIOUS, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const validationError = + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined); + + if (validationError) { + errors.push(validationError); + } + } + + // Check if the value is allowed + if (token.type === QUERY_TOKEN_KEYS.CONJUNCTION) { + const tokenWhitespaceNearToFieldNext = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.WHITESPACE, + MODE.NEXT, + { tokenReferencePosition: index }, + ); + const tokenFieldNearToFieldNext = getTokenNearTo( + tokens, + QUERY_TOKEN_KEYS.FIELD, + MODE.NEXT, + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + + if (!tokenWhitespaceNearToFieldNext?.value?.length) { + errors.push( + `There is no whitespace after conjunction "${token.value}".`, + ); + } + + if (!tokenFieldNearToFieldNext?.value?.length) { + errors.push( + `There is no sentence after conjunction "${token.value}".`, + ); + } + } + } + + return errors.length > 0 ? errors : undefined; + }) + .filter(Boolean) + .flat(); + + return errors.length > 0 ? errors : undefined; + } + + return undefined; +} + +export const WQL = { + id: WQL_ID, + label: 'WQL', + description: + 'WQL (Wazuh Query Language) provides a human query syntax based on the Wazuh API query language.', + documentationLink: webDocumentationLink( + 'user-manual/wazuh-dashboard/queries.html', + ), + getConfiguration() { + return { + isOpenPopoverImplicitFilter: false, + }; + }, + async run(input, params) { + // Get the tokens from the input + const tokens: TokenList = tokenizer(input); + // Get the implicit query as query language syntax + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const implicitQueryAsQL = params.queryLanguage.parameters?.options + ?.implicitQuery + ? transformUQLToQL( + params.queryLanguage.parameters.options.implicitQuery.query + + params.queryLanguage.parameters.options.implicitQuery.conjunction, + ) + : ''; + const fieldsSuggestion: string[] = + await params.queryLanguage.parameters.suggestions + .field() + .map(({ label }) => label); + const validators = { + field: ({ value }) => + fieldsSuggestion.includes(value) + ? undefined + : `"${value}" is not valid field.`, + ...(params.queryLanguage.parameters?.validate?.value + ? { + value: params.queryLanguage.parameters?.validate?.value, + } + : {}), + }; + // Validate the user input + const validationPartial = validatePartial(tokens, validators); + const validationStrict = validate(tokens, validators); + // Get the output of query language + const output = { + ...getOutput(input, params.queryLanguage.parameters), + error: validationStrict, + }; + + const onSearch = output => { + if (output?.error) { + params.setQueryLanguageOutput(state => ({ + ...state, + searchBarProps: { + ...state.searchBarProps, + suggestions: transformSuggestionsToEuiSuggestItem( + output.error.map(error => ({ + type: QUERY_TOKEN_KEYS.VALIDATION_ERROR, + label: 'Invalid', + description: error, + })), + ), + isInvalid: true, + }, + })); + } else { + params.onSearch(output); + } + }; + + return { + filterButtons: params.queryLanguage.parameters?.options?.filterButtons ? ( + ({ id, label }), + )} + idToSelectedMap={{}} + type='multi' + onChange={(id: string) => { + const buttonParams = + params.queryLanguage.parameters?.options?.filterButtons.find( + ({ id: buttonID }) => buttonID === id, + ); + + if (buttonParams) { + params.setInput(buttonParams.input); + + const output = { + ...getOutput( + buttonParams.input, + params.queryLanguage.parameters, + ), + error: undefined, + }; + + params.onSearch(output); + } + }} + /> + ) : null, + searchBarProps: { + // Props that will be used by the EuiSuggest component + // Suggestions + suggestions: transformSuggestionsToEuiSuggestItem( + validationPartial + ? [ + { + type: QUERY_TOKEN_KEYS.VALIDATION_ERROR, + label: 'Invalid', + description: validationPartial, + }, + ] + : await getSuggestions(tokens, params.queryLanguage.parameters), + ), + // Handler to manage when clicking in a suggestion item + onItemClick: currentInput => item => { + // There is an error, clicking on the item does nothing + if (item.type.iconType === ICON_TYPE.ALERT) { + return; + } + + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === ICON_TYPE.SEARCH) { + // Execute the search action + // Get the tokens from the input + const tokens: TokenList = tokenizer(currentInput); + const validationStrict = validate(tokens, validators); + // Get the output of query language + const output = { + ...getOutput(currentInput, params.queryLanguage.parameters), + error: validationStrict, + }; + + onSearch(output); + } else { + // When the clicked item has another iconType + const lastToken: TokenDescriptor | undefined = + getLastTokenDefined(tokens); + + // if the clicked suggestion is of same type of last token + if ( + lastToken && + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE[lastToken.type] + .iconType === item.type.iconType + ) { + // replace the value of last token with the current one. + // if the current token is a value, then transform it + lastToken.value = + item.type.iconType === + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE.value.iconType + ? transformQLValue(item.label) + : item.label; + } else { + // add a whitespace for conjunction + // add a whitespace for grouping operator ) + if ( + !/\s$/.test(input) && + (item.type.iconType === + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE.conjunction.iconType || + lastToken?.type === QUERY_TOKEN_KEYS.CONJUNCTION || + (item.type.iconType === + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE.operator_group + .iconType && + item.label === OPERATOR_GROUP.CLOSE)) + ) { + tokens.push({ + type: QUERY_TOKEN_KEYS.WHITESPACE, + value: ' ', + }); + } + + // add a new token of the selected type and value + tokens.push({ + type: Object.entries( + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE, + ).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: + item.type.iconType === + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE.value.iconType + ? transformQLValue(item.label) + : item.label, + }); + + // add a whitespace for conjunction + if ( + item.type.iconType === + SUGGESTION_MAPPING_LANGUAGE_TOKEN_TYPE.conjunction.iconType + ) { + tokens.push({ + type: QUERY_TOKEN_KEYS.WHITESPACE, + value: ' ', + }); + } + } + + // Change the input + params.setInput( + tokens + .filter(Boolean) // Ensure the input is rebuilt using tokens with value. + // The input tokenization can contain tokens with no value due to the used + // regular expression. + .map(({ value }) => value) + .join(''), + ); + } + }, + // Disable the focus trap in the EuiInputPopover. + // This causes when using the Search suggestion, the suggestion popover can be closed. + // If this is disabled, then the suggestion popover is open after a short time for this + // use case. + disableFocusTrap: true, + // Show the input is invalid + isInvalid: Boolean(validationStrict), + // Define the handler when the a key is pressed while the input is focused + onKeyPress: event => { + if (event.key === 'Enter') { + // Get the tokens from the input + const input = event.currentTarget.value; + const tokens: TokenList = tokenizer(input); + const validationStrict = validate(tokens, validators); + // Get the output of query language + const output = { + ...getOutput(input, params.queryLanguage.parameters), + error: validationStrict, + }; + + onSearch(output); + } + }, + }, + output, + }; + }, + transformInput: (unifiedQuery: string, { parameters }) => { + const input = + unifiedQuery && parameters?.options?.implicitQuery + ? unifiedQuery.replace( + new RegExp( + `^${parameters.options.implicitQuery.query}${parameters.options.implicitQuery.conjunction}`, + ), + '', + ) + : unifiedQuery; + + return transformUQLToQL(input); + }, +}; diff --git a/plugins/wazuh-core/public/components/table-data/README.md b/plugins/wazuh-core/public/components/table-data/README.md new file mode 100644 index 0000000000..0a8a87e57e --- /dev/null +++ b/plugins/wazuh-core/public/components/table-data/README.md @@ -0,0 +1,28 @@ +# TableData + +This is a generic table data component that represents the data in pages that is obtained using a parameter. When the pagination or sorting changes, the parameter to get the data is executed. + +# Layout + +``` +title? (totalItems?) postTitle? preActionButtons? actionReload postActionButtons? +description? +preTable? +table +postTable? +``` + +# Features + +- Ability to reload the data +- Ability to select the visible columns (persist data in localStorage or sessionStorage) +- Customizable: + - Title + - Post title + - Description + - Pre action buttons + - Post action buttons + - Above table + - Below table + - Table columns + - Table initial sorting column diff --git a/plugins/wazuh-core/public/components/table-data/index.ts b/plugins/wazuh-core/public/components/table-data/index.ts new file mode 100644 index 0000000000..7f30d5b41c --- /dev/null +++ b/plugins/wazuh-core/public/components/table-data/index.ts @@ -0,0 +1,2 @@ +export * from './table-data'; +export * from './types'; diff --git a/plugins/wazuh-core/public/components/table-data/table-data.tsx b/plugins/wazuh-core/public/components/table-data/table-data.tsx new file mode 100644 index 0000000000..919dcc76cb --- /dev/null +++ b/plugins/wazuh-core/public/components/table-data/table-data.tsx @@ -0,0 +1,381 @@ +/* + * Wazuh app - Table with search bar + * Copyright (C) 2015-2022 Wazuh, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * Find more information about this on the LICENSE file. + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { + EuiTitle, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiButtonEmpty, + EuiToolTip, + EuiIcon, + EuiCheckboxGroup, + EuiBasicTable, +} from '@elastic/eui'; +import { isEqual } from 'lodash'; +import { useStateStorage } from '../../hooks'; +import { TableDataProps } from './types'; + +const getColumMetaField = item => item.field || item.name; + +const TableDataRenderElement = ({ + render, + ...rest +}: { + render: ((params: any) => React.JSXElement) | React.JSXElement; +}) => { + if (typeof render === 'function') { + return {render(rest)}; + } + + if (typeof render === 'object') { + return {render}; + } + + return null; +}; + +export function TableData({ + preActionButtons, + postActionButtons, + postTitle, + onReload, + fetchData, + tablePageSizeOptions = [15, 25, 50, 100], + tableInitialSortingDirection = 'asc', + tableInitialSortingField = '', + ...rest +}: TableDataProps) { + const [isLoading, setIsLoading] = useState(false); + const [items, setItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: tablePageSizeOptions[0], + }); + const [sorting, setSorting] = useState({ + sort: { + field: tableInitialSortingField, + direction: tableInitialSortingDirection, + }, + }); + const [refresh, setRefresh] = useState(rest.reload || 0); + const [fetchContext, setFetchContext] = useState(rest.fetchContext || {}); + const isMounted = useRef(false); + const tableRef = useRef(); + const [selectedFields, setSelectedFields] = useStateStorage( + rest.tableColumns.some(({ show }) => show) + ? rest.tableColumns.filter(({ show }) => show).map(({ field }) => field) + : rest.tableColumns.map(({ field }) => field), + rest?.saveStateStorage?.system, + rest?.saveStateStorage?.key + ? `${rest?.saveStateStorage?.key}-visible-fields` + : undefined, + ); + const [isOpenFieldSelector, setIsOpenFieldSelector] = useState(false); + + const onFetch = async ({ pagination, sorting }) => { + try { + const enhancedFetchContext = { + pagination, + sorting, + fetchContext, + }; + + setIsLoading(true); + rest?.onFetchContextChange?.(enhancedFetchContext); + + const { items, totalItems } = await fetchData(enhancedFetchContext); + + setIsLoading(false); + setItems(items); + setTotalItems(totalItems); + + const result = { + items: rest.mapResponseItem + ? items.map((element, index, array) => + rest.mapResponseItem(element, index, array), + ) + : items, + totalItems, + }; + + rest?.onDataChange?.(result); + } catch (error) { + setIsLoading(false); + setTotalItems(0); + + if (error?.name) { + /* This replaces the error name. The intention is that an AxiosError + doesn't appear in the toast message. + TODO: This should be managed by the service that does the request instead of only changing + the name in this case. + */ + error.name = 'RequestError'; + } + + throw error; + } + }; + + const tableColumns = rest.tableColumns.filter(item => + selectedFields.includes(getColumMetaField(item)), + ); + + const renderActionButtons = actionButtons => { + if (Array.isArray(actionButtons)) { + return actionButtons.map((button, key) => ( + + {button} + + )); + } + + if (typeof actionButtons === 'object') { + return {actionButtons}; + } + + if (typeof actionButtons === 'function') { + return actionButtons({ + fetchContext, + pagination, + sorting, + items, + totalItems, + tableColumns, + }); + } + }; + + /** + * Generate a new reload footprint and set reload to propagate refresh + */ + const triggerReload = () => { + setRefresh(Date.now()); + + if (onReload) { + onReload(Date.now()); + } + }; + + function updateRefresh() { + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + setRefresh(Date.now()); + } + + function tableOnChange({ page = {}, sort = {} }) { + if (isMounted.current) { + const { index: pageIndex, size: pageSize } = page; + const { field, direction } = sort; + + setPagination({ + pageIndex, + pageSize, + }); + setSorting({ + sort: { + field, + direction, + }, + }); + } + } + + useEffect(() => { + // This effect is triggered when the component is mounted because of how to the useEffect hook works. + // We don't want to set the pagination state because there is another effect that has this dependency + // and will cause the effect is triggered (redoing the onFetch function). + if (isMounted.current) { + // Reset the page index when the reload changes. + // This will cause that onFetch function is triggered because to changes in pagination in the another effect. + updateRefresh(); + } + }, [rest?.reload]); + + useEffect(() => { + onFetch({ pagination, sorting }); + }, [fetchContext, pagination, sorting, refresh]); + + useEffect(() => { + // This effect is triggered when the component is mounted because of how to the useEffect hook works. + // We don't want to set the searchParams state because there is another effect that has this dependency + // and will cause the effect is triggered (redoing the onFetch function). + if (isMounted.current && !isEqual(rest.fetchContext, fetchContext)) { + setFetchContext(rest.fetchContext); + updateRefresh(); + } + }, [rest?.fetchContext]); + + useEffect(() => { + if (rest.reload) { + triggerReload(); + } + }, [rest.reload]); + + // It is required that this effect runs after other effects that use isMounted + // to avoid that these effects run when the component is mounted, only running + // when one of its dependencies changes. + useEffect(() => { + isMounted.current = true; + }, []); + + const tablePagination = { + ...pagination, + totalItemCount: totalItems, + pageSizeOptions: tablePageSizeOptions, + }; + const ReloadButton = ( + + triggerReload()}> + Refresh + + + ); + const header = ( + <> + + + + + {rest.title && ( + +

+ {rest.title}{' '} + {isLoading ? ( + + ) : ( + ({totalItems}) + )} +

+
+ )} +
+ {postTitle ? ( + + {postTitle} + + ) : null} +
+
+ + + {/* Render optional custom action button */} + {renderActionButtons(preActionButtons)} + {/* Render optional reload button */} + {rest.showActionReload && ReloadButton} + {/* Render optional post custom action button */} + {renderActionButtons(postActionButtons)} + {rest.showFieldSelector && ( + + + setIsOpenFieldSelector(state => !state)} + > + + + + + )} + + +
+ {isOpenFieldSelector && ( + + + { + const metaField = getColumMetaField(item); + + return { + id: metaField, + label: item.name, + checked: selectedFields.includes(metaField), + }; + })} + onChange={optionID => { + setSelectedFields(state => { + if (state.includes(optionID)) { + if (state.length > 1) { + return state.filter(field => field !== optionID); + } + + return state; + } + + return [...state, optionID]; + }); + }} + className='columnsSelectedCheckboxs' + idToSelectedMap={{}} + /> + + + )} + + ); + const tableDataRenderElementsProps = { + ...rest, + tableColumns, + isOpenFieldSelector, + selectedFields, + refresh, + updateRefresh, + fetchContext, + setFetchContext, + pagination, + setPagination, + sorting, + setSorting, + tableRef, + }; + + return ( + + {header} + {rest.description && ( + + {rest.description} + + )} + + + ({ ...rest }), + )} + items={items} + loading={isLoading} + pagination={tablePagination} + sorting={sorting} + onChange={tableOnChange} + rowProps={rest.rowProps} + {...rest.tableProps} + /> + + + + ); +} diff --git a/plugins/wazuh-core/public/components/table-data/types.ts b/plugins/wazuh-core/public/components/table-data/types.ts new file mode 100644 index 0000000000..9fe33650b3 --- /dev/null +++ b/plugins/wazuh-core/public/components/table-data/types.ts @@ -0,0 +1,86 @@ +import { ReactNode } from 'react'; +import { EuiBasicTableProps } from '@elastic/eui'; + +export interface TableDataProps { + preActionButtons?: ReactNode | ((options: any) => ReactNode); + postActionButtons?: ReactNode | ((options: any) => ReactNode); + title?: string; + postTitle?: ReactNode; + description?: string; + /** + * Define a render above to the table + */ + preTable?: ReactNode | ((options: any) => ReactNode); + /** + * Define a render below to the table + */ + postTable?: ReactNode | ((options: any) => ReactNode); + /** + * Enable the action to reload the data + */ + showActionReload?: boolean; + onDataChange?: (data: any) => void; + onReload?: (newValue: number) => void; + /** + * Fetch context + */ + fetchContext: any; + /** + * Function to fetch the data + */ + fetchData: (params: { + fetchContext: any; + pagination: EuiBasicTableProps['pagination']; + sorting: EuiBasicTableProps['sorting']; + }) => Promise<{ items: any[]; totalItems: number }>; + onFetchContextChange?: (context: any) => void; + /** + * Columns for the table + */ + tableColumns: EuiBasicTableProps['columns'] & { + composeField?: string[]; + searchable?: string; + show?: boolean; + }; + /** + * Table row properties for the table + */ + rowProps?: EuiBasicTableProps['rowProps']; + /** + * Table page size options + */ + tablePageSizeOptions?: number[]; + /** + * Table initial sorting direction + */ + tableInitialSortingDirection?: 'asc' | 'desc'; + /** + * Table initial sorting field + */ + tableInitialSortingField?: string; + /** + * Table properties + */ + tableProps?: Omit< + EuiBasicTableProps, + | 'columns' + | 'items' + | 'loading' + | 'pagination' + | 'sorting' + | 'onChange' + | 'rowProps' + >; + /** + * Refresh the fetch of data + */ + reload?: number; + saveStateStorage?: { + system: 'localStorage' | 'sessionStorage'; + key: string; + }; + /** + * Show the field selector + */ + showFieldSelector?: boolean; +} diff --git a/plugins/wazuh-core/public/hooks/index.ts b/plugins/wazuh-core/public/hooks/index.ts index d06d93425c..808261cd95 100644 --- a/plugins/wazuh-core/public/hooks/index.ts +++ b/plugins/wazuh-core/public/hooks/index.ts @@ -1 +1,2 @@ export { useDockedSideNav } from './use-docked-side-nav'; +export * from './use-state-storage'; diff --git a/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx b/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx index ad33997d4c..5f1af3870f 100644 --- a/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx +++ b/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx @@ -2,9 +2,12 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { getChrome } from '../plugin-services'; export const useDockedSideNav = () => { - const [sideNavDocked, _setSideNavDocked] = useState(false); + const [sideNavDocked, setSideNavDockedState] = useState(false); const [isDockedSideNavVisible, setIsDockedSideNavVisible] = useState(false); + // Create references so the event handler can read the latest values + const timeoutID = useRef(0); + const currentSideNavDocked = useRef(sideNavDocked); /* We have to create a reference to the state of the react component, @@ -12,13 +15,9 @@ export const useDockedSideNav = () => { */ const setSideNavDocked = (value: boolean) => { currentSideNavDocked.current = value; - _setSideNavDocked(value); + setSideNavDockedState(value); }; - // Create references so the event handler can read the latest values - let timeoutID = useRef(0); - const currentSideNavDocked = useRef(sideNavDocked); - // If the inner width of the window is less than 992px, the side nav is always hidden. // The use of useCallback is to keep the function reference the same so we can remove it in the event listener const onWindowResize = useCallback(() => { @@ -43,6 +42,7 @@ export const useDockedSideNav = () => { }); window.addEventListener('resize', onWindowResize, true); + return () => { isNavDrawerSubscription.unsubscribe(); window.removeEventListener('resize', onWindowResize, true); @@ -51,3 +51,5 @@ export const useDockedSideNav = () => { return isDockedSideNavVisible; }; + +export type UseDockedSideNav = () => boolean; diff --git a/plugins/wazuh-core/public/hooks/use-state-storage.ts b/plugins/wazuh-core/public/hooks/use-state-storage.ts new file mode 100644 index 0000000000..ba41095327 --- /dev/null +++ b/plugins/wazuh-core/public/hooks/use-state-storage.ts @@ -0,0 +1,52 @@ +import { useState } from 'react'; + +export type UseStateStorageSystem = 'sessionStorage' | 'localStorage'; +export type UseStateStorageReturn = [T, (value: T) => void]; +export type UseStateStorage = ( + initialValue: T, + storageSystem?: UseStateStorageSystem, + storageKey?: string, +) => [T, (value: T) => void]; + +function transformValueToStorage(value: any) { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function transformValueFromStorage(value: any) { + return typeof value === 'string' ? JSON.parse(value) : value; +} + +export type UseStateStorageHook = ( + initialValue: T, + storageSystem?: UseStateStorageSystem, + storageKey?: string, +) => UseStateStorageReturn; + +export function useStateStorage( + initialValue: T, + storageSystem?: UseStateStorageSystem, + storageKey?: string, +): UseStateStorageReturn { + const [state, setState] = useState( + storageSystem && storageKey && window?.[storageSystem]?.getItem(storageKey) + ? transformValueFromStorage(window?.[storageSystem]?.getItem(storageKey)) + : initialValue, + ); + + function setStateStorage(value: T) { + setState(state => { + const formattedValue = typeof value === 'function' ? value(state) : value; + + if (storageSystem && storageKey) { + window?.[storageSystem]?.setItem( + storageKey, + transformValueToStorage(formattedValue), + ); + } + + return formattedValue; + }); + } + + return [state, setStateStorage]; +} diff --git a/plugins/wazuh-core/public/plugin.ts b/plugins/wazuh-core/public/plugin.ts index ef08e41595..8b9135fc44 100644 --- a/plugins/wazuh-core/public/plugin.ts +++ b/plugins/wazuh-core/public/plugin.ts @@ -1,57 +1,82 @@ import { CoreSetup, CoreStart, Plugin } from 'opensearch-dashboards/public'; -import { WazuhCorePluginSetup, WazuhCorePluginStart } from './types'; -import { setChrome, setCore, setUiSettings } from './plugin-services'; -import * as utils from './utils'; import { API_USER_STATUS_RUN_AS } from '../common/api-user-status-run-as'; import { Configuration } from '../common/services/configuration'; -import { ConfigurationStore } from './utils/configuration-store'; import { PLUGIN_SETTINGS, PLUGIN_SETTINGS_CATEGORIES, } from '../common/constants'; +import { WazuhCorePluginSetup, WazuhCorePluginStart } from './types'; +import { setChrome, setCore, setUiSettings } from './plugin-services'; +import * as utils from './utils'; +import * as uiComponents from './components'; +import { ConfigurationStore } from './utils/configuration-store'; import { DashboardSecurity } from './utils/dashboard-security'; import * as hooks from './hooks'; +import { CoreHTTPClient } from './services/http/http-client'; + +const noop = () => {}; export class WazuhCorePlugin implements Plugin { - _internal: { [key: string]: any } = {}; - services: { [key: string]: any } = {}; + runtime = { setup: {} }; + internal: Record = {}; + services: Record = {}; + public async setup(core: CoreSetup): Promise { - const noop = () => {}; - const logger = { + // No operation logger + const noopLogger = { info: noop, error: noop, debug: noop, warn: noop, }; - this._internal.configurationStore = new ConfigurationStore( + const logger = noopLogger; + + this.internal.configurationStore = new ConfigurationStore( logger, core.http, ); this.services.configuration = new Configuration( logger, - this._internal.configurationStore, + this.internal.configurationStore, ); // Register the plugin settings - Object.entries(PLUGIN_SETTINGS).forEach(([key, value]) => - this.services.configuration.register(key, value), - ); + for (const [key, value] of Object.entries(PLUGIN_SETTINGS)) { + this.services.configuration.register(key, value); + } // Add categories to the configuration - Object.entries(PLUGIN_SETTINGS_CATEGORIES).forEach(([key, value]) => { + for (const [key, value] of Object.entries(PLUGIN_SETTINGS_CATEGORIES)) { this.services.configuration.registerCategory({ ...value, id: key }); - }); + } + // Create dashboardSecurity this.services.dashboardSecurity = new DashboardSecurity(logger, core.http); + // Create http + this.services.http = new CoreHTTPClient(logger, { + getTimeout: async () => + (await this.services.configuration.get('timeout')) as number, + getURL: (path: string) => core.http.basePath.prepend(path), + getServerAPI: () => 'imposter', // TODO: implement + getIndexPatternTitle: async () => 'wazuh-alerts-*', // TODO: implement + http: core.http, + }); + + // Setup services await this.services.dashboardSecurity.setup(); + this.runtime.setup.http = await this.services.http.setup({ core }); return { ...this.services, utils, API_USER_STATUS_RUN_AS, + ui: { + ...uiComponents, + ...this.runtime.setup.http.ui, + }, }; } @@ -60,13 +85,20 @@ export class WazuhCorePlugin setCore(core); setUiSettings(core.uiSettings); + // Start services await this.services.configuration.start({ http: core.http }); + await this.services.dashboardSecurity.start(); + await this.services.http.start(); return { ...this.services, utils, API_USER_STATUS_RUN_AS, hooks, + ui: { + ...uiComponents, + ...this.runtime.setup.http.ui, + }, }; } diff --git a/plugins/wazuh-core/public/services/http/README.md b/plugins/wazuh-core/public/services/http/README.md new file mode 100644 index 0000000000..96b14e9807 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/README.md @@ -0,0 +1,105 @@ +# HTTPClient + +The `HTTPClient` provides a custom mechanim to do an API request to the backend side. + +This defines a request interceptor that disables the requests when `core.http` returns a response with status code 401, avoiding a problem in the login flow (with SAML). + +The request interceptor is used in the clients: + +- generic +- server + +## Generic + +This client provides a method to run the request that injects some properties related to an index pattern and selected server API host in the headers of the API request that could be used for some backend endpoints + +### Usage + +#### Request + +```ts +plugins.wazuhCore.http.request('GET', '/api/check-api', {}); +``` + +## Server + +This client provides: + +- some methods to communicate with the Wazuh server API +- manage authentication with Wazuh server API +- store the login data + +### Usage + +#### Authentication + +```ts +plugins.wazuhCore.http.auth(); +``` + +#### Unauthentication + +```ts +plugins.wazuhCore.http.unauth(); +``` + +#### Request + +```ts +plugins.wazuhCore.http.request('GET', '/agents', {}); +``` + +#### CSV + +```ts +plugins.wazuhCore.http.csv('GET', '/agents', {}); +``` + +#### Check API id + +```ts +plugins.wazuhCore.http.checkApiById('api-host-id'); +``` + +#### Check API + +```ts +plugins.wazuhCore.http.checkApi(apiHostData); +``` + +#### Get user data + +```ts +plugins.wazuhCore.http.getUserData(); +``` + +The changes in the user data can be retrieved thourgh the `userData$` observable. + +```ts +plugins.wazuhCore.http.userData$.subscribe(userData => { + // do something with the data +}); +``` + +### Register interceptor + +In each application when this is mounted through the `mount` method, the request interceptor must be registered and when the application is unmounted must be unregistered. + +> We should research about the possibility to register/unregister the interceptor once in the `wazuh-core` plugin instead of registering/unregisting in each mount of application. + +```ts +// setup lifecycle plugin method + +// Register an application +core.application.register({ + // rest of registration properties + mount: () => { + // Register the interceptor + plugins.wazuhCore.http.register(); + return () => { + // Unregister the interceptor + plugins.wazuhCore.http.unregister(); + }; + }, +}); +``` diff --git a/plugins/wazuh-core/public/services/http/constants.ts b/plugins/wazuh-core/public/services/http/constants.ts new file mode 100644 index 0000000000..ad812bbb3d --- /dev/null +++ b/plugins/wazuh-core/public/services/http/constants.ts @@ -0,0 +1,5 @@ +export const PLUGIN_PLATFORM_REQUEST_HEADERS = { + 'osd-xsrf': 'kibana', +}; + +export const HTTP_CLIENT_DEFAULT_TIMEOUT = 20_000; diff --git a/plugins/wazuh-core/public/services/http/generic-client.ts b/plugins/wazuh-core/public/services/http/generic-client.ts new file mode 100644 index 0000000000..967ffa0911 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/generic-client.ts @@ -0,0 +1,143 @@ +import { Logger } from '../../../common/services/configuration'; +import { PLUGIN_PLATFORM_REQUEST_HEADERS } from './constants'; +import { + HTTPClientGeneric, + HTTPClientRequestInterceptor, + HTTPVerb, +} from './types'; + +interface GenericRequestServices { + request: HTTPClientRequestInterceptor['request']; + getURL: (path: string) => string; + getTimeout: () => Promise; + getIndexPatternTitle: () => Promise; + getServerAPI: () => string; + checkAPIById: (apiId: string) => Promise; +} + +export class GenericRequest implements HTTPClientGeneric { + onErrorInterceptor?: (error: any) => Promise; + + constructor( + private readonly logger: Logger, + private readonly services: GenericRequestServices, + ) {} + + async request( + method: HTTPVerb, + path: string, + payload = null, + returnError = false, + ) { + try { + if (!method || !path) { + throw new Error('Missing parameters'); + } + + const timeout = await this.services.getTimeout(); + const requestHeaders = { + ...PLUGIN_PLATFORM_REQUEST_HEADERS, + 'content-type': 'application/json', + }; + const url = this.services.getURL(path); + + try { + requestHeaders.pattern = await this.services.getIndexPatternTitle(); + } catch { + /* empty */ + } + + try { + requestHeaders.id = this.services.getServerAPI(); + } catch { + // Intended + } + + let options = {}; + + if (method === 'GET') { + options = { + method: method, + headers: requestHeaders, + url: url, + timeout: timeout, + }; + } + + if (method === 'PUT') { + options = { + method: method, + headers: requestHeaders, + data: payload, + url: url, + timeout: timeout, + }; + } + + if (method === 'POST') { + options = { + method: method, + headers: requestHeaders, + data: payload, + url: url, + timeout: timeout, + }; + } + + if (method === 'DELETE') { + options = { + method: method, + headers: requestHeaders, + data: payload, + url: url, + timeout: timeout, + }; + } + + const data = await this.services.request(options); + + if (!data) { + throw new Error(`Error doing a request to ${url}, method: ${method}.`); + } + + return data; + } catch (error) { + // if the requests fails, we need to check if the API is down + const currentApi = this.services.getServerAPI(); // JSON.parse(AppState.getCurrentAPI() || '{}'); + + if (currentApi) { + try { + await this.services.checkAPIById(currentApi); + } catch { + // const wzMisc = new WzMisc(); + // wzMisc.setApiIsDown(true); + // if ( + // ['/settings', '/health-check', '/blank-screen'].every( + // pathname => + // !NavigationService.getInstance() + // .getPathname() + // .startsWith(pathname), + // ) + // ) { + // NavigationService.getInstance().navigate('/health-check'); + // } + } + } + + // if(this.onErrorInterceptor){ + // await this.onErrorInterceptor(error) + // } + if (returnError) { + throw error; + } + + return error?.response?.data?.message || false + ? Promise.reject(new Error(error.response.data.message)) + : Promise.reject(error || new Error('Server did not respond')); + } + } + + setOnErrorInterceptor(onErrorInterceptor: (error: any) => Promise) { + this.onErrorInterceptor = onErrorInterceptor; + } +} diff --git a/plugins/wazuh-core/public/services/http/http-client.ts b/plugins/wazuh-core/public/services/http/http-client.ts new file mode 100644 index 0000000000..bbf2a84a1b --- /dev/null +++ b/plugins/wazuh-core/public/services/http/http-client.ts @@ -0,0 +1,78 @@ +import { Logger } from '../../../common/services/configuration'; +import { HTTP_CLIENT_DEFAULT_TIMEOUT } from './constants'; +import { GenericRequest } from './generic-client'; +import { RequestInterceptorClient } from './request-interceptor'; +import { WzRequest } from './server-client'; +import { HTTPClient, HTTPClientRequestInterceptor } from './types'; +import { createUI } from './ui/create'; + +interface HTTPClientServices { + http: any; + getTimeout: () => Promise; + getURL: (path: string) => string; + getServerAPI: () => string; + getIndexPatternTitle: () => Promise; +} + +export class CoreHTTPClient implements HTTPClient { + private readonly requestInterceptor: HTTPClientRequestInterceptor; + public generic; + public server; + private readonly TIMEOUT: number = HTTP_CLIENT_DEFAULT_TIMEOUT; + + constructor( + private readonly logger: Logger, + private readonly services: HTTPClientServices, + ) { + this.logger.debug('Creating client'); + // Create request interceptor + this.requestInterceptor = new RequestInterceptorClient( + logger, + this.services.http, + ); + + const { getTimeout } = this.services; + const internalServices = { + getTimeout: async () => (await getTimeout()) || this.TIMEOUT, + getServerAPI: this.services.getServerAPI, + getURL: this.services.getURL, + }; + + // Create clients + this.server = new WzRequest(logger, { + request: options => this.requestInterceptor.request(options), + ...internalServices, + }); + this.generic = new GenericRequest(logger, { + request: options => this.requestInterceptor.request(options), + getIndexPatternTitle: this.services.getIndexPatternTitle, + ...internalServices, + checkAPIById: apiId => this.server.checkAPIById(apiId), + }); + this.logger.debug('Created client'); + } + + async setup(deps) { + this.logger.debug('Setup'); + + return { + ui: createUI({ ...deps, http: this }), + }; + } + + async start() {} + + async stop() {} + + async register() { + this.logger.debug('Starting client'); + this.requestInterceptor.init(); + this.logger.debug('Started client'); + } + + async unregister() { + this.logger.debug('Stopping client'); + this.requestInterceptor.destroy(); + this.logger.debug('Stopped client'); + } +} diff --git a/plugins/wazuh-core/public/services/http/index.ts b/plugins/wazuh-core/public/services/http/index.ts new file mode 100644 index 0000000000..f6c9b1c770 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export { CoreHTTPClient } from './http-client'; diff --git a/plugins/wazuh-core/public/services/http/request-interceptor.ts b/plugins/wazuh-core/public/services/http/request-interceptor.ts new file mode 100644 index 0000000000..29311451a7 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/request-interceptor.ts @@ -0,0 +1,99 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { HTTP_STATUS_CODES } from '../../../common/constants'; +import { Logger } from '../../../common/services/configuration'; +import { HTTPClientRequestInterceptor } from './types'; + +export class RequestInterceptorClient implements HTTPClientRequestInterceptor { + // define if the request is allowed to run + private allow = true; + // store the cancel token to abort the requests + private readonly cancelTokenSource: any; + // unregister the interceptor + private unregisterInterceptor: () => void = function () {}; + + constructor( + private readonly logger: Logger, + private readonly http: any, + ) { + this.logger.debug('Creating'); + this.cancelTokenSource = axios.CancelToken.source(); + this.logger.debug('Created'); + } + + private registerInterceptor() { + this.logger.debug('Registering interceptor in core http'); + this.unregisterInterceptor = this.http.intercept({ + responseError: (httpErrorResponse, _controller) => { + if ( + httpErrorResponse.response?.status === HTTP_STATUS_CODES.UNAUTHORIZED + ) { + this.cancel(); + } + }, + request: (_current, _controller) => { + if (!this.allow) { + throw new Error('Disable request'); + } + }, + }); + this.logger.debug('Registered interceptor in core http'); + } + + init() { + this.logger.debug('Initiating'); + this.registerInterceptor(); + this.logger.debug('Initiated'); + } + + destroy() { + this.logger.debug('Destroying'); + this.logger.debug('Unregistering interceptor in core http'); + this.unregisterInterceptor(); + + this.unregisterInterceptor = () => {}; + + this.logger.debug('Unregistered interceptor in core http'); + this.logger.debug('Destroyed'); + } + + cancel() { + this.logger.debug('Disabling requests'); + this.allow = false; + this.cancelTokenSource.cancel('Requests cancelled'); + this.logger.debug('Disabled requests'); + } + + async request(options: AxiosRequestConfig = {}) { + if (!this.allow) { + throw 'Requests are disabled'; + } + + if (!options.method || !options.url) { + throw 'Missing parameters'; + } + + const optionsWithCancelToken = { + ...options, + cancelToken: this.cancelTokenSource?.token, + }; + + if (this.allow) { + try { + const requestData = await axios(optionsWithCancelToken); + + return requestData; + } catch (error) { + if ( + error.response?.data?.message === 'Unauthorized' || + error.response?.data?.message === 'Authentication required' + ) { + this.cancel(); + // To reduce the dependencies, we use window object instead of the NavigationService + globalThis.location.reload(); + } + + throw error; + } + } + } +} diff --git a/plugins/wazuh-core/public/services/http/server-client.test.ts b/plugins/wazuh-core/public/services/http/server-client.test.ts new file mode 100644 index 0000000000..878e30a82f --- /dev/null +++ b/plugins/wazuh-core/public/services/http/server-client.test.ts @@ -0,0 +1,106 @@ +import { WzRequest } from './server-client'; + +const noop = () => {}; + +const logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, +}; +const USER_TOKEN = + 'eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ3YXp1aCIsImF1ZCI6IldhenVoIEFQSSBSRVNUIiwibmJmIjoxNzI2NzM3MDY3LCJleHAiOjE3MjY3Mzc5NjcsInN1YiI6IndhenVoLXd1aSIsInJ1bl9hcyI6ZmFsc2UsInJiYWNfcm9sZXMiOlsxXSwicmJhY19tb2RlIjoid2hpdGUifQ.AOL4dDe3c4WCYXMjqbkBqfKFAChtjvD_uZ0FXfLOMnfU0n6zPo61OZ43Kt0bYhW25BQIXR9Belb49gG3_qAIZpcaAQhQv4HPcL41ESRSvZc2wsa9_HYgV8Z7gieSuT15gdnSNogLKFS7yK5gQQivLo1e4QfVsDThrG_TVdJPbCG3GPq9'; + +function createClient() { + const mockRequest = jest.fn(options => { + if (options.url === '/api/login') { + return { + data: { + token: USER_TOKEN, + }, + }; + } else if (options.url === '/api/request') { + if (options.data.path === '/security/users/me/policies') { + return { + data: { + rbac_mode: 'white', + }, + }; + } else if ( + options.data.method === 'DELETE' && + options.data.path === '/security/user/authenticate' + ) { + return { + data: { + message: 'User wazuh-wui was successfully logged out', + error: 0, + }, + }; + } + } + }); + const client = new WzRequest(logger, { + getServerAPI: () => 'test', + getTimeout: () => Promise.resolve(1000), + getURL: path => path, + request: mockRequest, + }); + + return { client, mockRequest }; +} + +describe('Create client', () => { + it('Ensure the initial userData value', done => { + const { client } = createClient(); + + client.userData$.subscribe(userData => { + expect(userData).toEqual({ + logged: false, + token: null, + account: null, + policies: null, + }); + done(); + }); + }); + + it('Authentication', done => { + const { client } = createClient(); + + client.auth().then(data => { + expect(data).toEqual({ + token: USER_TOKEN, + policies: {}, + account: null, + logged: true, + }); + + client.userData$.subscribe(userData => { + expect(userData).toEqual({ + token: USER_TOKEN, + policies: {}, + account: null, + logged: true, + }); + done(); + }); + }); + }); + + it('Unauthentication', done => { + const { client } = createClient(); + + client.unauth().then(data => { + expect(data).toEqual({}); + done(); + }); + }); + + it('Request', async () => { + const { client, mockRequest } = createClient(); + const data = await client.request('GET', '/security/users/me/policies', {}); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(data).toEqual({ data: { rbac_mode: 'white' } }); + }); +}); diff --git a/plugins/wazuh-core/public/services/http/server-client.ts b/plugins/wazuh-core/public/services/http/server-client.ts new file mode 100644 index 0000000000..69faac8c77 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/server-client.ts @@ -0,0 +1,513 @@ +/* + * Wazuh app - API request service + * Copyright (C) 2015-2024 Wazuh, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * Find more information about this on the LICENSE file. + */ +import jwtDecode from 'jwt-decode'; +import { BehaviorSubject } from 'rxjs'; +import { Logger } from '../../../common/services/configuration'; +import { + HTTPClientServer, + HTTPVerb, + HTTPClientServerUserData, + WzRequestServices, + ServerAPIResponseItemsDataHTTPClient, +} from './types'; +import { PLUGIN_PLATFORM_REQUEST_HEADERS } from './constants'; + +interface RequestInternalOptions { + shouldRetry?: boolean; + checkCurrentApiIsUp?: boolean; + overwriteHeaders?: any; +} + +type RequestOptions = RequestInternalOptions & { + returnOriginalResponse?: boolean; +}; + +export class WzRequest implements HTTPClientServer { + onErrorInterceptor?: ( + error: any, + options: { + checkCurrentApiIsUp: boolean; + shouldRetry: boolean; + overwriteHeaders?: any; + }, + ) => Promise; + private userData: HTTPClientServerUserData; + userData$: BehaviorSubject; + + constructor( + private readonly logger: Logger, + private readonly services: WzRequestServices, + ) { + this.userData = { + logged: false, + token: null, + account: null, + policies: null, + }; + this.userData$ = new BehaviorSubject(this.userData); + } + + /** + * Perform a generic request + * @param {String} method + * @param {String} path + * @param {Object} payload + */ + private async requestInternal( + method: HTTPVerb, + path: string, + payload: any = null, + options: RequestInternalOptions, + ): Promise { + const { shouldRetry, checkCurrentApiIsUp, overwriteHeaders } = { + shouldRetry: true, + checkCurrentApiIsUp: true, + overwriteHeaders: {}, + ...options, + }; + + try { + if (!method || !path) { + throw new Error('Missing parameters'); + } + + const timeout = await this.services.getTimeout(); + const url = this.services.getURL(path); + const options = { + method: method, + headers: { + ...PLUGIN_PLATFORM_REQUEST_HEADERS, + 'content-type': 'application/json', + ...overwriteHeaders, + }, + url: url, + data: payload, + timeout: timeout, + }; + const data = await this.services.request(options); + + if (data['error']) { + throw new Error(data['error']); + } + + return data; + } catch (error) { + // if the requests fails, we need to check if the API is down + if (checkCurrentApiIsUp) { + const currentApi = this.services.getServerAPI(); + + if (currentApi) { + // eslint-disable-next-line no-useless-catch + try { + await this.checkAPIById(currentApi); + } catch (error) { + // TODO :implement + // const wzMisc = new WzMisc(); + // wzMisc.setApiIsDown(true); + // if ( + // !NavigationService.getInstance() + // .getPathname() + // .startsWith('/settings') + // ) { + // NavigationService.getInstance().navigate('/health-check'); + // } + throw error; + } + } + } + + // if(this.onErrorInterceptor){ + // await this.onErrorInterceptor(error, {checkCurrentApiIsUp, shouldRetry, overwriteHeaders}) + // } + const errorMessage = error?.response?.data?.message || error?.message; + + if ( + typeof errorMessage === 'string' && + errorMessage.includes('status code 401') && + shouldRetry + ) { + try { + await this.auth(true); // await WzAuthentication.refresh(true); + + return this.requestInternal(method, path, payload, { + shouldRetry: false, + }); + } catch (error) { + throw this.returnErrorInstance( + error, + error?.data?.message || error.message, + ); + } + } + + throw this.returnErrorInstance( + error, + errorMessage || 'Server did not respond', + ); + } + } + + /** + * Perform a request to the Wazuh API + * @param {String} method Eg. GET, PUT, POST, DELETE + * @param {String} path API route + * @param {Object} body Request body + */ + async request( + method: HTTPVerb, + path: string, + body: any, + options: RequestOptions, + ): Promise> { + const { + checkCurrentApiIsUp, + returnOriginalResponse, + ...restRequestInternalOptions + } = { + checkCurrentApiIsUp: true, + returnOriginalResponse: false, + ...options, + }; + + try { + if (!method || !path || !body) { + throw new Error('Missing parameters'); + } + + const id = this.services.getServerAPI(); + const requestData = { method, path, body, id }; + const response = await this.requestInternal( + 'POST', + '/api/request', + requestData, + { ...restRequestInternalOptions, checkCurrentApiIsUp }, + ); + + if (returnOriginalResponse) { + return response; + } + + const hasFailed = response?.data?.data?.total_failed_items || 0; + + if (hasFailed) { + const error = response?.data?.data?.failed_items?.[0]?.error || {}; + const failedIds = response?.data?.data?.failed_items?.[0]?.id || {}; + const message = response.data?.message || 'Unexpected error'; + const errorMessage = `${message} (${error.code}) - ${error.message} ${ + failedIds && failedIds.length > 1 + ? ` Affected ids: ${failedIds} ` + : '' + }`; + + throw this.returnErrorInstance(null, errorMessage); + } + + return response; + } catch (error) { + throw this.returnErrorInstance( + error, + error?.data?.message || error.message, + ); + } + } + + /** + * Perform a request to generate a CSV + * @param {String} path + * @param {Object} filters + */ + async csv(path: string, filters: any) { + try { + if (!path || !filters) { + throw new Error('Missing parameters'); + } + + const id = this.services.getServerAPI(); + const requestData = { path, id, filters }; + const data = await this.requestInternal('POST', '/api/csv', requestData); + + return data; + } catch (error) { + throw this.returnErrorInstance( + error, + error?.data?.message || error?.message, + ); + } + } + + /** + * Customize message and return an error object + * @param error + * @param message + * @returns error + */ + private returnErrorInstance(error: any, message: string | undefined) { + if (!error || typeof error === 'string') { + return new Error(message || error); + } + + error.message = message; + + return error; + } + + setOnErrorInterceptor(onErrorInterceptor: (error: any) => Promise) { + this.onErrorInterceptor = onErrorInterceptor; + } + + /** + * Requests and returns an user token to the API. + * + * @param {boolean} force + * @returns {string} token as string or Promise.reject error + */ + private async login(force = false) { + try { + let idHost = this.services.getServerAPI(); + + while (!idHost) { + // eslint-disable-next-line no-await-in-loop + await new Promise(r => setTimeout(r, 500)); + idHost = this.services.getServerAPI(); + } + + const response = await this.requestInternal('POST', '/api/login', { + idHost, + force, + }); + const token = response?.data?.token; + + return token as string; + } catch (error) { + this.logger.error(`Error in the login: ${error.message}`); + throw error; + } + } + + /** + * Refresh the user's token + * + * @param {boolean} force + * @returns {void} nothing or Promise.reject error + */ + async auth(force = false) { + try { + // Get user token + const token: string = await this.login(force); + + if (!token) { + // Remove old existent token + // await this.unauth(); + return; + } + + // Decode token and get expiration time + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const jwtPayload = jwtDecode(token); + // Get user Policies + const userPolicies = await this.getUserPolicies(); + // Dispatch actions to set permissions and administrator consideration + // TODO: implement + // store.dispatch(updateUserPermissions(userPolicies)); + // store.dispatch( + // updateUserAccount( + // getWazuhCorePlugin().dashboardSecurity.getAccountFromJWTAPIDecodedToken( + // jwtPayload, + // ), + // ), + // ); + // store.dispatch(updateWithUserLogged(true)); + const data = { + token, + policies: userPolicies, + account: null, // TODO: implement + logged: true, + }; + + this.updateUserData(data); + + return data; + } catch (error) { + // TODO: implement + // const options: UIErrorLog = { + // context: `${WzAuthentication.name}.refresh`, + // level: UI_LOGGER_LEVELS.ERROR as UILogLevel, + // severity: UI_ERROR_SEVERITIES.BUSINESS as UIErrorSeverity, + // error: { + // error: error, + // message: error.message || error, + // title: `${error.name}: Error getting the authorization token`, + // }, + // }; + // getErrorOrchestrator().handleError(options); + // store.dispatch( + // updateUserAccount( + // getWazuhCorePlugin().dashboardSecurity.getAccountFromJWTAPIDecodedToken( + // {}, // This value should cause the user is not considered as an administrator + // ), + // ), + // ); + // store.dispatch(updateWithUserLogged(true)); + this.updateUserData({ + token: null, + policies: null, + account: null, // TODO: implement + logged: true, + }); + throw error; + } + } + + /** + * Get current user's policies + * + * @returns {Object} user's policies or Promise.reject error + */ + private async getUserPolicies() { + try { + let idHost = this.services.getServerAPI(); + + while (!idHost) { + // eslint-disable-next-line no-await-in-loop + await new Promise(r => setTimeout(r, 500)); + idHost = this.services.getServerAPI(); + } + + const response = await this.request( + 'GET', + '/security/users/me/policies', + { idHost }, + ); + + return response?.data?.data || {}; + } catch (error) { + this.logger.error(`Error getting the user policies: ${error.message}`); + throw error; + } + } + + getUserData() { + return this.userData; + } + + /** + * Sends a request to the Wazuh's API to delete the user's token. + * + * @returns {Object} + */ + async unauth() { + try { + const response = await this.request( + 'DELETE', + '/security/user/authenticate', + { delay: 5000 }, + ); + + return response?.data?.data || {}; + } catch (error) { + this.logger.error(`Error in the unauthentication: ${error.message}`); + throw error; + } + } + + /** + * Update the internal user data and emit the value to the subscribers of userData$ + * @param data + */ + private updateUserData(data: HTTPClientServerUserData) { + this.userData = data; + this.userData$.next(this.getUserData()); + } + + async checkAPIById(serverHostId: string, idChanged = false) { + try { + const timeout = await this.services.getTimeout(); + const payload = { id: serverHostId }; + + if (idChanged) { + payload.idChanged = serverHostId; + } + + const url = this.services.getURL('/api/check-stored-api'); + const options = { + method: 'POST', + headers: { + ...PLUGIN_PLATFORM_REQUEST_HEADERS, + 'content-type': 'application/json', + }, + url: url, + data: payload, + timeout: timeout, + }; + const response = await this.services.request(options); + + if (response.error) { + throw this.returnErrorInstance(response); // FIXME: this could cause an expected error due to missing message argument or wrong response argument when this should be a string according to the implementation of returnErrorInstance + } + + return response; + } catch (error) { + if (error.response) { + // TODO: implement + // const wzMisc = new WzMisc(); + // wzMisc.setApiIsDown(true); + const response: string = error.response.data?.message || error.message; + + throw this.returnErrorInstance(response); + } else { + throw this.returnErrorInstance( + error, + error?.message || error || 'Server did not respond', + ); + } + } + } + + /** + * Check the status of an API entry + * @param {String} apiObject + */ + async checkAPI(apiEntry: any, forceRefresh = false) { + try { + const timeout = await this.services.getTimeout(); + const url = this.services.getURL('/api/check-api'); + const options = { + method: 'POST', + headers: { + ...PLUGIN_PLATFORM_REQUEST_HEADERS, + 'content-type': 'application/json', + }, + url: url, + data: { ...apiEntry, forceRefresh }, + timeout: timeout, + }; + const response = await this.services.request(options); + + if (response.error) { + throw this.returnErrorInstance(response); // FIXME: this could cause an expected error due to missing message argument or wrong response argument when this should be a string according to the implementation of returnErrorInstance + } + + return response; + } catch (error) { + if (error.response) { + const response = error.response.data?.message || error.message; + + throw this.returnErrorInstance(response); + } else { + throw this.returnErrorInstance( + error, + error?.message || error || 'Server did not respond', + ); + } + } + } +} diff --git a/plugins/wazuh-core/public/services/http/types.ts b/plugins/wazuh-core/public/services/http/types.ts new file mode 100644 index 0000000000..ce0e47f558 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/types.ts @@ -0,0 +1,73 @@ +import { AxiosRequestConfig } from 'axios'; +import { BehaviorSubject } from 'rxjs'; + +export interface HTTPClientRequestInterceptor { + init: () => void; + destroy: () => void; + cancel: () => void; + request: (options: AxiosRequestConfig) => Promise; +} + +export type HTTPVerb = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT'; + +export interface HTTPClientGeneric { + request: ( + method: HTTPVerb, + path: string, + payload?: any, + returnError?: boolean, + ) => Promise; +} + +export interface HTTPClientServerUserData { + token: string | null; + policies: any | null; + account: any | null; + logged: boolean; +} + +export interface HTTPClientServer { + request: ( + method: HTTPVerb, + path: string, + body: any, + options: { + checkCurrentApiIsUp?: boolean; + returnOriginalResponse?: boolean; + }, + ) => Promise; + csv: (path: string, filters: any) => Promise; + auth: (force: boolean) => Promise; + unauth: (force: boolean) => Promise; + userData$: BehaviorSubject; + getUserData: () => HTTPClientServerUserData; +} + +export interface HTTPClient { + generic: HTTPClientGeneric; + server: HTTPClientServer; +} + +export interface WzRequestServices { + request: HTTPClientRequestInterceptor['request']; + getURL: (path: string) => string; + getTimeout: () => Promise; + getServerAPI: () => string; +} + +export interface ServerAPIResponseItems { + affected_items: T[]; + failed_items: any[]; + total_affected_items: number; + total_failed_items: number; +} + +export interface ServerAPIResponseItemsData { + data: ServerAPIResponseItems; + message: string; + error: number; +} + +export interface ServerAPIResponseItemsDataHTTPClient { + data: ServerAPIResponseItemsData; +} diff --git a/plugins/wazuh-core/public/services/http/ui/components/README.md b/plugins/wazuh-core/public/services/http/ui/components/README.md new file mode 100644 index 0000000000..2a9fe4d7e9 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/README.md @@ -0,0 +1,19 @@ +# ServerTable + +This is a specific table data component that represents the data from the "server". + +It is based in the `TableData` and adds some features: + +- Ability to export the data +- Ability to render a search bar + +# Layout + +``` +title? (totalItems?) postTitle? preActionButtons? actionReload actionExportFormatted? postActionButtons? +description? +searchBar? +preTable? +table +postTable? +``` diff --git a/plugins/wazuh-core/public/services/http/ui/components/__snapshots__/export-table-csv.test.tsx.snap b/plugins/wazuh-core/public/services/http/ui/components/__snapshots__/export-table-csv.test.tsx.snap new file mode 100644 index 0000000000..b5a8744e57 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/__snapshots__/export-table-csv.test.tsx.snap @@ -0,0 +1,183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Export Table Csv component renders correctly to match the snapshot when the button is disabled 1`] = ` + + +
+ + + +
+
+
+`; + +exports[`Export Table Csv component renders correctly to match the snapshot when the button is enabled 1`] = ` + + +
+ + + +
+
+
+`; diff --git a/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.test.tsx b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.test.tsx new file mode 100644 index 0000000000..5f2bb2953e --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.test.tsx @@ -0,0 +1,49 @@ +/* + * Wazuh app - React test for Export Table Csv component. + * + * Copyright (C) 2015-2022 Wazuh, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * Find more information about this on the LICENSE file. + * + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { ExportTableCsv } from './export-table-csv'; + +const noop = () => {}; + +describe('Export Table Csv component', () => { + it('renders correctly to match the snapshot when the button is disabled', () => { + const wrapper = mount( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); + + it('renders correctly to match the snapshot when the button is enabled', () => { + const wrapper = mount( + , + ); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.tsx b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.tsx new file mode 100644 index 0000000000..32750f000c --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.tsx @@ -0,0 +1,89 @@ +/* + * Wazuh app - Table with search bar + * Copyright (C) 2015-2022 Wazuh, Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * Find more information about this on the LICENSE file. + */ + +import React from 'react'; +import { EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; + +export function ExportTableCsv({ + fetchContext, + totalItems, + title, + showToast, + exportCSV, +}: { + fetchContext: { endpoint: string; filters: Record }; + totalItems: number; + title: string; + showToast: (options: { + color: string; + title: string; + toastLifeTimeMs: number; + }) => any; + exportCSV: ( + endpoint: string, + formattedFilters: any[], + title: string, + ) => Promise; +}) { + const downloadCSV = async () => { + try { + const { endpoint, filters } = fetchContext; + const formattedFilters = Object.entries(filters || []).map( + ([name, value]) => ({ + name, + value, + }), + ); + + showToast({ + color: 'success', + title: 'Your download should begin automatically...', + toastLifeTimeMs: 3000, + }); + + await exportCSV(endpoint, formattedFilters, title.toLowerCase()); + } catch { + // TODO: implement + // const options = { + // context: `${ExportTableCsv.name}.downloadCsv`, + // level: UI_LOGGER_LEVELS.ERROR, + // severity: UI_ERROR_SEVERITIES.BUSINESS, + // error: { + // error: error, + // message: error.message || error, + // title: `${error.name}: Error downloading csv`, + // }, + // }; + // getErrorOrchestrator().handleError(options); + } + }; + + return ( + + + Export formatted + + + ); +} + +// Set default props +ExportTableCsv.defaultProps = { + endpoint: '/', + totalItems: 0, + filters: [], + title: '', +}; diff --git a/plugins/wazuh-core/public/services/http/ui/components/server-table-data.tsx b/plugins/wazuh-core/public/services/http/ui/components/server-table-data.tsx new file mode 100644 index 0000000000..79f6fbf928 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/server-table-data.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { SearchBar, TableData } from '../../../../components'; +import { ServerDataProps } from './types'; + +export function ServerTableData({ + showActionExportFormatted, + postActionButtons, + ActionExportFormatted, + ...props +}: ServerDataProps) { + return ( + ( + <> + {showActionExportFormatted && ( + + )} + {postActionButtons && postActionButtons(params)} + + )} + preTable={ + props.showSearchBar && + (({ tableColumns, ...rest }) => { + /* Render search bar*/ + const searchBarWQLOptions = useMemo( + () => ({ + searchTermFields: tableColumns + .filter( + ({ field, searchable }) => + searchable && rest.selectedFields.includes(field), + ) + .flatMap(({ field, composeField }) => + [composeField || field].flat(), + ), + ...rest?.searchBarWQL?.options, + }), + [rest?.searchBarWQL?.options, rest?.selectedFields], + ); + + return ( + <> + { + // Set the query, reset the page index and update the refresh + rest.setFetchContext({ + ...rest.fetchContext, + filters: apiQuery, + }); + rest.updateRefresh(); + }} + /> + + + ); + }) + } + /> + ); +} diff --git a/plugins/wazuh-core/public/services/http/ui/components/types.ts b/plugins/wazuh-core/public/services/http/ui/components/types.ts new file mode 100644 index 0000000000..ae940174cd --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/types.ts @@ -0,0 +1,44 @@ +import { SearchBarProps } from '../../../../components'; +import { TableDataProps } from '../../../../components/table-data/types'; + +export interface ServerDataProps extends TableDataProps { + /** + * Component to render the export formatted action + */ + ActionExportFormatted: any; + /** + * Properties for the search bar + */ + searchBarProps?: Omit< + SearchBarProps, + 'defaultMode' | 'modes' | 'onSearch' | 'input' + >; + /** + * Options releated to WQL. This is a shortcut that add properties to the WQL language. + */ + searchBarWQL?: { + options: { + searchTermFields: string[]; + implicitQuery: { + query: string; + conjunction: ';' | ','; + }; + }; + suggestions?: { + field?: () => any; + value?: () => any; + }; + validate?: { + field?: () => any; + value?: () => any; + }; + }; + /** + * Show the search bar + */ + showSearchBar?: boolean; + /** + * Show the the export formatted action + */ + showActionExportFormatted?: boolean; +} diff --git a/plugins/wazuh-core/public/services/http/ui/create.tsx b/plugins/wazuh-core/public/services/http/ui/create.tsx new file mode 100644 index 0000000000..82ec0937c6 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/create.tsx @@ -0,0 +1,30 @@ +import * as FileSaver from '../../../utils/file-saver'; +import { fetchServerTableDataCreator } from './services/fetch-server-data'; +import { withServices } from './with-services'; +import { ExportTableCsv } from './components/export-table-csv'; +import { ServerTableData } from './components/server-table-data'; + +export const createUI = deps => { + const serverDataFetch = fetchServerTableDataCreator( + deps.http.server.request.bind(deps.http.server), + ); + const ActionExportFormatted = withServices({ + showToast: deps.core.notifications.toasts.add.bind( + deps.core.notifications.toasts, + ), + exportCSV: async (path, filters = [], exportName = 'data') => { + const data = await deps.http.server.csv(path, filters); + const output = data.data ? [data.data] : []; + const blob = new Blob(output, { type: 'text/csv' }); + + FileSaver.saveAs(blob, `${exportName}.csv`); + }, + })(ExportTableCsv); + + return { + ServerTable: withServices({ + ActionExportFormatted, + fetchData: serverDataFetch, + })(ServerTableData), + }; +}; diff --git a/plugins/wazuh-core/public/services/http/ui/services/fetch-server-data.ts b/plugins/wazuh-core/public/services/http/ui/services/fetch-server-data.ts new file mode 100644 index 0000000000..ad1701ce3e --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/services/fetch-server-data.ts @@ -0,0 +1,34 @@ +const getFilters = filters => { + if (!filters) { + return {}; + } + + const { default: defaultFilters, ...restFilters } = filters; + + return Object.keys(restFilters).length > 0 ? restFilters : defaultFilters; +}; + +export const fetchServerTableDataCreator = + fetchData => + async ({ pagination, sorting, fetchContext }) => { + const { pageIndex, pageSize } = pagination; + const { field, direction } = sorting.sort; + const params = { + ...getFilters(fetchContext.filters), + offset: pageIndex * pageSize, + limit: pageSize, + sort: `${direction === 'asc' ? '+' : '-'}${field}`, + }; + const response = await fetchData( + fetchContext.method, + fetchContext.endpoint, + { + params, + }, + ); + + return { + items: response?.data?.data?.affected_items, + totalItems: response?.data?.data?.total_affected_items, + }; + }; diff --git a/plugins/wazuh-core/public/services/http/ui/with-services.tsx b/plugins/wazuh-core/public/services/http/ui/with-services.tsx new file mode 100644 index 0000000000..f726472aaf --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/with-services.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const withServices = + services => (WrappedComponent: React.ElementType) => { + const ComponentWithServices = (props: any) => ( + + ); + + ComponentWithServices.displayName = `WithServices(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + + return ComponentWithServices; + }; diff --git a/plugins/wazuh-core/public/types.ts b/plugins/wazuh-core/public/types.ts index a3acfa7c4d..692922ac63 100644 --- a/plugins/wazuh-core/public/types.ts +++ b/plugins/wazuh-core/public/types.ts @@ -1,5 +1,11 @@ +import React from 'react'; import { API_USER_STATUS_RUN_AS } from '../common/api-user-status-run-as'; import { Configuration } from '../common/services/configuration'; +import { TableDataProps } from './components'; +import { UseStateStorageHook } from './hooks'; +import { UseDockedSideNav } from './hooks/use-docked-side-nav'; +import { HTTPClient } from './services/http/types'; +import { ServerDataProps } from './services/http/ui/components/types'; import { DashboardSecurity } from './utils/dashboard-security'; export interface WazuhCorePluginSetup { @@ -7,14 +13,37 @@ export interface WazuhCorePluginSetup { API_USER_STATUS_RUN_AS: API_USER_STATUS_RUN_AS; configuration: Configuration; dashboardSecurity: DashboardSecurity; + http: HTTPClient; + ui: { + TableData: ( + prop: TableDataProps, + ) => React.ComponentType>; + SearchBar: (prop: any) => React.ComponentType; + ServerTable: ( + prop: ServerDataProps, + ) => React.ComponentType>; + }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface + export interface WazuhCorePluginStart { - hooks: { useDockedSideNav: () => boolean }; + hooks: { + useDockedSideNav: UseDockedSideNav; + useStateStorage: UseStateStorageHook; // TODO: enhance + }; utils: { formatUIDate: (date: Date) => string }; API_USER_STATUS_RUN_AS: API_USER_STATUS_RUN_AS; configuration: Configuration; dashboardSecurity: DashboardSecurity; + http: HTTPClient; + ui: { + TableData: ( + prop: TableDataProps, + ) => React.ComponentType>; + SearchBar: (prop: any) => React.ComponentType; + ServerTable: ( + prop: ServerDataProps, + ) => React.ComponentType>; + }; } -export interface AppPluginStartDependencies {} +export type AppPluginStartDependencies = object; diff --git a/plugins/wazuh-core/public/utils/configuration-store.ts b/plugins/wazuh-core/public/utils/configuration-store.ts index 305291c21d..993ed745d1 100644 --- a/plugins/wazuh-core/public/utils/configuration-store.ts +++ b/plugins/wazuh-core/public/utils/configuration-store.ts @@ -1,77 +1,100 @@ import { IConfigurationStore, - ILogger, + Logger, IConfiguration, } from '../../common/services/configuration'; export class ConfigurationStore implements IConfigurationStore { - private _stored: any; - file: string = ''; + private stored: any; + file = ''; configuration: IConfiguration | null = null; - constructor(private logger: ILogger, private http: any) { - this._stored = {}; + + constructor( + private readonly logger: Logger, + private readonly http: any, + ) { + this.stored = {}; } + setConfiguration(configuration: IConfiguration) { this.configuration = configuration; } + async setup() { this.logger.debug('Setup'); } + async start() { try { this.logger.debug('Start'); + const response = await this.http.get('/api/setup'); + this.file = response.data.configuration_file; } catch (error) { this.logger.error(`Error on start: ${error.message}`); } } + async stop() { this.logger.debug('Stop'); } + private storeGet() { - return this._stored; + return this.stored; } + private storeSet(value: any) { - this._stored = value; + this.stored = value; } - async get(...settings: string[]): Promise { + + async get(...settings: string[]): Promise> { const stored = this.storeGet(); - return settings.length - ? settings.reduce( - (accum, settingKey: string) => ({ - ...accum, - [settingKey]: stored[settingKey], - }), - {}, + return settings.length > 0 + ? Object.fromEntries( + settings.map((settingKey: string) => [ + settingKey, + stored[settingKey], + ]), ) : stored; } - async set(settings: { [key: string]: any }): Promise { + + async set(settings: Record): Promise { try { const attributes = this.storeGet(); const newSettings = { ...attributes, ...settings, }; + this.logger.debug(`Updating store with ${JSON.stringify(newSettings)}`); + const response = this.storeSet(newSettings); + this.logger.debug('Store was updated'); + return response; } catch (error) { this.logger.error(error.message); throw error; } } + async clear(...settings: string[]): Promise { try { const attributes = await this.get(); const updatedSettings = { ...attributes, }; - settings.forEach(setting => delete updatedSettings[setting]); + + for (const setting of settings) { + delete updatedSettings[setting]; + } + const response = this.storeSet(updatedSettings); + return response; } catch (error) { this.logger.error(error.message); diff --git a/plugins/wazuh-core/public/utils/dashboard-security.ts b/plugins/wazuh-core/public/utils/dashboard-security.ts index 669ef6fbae..d03b530fca 100644 --- a/plugins/wazuh-core/public/utils/dashboard-security.ts +++ b/plugins/wazuh-core/public/utils/dashboard-security.ts @@ -1,22 +1,31 @@ import { WAZUH_ROLE_ADMINISTRATOR_ID } from '../../common/constants'; -import { ILogger } from '../../common/services/configuration'; +import { Logger } from '../../common/services/configuration'; export class DashboardSecurity { - private securityPlatform: string = ''; - constructor(private logger: ILogger, private http) {} + private securityPlatform = ''; + + constructor( + private readonly logger: Logger, + private readonly http: { get: (path: string) => any }, + ) {} + private async fetchCurrentPlatform() { try { this.logger.debug('Fetching the security platform'); + const response = await this.http.get( '/elastic/security/current-platform', ); + this.logger.debug(`Security platform: ${this.securityPlatform}`); + return response.platform; } catch (error) { this.logger.error(error.message); throw error; } } + async setup() { try { this.logger.debug('Setup'); @@ -26,17 +35,21 @@ export class DashboardSecurity { this.logger.error(error.message); } } + async start() {} + async stop() {} + getAccountFromJWTAPIDecodedToken(decodedToken: number[]) { const isAdministrator = decodedToken?.rbac_roles?.some?.( (role: number) => role === WAZUH_ROLE_ADMINISTRATOR_ID, ); + return { administrator: isAdministrator, - administrator_requirements: !isAdministrator - ? 'User has no administrator role in the selected API connection.' - : null, + administrator_requirements: isAdministrator + ? null + : 'User has no administrator role in the selected API connection.', }; } } diff --git a/plugins/wazuh-core/public/utils/file-saver.js b/plugins/wazuh-core/public/utils/file-saver.js new file mode 100644 index 0000000000..136d1e9ffe --- /dev/null +++ b/plugins/wazuh-core/public/utils/file-saver.js @@ -0,0 +1,206 @@ +/* eslint-disable unicorn/no-abusive-eslint-disable */ +/* eslint-disable */ +/* FileSaver.js + * A saveAs() FileSaver implementation. + * 1.3.8 + * 2018-03-22 14:03:47 + * + * By Eli Grey, https://eligrey.com + * License: MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/*global self */ +/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ + +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/src/FileSaver.js */ + +export var saveAs = + saveAs || + (function (view) { + 'use strict'; + // IE <10 is explicitly unsupported + if ( + typeof view === 'undefined' || + (typeof navigator !== 'undefined' && + /MSIE [1-9]\./.test(navigator.userAgent)) + ) { + return; + } + var doc = view.document, + // only get URL when necessary in case Blob.js hasn't overridden it yet + get_URL = function () { + return view.URL || view.webkitURL || view; + }, + save_link = doc.createElementNS('http://www.w3.org/1999/xhtml', 'a'), + can_use_save_link = 'download' in save_link, + click = function (node) { + var event = new MouseEvent('click'); + node.dispatchEvent(event); + }, + is_safari = /constructor/i.test(view.HTMLElement) || view.safari, + is_chrome_ios = /CriOS\/[\d]+/.test(navigator.userAgent), + setImmediate = view.setImmediate || view.setTimeout, + throw_outside = function (ex) { + setImmediate(function () { + throw ex; + }, 0); + }, + force_saveable_type = 'application/octet-stream', + // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to + arbitrary_revoke_timeout = 1000 * 40, // in ms + revoke = function (file) { + var revoker = function () { + if (typeof file === 'string') { + // file is an object URL + get_URL().revokeObjectURL(file); + } else { + // file is a File + file.remove(); + } + }; + setTimeout(revoker, arbitrary_revoke_timeout); + }, + dispatch = function (filesaver, event_types, event) { + event_types = [].concat(event_types); + var i = event_types.length; + while (i--) { + var listener = filesaver['on' + event_types[i]]; + if (typeof listener === 'function') { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throw_outside(ex); + } + } + } + }, + auto_bom = function (blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if ( + /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test( + blob.type, + ) + ) { + return new Blob([String.fromCharCode(0xfeff), blob], { + type: blob.type, + }); + } + return blob; + }, + FileSaver = function (blob, name, no_auto_bom) { + if (!no_auto_bom) { + blob = auto_bom(blob); + } + // First try a.download, then web filesystem, then object URLs + var filesaver = this, + type = blob.type, + force = type === force_saveable_type, + object_url, + dispatch_all = function () { + dispatch( + filesaver, + 'writestart progress write writeend'.split(' '), + ); + }, + // on any filesys errors revert to saving with object URLs + fs_error = function () { + if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { + // Safari doesn't allow downloading of blob urls + var reader = new FileReader(); + reader.onloadend = function () { + var url = is_chrome_ios + ? reader.result + : reader.result.replace( + /^data:[^;]*;/, + 'data:attachment/file;', + ); + var popup = view.open(url, '_blank'); + if (!popup) view.location.href = url; + url = undefined; // release reference before dispatching + filesaver.readyState = filesaver.DONE; + dispatch_all(); + }; + reader.readAsDataURL(blob); + filesaver.readyState = filesaver.INIT; + return; + } + // don't create more object URLs than needed + if (!object_url) { + object_url = get_URL().createObjectURL(blob); + } + if (force) { + view.location.href = object_url; + } else { + var opened = view.open(object_url, '_blank'); + if (!opened) { + // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html + view.location.href = object_url; + } + } + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + }; + filesaver.readyState = filesaver.INIT; + + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob); + setImmediate(function () { + save_link.href = object_url; + save_link.download = name; + click(save_link); + dispatch_all(); + revoke(object_url); + filesaver.readyState = filesaver.DONE; + }, 0); + return; + } + + fs_error(); + }, + FS_proto = FileSaver.prototype, + saveAs = function (blob, name, no_auto_bom) { + return new FileSaver( + blob, + name || blob.name || 'download', + no_auto_bom, + ); + }; + + // IE 10+ (native saveAs) + if (typeof navigator !== 'undefined' && navigator.msSaveOrOpenBlob) { + return function (blob, name, no_auto_bom) { + name = name || blob.name || 'download'; + + if (!no_auto_bom) { + blob = auto_bom(blob); + } + return navigator.msSaveOrOpenBlob(blob, name); + }; + } + + // todo: detect chrome extensions & packaged apps + //save_link.target = "_blank"; + + FS_proto.abort = function () {}; + FS_proto.readyState = FS_proto.INIT = 0; + FS_proto.WRITING = 1; + FS_proto.DONE = 2; + + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null; + + return saveAs; + })( + (typeof self !== 'undefined' && self) || + (typeof window !== 'undefined' && window) || + this, + ); diff --git a/plugins/wazuh-core/server/services/server-api-client.ts b/plugins/wazuh-core/server/services/server-api-client.ts index be9622b642..910d01e39e 100644 --- a/plugins/wazuh-core/server/services/server-api-client.ts +++ b/plugins/wazuh-core/server/services/server-api-client.ts @@ -10,8 +10,8 @@ * Find more information about this on the LICENSE file. */ +import https from 'node:https'; import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import https from 'https'; import { Logger } from 'opensearch-dashboards/server'; import { getCookieValueByName } from './cookie'; import { ManageHosts } from './manage-hosts'; @@ -64,35 +64,42 @@ export interface ServerAPIScopedUserClient { ) => Promise>; } +export interface ServerAPIAuthenticateOptions { + useRunAs: boolean; + authContext?: any; +} + /** * This service communicates with the Wazuh server APIs */ export class ServerAPIClient { - private _CacheInternalUserAPIHostToken: Map; - private _axios: typeof axios; - private asInternalUser: ServerAPIInternalUserClient; - private _axios: AxiosInstance; + private readonly cacheInternalUserAPIHostToken: Map; + private readonly client: AxiosInstance; + private readonly asInternalUser: ServerAPIInternalUserClient; + constructor( - private logger: Logger, // TODO: add logger as needed - private manageHosts: ManageHosts, - private dashboardSecurity: ISecurityFactory, + private readonly logger: Logger, // TODO: add logger as needed + private readonly manageHosts: ManageHosts, + private readonly dashboardSecurity: ISecurityFactory, ) { const httpsAgent = new https.Agent({ rejectUnauthorized: false, }); - this._axios = axios.create({ httpsAgent }); + + this.client = axios.create({ httpsAgent }); // Cache to save the token for the internal user by API host ID - this._CacheInternalUserAPIHostToken = new Map(); + this.cacheInternalUserAPIHostToken = new Map(); // Create internal user client this.asInternalUser = { - authenticate: async apiHostID => await this._authenticate(apiHostID), + authenticate: async apiHostID => + await this.authenticateInternalUser(apiHostID), request: async ( method: RequestHTTPMethod, path: RequestPath, data: any, options, - ) => await this._requestAsInternalUser(method, path, data, options), + ) => await this.requestAsInternalUser(method, path, data, options), }; } @@ -104,7 +111,7 @@ export class ServerAPIClient { * @param options Options. Data about the Server API ID and the token * @returns */ - private async _request( + private async request( method: RequestHTTPMethod, path: RequestPath, data: any, @@ -112,13 +119,14 @@ export class ServerAPIClient { | APIInterceptorRequestOptionsInternalUser | APIInterceptorRequestOptionsScopedUser, ): Promise { - const optionsRequest = await this._buildRequestOptions( + const optionsRequest = await this.buildRequestOptions( method, path, data, options, ); - return await this._axios(optionsRequest); + + return await this.client(optionsRequest); } /** @@ -129,20 +137,21 @@ export class ServerAPIClient { * @param options Options. Data about the Server API ID and the token * @returns */ - private async _buildRequestOptions( + private async buildRequestOptions( method: RequestHTTPMethod, path: RequestPath, data: any, { apiHostID, token }: APIInterceptorRequestOptions, ) { const api = await this.manageHosts.get(apiHostID); - const { body, params, headers, ...rest } = data; + const { body, params, headers = {}, ...rest } = data; + return { method: method, headers: { 'content-type': 'application/json', Authorization: 'Bearer ' + token, - ...(headers ? headers : {}), + ...headers, }, data: body || rest || {}, params: params || {}, @@ -156,9 +165,9 @@ export class ServerAPIClient { * @param authContext Authentication context to get the token * @returns */ - private async _authenticate( + private async authenticate( apiHostID: string, - authContext?: any, + options: ServerAPIAuthenticateOptions, ): Promise { const api: APIHost = await this.manageHosts.get(apiHostID); const optionsRequest = { @@ -171,16 +180,26 @@ export class ServerAPIClient { password: api.password, }, url: `${api.url}:${api.port}/security/user/authenticate${ - !!authContext ? '/run_as' : '' + options.useRunAs ? '/run_as' : '' }`, - ...(!!authContext ? { data: authContext } : {}), + ...(options?.authContext ? { data: options?.authContext } : {}), }; + const response: AxiosResponse = await this.client(optionsRequest); + const token: string = response?.data?.data?.token; + + return token; + } + + /** + * Get the authentication token for the internal user and cache it + * @param apiHostID Server API ID + * @returns + */ + private async authenticateInternalUser(apiHostID: string): Promise { + const token = await this.authenticate(apiHostID, { useRunAs: false }); + + this.cacheInternalUserAPIHostToken.set(apiHostID, token); - const response: AxiosResponse = await this._axios(optionsRequest); - const token: string = (((response || {}).data || {}).data || {}).token; - if (!authContext) { - this._CacheInternalUserAPIHostToken.set(apiHostID, token); - } return token; } @@ -192,24 +211,38 @@ export class ServerAPIClient { */ asScoped(context: any, request: any): ServerAPIScopedUserClient { return { - authenticate: async (apiHostID: string) => - await this._authenticate( - apiHostID, - ( - await this.dashboardSecurity.getCurrentUser(request, context) - ).authContext, - ), + authenticate: async (apiHostID: string) => { + const useRunAs = this.manageHosts.isEnabledAuthWithRunAs(apiHostID); + let token: string; + + if (useRunAs) { + const { authContext } = await this.dashboardSecurity.getCurrentUser( + request, + context, + ); + + token = await this.authenticate(apiHostID, { + useRunAs: true, + authContext, + }); + } else { + token = await this.authenticate(apiHostID, { + useRunAs: false, + }); + } + + return token; + }, request: async ( method: RequestHTTPMethod, path: string, data: any, options: APIInterceptorRequestOptionsScopedUser, - ) => { - return await this._request(method, path, data, { + ) => + await this.request(method, path, data, { ...options, token: getCookieValueByName(request.headers.cookie, 'wz-token'), - }); - }, + }), }; } @@ -221,7 +254,7 @@ export class ServerAPIClient { * @param options Options. Data about the Server API ID and the token * @returns */ - private async _requestAsInternalUser( + private async requestAsInternalUser( method: RequestHTTPMethod, path: RequestPath, data: any, @@ -229,16 +262,21 @@ export class ServerAPIClient { ) { try { const token = - this._CacheInternalUserAPIHostToken.has(options.apiHostID) && + this.cacheInternalUserAPIHostToken.has(options.apiHostID) && !options.forceRefresh - ? this._CacheInternalUserAPIHostToken.get(options.apiHostID) - : await this._authenticate(options.apiHostID); - return await this._request(method, path, data, { ...options, token }); + ? this.cacheInternalUserAPIHostToken.get(options.apiHostID) + : await this.authenticateInternalUser(options.apiHostID); + + return await this.request(method, path, data, { ...options, token }); } catch (error) { if (error.response && error.response.status === 401) { - const token: string = await this._authenticate(options.apiHostID); - return await this._request(method, path, data, { ...options, token }); + const token: string = await this.authenticateInternalUser( + options.apiHostID, + ); + + return await this.request(method, path, data, { ...options, token }); } + throw error; } }