diff --git a/plugins/main/server/controllers/wazuh-api.ts b/plugins/main/server/controllers/wazuh-api.ts index a5c3772c8f..f16177223e 100644 --- a/plugins/main/server/controllers/wazuh-api.ts +++ b/plugins/main/server/controllers/wazuh-api.ts @@ -76,16 +76,9 @@ 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') { diff --git a/plugins/wazuh-core/common/services/configuration.ts b/plugins/wazuh-core/common/services/configuration.ts index fcb642e2bb..3355bb96a4 100644 --- a/plugins/wazuh-core/common/services/configuration.ts +++ b/plugins/wazuh-core/common/services/configuration.ts @@ -2,7 +2,7 @@ import { cloneDeep } from 'lodash'; import { formatLabelValuePair } from './settings'; import { formatBytes } from './file-size'; -export interface ILogger { +export interface Logger { debug(message: string): void; info(message: string): void; warn(message: string): void; @@ -180,7 +180,7 @@ export class Configuration implements IConfiguration { store: IConfigurationStore | null = null; _settings: Map; _categories: Map; - constructor(private logger: ILogger, store: IConfigurationStore) { + constructor(private logger: Logger, store: IConfigurationStore) { this._settings = new Map(); this._categories = new Map(); this.setStore(store); 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..93ac8c3eab --- /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.js b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest.js new file mode 100644 index 0000000000..4298a003d7 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest.js @@ -0,0 +1,84 @@ +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 = e => { + this.props.onInputChange(e.target.value); + }; + + render() { + const { + onItemClick, + onInputChange, + 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/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..3cb28b935f --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/components/eui-suggest/suggest_input.js @@ -0,0 +1,143 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + EuiFilterButton, + EuiFieldText, + EuiToolTip, + EuiIcon, + EuiPopover, +} from '@elastic/eui'; +import { 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 = e => { + this.setState({ + value: e.target.value, + isPopoverOpen: e.target.value !== '' ? true : false, + }); + this.props.sendValue(e.target.value); + }; + + render() { + const { + className, + status, + append, + tooltipContent, + suggestions, + sendValue, + 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, +}; + +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..9e847941e6 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/index.test.tsx @@ -0,0 +1,52 @@ +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 []; + }, + }, + }, + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + 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..71c56447ce --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/index.tsx @@ -0,0 +1,264 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + EuiButtonEmpty, + EuiFormRow, + EuiLink, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSelect, + EuiText, + EuiFlexGroup, + EuiFlexItem, + // EuiSuggest, +} from '@elastic/eui'; +import { EuiSuggest } from './components/eui-suggest'; +import { searchBarQueryLanguages } from './query-language'; +import _ from 'lodash'; +import { ISearchBarModeWQL } from './query-language/wql'; +import { SEARCH_BAR_DEBOUNCE_UPDATE_TIME } from '../../../common/constants'; + +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 + 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 + 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(() => { + 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..3c6a57caf3 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/aql.test.tsx @@ -0,0 +1,211 @@ +import { AQL, getSuggestions, tokenizer } from './aql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; + +describe('SearchBar component', () => { + const componentProps = { + defaultMode: AQL.id, + input: '', + modes: [ + { + id: AQL.id, + implicitQuery: 'id!=000;', + suggestions: { + field(currentValue) { + return []; + }, + value(currentValue, { previousField }) { + return []; + }, + }, + }, + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + 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 })); + break; + 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 })); + break; + default: + return []; + break; + } + }, + }, + }), + ).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, 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..68d1292a23 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/aql.tsx @@ -0,0 +1,545 @@ +import React from 'react'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiCode } from '@elastic/eui'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction'; +type IToken = { type: ITokenType; value: string }; +type ITokens = IToken[]; + +/* 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: { + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + ';': 'and', + ',': 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, +}; + +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType) { + return function ({ ...params }) { + return { + type, + ...params, + }; + }; +} + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('value'); + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): ITokens { + // 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. + '(?\\()?' + + // Field name: name of the field to look on DB. + '(?[\\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 + `(?[${Object.keys( + language.tokens.operator_compare.literal, + )}]{1,2})?` + // Added an optional find + // Value: A string. + '(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]*)\\))*)+)?' + // Added an optional find + // A ) character. + '(?\\))?' + + `(?[${Object.keys(language.tokens.conjunction.literal)}])?`, + 'g', + ); + + return [...input.matchAll(re)] + .map(({ groups }) => + Object.entries(groups).map(([key, value]) => ({ + type: key.startsWith('operator_group') ? 'operator_group' : key, + value, + })), + ) + .flat(); +} + +type QLOptionSuggestionEntityItem = { + description?: string; + label: string; +}; + +type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem & { + type: + | 'operator_group' + | 'field' + | 'operator_compare' + | 'value' + | 'conjunction'; +}; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string; color: string }; +}; + +type QLOptionSuggestionHandler = ( + currentValue: string | undefined, + { + previousField, + previousOperatorCompare, + }: { previousField: string; previousOperatorCompare: string }, +) => Promise; + +type 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: ITokens): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...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: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type === tokenType && value, + ); + return tokenFound; +} + +/** + * Get the suggestions from the tokens + * @param tokens + * @param language + * @param options + * @returns + */ +export async function getSuggestions( + tokens: ITokens, + options: optionsQL, +): Promise { + if (!tokens.length) { + return []; + } + + // 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 + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + } + + switch (lastToken.type) { + case '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(mapSuggestionCreatorField), + // 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: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': + return [ + ...Object.keys(language.tokens.operator_compare.literal) + .filter( + operator => + operator.startsWith(lastToken.value) && + operator !== lastToken.value, + ) + .map(operator => ({ + type: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === lastToken.value, + ) + ? [ + ...( + await options.suggestions.value(undefined, { + previousField: getLastTokenWithValueByType(tokens, 'field')! + .value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + }) + ).map(mapSuggestionCreatorValue), + ] + : []), + ]; + break; + case 'value': + return [ + ...(lastToken.value + ? [ + { + type: 'function_search', + label: 'Search', + description: 'run the search query', + }, + ] + : []), + ...( + await options.suggestions.value(lastToken.value, { + previousField: getLastTokenWithValueByType(tokens, 'field')!.value, + previousOperatorCompare: getLastTokenWithValueByType( + tokens, + 'operator_compare', + )!.value, + }) + ).map(mapSuggestionCreatorValue), + ...Object.entries(language.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + mapSuggestionCreatorField, + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + 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: { ...suggestionMappingLanguageTokenType[type] }, + ...rest, + }; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @returns + */ +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[], +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); +} + +/** + * 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', + 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, params) { + // Get the tokens from the input + const tokens: ITokens = 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 => item => { + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + params.onSearch( + getOutput(currentInput, params.queryLanguage.parameters), + ); + } else { + // When the clicked item has another iconType + const lastToken: IToken = getLastTokenWithValue(tokens); + // if the clicked suggestion is of same type of last token + if ( + lastToken && + suggestionMappingLanguageTokenType[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 + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + 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/index.ts b/plugins/wazuh-core/public/components/search-bar/query-language/index.ts new file mode 100644 index 0000000000..ba9a0554c4 --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/index.ts @@ -0,0 +1,38 @@ +import { AQL } from './aql'; +import { WQL } from './wql'; + +type SearchBarQueryLanguage = { + description: string; + documentationLink?: string; + id: string; + label: string; + getConfiguration?: () => any; + run: ( + input: string | undefined, + params: any, + ) => Promise<{ + searchBarProps: any; + output: { + language: string; + unifiedQuery: string; + query: string; + }; + }>; + transformInput: ( + unifiedQuery: string, + options: { configuration: any; parameters: any }, + ) => 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.`); + } + return { + ...accum, + [item.id]: item, + }; +}, {}); 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..a803a79ecf --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/wql.test.tsx @@ -0,0 +1,472 @@ +import { + getSuggestions, + tokenizer, + transformSpecificQLToUnifiedQL, + WQL, +} from './wql'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SearchBar } from '../index'; + +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 }) { + return []; + }, + }, + }, + ], + /* eslint-disable @typescript-eslint/no-empty-function */ + onChange: () => {}, + onSearch: () => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ + }; + + it('Renders correctly to match the snapshot of query language', async () => { + const wrapper = render(); + + await waitFor(() => { + expect(wrapper.container).toMatchSnapshot(); + }); + }); +}); + +/* eslint-disable max-len */ +describe('Query language - WQL', () => { + // Tokenize the input + function tokenCreator({ type, value, formattedValue }) { + return { type, value, ...(formattedValue ? { formattedValue } : {}) }; + } + + const t = { + opGroup: (value = undefined) => + tokenCreator({ type: 'operator_group', value }), + opCompare: (value = undefined) => + tokenCreator({ type: 'operator_compare', value }), + field: (value = undefined) => tokenCreator({ type: 'field', value }), + value: (value = undefined, formattedValue = undefined) => + tokenCreator({ + type: 'value', + value, + formattedValue: formattedValue ?? value, + }), + whitespace: (value = undefined) => + tokenCreator({ type: 'whitespace', value }), + conjunction: (value = undefined) => + 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 })); + break; + 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 })); + break; + default: + return []; + break; + } + }, + }, + }), + ).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 */} + ${'field="custom \\"value"'} | ${'field=custom "value'} + ${'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' }} | ${'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' }} | ${'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="'} | ${'field="\\""'} + ${'field=with spaces'} | ${'field="with spaces"'} + ${'field=with "spaces'} | ${'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} + ${'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 }) => { + 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..7d139db27b --- /dev/null +++ b/plugins/wazuh-core/public/components/search-bar/query-language/wql.tsx @@ -0,0 +1,1203 @@ +import React from 'react'; +import { + EuiButtonEmpty, + EuiButtonGroup, + EuiPopover, + EuiText, + EuiCode, +} from '@elastic/eui'; +import { tokenizer as tokenizerUQL } from './aql'; +import { SEARCH_BAR_WQL_VALUE_SUGGESTIONS_DISPLAY_COUNT } from '../../../../common/constants'; +import { webDocumentationLink } from '../../../../common/services/web_documentation'; + +/* UI Query language +https://documentation.wazuh.com/current/user-manual/api/queries.html + +// Example of another query language definition +*/ + +type ITokenType = + | 'field' + | 'operator_compare' + | 'operator_group' + | 'value' + | 'conjunction' + | 'whitespace'; +type IToken = { type: ITokenType; value: string; formattedValue?: string }; +type ITokens = IToken[]; + +/* 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: { + // eslint-disable-next-line camelcase + operator_compare: { + literal: { + '=': 'equality', + '!=': 'not equality', + '>': 'bigger', + '<': 'smaller', + '~': 'like as', + }, + }, + conjunction: { + literal: { + and: 'and', + or: 'or', + }, + }, + // eslint-disable-next-line camelcase + operator_group: { + literal: { + '(': 'open group', + ')': 'close group', + }, + }, + }, + equivalencesToUQL: { + conjunction: { + literal: { + and: ';', + or: ',', + }, + }, + }, +}; + +// Suggestion mapper by language token type +const suggestionMappingLanguageTokenType = { + field: { iconType: 'kqlField', color: 'tint4' }, + // eslint-disable-next-line camelcase + operator_compare: { iconType: 'kqlOperand', color: 'tint1' }, + value: { iconType: 'kqlValue', color: 'tint0' }, + conjunction: { iconType: 'kqlSelector', color: 'tint3' }, + // eslint-disable-next-line camelcase + operator_group: { iconType: 'tokenDenseVector', color: 'tint3' }, + // eslint-disable-next-line camelcase + function_search: { iconType: 'search', color: 'tint5' }, + // eslint-disable-next-line camelcase + validation_error: { iconType: 'alert', color: 'tint2' }, +}; + +/** + * Creator of intermediate interface of EuiSuggestItem + * @param type + * @returns + */ +function mapSuggestionCreator(type: ITokenType) { + 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 + */ + ...(typeof label !== 'undefined' ? { label: String(label) } : {}), + }; + }; +} + +const mapSuggestionCreatorField = mapSuggestionCreator('field'); +const mapSuggestionCreatorValue = mapSuggestionCreator('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['and']} ` + : ` ${language.tokens.conjunction.literal['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(/"/, '\\"')}"` + : // Raw value + value; +} + +/** + * Tokenize the input string. Returns an array with the tokens. + * @param input + * @returns + */ +export function tokenizer(input: string): ITokens { + const re = new RegExp( + // A ( character. + '(?\\()?' + + // Whitespace + '(?\\s+)?' + + // Field name: name of the field to look on DB. + '(?[\\w.]+)?' + // Added an optional find + // Whitespace + '(?\\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 + `(?[${Object.keys( + language.tokens.operator_compare.literal, + )}]{1,2})?` + // Added an optional find + // Whitespace + '(?\\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 + '(?(?:(?:[^"\\s]+|(?:"(?:[^"\\\\]|\\\\")*")|(?:"(?:[^"\\\\]|\\\\")*)|")))?' + + // Whitespace + '(?\\s+)?' + + // A ) character. + '(?\\))?' + + // Whitespace + '(?\\s+)?' + + `(?${Object.keys(language.tokens.conjunction.literal).join( + '|', + )})?` + + // Whitespace + '(?\\s+)?', + 'g', + ); + + return [...input.matchAll(re)] + .map(({ groups }) => + Object.entries(groups).map(([key, value]) => ({ + type: key.startsWith('operator_group') // Transform operator_group group match + ? 'operator_group' + : key.startsWith('whitespace') // Transform whitespace group match + ? 'whitespace' + : key, + value, + ...(key === 'value' && + (value && /^"([\s\S]+)"$/.test(value) + ? { formattedValue: value.match(/^"([\s\S]+)"$/)[1] } + : { formattedValue: value })), + })), + ) + .flat(); +} + +type QLOptionSuggestionEntityItem = { + description?: string; + label: string; +}; + +type QLOptionSuggestionEntityItemTyped = QLOptionSuggestionEntityItem & { + type: + | 'operator_group' + | 'field' + | 'operator_compare' + | 'value' + | 'conjunction' + | 'function_search'; +}; + +type SuggestItem = QLOptionSuggestionEntityItem & { + type: { iconType: string; color: string }; +}; + +type QLOptionSuggestionHandler = ( + currentValue: string | undefined, + { field, operatorCompare }: { field: string; operatorCompare: string }, +) => Promise; + +type OptionsQLImplicitQuery = { + query: string; + conjunction: string; +}; +type OptionsQL = { + options?: { + implicitQuery?: OptionsQLImplicitQuery; + searchTermFields?: string[]; + filterButtons: { id: string; label: string; input: string }[]; + }; + suggestions: { + field: QLOptionSuggestionHandler; + value: QLOptionSuggestionHandler; + }; + validate?: { + value?: { + [key: string]: ( + token: IToken, + nearTokens: { field: string; operator: string }, + ) => string | undefined; + }; + }; +}; + +export interface ISearchBarModeWQL extends OptionsQL { + id: 'wql'; +} + +/** + * Get the last token with value + * @param tokens Tokens + * @param tokenType token type to search + * @returns + */ +function getLastTokenDefined(tokens: ITokens): IToken | undefined { + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...tokens]); + const shallowCopyArrayReversed = shallowCopyArray.reverse(); + const tokenFound = shallowCopyArrayReversed.find( + ({ type, value }) => type !== '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: ITokens, + tokenType: ITokenType, +): IToken | undefined { + // Find the last token by type + // Reverse the tokens array and use the Array.protorype.find method + const shallowCopyArray = Array.from([...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: ITokens, + tokenType: ITokenType, + mode: 'previous' | 'next' = 'previous', + options: { + tokenReferencePosition?: number; + tokenFoundShouldHaveValue?: boolean; + } = {}, +): IToken | undefined { + const shallowCopyTokens = Array.from([...tokens]); + const computedShallowCopyTokens = + mode === 'previous' + ? shallowCopyTokens + .slice(0, options?.tokenReferencePosition || tokens.length) + .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. + '^(?(?:(?:\\((?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|[\\[\\]\\w _\\-.:?\\/\'"=@%<>{}]*)\\))*' + + '(?:\\[[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}]*]|^[\\[\\]\\w _\\-.:?\\\\/\'"=@%<>{}]+)' + + '(?:\\((?:\\[[\\[\\]\\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: ITokens, + options: OptionsQL, +): Promise { + if (!tokens.length) { + 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: 'function_search', + label: 'Search', + description: 'run the search query', + }, + // fields + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + } + + switch (lastToken.type) { + case '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(mapSuggestionCreatorField), + // 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: 'operator_compare', + label: operator, + description: + language.tokens.operator_compare.literal[operator], + }), + ), + ] + : []), + ]; + break; + case 'operator_compare': { + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( + tokens, + '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: 'operator_compare', + label: operator, + description: language.tokens.operator_compare.literal[operator], + })), + ...(Object.keys(language.tokens.operator_compare.literal).some( + operator => operator === 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(mapSuggestionCreatorValue), + ] + : []), + ]; + break; + } + case 'value': { + const field = getLastTokenDefinedByType(tokens, 'field')?.value; + const operatorCompare = getLastTokenDefinedByType( + tokens, + '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: '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(mapSuggestionCreatorValue), + ...Object.entries(language.tokens.conjunction.literal).map( + ([conjunction, description]) => ({ + type: 'conjunction', + label: conjunction, + description, + }), + ), + { + type: 'operator_group', + label: ')', + description: language.tokens.operator_group.literal[')'], + }, + ]; + break; + } + case 'conjunction': + return [ + ...Object.keys(language.tokens.conjunction.literal) + .filter( + conjunction => + conjunction.startsWith(lastToken.value) && + conjunction !== lastToken.value, + ) + .map(conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + })), + // fields if the input field is exact + ...(Object.keys(language.tokens.conjunction.literal).some( + conjunction => conjunction === lastToken.value, + ) + ? [ + ...(await options.suggestions.field()).map( + mapSuggestionCreatorField, + ), + ] + : []), + { + type: 'operator_group', + label: '(', + description: language.tokens.operator_group.literal['('], + }, + ]; + break; + case 'operator_group': + if (lastToken.value === '(') { + return [ + // fields + ...(await options.suggestions.field()).map(mapSuggestionCreatorField), + ]; + } else if (lastToken.value === ')') { + return [ + // conjunction + ...Object.keys(language.tokens.conjunction.literal).map( + conjunction => ({ + type: 'conjunction', + label: conjunction, + description: language.tokens.conjunction.literal[conjunction], + }), + ), + ]; + } + break; + default: + return []; + break; + } + + 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: { ...suggestionMappingLanguageTokenType[type] }, + ...rest, + }; +} + +/** + * Transform the suggestion object to the expected object by EuiSuggestItem + * @param suggestions + * @returns + */ +function transformSuggestionsToEuiSuggestItem( + suggestions: QLOptionSuggestionEntityItemTyped[], +): SuggestItem[] { + return suggestions.map(transformSuggestionToEuiSuggestItem); +} + +/** + * 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 'conjunction': + return transformQLConjunction(value); + break; + case 'value': + return transformQLValue(value); + break; + default: + return value; + break; + } + }) + .join(''); +} + +export function shouldUseSearchTerm(tokens: ITokens): boolean { + return !( + tokens.some(({ type, value }) => type === 'operator_compare' && value) && + tokens.some(({ type, value }) => type === '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 !== 'whitespace' && (formattedValue ?? value), + ) + .map(({ type, value, formattedValue }) => { + switch (type) { + case '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.replace(/\\"/g, '"') + : formattedValue; + return extractedValue || value; + break; + } + case 'conjunction': + return value === 'and' + ? language.equivalencesToUQL.conjunction.literal['and'] + : language.equivalencesToUQL.conjunction.literal['or']; + break; + default: + return value; + break; + } + }) + .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: IToken): 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 + .split('') + .filter((value, index, array) => array.indexOf(value) === index) + .filter( + character => + !new RegExp('[\\[\\]\\w _\\-.,:?\\\\/\'"=@%<>{}\\(\\)]').test( + character, + ), + ); + + return [ + `"${value}" is not a valid value.`, + ...(invalidCharacters.length + ? [`Invalid characters found: ${invalidCharacters.join('')}`] + : []), + ].join(' '); +} + +type ITokenValidator = ( + tokenValue: IToken, + proximityTokens: any, +) => string | undefined; +/** + * Validate the tokens while the user is building the query + * @param tokens + * @param validate + * @returns + */ +function validatePartial( + tokens: ITokens, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string { + // Ensure is not in search term mode + if (!shouldUseSearchTerm(tokens)) { + return ( + tokens + .map((token: IToken, index) => { + if (token.value) { + if (token.type === '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, + 'operator_compare', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + return tokenOperatorNearToField + ? validate.field(token) + : undefined; + } + // Check if the value is allowed + if (token.type === 'value') { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + return ( + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined) + ); + } + } + }) + .filter(t => typeof t !== 'undefined') + .join('\n') || undefined + ); + } +} + +/** + * Validate the tokens if they are a valid syntax + * @param tokens + * @param validate + * @returns + */ +function validate( + tokens: ITokens, + validate: { field: ITokenValidator; value: ITokenValidator }, +): undefined | string[] { + if (!shouldUseSearchTerm(tokens)) { + const errors = tokens + .map((token: IToken, index) => { + const errors = []; + if (token.value) { + if (token.type === 'field') { + const tokenOperatorNearToField = getTokenNearTo( + tokens, + 'operator_compare', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenValueNearToField = getTokenNearTo( + tokens, + 'value', + '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 === 'value') { + const tokenFieldNearToValue = getTokenNearTo( + tokens, + 'field', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const tokenOperatorCompareNearToValue = getTokenNearTo( + tokens, + 'operator_compare', + 'previous', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + const validationError = + validateTokenValue(token) || + (tokenFieldNearToValue && + tokenOperatorCompareNearToValue && + validate.value + ? validate.value(token, { + field: tokenFieldNearToValue?.value, + operator: tokenOperatorCompareNearToValue?.value, + }) + : undefined); + + validationError && errors.push(validationError); + } + + // Check if the value is allowed + if (token.type === 'conjunction') { + const tokenWhitespaceNearToFieldNext = getTokenNearTo( + tokens, + 'whitespace', + 'next', + { tokenReferencePosition: index }, + ); + const tokenFieldNearToFieldNext = getTokenNearTo( + tokens, + 'field', + 'next', + { + tokenReferencePosition: index, + tokenFoundShouldHaveValue: true, + }, + ); + !tokenWhitespaceNearToFieldNext?.value?.length && + errors.push( + `There is no whitespace after conjunction "${token.value}".`, + ); + !tokenFieldNearToFieldNext?.value?.length && + errors.push( + `There is no sentence after conjunction "${token.value}".`, + ); + } + } + return errors.length ? errors : undefined; + }) + .filter(errors => errors) + .flat(); + return errors.length ? errors : undefined; + } + return undefined; +} + +export const WQL = { + id: 'wql', + 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: ITokens = tokenizer(input); + + // Get the implicit query as query language syntax + 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: '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: '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 === 'alert') { + return; + } + // When the clicked item has the `search` iconType, run the `onSearch` function + if (item.type.iconType === 'search') { + // Execute the search action + // Get the tokens from the input + const tokens: ITokens = 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: IToken | undefined = getLastTokenDefined(tokens); + // if the clicked suggestion is of same type of last token + if ( + lastToken && + suggestionMappingLanguageTokenType[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 === + suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label; + } else { + // add a whitespace for conjunction + // add a whitespace for grouping operator ) + !/\s$/.test(input) && + (item.type.iconType === + suggestionMappingLanguageTokenType.conjunction.iconType || + lastToken?.type === 'conjunction' || + (item.type.iconType === + suggestionMappingLanguageTokenType.operator_group + .iconType && + item.label === ')')) && + tokens.push({ + type: 'whitespace', + value: ' ', + }); + + // add a new token of the selected type and value + tokens.push({ + type: Object.entries(suggestionMappingLanguageTokenType).find( + ([, { iconType }]) => iconType === item.type.iconType, + )[0], + value: + item.type.iconType === + suggestionMappingLanguageTokenType.value.iconType + ? transformQLValue(item.label) + : item.label, + }); + + // add a whitespace for conjunction + item.type.iconType === + suggestionMappingLanguageTokenType.conjunction.iconType && + tokens.push({ + type: 'whitespace', + value: ' ', + }); + } + + // 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(''), + ); + } + }, + // 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: ITokens = 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..5ab60623f6 --- /dev/null +++ b/plugins/wazuh-core/public/components/table-data/table-data.tsx @@ -0,0 +1,360 @@ +/* + * 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 { useStateStorage } from '../../hooks'; +import { isEqual } from 'lodash'; +import { TableDataProps } from './types'; + +const getColumMetaField = item => item.field || item.name; + +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(rest.mapResponseItem) : 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} + /> + + + + ); +} + +const TableDataRenderElement = ({ render, ...rest }) => { + if (typeof render === 'function') { + return {render(rest)}; + } + if (typeof render === 'object') { + return {render}; + } + return null; +}; 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..bd5d6580ab --- /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?: Function; + 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?: Function; + /** + * 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..18894b861b 100644 --- a/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx +++ b/plugins/wazuh-core/public/hooks/use-docked-side-nav.tsx @@ -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..4aacbd55b0 --- /dev/null +++ b/plugins/wazuh-core/public/hooks/use-state-storage.ts @@ -0,0 +1,47 @@ +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' ? JSON.stringify(value) : value; +} + +function transformValueFromStorage(value: any) { + return typeof value === 'string' ? JSON.parse(value) : value; +} + +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; + + 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..bf2a7c1c31 100644 --- a/plugins/wazuh-core/public/plugin.ts +++ b/plugins/wazuh-core/public/plugin.ts @@ -2,6 +2,7 @@ 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 * as uiComponents from './components'; 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'; @@ -11,20 +12,31 @@ import { } from '../common/constants'; import { DashboardSecurity } from './utils/dashboard-security'; import * as hooks from './hooks'; +import { CoreHTTPClient } from './services/http/http-client'; export class WazuhCorePlugin implements Plugin { + runtime = { setup: {} }; _internal: { [key: string]: any } = {}; services: { [key: string]: any } = {}; public async setup(core: CoreSetup): Promise { const noop = () => {}; - const logger = { + // Debug logger + const consoleLogger = { + info: console.log, + error: console.error, + debug: console.debug, + warn: console.warn, + }; + // No operation logger + const noopLogger = { info: noop, error: noop, debug: noop, warn: noop, }; + const logger = noopLogger; this._internal.configurationStore = new ConfigurationStore( logger, core.http, @@ -44,14 +56,31 @@ export class WazuhCorePlugin 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 +89,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..8ea7ec6d05 --- /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 = 20000; 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..7d193b3599 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/generic-client.ts @@ -0,0 +1,127 @@ +import { PLUGIN_PLATFORM_REQUEST_HEADERS } from './constants'; +import { Logger } from '../../../common/services/configuration'; +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 logger: Logger, + private 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 (error) {} + + try { + requestHeaders.id = this.services.getServerAPI(); + } catch (error) { + // Intended + } + var 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 (err) { + // 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) return Promise.reject(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..e7d8df3729 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/http-client.ts @@ -0,0 +1,70 @@ +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 requestInterceptor: HTTPClientRequestInterceptor; + public generic; + public server; + private _timeout: number = HTTP_CLIENT_DEFAULT_TIMEOUT; + constructor(private logger: Logger, private services: HTTPClientServices) { + this.logger.debug('Creating client'); + // Create request interceptor + this.requestInterceptor = new RequestInterceptorClient( + logger, + this.services.http, + ); + + const getTimeout = async () => + (await this.services.getTimeout()) || this._timeout; + + const internalServices = { + getTimeout, + 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..21b2600da3 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/request-interceptor.ts @@ -0,0 +1,84 @@ +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: boolean = true; + // store the cancel token to abort the requests + private _source: any; + // unregister the interceptor + private unregisterInterceptor: () => void = () => {}; + constructor(private logger: Logger, private http: any) { + this.logger.debug('Creating'); + this._source = 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._source.cancel('Requests cancelled'); + this.logger.debug('Disabled requests'); + } + async request(options: AxiosRequestConfig = {}) { + if (!this._allow) { + return Promise.reject('Requests are disabled'); + } + if (!options.method || !options.url) { + return Promise.reject('Missing parameters'); + } + const optionsWithCancelToken = { + ...options, + cancelToken: this._source?.token, + }; + + if (this._allow) { + try { + const requestData = await axios(optionsWithCancelToken); + return Promise.resolve(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 + window.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..155af5ba5e --- /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, mockRequest } = 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.only('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..168c18f395 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/server-client.ts @@ -0,0 +1,495 @@ +/* + * 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 { + HTTPClientServer, + HTTPVerb, + HTTPClientServerUserData, + WzRequestServices, + ServerAPIResponseItemsData, +} from './types'; +import { Logger } from '../../../common/services/configuration'; +import { PLUGIN_PLATFORM_REQUEST_HEADERS } from './constants'; +import jwtDecode from 'jwt-decode'; +import { BehaviorSubject } from 'rxjs'; + +export interface ServerAPIResponseItemsDataHTTPClient { + data: ServerAPIResponseItemsData; +} + +export class WzRequest implements HTTPClientServer { + onErrorInterceptor?: ( + error: any, + options: { + checkCurrentApiIsUp: boolean; + shouldRetry: boolean; + overwriteHeaders?: any; + }, + ) => Promise; + private userData: HTTPClientServerUserData; + userData$: BehaviorSubject; + constructor(private logger: Logger, private services: WzRequestServices) { + this.userData = { + logged: false, + token: null, + account: null, + policies: null, + }; + this.userData$ = new BehaviorSubject(this.userData); + } + + /** + * Permorn a generic request + * @param {String} method + * @param {String} path + * @param {Object} payload + */ + private async _request( + method: HTTPVerb, + path: string, + payload: any = null, + extraOptions: { + shouldRetry?: boolean; + checkCurrentApiIsUp?: boolean; + overwriteHeaders?: any; + } = { + shouldRetry: true, + checkCurrentApiIsUp: true, + overwriteHeaders: {}, + }, + ): Promise { + const shouldRetry = + typeof extraOptions.shouldRetry === 'boolean' + ? extraOptions.shouldRetry + : true; + const checkCurrentApiIsUp = + typeof extraOptions.checkCurrentApiIsUp === 'boolean' + ? extraOptions.checkCurrentApiIsUp + : true; + const overwriteHeaders = + typeof extraOptions.overwriteHeaders === 'object' + ? extraOptions.overwriteHeaders + : {}; + 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 Promise.resolve(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) { + 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._request(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: { + checkCurrentApiIsUp?: boolean; + returnOriginalResponse?: boolean; + } = { checkCurrentApiIsUp: true, returnOriginalResponse: false }, + ): Promise> { + try { + if (!method || !path || !body) { + throw new Error('Missing parameters'); + } + + const { returnOriginalResponse, ...optionsToGenericReq } = options; + + const id = this.services.getServerAPI(); + const requestData = { method, path, body, id }; + const response = await this._request( + 'POST', + '/api/request', + requestData, + optionsToGenericReq, + ); + + 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._request('POST', '/api/csv', requestData); + return Promise.resolve(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) { + await new Promise(r => setTimeout(r, 500)); + idHost = this.services.getServerAPI(); + } + + const response = await this._request('POST', '/api/login', { + idHost, + force, + }); + + const token = ((response || {}).data || {}).token; + return token as string; + } catch (error) { + 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 + 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) { + 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) { + 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) { + 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, + }; + + // TODO: implement + // if (Object.keys(configuration).length) { + // AppState.setPatternSelector(configuration['ip.selector']); + // } + + const response = await this.services.request(options); + + if (response.error) { + throw this.returnErrorInstance(response); + } + + return response; + } catch (error) { + if (error.response) { + // TODO: implement + // const wzMisc = new WzMisc(); + // wzMisc.setApiIsDown(true); + const response = (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); + } + + 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..3b9990343f --- /dev/null +++ b/plugins/wazuh-core/public/services/http/types.ts @@ -0,0 +1,69 @@ +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 type 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: Array; + failed_items: Array; + total_affected_items: number; + total_failed_items: number; +} + +export interface ServerAPIResponseItemsData { + data: ServerAPIResponseItems; + message: string; + error: number; +} 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..4d81a1cfb6 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.test.tsx @@ -0,0 +1,44 @@ +/* + * 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..2665ccbc92 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/components/export-table-csv.tsx @@ -0,0 +1,74 @@ +/* + * 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, +}) { + 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 (error) { + // 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..91d30df500 --- /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 { SearchBar, TableData } from '../../../../components'; +import { EuiSpacer } from '@elastic/eui'; +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), + ) + .map(({ field, composeField }) => + [composeField || field].flat(), + ) + .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..0fd0c23178 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/create.tsx @@ -0,0 +1,30 @@ +import { fetchServerTableDataCreator } from './services/fetch-server-data'; +import { withServices } from './withServices'; +import { ExportTableCsv } from './components/export-table-csv'; +import * as FileSaver from '../../../utils/file-saver'; +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..8baa159794 --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/services/fetch-server-data.ts @@ -0,0 +1,32 @@ +const getFilters = filters => { + if (!filters) { + return {}; + } + const { default: defaultFilters, ...restFilters } = filters; + return Object.keys(restFilters).length ? 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/withServices.tsx b/plugins/wazuh-core/public/services/http/ui/withServices.tsx new file mode 100644 index 0000000000..4ee5ac081f --- /dev/null +++ b/plugins/wazuh-core/public/services/http/ui/withServices.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +export const withServices = services => WrappedComponent => props => + ; diff --git a/plugins/wazuh-core/public/types.ts b/plugins/wazuh-core/public/types.ts index a3acfa7c4d..411d3fbda0 100644 --- a/plugins/wazuh-core/public/types.ts +++ b/plugins/wazuh-core/public/types.ts @@ -1,5 +1,10 @@ import { API_USER_STATUS_RUN_AS } from '../common/api-user-status-run-as'; import { Configuration } from '../common/services/configuration'; +import { TableDataProps } from './components'; +import { UseStateStorage, UseStateStorageSystem } 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 +12,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: UseStateStorage; // 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 {} diff --git a/plugins/wazuh-core/public/utils/configuration-store.ts b/plugins/wazuh-core/public/utils/configuration-store.ts index 305291c21d..ab91cf002c 100644 --- a/plugins/wazuh-core/public/utils/configuration-store.ts +++ b/plugins/wazuh-core/public/utils/configuration-store.ts @@ -1,6 +1,6 @@ import { IConfigurationStore, - ILogger, + Logger, IConfiguration, } from '../../common/services/configuration'; @@ -8,7 +8,7 @@ export class ConfigurationStore implements IConfigurationStore { private _stored: any; file: string = ''; configuration: IConfiguration | null = null; - constructor(private logger: ILogger, private http: any) { + constructor(private logger: Logger, private http: any) { this._stored = {}; } setConfiguration(configuration: IConfiguration) { diff --git a/plugins/wazuh-core/public/utils/dashboard-security.ts b/plugins/wazuh-core/public/utils/dashboard-security.ts index 669ef6fbae..dd0889c192 100644 --- a/plugins/wazuh-core/public/utils/dashboard-security.ts +++ b/plugins/wazuh-core/public/utils/dashboard-security.ts @@ -1,9 +1,9 @@ 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) {} + constructor(private logger: Logger, private http) {} private async fetchCurrentPlatform() { try { this.logger.debug('Fetching the security platform'); 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..92beea7e6a --- /dev/null +++ b/plugins/wazuh-core/public/utils/file-saver.js @@ -0,0 +1,205 @@ +/* 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..cd87333fb1 100644 --- a/plugins/wazuh-core/server/services/server-api-client.ts +++ b/plugins/wazuh-core/server/services/server-api-client.ts @@ -64,6 +64,11 @@ export interface ServerAPIScopedUserClient { ) => Promise>; } +export interface ServerAPIAuthenticateOptions { + useRunAs: boolean; + authContext?: any; +} + /** * This service communicates with the Wazuh server APIs */ @@ -86,7 +91,8 @@ export class ServerAPIClient { // 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, @@ -158,7 +164,7 @@ export class ServerAPIClient { */ private async _authenticate( apiHostID: string, - authContext?: any, + options: ServerAPIAuthenticateOptions, ): Promise { const api: APIHost = await this.manageHosts.get(apiHostID); const optionsRequest = { @@ -171,16 +177,24 @@ 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._axios(optionsRequest); const token: string = (((response || {}).data || {}).data || {}).token; - if (!authContext) { - this._CacheInternalUserAPIHostToken.set(apiHostID, 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); return token; } @@ -192,13 +206,21 @@ 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); + + const token = useRunAs + ? await this._authenticate(apiHostID, { + useRunAs: true, + authContext: ( + await this.dashboardSecurity.getCurrentUser(request, context) + ).authContext, + }) + : await this._authenticate(apiHostID, { + useRunAs: false, + }); + return token; + }, request: async ( method: RequestHTTPMethod, path: string, @@ -232,11 +254,13 @@ export class ServerAPIClient { this._CacheInternalUserAPIHostToken.has(options.apiHostID) && !options.forceRefresh ? this._CacheInternalUserAPIHostToken.get(options.apiHostID) - : await this._authenticate(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); + const token: string = await this._authenticateInternalUser( + options.apiHostID, + ); return await this._request(method, path, data, { ...options, token }); } throw error;