diff --git a/.cypress/integration/1_detectors.spec.js b/.cypress/integration/1_detectors.spec.js
index 7ffc70b20..67cf82051 100644
--- a/.cypress/integration/1_detectors.spec.js
+++ b/.cypress/integration/1_detectors.spec.js
@@ -137,7 +137,7 @@ const createDetector = (detectorName, dataSource, expectFailure) => {
fillDetailsForm(detectorName, dataSource, expectFailure);
- cy.getElementByText('.euiAccordion .euiTitle', 'Selected detection rules (14)')
+ cy.getElementByText('.euiAccordion .euiTitle', 'Selected rules (14)') // Wazuh: rename 'Detection rules' to 'Rules'
.click({ force: true, timeout: 5000 })
.then(() => cy.contains('.euiTable .euiTableRow', getLogTypeLabel(cypressLogTypeDns)));
@@ -184,7 +184,7 @@ const createDetector = (detectorName, dataSource, expectFailure) => {
cy.validateDetailsItem('Detector name', detectorName);
cy.validateDetailsItem('Description', '-');
cy.validateDetailsItem('Detector schedule', 'Every 1 minute');
- cy.validateDetailsItem('Detection rules', '14');
+ cy.validateDetailsItem('Rules', '14'); // Wazuh: rename 'Detection rules' to 'Rules'
cy.validateDetailsItem('Detector dashboard', 'Not available for this log type');
cy.wait(5000); // waiting for the page to be reloaded after pushing detector id into route
@@ -418,7 +418,7 @@ describe('Detectors', () => {
openDetectorDetails(detectorName);
editDetectorDetails(detectorName, 'Active rules');
- cy.getElementByText('.euiText', 'Detection rules (14)');
+ cy.getElementByText('.euiText', 'Rules (14)'); // Wazuh: rename 'Detection rules' to 'Rules'
cy.getInputByPlaceholder('Search...').type(`${cypressDNSRule}`).pressEnterKey();
@@ -428,7 +428,7 @@ describe('Detectors', () => {
.find('.euiTableCellContent button')
.click();
- cy.getElementByText('.euiText', 'Detection rules (13)');
+ cy.getElementByText('.euiText', 'Rules (13)'); // Wazuh: rename 'Detection rules' to 'Rules'
cy.getElementByText('button', 'Save changes').click({ force: true });
cy.urlShouldContain('detector-details').then(() => {
cy.getElementByText('.euiText', detectorName);
diff --git a/.cypress/integration/2_rules.spec.js b/.cypress/integration/2_rules.spec.js
index f4f3ac762..f6be208fe 100644
--- a/.cypress/integration/2_rules.spec.js
+++ b/.cypress/integration/2_rules.spec.js
@@ -203,7 +203,7 @@ describe('Rules', () => {
// Check that correct page is showing
cy.waitForPageLoad('rules', {
- contains: 'Detection rules',
+ contains: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
});
getCreateButton().click({ force: true });
@@ -496,7 +496,7 @@ describe('Rules', () => {
// Check that correct page is showing
cy.waitForPageLoad('rules', {
- contains: 'Detection rules',
+ contains: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
});
});
@@ -520,7 +520,7 @@ describe('Rules', () => {
cy.wait('@getRules');
cy.waitForPageLoad('rules', {
- contains: 'Detection rules',
+ contains: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
});
checkRulesFlyout();
@@ -528,7 +528,7 @@ describe('Rules', () => {
it('...can be edited', () => {
cy.waitForPageLoad('rules', {
- contains: 'Detection rules',
+ contains: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
});
cy.get(`input[placeholder="Search rules"]`).ospSearch(SAMPLE_RULE.name);
@@ -568,7 +568,7 @@ describe('Rules', () => {
submitRule();
cy.waitForPageLoad('rules', {
- contains: 'Detection rules',
+ contains: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
});
cy.wait('@getRules');
diff --git a/.cypress/integration/3_alerts.spec.js b/.cypress/integration/3_alerts.spec.js
index 15c7f4101..653598929 100644
--- a/.cypress/integration/3_alerts.spec.js
+++ b/.cypress/integration/3_alerts.spec.js
@@ -121,7 +121,7 @@ describe('Alerts', () => {
// Confirm alert findings contain expected values
cy.get('tbody > tr').should(($tr) => {
expect($tr, `timestamp`).to.contain(date);
- expect($tr, `detection`).to.contain('Detection rules');
+ expect($tr, `detection`).to.contain('Rules'); // Wazuh: rename 'Detection rules' to 'Rules'
expect($tr, `detector name`).to.contain(testDetector.name);
expect($tr, `log type`).to.contain(
`System Activity: ${getLogTypeLabel(testDetector.detector_type)}`
@@ -150,7 +150,7 @@ describe('Alerts', () => {
cy.get('[data-test-subj="alert-details-flyout"]').within(() => {
// Wait for findings table to finish loading
cy.wait(3000);
- cy.contains('Detection rules');
+ cy.contains('Rules'); // Wazuh: rename 'Detection rules' to 'Rules'
// Click the details button for the first finding
cy.get('tbody > tr')
diff --git a/.github/workflows/5_builderpackage_security_analytics_plugin.yml b/.github/workflows/5_builderpackage_security_analytics_plugin.yml
new file mode 100644
index 000000000..78393daf0
--- /dev/null
+++ b/.github/workflows/5_builderpackage_security_analytics_plugin.yml
@@ -0,0 +1,44 @@
+# π Based on: `5_builderprecompiled_base-dev-environment.yml`
+#
+# π¦ Manual Build Workflow for Wazuh Dashboard Security Analytics Plugin
+#
+# β‘ Overview:
+# This workflow builds and tests production-ready packages for Wazuh Dashboard Security Analytics plugin,
+# either manually or triggered by other workflows.
+#
+# π Key Features:
+# - ποΈ Manual or Automated Builds: Supports manual execution or invocation from other workflows,
+# specifying any valid code reference (branch, tag, or commit SHA).
+# - ποΈ Production-Ready Package Generation: Builds a production-ready package from the provided code reference.
+# - π Reusable Build Environment: Reuses a preconfigured build environment, ensuring consistency and easier maintenance.
+#
+
+name: Build app package (on demand)
+
+on:
+ workflow_call:
+ inputs:
+ reference:
+ required: true
+ type: string
+ description: Git reference (branch, tag, or commit SHA) to build from.
+ default: 5.0.0
+ workflow_dispatch:
+ inputs:
+ reference:
+ required: true
+ type: string
+ default: 5.0.0
+ description: Git reference (branch, tag, or commit SHA) to build from.
+
+jobs:
+ # Build an app package from the given source code reference.
+ build:
+ name: Build app package
+ uses: ./.github/workflows/5_builderprecompiled_base-dev-environment.yml
+ with:
+ reference: ${{ inputs.reference }}
+ command: 'yarn build'
+ artifact_name: 'security-analytics-dashboards'
+ artifact_path: './wazuh-dashboard-security-analytics/build'
+ secrets: inherit
diff --git a/.github/workflows/5_builderprecompiled_base-dev-environment.yml b/.github/workflows/5_builderprecompiled_base-dev-environment.yml
new file mode 100644
index 000000000..9ea9900af
--- /dev/null
+++ b/.github/workflows/5_builderprecompiled_base-dev-environment.yml
@@ -0,0 +1,115 @@
+# π Base Workflow - Environment Setup and Command Execution
+#
+# β‘ Overview:
+# This workflow serves as a reusable base for other workflows, providing a
+# standardized environment to execute custom commands (e.g., `yarn build`, `yarn test`)
+# on source code fetched from a specified Git reference.
+#
+# π Key Features:
+# - π» Docker-Based Environment Setup: Prepares a Docker environment with OpenSearch Dashboards or Kibana.
+# - βοΈ Custom Command Execution: Runs any specified command on the downloaded source code.
+# - π¦ Artifact and Coverage Upload: Uploads build artifacts and test coverage results to GitHub when configured.
+#
+# π Designed for: Easy integration and reuse by other workflows.
+
+name: Base workflow - Environment
+
+on:
+ workflow_call:
+ inputs:
+ reference:
+ required: true
+ type: string
+ default: 5.0.0
+ description: Git reference (branch, tag, or commit SHA) to build from.
+ command:
+ required: true
+ type: string
+ default: 'yarn build'
+ description: Command to run in the environment.
+ docker_run_extra_args:
+ type: string
+ default: ''
+ description: Additional parameters for the docker run command.
+ required: false
+ artifact_name:
+ type: string
+ default: ''
+ description: Artifact name (will be automatically suffixed with .zip).
+ required: false
+ artifact_path:
+ type: string
+ default: ''
+ description: Folder to include in the archive.
+ required: false
+ notify_jest_coverage_summary:
+ type: boolean
+ default: false
+ required: false
+
+jobs:
+ # Deploy the plugin in a development environment and run a command
+ # using a pre-built Docker image, hosted in Quay.io.
+ deploy_and_run_command:
+ name: Deploy and run command
+ runs-on: codebuild-github-actions-codebuild-runner-dashboard-amd-${{ github.run_id }}-${{ github.run_attempt }}
+ steps:
+ - name: Step 01 - Download the plugin's source code
+ uses: actions/checkout@v4
+ with:
+ repository: wazuh/wazuh-dashboard-security-analytics
+ ref: ${{ inputs.reference }}
+ path: wazuh-dashboard-security-analytics
+
+ # Fix source code ownership so the internal user of the Docker
+ # container is also owner.
+ - name: Step 02 - Change code ownership
+ run: sudo chown 1000:1000 -R wazuh-dashboard-security-analytics;
+
+ - name: Step 03 - Set up the environment and run the command
+ run: |
+ # Read the platform version from the package.json file
+ echo "Reading the platform version from the package.json...";
+ platform_version=$(jq -r '.opensearchDashboards.version | select(. != null)' wazuh-dashboard-security-analytics/package.json);
+ echo "Plugin platform version: $platform_version";
+
+ # Get Wazuh version and concatenate with platform version
+ wazuh_version=$(jq -r '.wazuh.version' wazuh-dashboard-security-analytics/package.json);
+ echo "Wazuh version: $wazuh_version";
+
+ # Concatenate versions in format: -
+ combined_version="${platform_version}-${wazuh_version}";
+ echo "Combined platform version: $combined_version";
+
+ # Up the environment and run the command
+ docker run -t --rm \
+ -e OPENSEARCH_DASHBOARDS_VERSION=${combined_version} \
+ -e GIT_REF=main \
+ -v `pwd`/wazuh-dashboard-security-analytics:/home/node/kbn/plugins/wazuh-dashboard-security-analytics \
+ ${{ inputs.docker_run_extra_args }} \
+ quay.io/wazuh/osd-dev:${combined_version} \
+ bash -c '
+ yarn config set registry https://registry.yarnpkg.com;
+ cd /home/node/kbn/plugins/wazuh-dashboard-security-analytics && yarn && ${{ inputs.command }};
+ '
+ - name: Get the plugin version and and format reference name
+ run: |
+ echo "githubReference=$(echo ${{ inputs.reference }} | sed 's/\//-/g')" >> $GITHUB_ENV
+ echo "version=$(jq -r '.wazuh.version' $(pwd)/wazuh-dashboard-security-analytics/package.json)" >> $GITHUB_ENV
+ echo "revision=$(jq -r '.wazuh.revision' $(pwd)/wazuh-dashboard-security-analytics/package.json)" >> $GITHUB_ENV
+
+ - name: Step 04 - Upload artifact to GitHub
+ if: ${{ inputs.artifact_name && inputs.artifact_path }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ inputs.artifact_name }}_${{ env.version }}-${{ env.revision }}_${{ env.githubReference }}.zip
+ path: ${{ inputs.artifact_path }}
+ overwrite: true
+
+ - name: Step 05 - Upload coverage results to GitHub
+ if: ${{ inputs.notify_jest_coverage_summary && github.event_name == 'pull_request' }}
+ uses: AthleticNet/comment-test-coverage@1.2.2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ path: ./wazuh-dashboard-security-analytics/target/test-coverage/coverage-summary.json
+ title: 'Code coverage (Jest)'
diff --git a/.github/workflows/5_bumper_repository.yml b/.github/workflows/5_bumper_repository.yml
new file mode 100644
index 000000000..49eedcb5b
--- /dev/null
+++ b/.github/workflows/5_bumper_repository.yml
@@ -0,0 +1,249 @@
+name: Repository bumper
+run-name: Bump ${{ github.ref_name }} (${{ inputs.id }})
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Target version (e.g. 4.12.0)"
+ default: ""
+ required: false
+ type: string
+ stage:
+ description: "Version stage (e.g. alpha0)"
+ default: ""
+ required: false
+ type: string
+ tag:
+ description: "Change branches references to tag-like references (e.g. v4.12.0-alpha7)"
+ default: false
+ required: false
+ type: boolean
+ set_as_main:
+ description: "Enable main branch mode: bump version values only, keep branch references pointing to main"
+ required: false
+ type: boolean
+ default: false
+ issue-link:
+ description: "Issue link in format https://github.com/wazuh//issues/"
+ required: true
+ type: string
+ bump-issue-link:
+ description: 'Issue link used in the original bump (required for revert if different from issue-link)'
+ required: false
+ type: string
+ id:
+ description: "Optional identifier for the run"
+ required: false
+ type: string
+ revert:
+ description: "Set to true to revert the bump changes applied for this issue"
+ default: false
+ required: false
+ type: boolean
+
+jobs:
+ bump:
+ name: Repository bumper
+ runs-on: codebuild-github-actions-codebuild-runner-dashboard-amd-${{ github.run_id }}-${{ github.run_attempt }}
+ permissions:
+ contents: write
+ pull-requests: write
+
+ env:
+ CI_COMMIT_AUTHOR: wazuhci
+ CI_COMMIT_EMAIL: 22834044+wazuhci@users.noreply.github.com
+ CI_GPG_PRIVATE_KEY: ${{ secrets.CI_WAZUHCI_GPG_PRIVATE }}
+ GH_TOKEN: ${{ secrets.CI_WAZUHCI_BUMPER_TOKEN }}
+ BUMP_SCRIPT_PATH: tools/repository_bumper.sh
+ BUMP_LOG_PATH: tools/
+
+ steps:
+ - name: Dump event payload
+ run: |
+ cat $GITHUB_EVENT_PATH | jq '.inputs'
+
+ - name: Set up GPG key
+ id: signing_setup
+ run: |
+ echo "${{ env.CI_GPG_PRIVATE_KEY }}" | gpg --batch --import
+ KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/ {print $5; exit}')
+ echo "gpg_key_id=$KEY_ID" >> $GITHUB_OUTPUT
+
+ - name: Set up git
+ run: |
+ git config --global user.name "${{ env.CI_COMMIT_AUTHOR }}"
+ git config --global user.email "${{ env.CI_COMMIT_EMAIL }}"
+ git config --global commit.gpgsign true
+ git config --global user.signingkey "${{ steps.signing_setup.outputs.gpg_key_id }}"
+ echo "use-agent" >> ~/.gnupg/gpg.conf
+ echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf
+ echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
+ echo RELOADAGENT | gpg-connect-agent
+ export DEBIAN_FRONTEND=noninteractive
+ export GPG_TTY=$(tty)
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ env.GH_TOKEN }}
+ fetch-depth: 0
+
+ - name: Determine branch name
+ id: vars
+ env:
+ VERSION: ${{ inputs.version }}
+ STAGE: ${{ inputs.stage }}
+ TAG: ${{ inputs.tag }}
+ SET_AS_MAIN: ${{ inputs.set_as_main }}
+ run: |
+ script_params=""
+ version=${{ env.VERSION }}
+ stage=${{ env.STAGE }}
+ tag=${{ env.TAG }}
+ set_as_main=${{ env.SET_AS_MAIN }}
+
+ # Both version and stage provided (no tag)
+ if [[ -n "$version" && -n "$stage" && "$tag" != "true" ]]; then
+ script_params="--version ${version} --stage ${stage}"
+ elif [[ -z "$version" && -n "$stage" && "$tag" == "true" ]]; then
+ script_params="--stage ${stage} --tag"
+ elif [[ -z "$version" && -z "$stage" && "$tag" == "true" ]]; then
+ script_params="--tag"
+ fi
+
+ if [[ "$set_as_main" == "true" ]]; then
+ script_params="${script_params} --set-as-main"
+ fi
+
+ issue_number=$(echo "${{ inputs.issue-link }}" | awk -F'/' '{print $NF}')
+ if [[ "${{ inputs.revert }}" == "true" ]]; then
+ BRANCH_NAME="enhancement/wqa${issue_number}-revert-bump-${{ github.ref_name }}"
+ echo "pr_title=Revert bump ${{ github.ref_name }} branch" >> "$GITHUB_OUTPUT"
+ else
+ BRANCH_NAME="enhancement/wqa${issue_number}-bump-${{ github.ref_name }}"
+ echo "pr_title=Bump ${{ github.ref_name }} branch" >> "$GITHUB_OUTPUT"
+ fi
+ echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
+ echo "script_params=${script_params}" >> $GITHUB_OUTPUT
+
+ - name: Create and switch to bump branch
+ run: |
+ git checkout -b ${{ steps.vars.outputs.branch_name }}
+
+ - name: Make version bump changes
+ if: inputs.revert != true
+ run: |
+ echo "Running bump script"
+ bash ${{ env.BUMP_SCRIPT_PATH }} ${{ steps.vars.outputs.script_params }}
+
+ - name: Detect if bump produced changes
+ id: bump_changes
+ run: |
+ if [[ "${{ inputs.revert }}" == "true" ]]; then
+ echo "has_changes=true" >> "$GITHUB_OUTPUT"
+ elif [[ -n "$(git status --porcelain)" ]]; then
+ echo "has_changes=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_changes=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Commit changes (Bump)
+ if: inputs.revert != true && steps.bump_changes.outputs.has_changes == 'true'
+ run: |
+ git add .
+ git commit -m "feat: bump ${{ github.ref_name }}"
+
+ - name: Revert references (Revert)
+ id: revert_step
+ if: inputs.revert == true
+ run: |
+ # 1. Get the current issue number (for the new revert branch/PR)
+ ISSUE_NUMBER=$(echo "${{ inputs.issue-link }}" | awk -F'/' '{print $NF}')
+
+ # 2. Get the issue number from the original bump (if provided; otherwise, defaults to the current one)
+ if [ -n "${{ inputs.bump-issue-link }}" ]; then
+ BUMP_ISSUE_NUMBER=$(echo "${{ inputs.bump-issue-link }}" | awk -F'/' '{print $NF}')
+ else
+ BUMP_ISSUE_NUMBER=$ISSUE_NUMBER
+ fi
+
+ # 3. Search for the original bump branch using the obtained BUMP ISSUE number
+ BUMP_BRANCH="enhancement/wqa${BUMP_ISSUE_NUMBER}-bump-${{ github.ref_name }}"
+
+ PR_NUMBER=$(gh pr list --head "$BUMP_BRANCH" --base "${{ github.ref_name }}" --state merged --json number --jq '.[0].number')
+
+ if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" == "null" ]; then
+ echo "Error: The original PR for the bump was not found"
+ echo "Searching merged PR from: $BUMP_BRANCH to ${{ github.ref_name }}"
+ exit 1
+ fi
+
+ echo "Original PR found: #$PR_NUMBER"
+
+ MERGE_COMMIT=$(gh pr view $PR_NUMBER --json mergeCommit --jq '.mergeCommit.oid')
+
+ git revert -m 1 $MERGE_COMMIT --no-commit
+
+ # Remove the files to prevent them from being included in the revert commit
+ git checkout HEAD -- VERSION.json 2>/dev/null || true
+ git checkout HEAD -- CHANGELOG.md 2>/dev/null || true
+ git checkout HEAD -- package.json 2>/dev/null || true
+
+ if git diff --staged --quiet; then
+ echo "No references to revert. Skipping commit."
+ echo "has_changes=false" >> "$GITHUB_OUTPUT"
+ else
+ git commit -m "feat: revert ${{ github.ref_name }} references"
+ echo "has_changes=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Push changes
+ if: (inputs.revert != true && steps.bump_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ git push origin ${{ steps.vars.outputs.branch_name }}
+
+ - name: Create pull request
+ id: create_pr
+ if: (inputs.revert != true && steps.bump_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ gh auth setup-git
+ PR_URL=$(gh pr create \
+ --title "${{ steps.vars.outputs.pr_title }}" \
+ --body "Issue: ${{ inputs.issue-link }}" \
+ --base ${{ github.ref_name }} \
+ --head ${{ steps.vars.outputs.branch_name }})
+
+ echo "Pull request created: ${PR_URL}"
+ echo "pull_request_url=${PR_URL}" >> $GITHUB_OUTPUT
+
+ - name: Merge pull request
+ if: (inputs.revert != true && steps.bump_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ gh pr merge "${{ steps.create_pr.outputs.pull_request_url }}" --merge --admin
+
+ - name: Show logs
+ if: inputs.revert != true
+ run: |
+ echo "Bump complete."
+ echo "Branch: ${{ steps.vars.outputs.branch_name }}"
+ if [[ "${{ steps.bump_changes.outputs.has_changes }}" == "true" ]]; then
+ echo "PR: ${{ steps.create_pr.outputs.pull_request_url }}"
+ else
+ echo "No file changes from bumper (no PR created)."
+ fi
+ echo "Bumper scripts logs:"
+ cat ${BUMP_LOG_PATH}/repository_bumper*log
+
+ - name: Show revert logs
+ if: inputs.revert == true
+ run: |
+ echo "Revert bump complete."
+ echo "Branch: ${{ steps.vars.outputs.branch_name }}"
+ if [[ "${{ steps.revert_step.outputs.has_changes }}" == "true" ]]; then
+ echo "PR: ${{ steps.create_pr.outputs.pull_request_url }}"
+ else
+ echo "No references to revert (no PR created)."
+ fi
+ echo "Revert bumper scripts logs:"
+ cat ${BUMP_LOG_PATH}/repository_bumper*log || true
diff --git a/.github/workflows/6_builderpackage_security_analytics_plugin.yml b/.github/workflows/6_builderpackage_security_analytics_plugin.yml
new file mode 100644
index 000000000..bb319b7d3
--- /dev/null
+++ b/.github/workflows/6_builderpackage_security_analytics_plugin.yml
@@ -0,0 +1,44 @@
+# π Based on: `6_builderprecompiled_base-dev-environment.yml`
+#
+# π¦ Manual Build Workflow for Wazuh Dashboard Security Analytics Plugin
+#
+# β‘ Overview:
+# This workflow builds and tests production-ready packages for Wazuh Dashboard Security Analytics plugin,
+# either manually or triggered by other workflows.
+#
+# π Key Features:
+# - ποΈ Manual or Automated Builds: Supports manual execution or invocation from other workflows,
+# specifying any valid code reference (branch, tag, or commit SHA).
+# - ποΈ Production-Ready Package Generation: Builds a production-ready package from the provided code reference.
+# - π Reusable Build Environment: Reuses a preconfigured build environment, ensuring consistency and easier maintenance.
+#
+
+name: Build app package (on demand)
+
+on:
+ workflow_call:
+ inputs:
+ reference:
+ required: true
+ type: string
+ description: Git reference (branch, tag, or commit SHA) to build from.
+ default: main
+ workflow_dispatch:
+ inputs:
+ reference:
+ required: true
+ type: string
+ default: main
+ description: Git reference (branch, tag, or commit SHA) to build from.
+
+jobs:
+ # Build an app package from the given source code reference.
+ build:
+ name: Build app package
+ uses: ./.github/workflows/6_builderprecompiled_base-dev-environment.yml
+ with:
+ reference: ${{ inputs.reference }}
+ command: 'yarn build'
+ artifact_name: 'security-analytics-dashboard'
+ artifact_path: './wazuh-dashboard-security-analytics/build'
+ secrets: inherit
diff --git a/.github/workflows/6_builderprecompiled_base-dev-environment.yml b/.github/workflows/6_builderprecompiled_base-dev-environment.yml
new file mode 100644
index 000000000..2b690e348
--- /dev/null
+++ b/.github/workflows/6_builderprecompiled_base-dev-environment.yml
@@ -0,0 +1,115 @@
+# π Base Workflow - Environment Setup and Command Execution
+#
+# β‘ Overview:
+# This workflow serves as a reusable base for other workflows, providing a
+# standardized environment to execute custom commands (e.g., `yarn build`, `yarn test`)
+# on source code fetched from a specified Git reference.
+#
+# π Key Features:
+# - π» Docker-Based Environment Setup: Prepares a Docker environment with OpenSearch Dashboards or Kibana.
+# - βοΈ Custom Command Execution: Runs any specified command on the downloaded source code.
+# - π¦ Artifact and Coverage Upload: Uploads build artifacts and test coverage results to GitHub when configured.
+#
+# π Designed for: Easy integration and reuse by other workflows.
+
+name: Base workflow - Environment
+
+on:
+ workflow_call:
+ inputs:
+ reference:
+ required: true
+ type: string
+ default: main
+ description: Git reference (branch, tag, or commit SHA) to build from.
+ command:
+ required: true
+ type: string
+ default: 'yarn build'
+ description: Command to run in the environment.
+ docker_run_extra_args:
+ type: string
+ default: ''
+ description: Additional parameters for the docker run command.
+ required: false
+ artifact_name:
+ type: string
+ default: ''
+ description: Artifact name (will be automatically suffixed with .zip).
+ required: false
+ artifact_path:
+ type: string
+ default: ''
+ description: Folder to include in the archive.
+ required: false
+ notify_jest_coverage_summary:
+ type: boolean
+ default: false
+ required: false
+
+jobs:
+ # Deploy the plugin in a development environment and run a command
+ # using a pre-built Docker image, hosted in Quay.io.
+ deploy_and_run_command:
+ name: Deploy and run command
+ runs-on: codebuild-github-actions-codebuild-runner-dashboard-amd-${{ github.run_id }}-${{ github.run_attempt }}
+ steps:
+ - name: Step 01 - Download the plugin's source code
+ uses: actions/checkout@v4
+ with:
+ repository: wazuh/wazuh-dashboard-security-analytics
+ ref: ${{ inputs.reference }}
+ path: wazuh-dashboard-security-analytics
+
+ # Fix source code ownership so the internal user of the Docker
+ # container is also owner.
+ - name: Step 02 - Change code ownership
+ run: sudo chown 1000:1000 -R wazuh-dashboard-security-analytics;
+
+ - name: Step 03 - Set up the environment and run the command
+ run: |
+ # Read the platform version from the package.json file
+ echo "Reading the platform version from the package.json...";
+ platform_version=$(jq -r '.opensearchDashboards.version | select(. != null)' wazuh-dashboard-security-analytics/package.json);
+ echo "Plugin platform version: $platform_version";
+
+ # Get Wazuh version and concatenate with platform version
+ wazuh_version=$(jq -r '.wazuh.version' wazuh-dashboard-security-analytics/package.json);
+ echo "Wazuh version: $wazuh_version";
+
+ # Concatenate versions in format: -
+ combined_version="${platform_version}-${wazuh_version}";
+ echo "Combined platform version: $combined_version";
+
+ # Up the environment and run the command
+ docker run -t --rm \
+ -e OPENSEARCH_DASHBOARDS_VERSION=${combined_version} \
+ -e GIT_REF=main \
+ -v `pwd`/wazuh-dashboard-security-analytics:/home/node/kbn/plugins/wazuh-dashboard-security-analytics \
+ ${{ inputs.docker_run_extra_args }} \
+ quay.io/wazuh/osd-dev:${combined_version} \
+ bash -c '
+ yarn config set registry https://registry.yarnpkg.com;
+ cd /home/node/kbn/plugins/wazuh-dashboard-security-analytics && yarn && ${{ inputs.command }};
+ '
+ - name: Get the plugin version and and format reference name
+ run: |
+ echo "githubReference=$(echo ${{ inputs.reference }} | sed 's/\//-/g')" >> $GITHUB_ENV
+ echo "version=$(jq -r '.wazuh.version' $(pwd)/wazuh-dashboard-security-analytics/package.json)" >> $GITHUB_ENV
+ echo "revision=$(jq -r '.wazuh.revision' $(pwd)/wazuh-dashboard-security-analytics/package.json)" >> $GITHUB_ENV
+
+ - name: Step 04 - Upload artifact to GitHub
+ if: ${{ inputs.artifact_name && inputs.artifact_path }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ inputs.artifact_name }}_${{ env.version }}-${{ env.revision }}_${{ env.githubReference }}.zip
+ path: ${{ inputs.artifact_path }}
+ overwrite: true
+
+ - name: Step 05 - Upload coverage results to GitHub
+ if: ${{ inputs.notify_jest_coverage_summary && github.event_name == 'pull_request' }}
+ uses: AthleticNet/comment-test-coverage@1.2.2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ path: ./wazuh-dashboard-security-analytics/target/test-coverage/coverage-summary.json
+ title: 'Code coverage (Jest)'
diff --git a/.github/workflows/6_bumper_repository.yml b/.github/workflows/6_bumper_repository.yml
new file mode 100644
index 000000000..f7c829bbf
--- /dev/null
+++ b/.github/workflows/6_bumper_repository.yml
@@ -0,0 +1,216 @@
+name: Repository bumper
+run-name: Bump ${{ github.ref_name }} (${{ inputs.id }})
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Target version (e.g. 4.12.0)'
+ default: ''
+ required: false
+ type: string
+ stage:
+ description: 'Version stage (e.g. alpha0)'
+ default: ''
+ required: false
+ type: string
+ issue-link:
+ description: 'Issue link in format https://github.com/wazuh//issues/'
+ required: true
+ type: string
+ id:
+ description: 'Optional identifier for the run'
+ required: false
+ type: string
+ revert:
+ description: 'Set to true to revert the bump changes applied for this issue'
+ default: false
+ required: false
+ type: boolean
+
+jobs:
+ bump:
+ name: Repository bumper
+ runs-on: codebuild-github-actions-codebuild-runner-dashboard-amd-${{ github.run_id }}-${{ github.run_attempt }}
+ permissions:
+ contents: write
+ pull-requests: write
+
+ env:
+ CI_COMMIT_AUTHOR: wazuhci
+ CI_COMMIT_EMAIL: 22834044+wazuhci@users.noreply.github.com
+ CI_GPG_PRIVATE_KEY: ${{ secrets.CI_WAZUHCI_GPG_PRIVATE }}
+ GH_TOKEN: ${{ secrets.CI_WAZUHCI_BUMPER_TOKEN }}
+ BUMP_SCRIPT_PATH: tools/repository_bumper.sh
+ BUMP_LOG_PATH: tools/
+
+ steps:
+ - name: Dump event payload
+ run: |
+ cat $GITHUB_EVENT_PATH | jq '.inputs'
+
+ - name: Set up GPG key
+ id: signing_setup
+ run: |
+ echo "${{ env.CI_GPG_PRIVATE_KEY }}" | gpg --batch --import
+ KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/ {print $5; exit}')
+ echo "gpg_key_id=$KEY_ID" >> $GITHUB_OUTPUT
+
+ - name: Set up git
+ run: |
+ git config --global user.name "${{ env.CI_COMMIT_AUTHOR }}"
+ git config --global user.email "${{ env.CI_COMMIT_EMAIL }}"
+ git config --global commit.gpgsign true
+ git config --global user.signingkey "${{ steps.signing_setup.outputs.gpg_key_id }}"
+ echo "use-agent" >> ~/.gnupg/gpg.conf
+ echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf
+ echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf
+ echo RELOADAGENT | gpg-connect-agent
+ export DEBIAN_FRONTEND=noninteractive
+ export GPG_TTY=$(tty)
+
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ env.GH_TOKEN }}
+ fetch-depth: 0
+
+ - name: Determine branch name
+ id: vars
+ env:
+ VERSION: ${{ inputs.version }}
+ STAGE: ${{ inputs.stage }}
+ run: |
+ script_params=""
+ version=${{ env.VERSION }}
+ stage=${{ env.STAGE }}
+
+ # Both version and stage provided
+ if [[ -n "$version" && -n "$stage" ]]; then
+ script_params="--version ${version} --stage ${stage}"
+ elif [[ -z "$version" && -n "$stage" ]]; then
+ script_params="--stage ${stage}"
+ fi
+
+ issue_number=$(echo "${{ inputs.issue-link }}" | awk -F'/' '{print $NF}')
+ if [[ "${{ inputs.revert }}" == "true" ]]; then
+ BRANCH_NAME="enhancement/wqa${issue_number}-revert-bump-${{ github.ref_name }}"
+ echo "pr_title=Revert bump ${{ github.ref_name }} branch" >> $GITHUB_OUTPUT
+ else
+ BRANCH_NAME="enhancement/wqa${issue_number}-bump-${{ github.ref_name }}"
+ echo "pr_title=Bump ${{ github.ref_name }} branch" >> $GITHUB_OUTPUT
+ fi
+ echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
+ echo "script_params=${script_params}" >> $GITHUB_OUTPUT
+
+ - name: Create and switch to bump branch
+ run: |
+ git checkout -b ${{ steps.vars.outputs.branch_name }}
+
+ - name: Make version bump changes
+ if: inputs.revert != true
+ run: |
+ echo "Running bump script"
+ bash ${{ env.BUMP_SCRIPT_PATH }} ${{ steps.vars.outputs.script_params }}
+
+ - name: Check for changes
+ id: check_changes
+ run: |
+ if [[ "${{ inputs.revert }}" == "true" ]]; then
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ elif git diff --quiet; then
+ echo "No changes detected; skipping PR creation."
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ else
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Commit changes (Bump)
+ if: inputs.revert != true && steps.check_changes.outputs.has_changes == 'true'
+ run: |
+ git add .
+ git commit -m "feat: bump ${{ github.ref_name }}"
+
+ - name: Revert references (Revert)
+ id: revert_step
+ if: inputs.revert == true
+ run: |
+ ISSUE_NUMBER=$(echo "${{ inputs.issue-link }}" | awk -F'/' '{print $NF}')
+
+ BUMP_BRANCH="enhancement/wqa${ISSUE_NUMBER}-bump-${{ github.ref_name }}"
+
+ PR_NUMBER=$(gh pr list --head "$BUMP_BRANCH" --base "${{ github.ref_name }}" --state merged --json number --jq '.[0].number')
+
+ if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" == "null" ]; then
+ echo "Error: The original PR for the bump was not found"
+ echo "Searching merged PR from: $BUMP_BRANCH to ${{ github.ref_name }}"
+ exit 1
+ fi
+
+ echo "Original PR found: #$PR_NUMBER"
+
+ MERGE_COMMIT=$(gh pr view $PR_NUMBER --json mergeCommit --jq '.mergeCommit.oid')
+
+ git revert -m 1 $MERGE_COMMIT --no-commit
+
+ git checkout HEAD -- VERSION.json 2>/dev/null || true
+ git checkout HEAD -- CHANGELOG.md 2>/dev/null || true
+ git checkout HEAD -- package.json 2>/dev/null || true
+
+ if git diff --staged --quiet; then
+ echo "No references to revert. Skipping commit."
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ else
+ git commit -m "feat: revert ${{ github.ref_name }} references"
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Push changes
+ if: (inputs.revert != true && steps.check_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ git push origin ${{ steps.vars.outputs.branch_name }}
+
+ - name: Create pull request
+ id: create_pr
+ if: (inputs.revert != true && steps.check_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ gh auth setup-git
+ PR_URL=$(gh pr create \
+ --title "${{ steps.vars.outputs.pr_title }}" \
+ --body "Issue: ${{ inputs.issue-link }}" \
+ --base ${{ github.ref_name }} \
+ --head ${{ steps.vars.outputs.branch_name }})
+
+ echo "Pull request created: ${PR_URL}"
+ echo "pull_request_url=${PR_URL}" >> $GITHUB_OUTPUT
+
+ - name: Merge pull request
+ if: (inputs.revert != true && steps.check_changes.outputs.has_changes == 'true') || (inputs.revert == true && steps.revert_step.outputs.has_changes == 'true')
+ run: |
+ gh pr merge "${{ steps.create_pr.outputs.pull_request_url }}" --merge --admin
+
+ - name: Show logs
+ if: inputs.revert != true
+ run: |
+ echo "Bump complete."
+ echo "Branch: ${{ steps.vars.outputs.branch_name }}"
+ if [[ "${{ steps.check_changes.outputs.has_changes }}" == "true" ]]; then
+ echo "PR: ${{ steps.create_pr.outputs.pull_request_url }}"
+ else
+ echo "No file changes from bumper (no PR created)."
+ fi
+ echo "Bumper scripts logs:"
+ cat ${BUMP_LOG_PATH}/repository_bumper*log
+
+ - name: Show revert logs
+ if: inputs.revert == true
+ run: |
+ echo "Revert bump complete."
+ echo "Branch: ${{ steps.vars.outputs.branch_name }}"
+ if [[ "${{ steps.revert_step.outputs.has_changes }}" == "true" ]]; then
+ echo "PR: ${{ steps.create_pr.outputs.pull_request_url }}"
+ else
+ echo "No references to revert (no PR created)."
+ fi
+ echo "Revert bumper scripts logs:"
+ cat ${BUMP_LOG_PATH}/repository_bumper*log || true
diff --git a/.github/workflows/cypress-workflow.yml b/.github/workflows/cypress-workflow.yml
index 84aca1c5d..4098bffdd 100644
--- a/.github/workflows/cypress-workflow.yml
+++ b/.github/workflows/cypress-workflow.yml
@@ -2,10 +2,8 @@ name: Cypress integration tests workflow
on:
pull_request:
branches:
- - '*'
- push:
- branches:
- - '*'
+ - "*"
+ types: [opened, synchronize, reopened, ready_for_review]
env:
OPENSEARCH_DASHBOARDS_VERSION: 'main'
OPENSEARCH_VERSION: '3.1.0-SNAPSHOT'
@@ -17,10 +15,11 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest]
+ # os: [ubuntu-latest, windows-latest]
+ os: [ubuntu-latest]
include:
- - os: windows-latest
- cypress_cache_folder: ~/AppData/Local/Cypress/Cache
+ # - os: windows-latest
+ # cypress_cache_folder: ~/AppData/Local/Cypress/Cache
- os: ubuntu-latest
cypress_cache_folder: ~/.cache/Cypress
runs-on: ${{ matrix.os }}
@@ -29,6 +28,7 @@ jobs:
CI: 1
# avoid warnings like "tput: No value for $TERM and no -T specified"
TERM: xterm
+ if: github.event.pull_request.draft == false
steps:
- name: Set up JDK
uses: actions/setup-java@v1
@@ -83,6 +83,8 @@ jobs:
- run: yarn -v
- name: Bootstrap plugin/OpenSearch-Dashboards
+ env:
+ GIT_REF: 'main'
run: |
cd OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
yarn osd bootstrap --single-version=loose
@@ -138,6 +140,7 @@ jobs:
browser: firefox
env:
CYPRESS_CACHE_FOLDER: ${{ matrix.cypress_cache_folder }}
+ SKIP_DOWNLOAD_ENGINE_SCHEMAS: 'true'
# Screenshots are only captured on failure, will change this once we do visual regression tests
- uses: actions/upload-artifact@v4
diff --git a/.github/workflows/unit-tests-workflow.yml b/.github/workflows/unit-tests-workflow.yml
index 11650f13a..b2da3827d 100644
--- a/.github/workflows/unit-tests-workflow.yml
+++ b/.github/workflows/unit-tests-workflow.yml
@@ -3,13 +3,12 @@ on:
pull_request:
branches:
- "*"
- push:
- branches:
- - "*"
+ types: [opened, synchronize, reopened, ready_for_review]
env:
OPENSEARCH_DASHBOARDS_VERSION: 'main'
jobs:
Get-CI-Image-Tag:
+ if: github.event.pull_request.draft == false
uses: opensearch-project/opensearch-build/.github/workflows/get-ci-image-tag.yml@main
with:
product: opensearch-dashboards
@@ -27,7 +26,7 @@ jobs:
image: ${{ needs.Get-CI-Image-Tag.outputs.ci-image-version-linux }}
# need to switch to root so that github actions can install runner binary on container without permission issues.
options: --user root
-
+ if: github.event.pull_request.draft == false
steps:
- name: Checkout OpenSearch-Dashboards
uses: actions/checkout@v2
@@ -40,6 +39,8 @@ jobs:
with:
path: OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
- name: Bootstrap plugin/OpenSearch-Dashboards and unit tests
+ env:
+ GIT_REF: 'main'
run: |
chown -R 1000:1000 `pwd`
cd ./OpenSearch-Dashboards/
@@ -51,46 +52,47 @@ jobs:
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: codecov/codecov-action@v1
- tests-windows:
- name: Run Tests Windows
- strategy:
- matrix:
- os: [windows-latest]
- runs-on: ${{ matrix.os }}
- steps:
- # Enable longer filenames for windows
- - name: Enable longer filenames
- if: ${{ matrix.os == 'windows-latest' }}
- run: git config --system core.longpaths true
- - name: Checkout OpenSearch-Dashboards
- uses: actions/checkout@v2
- with:
- repository: opensearch-project/OpenSearch-Dashboards
- ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }}
- path: OpenSearch-Dashboards
- - name: Setup Node
- uses: actions/setup-node@v3
- with:
- node-version-file: './OpenSearch-Dashboards/.nvmrc'
- registry-url: 'https://registry.npmjs.org'
- - name: Install Yarn
- # Need to use bash to avoid having a windows/linux specific step
- shell: bash
- run: |
- YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn")
- echo "Installing yarn@$YARN_VERSION"
- npm i -g yarn@$YARN_VERSION
- - run: node -v
- - run: yarn -v
- - name: Checkout Security Analytics Dashboards plugin
- uses: actions/checkout@v2
- with:
- path: OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
- - name: Bootstrap plugin/OpenSearch-Dashboards
- run: |
- cd OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
- yarn osd bootstrap --single-version=loose
- - name: Run tests
- run: |
- cd OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
- yarn run test:jest --coverage
+ ### Commented because Windows is not currently supported by Wazuh dashboard
+ # tests-windows:
+ # name: Run Tests Windows
+ # strategy:
+ # matrix:
+ # os: [windows-latest]
+ # runs-on: ${{ matrix.os }}
+ # steps:
+ # # Enable longer filenames for windows
+ # - name: Enable longer filenames
+ # if: ${{ matrix.os == 'windows-latest' }}
+ # run: git config --system core.longpaths true
+ # - name: Checkout OpenSearch-Dashboards
+ # uses: actions/checkout@v2
+ # with:
+ # repository: opensearch-project/OpenSearch-Dashboards
+ # ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }}
+ # path: OpenSearch-Dashboards
+ # - name: Setup Node
+ # uses: actions/setup-node@v3
+ # with:
+ # node-version-file: './OpenSearch-Dashboards/.nvmrc'
+ # registry-url: 'https://registry.npmjs.org'
+ # - name: Install Yarn
+ # # Need to use bash to avoid having a windows/linux specific step
+ # shell: bash
+ # run: |
+ # YARN_VERSION=$(node -p "require('./OpenSearch-Dashboards/package.json').engines.yarn")
+ # echo "Installing yarn@$YARN_VERSION"
+ # npm i -g yarn@$YARN_VERSION
+ # - run: node -v
+ # - run: yarn -v
+ # - name: Checkout Security Analytics Dashboards plugin
+ # uses: actions/checkout@v2
+ # with:
+ # path: OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
+ # - name: Bootstrap plugin/OpenSearch-Dashboards
+ # run: |
+ # cd OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
+ # yarn osd bootstrap --single-version=loose
+ # - name: Run tests
+ # run: |
+ # cd OpenSearch-Dashboards/plugins/security-analytics-dashboards-plugin
+ # yarn run test:jest --coverage
diff --git a/.github/workflows/verify-binary-installation.yml b/.github/workflows/verify-binary-installation.yml
index 98a004eb1..24d2459d0 100644
--- a/.github/workflows/verify-binary-installation.yml
+++ b/.github/workflows/verify-binary-installation.yml
@@ -1,6 +1,10 @@
name: 'Install Dashboards with Plugin via Binary'
-on: [push, pull_request]
+on:
+ pull_request:
+ branches:
+ - "*"
+ types: [opened, synchronize, reopened, ready_for_review]
env:
OPENSEARCH_VERSION: '3.1.0'
CI: 1
@@ -16,6 +20,7 @@ jobs:
os: [ubuntu-latest]
# TODO: add windows support when OSD core is stable on windows
runs-on: ${{ matrix.os }}
+ if: github.event.pull_request.draft == false
steps:
- name: Checkout Branch
uses: actions/checkout@v3
@@ -44,6 +49,8 @@ jobs:
- name: Run Dashboard
id: setup-dashboards
uses: derek-ho/setup-opensearch-dashboards@v1
+ env:
+ GIT_REF: 'main'
with:
plugin_name: security-analytics-dashboards-plugin
built_plugin_name: security-analytics-dashboards
diff --git a/.gitignore b/.gitignore
index d94ba8f3b..31da74abc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ yarn-error.log
/.cypress/screenshots/
/.cypress/videos/
target
+/common/schemas/*.schema.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..2fb11150f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,47 @@
+# Change Log
+
+All notable changes to the Wazuh ML Commons project will be documented in this file.
+
+## Wazuh dashboard v5.0.0 - OpenSearch Dashboards 3.6.0 - Revision 03
+
+### Added
+
+- Support for Wazuh 5.0.0
+- Added KVDBs management feature with detailed views [#24](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/24) [#30](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/30) [#37](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/37) [#46](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/46) [#63](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/63) [#82](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/82) [#140](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/140) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#205](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/205) [#206](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/206) [#208](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/208) [#214](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/214) [#241](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/241) [#287](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/287) [#326](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/326) [#351](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/351)
+- Added Decoders management feature [#27](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/27) [#30](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/30) [#37](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/37) [#41](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/41) [#46](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/46) [#48](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/48) [#63](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/63) [#74](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/74) [#140](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/140) [#155](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/155) [#168](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/168) [#169](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/169) [#171](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/171) [#175](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/175) [#182](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/182) [#185](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/185) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#205](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/205) [#206](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/206) [#208](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/208) [#214](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/214) [#247](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/247) [#271](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/271) [#287](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/287) [#289](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/289) [#326](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/326) [#351](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/351)
+- Added Integrations management feature [#40](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/40) [#48](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/48) [#62](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/62) [#63](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/63) [#64](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/64) [#71](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/71) [#72](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/72) [#76](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/76) [#77](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/77) [#81](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/81) [#83](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/83) [#84](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/84) [#85](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/85) [#86](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/86) [#88](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/88) [#89](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/89) [#90](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/90) [#98](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/98) [#100](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/100) [#106](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/106) [#129](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/129) [#136](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/136) [#138](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/138) [#157](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/157) [#159](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/159) [#161](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/161) [#178](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/178) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#205](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/205) [#208](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/208) [#214](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/214) [#236](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/236) [#288](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/288) [#292](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/292) [#301](https://github.com/wazuh/wazuh-dashboard-security-analytics/issues/301) [#320](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/320) [#360](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/360) [#361](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/361)
+- Added Log test feature [#43](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/43) [#97](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/97) [#131](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/131) [#141](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/141) [#176](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/176) [#191](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/191) [#225](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/225) [#299](https://github.com/wazuh/wazuh-dashboard-security-analytics/issues/299)
+- Added space persistence when navigate [#63](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/63)
+- Added Filters management feature [#108](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/108) [#137](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/137) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#208](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/208) [#214](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/214) [#287](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/287) [#293](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/293) [#326](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/326) [#351](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/351)
+- Added clear space action to the Overview actions button [#186](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/186)
+- Added modifiers in the dropdown inside the rule creation and edition form [#259](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/259)
+- Added URL-based rule navigation from Log Test detection results [#308](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/308)
+- Added date formatter that respects the dateFormat advanced setting [#362](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/362)
+
+### Changed
+
+- Renamed Log types to Integrations [#11](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/11)
+- Restructured Security Analytics main menu navigation [#11](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/11) [#14](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/14) [#18](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/18) [#36](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/36)
+- Hide Alerts/Correlations and Correlation rules from the Security Analytics navigation, leaving Findings at the root level [#8004](https://github.com/wazuh/wazuh-dashboard-plugins/pull/8004)
+- Changed the management of rules [#96](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/96) [#125](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/125) [#165](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/165) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#205](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/205) [#206](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/206) [#221](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/221) [#252](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/252) [#292](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/292) [#318](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/318) [#326](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/326) [#338](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/338) [#351](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/351)
+- Renamed Dectection rules to rules [#96](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/96)
+- Updated Detectors management feature [#111](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/111) [#134](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/134) [#159](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/159) [#173](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/173) [#181](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/181) [#190](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/190) [#203](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/203) [#204](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/204) [#224](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/224) [#295](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/295) [#302](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/302) [#306](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/306) [#324](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/324) [#336](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/336) [#243](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/243) [#348](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/348) [#349](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/349) [#364](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/364)
+- Rules table and details now display the real integration title. [#164](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/164) [#347](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/347)
+- Updated nav menu group label to "Security analytics" to match Wazuh Dashboard capitalization style. [#178](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/178)
+- Removed getLogTypeLabel usage across Security analytics [#353](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/353)
+
+### Removed
+
+- Removed Threat Intelligence section entirely [#11](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/11) [#20](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/20)
+- Removed Findings app from the Security Analytics plugins [#132](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/132)
+
+### Fixed
+
+- Fixed YAML Editor when creating or editing detection rules [#9](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/9)
+- Fixed detection rule editor causing blank screen [#44](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/44)
+- Fixed rule JSON viewer showing the detection field as a YAML string instead of a structured object [#315](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/315)
+- Fixed data source didn't include data stream aliases for detector creation [#116](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/116)
+- Fixed decoders form not handling request errors properly [#194](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/194)
+- Fixed float numbers ending in .0 in the Decoders yaml editor being transformed into integers [#200](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/200)
+- Fixed detector details failing to load right after detector creation [#249](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/249)
+- Fixed integration documentation field being truncated in the details view [#322](https://github.com/wazuh/wazuh-dashboard-security-analytics/pull/322)
diff --git a/SECURITY.md b/SECURITY.md
index b56fa8618..e1895a318 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,3 +1,45 @@
-## Reporting a Vulnerability
+# Wazuh Open Source Project Security Policy
-If you discover a potential security issue in this project, notify OpenSearch Security directly by emailing security@opensearch.org. To prevent any additional risk caused by the potential issue, do **not** create a public GitHub issue.
+Version: 2023-06-12
+
+## Introduction
+This document outlines the Security Policy for Wazuh's open source projects. It emphasizes our commitment to maintain a secure environment for our users and contributors, and reflects our belief in the power of collaboration to identify and resolve security vulnerabilities.
+
+## Scope
+This policy applies to all open source projects developed, maintained, or hosted by Wazuh.
+
+## Reporting Security Vulnerabilities
+If you believe you've discovered a potential security vulnerability in one of our open source projects, we strongly encourage you to report it to us responsibly.
+
+Please submit your findings as security advisories under the "Security" tab in the relevant GitHub repository. Alternatively, you may send the details of your findings to [security@wazuh.com](mailto:security@wazuh.com).
+
+## Vulnerability Disclosure Policy
+Upon receiving a report of a potential vulnerability, our team will initiate an investigation. If the reported issue is confirmed as a vulnerability, we will take the following steps:
+
+1. Acknowledgment: We will acknowledge the receipt of your vulnerability report and begin our investigation.
+2. Validation: We will validate the issue and work on reproducing it in our environment.
+3. Remediation: We will work on a fix and thoroughly test it
+4. Release & Disclosure: After 90 days from the discovery of the vulnerability, or as soon as a fix is ready and thoroughly tested (whichever comes first), we will release a security update for the affected project. We will also publicly disclose the vulnerability by publishing a CVE (Common Vulnerabilities and Exposures) and acknowledging the discovering party.
+5. Exceptions: In order to preserve the security of the Wazuh community at large, we might extend the disclosure period to allow users to patch their deployments.
+
+This 90-day period allows for end-users to update their systems and minimizes the risk of widespread exploitation of the vulnerability.
+
+## Automatic Scanning
+We leverage GitHub Actions to perform automated scans of our supply chain. These scans assist us in identifying vulnerabilities and outdated dependencies in a proactive and timely manner.
+
+## Credit
+We believe in giving credit where credit is due. If you report a security vulnerability to us, and we determine that it is a valid vulnerability, we will publicly credit you for the discovery when we disclose the vulnerability. If you wish to remain anonymous, please indicate so in your initial report.
+
+We do appreciate and encourage feedback from our community, but currently we do not have a bounty program. We might start bounty programs in the future.
+
+## Compliance with this Policy
+We consider the discovery and reporting of security vulnerabilities an important public service. We encourage responsible reporting of any vulnerabilities that may be found in our site or applications.
+
+Furthermore, we will not take legal action against or suspend or terminate access to the site or services of those who discover and report security vulnerabilities in accordance with this policy because of the fact.
+
+We ask that all users and contributors respect this policy and the security of our community's users by disclosing vulnerabilities to us in accordance with this policy.
+
+## Changes to this Security Policy
+This policy may be revised from time to time. Each version of the policy will be identified at the top of the page by its effective date.
+
+If you have any questions about this Security Policy, please contact us at [security@wazuh.com](mailto:security@wazuh.com)
\ No newline at end of file
diff --git a/VERSION.json b/VERSION.json
new file mode 100644
index 000000000..a57868ae7
--- /dev/null
+++ b/VERSION.json
@@ -0,0 +1,4 @@
+{
+ "version": "5.0.0",
+ "stage": "beta3"
+}
diff --git a/common/constants.ts b/common/constants.ts
index 0c0e452e9..c2d30c726 100644
--- a/common/constants.ts
+++ b/common/constants.ts
@@ -3,10 +3,120 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { wazuh } from '../package.json';
+import { IntegrationDocumentCreate } from '../types';
+import { i18n } from '@osd/i18n';
+import { PromoteSpaces } from '../types';
+
export const DEFAULT_RULE_UUID = '25b9c01c-350d-4b95-bed1-836d04a4f324';
+export const WAZUH_VERSION = wazuh.version;
+
+export const PLUGIN_VERSION_SHORT = WAZUH_VERSION.split('.').splice(0, 2).join('.');
+
export enum ThreatIntelIocSourceType {
S3_CUSTOM = 'S3_CUSTOM',
IOC_UPLOAD = 'IOC_UPLOAD',
URL_DOWNLOAD = 'URL_DOWNLOAD',
}
+
+export const SpaceTypes = {
+ DRAFT: {
+ label: i18n.translate('securityAnalytics.spaceTypes.draftLabel', {
+ defaultMessage: 'Draft',
+ }),
+ value: 'draft',
+ description: i18n.translate('securityAnalytics.spaceTypes.draftDescription', {
+ defaultMessage: 'Staging area for creating or editing resources before testing.',
+ }),
+ },
+ TEST: {
+ label: i18n.translate('securityAnalytics.spaceTypes.testLabel', {
+ defaultMessage: 'Test',
+ }),
+ value: 'test',
+ description: i18n.translate('securityAnalytics.spaceTypes.testDescription', {
+ defaultMessage: 'Controlled environment for validation before production.',
+ }),
+ },
+ CUSTOM: {
+ label: i18n.translate('securityAnalytics.spaceTypes.customLabel', {
+ defaultMessage: 'Custom',
+ }),
+ value: 'custom',
+ description: i18n.translate('securityAnalytics.spaceTypes.customDescription', {
+ defaultMessage: 'Independent space for custom or modified content.',
+ }),
+ },
+ STANDARD: {
+ label: i18n.translate('securityAnalytics.spaceTypes.standardLabel', {
+ defaultMessage: 'Standard',
+ }),
+ value: 'standard',
+ description: i18n.translate('securityAnalytics.spaceTypes.standardDescription', {
+ defaultMessage: 'Wazuh CTI provided resources.',
+ }),
+ },
+} as const;
+
+export const SPACE_SELECTOR_LABEL = i18n.translate('securityAnalytics.spaceSelector.label', {
+ defaultMessage: 'Space:',
+});
+
+export const SPACE_ACTIONS = {
+ CREATE: 'create',
+ EDIT: 'edit',
+ DELETE: 'delete',
+ PROMOTE: 'promote',
+ DEFINE_ROOT_DECODER: 'define_root_decoder',
+ REARRANGE_INTEGRATIONS: 'rearrange_integrations',
+ EDIT_POLICY: 'edit_policy',
+ EDIT_POLICY_INDEXING_SETTINGS: 'edit_policy_indexing_settings',
+ EDIT_POLICY_ENRICHMENTS: 'edit_enrichments',
+ CLEAR_SPACE: 'clear_space',
+};
+
+export const AllowedActionsBySpace = {
+ [SpaceTypes.DRAFT.value]: [
+ SPACE_ACTIONS.CREATE,
+ SPACE_ACTIONS.EDIT,
+ SPACE_ACTIONS.DELETE,
+ SPACE_ACTIONS.PROMOTE,
+ SPACE_ACTIONS.DEFINE_ROOT_DECODER,
+ SPACE_ACTIONS.REARRANGE_INTEGRATIONS,
+ SPACE_ACTIONS.EDIT_POLICY,
+ SPACE_ACTIONS.EDIT_POLICY_INDEXING_SETTINGS,
+ SPACE_ACTIONS.EDIT_POLICY_ENRICHMENTS,
+ SPACE_ACTIONS.CLEAR_SPACE,
+ ],
+ [SpaceTypes.TEST.value]: [SPACE_ACTIONS.PROMOTE],
+ [SpaceTypes.CUSTOM.value]: [],
+ [SpaceTypes.STANDARD.value]: [
+ SPACE_ACTIONS.EDIT_POLICY_ENRICHMENTS,
+ SPACE_ACTIONS.EDIT_POLICY_INDEXING_SETTINGS,
+ ],
+};
+
+export const FiltersAllowedActionsBySpace = {
+ [SpaceTypes.DRAFT.value]: [SPACE_ACTIONS.CREATE, SPACE_ACTIONS.EDIT, SPACE_ACTIONS.DELETE],
+ [SpaceTypes.TEST.value]: [],
+ [SpaceTypes.CUSTOM.value]: [],
+ [SpaceTypes.STANDARD.value]: [SPACE_ACTIONS.CREATE, SPACE_ACTIONS.EDIT, SPACE_ACTIONS.DELETE],
+};
+
+export const UserSpacesOrder: PromoteSpaces[] = [
+ SpaceTypes.DRAFT.value,
+ SpaceTypes.TEST.value,
+ SpaceTypes.CUSTOM.value,
+];
+
+export const defaultIntegration: { document: IntegrationDocumentCreate } = {
+ document: {
+ title: '',
+ description: '',
+ documentation: '',
+ tags: null,
+ category: '',
+ author: '',
+ },
+};
diff --git a/common/helpers.ts b/common/helpers.ts
index b68194522..03aa48b06 100644
--- a/common/helpers.ts
+++ b/common/helpers.ts
@@ -5,9 +5,11 @@
import _ from 'lodash';
import { DEFAULT_METRICS_COUNTER } from '../server/utils/constants';
-import { MetricsCounter, PartialMetricsCounter } from '../types';
+import { MetricsCounter, PartialMetricsCounter, PromoteSpaces, Space } from '../types';
import { SecurityAnalyticsPluginConfigType } from '../config';
import { Get, Set } from '../../../src/plugins/opensearch_dashboards_utils/common';
+// Wazuh
+import { AllowedActionsBySpace, SpaceTypes, UserSpacesOrder } from './constants';
export function aggregateMetrics(
metrics: PartialMetricsCounter,
@@ -76,3 +78,35 @@ export function createNullableGetterSetter(): [Get, Set] {
return [get, set];
}
+
+// Wazuh
+export function actionIsAllowedOnSpace(
+ space: Space,
+ action: string,
+ allowedActionsBySpace = AllowedActionsBySpace
+): Boolean {
+ return allowedActionsBySpace?.[SpaceTypes[space.toUpperCase()]?.value]?.includes(action);
+}
+
+export function getSpacesAllowAction(
+ action: string,
+ allowedActionsBySpace = AllowedActionsBySpace
+): Space[] {
+ return Object.entries(allowedActionsBySpace)
+ .filter(([_, allowedActions]) => allowedActions.includes(action))
+ .map(([space]) => space) as Space[];
+}
+
+/** Localized label for a space value (from {@link SpaceTypes}). */
+export function getSpaceTypeLabel(space: Space): string {
+ const key = space.toUpperCase() as keyof typeof SpaceTypes;
+ return SpaceTypes[key]?.label ?? space;
+}
+
+export const getNextSpace = (space: PromoteSpaces) => {
+ const currentIndex = UserSpacesOrder.indexOf(space);
+ if (currentIndex === -1 || currentIndex === UserSpacesOrder.length - 1) {
+ return null; // No next space available
+ }
+ return UserSpacesOrder[currentIndex + 1];
+};
diff --git a/package.json b/package.json
index 1bdcff2d6..1fc10a061 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,14 @@
"main": "index.js",
"license": "Apache-2.0",
"homepage": "https://github.com/opensearch-project/security-analytics-dashboards-plugin",
+ "opensearchDashboards": {
+ "version": "3.6.0",
+ "templateVersion": "3.6.0"
+ },
+ "wazuh": {
+ "version": "5.0.0",
+ "revision": "03"
+ },
"config": {
"id": "securityAnalyticsDashboards",
"zip_name": "security-analytics-dashboards"
@@ -23,9 +31,11 @@
"test:jest": "../../node_modules/.bin/jest --config ./test/jest.config.js",
"test:jest:dev": "../../node_modules/.bin/jest --watch --config ./test/jest.config.js",
"test:jest:update-snapshots": "yarn run test:jest -u",
+ "generate:schemas": "if [ -f scripts/build-tools/update-engine-schemas ]; then bash scripts/build-tools/update-engine-schemas; else echo 'File does not exist. This could be related to the script file not being included at build time. Skipping...'; fi",
+ "prebuild": "yarn generate:schemas",
"build": "yarn plugin-helpers build",
"postbuild": "echo Renaming build artifact to [$npm_package_config_zip_name-$npm_package_version.zip] && mv build/$npm_package_config_id*.zip build/$npm_package_config_zip_name-$npm_package_version.zip",
- "postinstall": "node -e \"const { execSync } = require('child_process');const { INIT_CWD, PWD = process.cwd() } = process.env; if (INIT_CWD?.startsWith?.(PWD)) {try {execSync('husky install');} catch (e) {}}\""
+ "postinstall": "node -e \"const { execSync } = require('child_process');const { INIT_CWD, PWD = process.cwd() } = process.env; if (INIT_CWD?.startsWith?.(PWD)) {try {execSync('husky install');} catch (e) {}}\" && yarn run generate:schemas"
},
"resolutions": {
"@types/react": "^18.2.0",
@@ -94,9 +104,12 @@
"yarn": "^1.21.1"
},
"dependencies": {
+ "ajv": "^8.18.0",
"chart.js": "^4.4.8",
"chartjs-adapter-moment": "^1.0.1",
"formik": "^2.2.6",
- "react-graph-vis": "^1.0.7"
+ "lossless-json": "4.3.0",
+ "react-graph-vis": "^1.0.7",
+ "yaml": "2.8.3"
}
}
diff --git a/public/app.scss b/public/app.scss
index 51faa952d..180994bab 100644
--- a/public/app.scss
+++ b/public/app.scss
@@ -22,6 +22,7 @@ $euiTextColor: $euiColorDarkestShade !default;
@import "./pages/Rules/components/RuleEditor/DetectionVisualEditor.scss";
@import "./pages/Rules/components/RuleEditor/components/SelectionExpField.scss";
@import "./pages/ThreatIntel/containers/AddThreatIntelSource/AddThreatIntelSource.scss";
+@import "./pages/Integrations/containers/IntegrationSummaryPanel.scss";
.selected-radio-panel {
background-color: tintOrShade($euiColorPrimary, 90%, 70%);
diff --git a/public/components/DeleteModal/DeleteModal.tsx b/public/components/DeleteModal/DeleteModal.tsx
index 07415ac7b..d37dc1fa8 100644
--- a/public/components/DeleteModal/DeleteModal.tsx
+++ b/public/components/DeleteModal/DeleteModal.tsx
@@ -58,7 +58,11 @@ export default class DeleteModal extends Component
`Delete ${type}` }
+ title={
+
+ Delete {ids}
+
+ }
onCancel={closeDeleteModal}
onConfirm={() => {
onClickDelete();
@@ -76,7 +80,9 @@ export default class DeleteModal extends Component
{!!confirmation && (
-
+
- \`Delete $
- some type
- \`
+ Delete
+ some ids
diff --git a/public/components/FormFieldArray/FormFieldArray.tsx b/public/components/FormFieldArray/FormFieldArray.tsx
new file mode 100644
index 000000000..7a2557b62
--- /dev/null
+++ b/public/components/FormFieldArray/FormFieldArray.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiSmallButton,
+ EuiSmallButtonIcon,
+ EuiCompressedFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiCompressedFormRow,
+ EuiSpacer,
+ EuiToolTip,
+} from '@elastic/eui';
+import React, { ChangeEvent } from 'react';
+
+export interface FormFieldArrayProps {
+ label: string | React.ReactNode;
+ values: string[];
+ onChange: (values: string[]) => void;
+ placeholder?: string;
+ readOnly?: boolean;
+ addButtonLabel?: string;
+}
+
+export const FormFieldArray: React.FC = ({
+ label,
+ values,
+ onChange,
+ placeholder = '',
+ readOnly = false,
+ addButtonLabel = 'Add item',
+}) => {
+ return (
+ <>
+
+ <>
+ {values.map((value: string, index: number) => {
+ return (
+
+
+ ) => {
+ let newValues = [...values];
+ newValues[index] = e.target.value;
+ onChange(newValues);
+ }}
+ />
+
+ {!readOnly ? (
+
+
+ {
+ let newValues = [...values];
+ newValues.splice(index, 1);
+ onChange(newValues);
+ }}
+ />
+
+
+ ) : null}
+
+ );
+ })}
+ {values.length > 0 && }
+ {!readOnly && (
+ {
+ onChange([...values, '']);
+ }}
+ >
+ {addButtonLabel}
+
+ )}
+ >
+
+
+ >
+ );
+};
diff --git a/public/components/FormFieldArray/index.ts b/public/components/FormFieldArray/index.ts
new file mode 100644
index 000000000..8c59d67c8
--- /dev/null
+++ b/public/components/FormFieldArray/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export { FormFieldArray } from './FormFieldArray';
+export type { FormFieldArrayProps } from './FormFieldArray';
diff --git a/public/components/IntegrationComboBox/IntegrationComboBox.tsx b/public/components/IntegrationComboBox/IntegrationComboBox.tsx
new file mode 100644
index 000000000..d9e766c8f
--- /dev/null
+++ b/public/components/IntegrationComboBox/IntegrationComboBox.tsx
@@ -0,0 +1,157 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiButtonEmpty,
+ EuiCallOut,
+ EuiCompressedComboBox,
+ EuiCompressedFormRow,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+} from '@elastic/eui';
+import React, { useState } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import FormFieldHeader from '../FormFieldHeader';
+import { IntegrationOption } from './useIntegrationSelector';
+import { CreateIntegrationFlyout } from '../../pages/Integrations/components/CreateIntegrationFlyout';
+
+interface IntegrationComboBoxProps {
+ options: IntegrationOption[];
+ selectedId: string;
+ isLoading: boolean;
+ onChange: (options: IntegrationOption[]) => void;
+ resourceName: string;
+ /** Required to enable the inline create-integration flyout */
+ notifications?: NotificationsStart;
+ /** Called after a new integration is successfully created via the flyout */
+ onCreateSuccess?: (newOption: IntegrationOption) => void;
+ 'data-test-subj'?: string;
+ isInvalid?: boolean;
+ error?: string;
+ space?: string;
+}
+
+export const IntegrationComboBox: React.FC = ({
+ options,
+ selectedId,
+ isLoading,
+ onChange,
+ resourceName,
+ notifications,
+ onCreateSuccess,
+ 'data-test-subj': dataTestSubj,
+ isInvalid,
+ error,
+ space = 'draft',
+}) => {
+ const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
+ const selectedOption = options.find((o) => o.id === selectedId);
+
+ const handleFlyoutSuccess = (id: string, title: string) => {
+ setIsFlyoutOpen(false);
+ const newOption: IntegrationOption = { id, value: title, label: title };
+ onCreateSuccess?.(newOption);
+ };
+
+ return (
+ <>
+
+
+
+
+ }
+ isInvalid={isInvalid}
+ error={error}
+ >
+ {notifications ? (
+
+
+
+
+
+ setIsFlyoutOpen(true)}
+ >
+ Create integration
+
+
+
+ ) : (
+
+ )}
+
+
+ {!isLoading && options.length === 0 && (
+ <>
+
+
+
+ There are no integrations in {space} space available to add {resourceName}. Please
+ create an integration first before adding {resourceName}.
+
+
+ >
+ )}
+
+ {isFlyoutOpen && notifications && (
+ setIsFlyoutOpen(false)}
+ onSuccess={handleFlyoutSuccess}
+ />
+ )}
+ >
+ );
+};
diff --git a/public/components/IntegrationComboBox/index.ts b/public/components/IntegrationComboBox/index.ts
new file mode 100644
index 000000000..0f0309259
--- /dev/null
+++ b/public/components/IntegrationComboBox/index.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export { IntegrationComboBox } from './IntegrationComboBox';
+export { useIntegrationSelector, IntegrationOption } from './useIntegrationSelector';
diff --git a/public/components/IntegrationComboBox/useIntegrationSelector.ts b/public/components/IntegrationComboBox/useIntegrationSelector.ts
new file mode 100644
index 000000000..69233312f
--- /dev/null
+++ b/public/components/IntegrationComboBox/useIntegrationSelector.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { DataStore } from '../../store/DataStore';
+import { errorNotificationToast } from '../../utils/helpers';
+
+export interface IntegrationOption {
+ value: string;
+ label: string;
+ id: string;
+}
+
+interface UseIntegrationSelectorParams {
+ notifications: NotificationsStart;
+ enabled?: boolean;
+}
+
+export function useIntegrationSelector({
+ notifications,
+ enabled = true,
+}: UseIntegrationSelectorParams) {
+ const [loading, setLoading] = useState(false);
+ const [options, setOptions] = useState([]);
+ const [refreshCount, setRefreshCount] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ let cancelled = false;
+ setLoading(true);
+
+ DataStore.decoders
+ .getDraftIntegrations()
+ .then((result) => {
+ if (!cancelled) {
+ setOptions(
+ result.map((option: any) => ({
+ value: String(option?._source?.document?.metadata?.title ?? ''),
+ label: String(option?._source?.document?.metadata?.title ?? ''),
+ id: String(option?._id ?? ''),
+ }))
+ );
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ errorNotificationToast(
+ notifications,
+ 'retrieve',
+ 'integration types',
+ 'There was an error retrieving the integration types.'
+ );
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [notifications, enabled, refreshCount]);
+
+ const refresh = useCallback(() => setRefreshCount((c) => c + 1), []);
+
+ return { loading, options, refresh };
+}
diff --git a/public/components/MDS/DataSourceMenuWrapper.tsx b/public/components/MDS/DataSourceMenuWrapper.tsx
index 69ddc8419..93d7302e1 100644
--- a/public/components/MDS/DataSourceMenuWrapper.tsx
+++ b/public/components/MDS/DataSourceMenuWrapper.tsx
@@ -46,33 +46,41 @@ export const DataSourceMenuWrapper: React.FC = ({
>();
const readonlyDataSourcePaths = [
- ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS,
+ // Wazuh: hide Alert triggers edit route.
+ // ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS,
ROUTES.EDIT_DETECTOR_DETAILS,
ROUTES.EDIT_DETECTOR_RULES,
ROUTES.EDIT_FIELD_MAPPINGS,
ROUTES.RULES_EDIT,
- ROUTES.CORRELATION_RULE_EDIT,
+ // Wazuh: hide Correlation rules edit route.
+ // ROUTES.CORRELATION_RULE_EDIT,
ROUTES.DETECTOR_DETAILS,
`${ROUTES.LOG_TYPES}/:logTypeId`,
- `${ROUTES.ALERTS}/:detectorId`,
+ // Wazuh: hide Alerts route.
+ // `${ROUTES.ALERTS}/:detectorId`,
`${ROUTES.FINDINGS}/:detectorId`,
`${ROUTES.THREAT_INTEL_SOURCE_DETAILS}/:sourceId`,
ROUTES.THREAT_INTEL_EDIT_SCAN_CONFIG,
+ ROUTES.DECODERS_CREATE,
];
const pathToParentMap = {
- [ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS]: ROUTES.DETECTORS,
+ // Wazuh: hide Alert triggers edit route.
+ // [ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS]: ROUTES.DETECTORS,
[ROUTES.EDIT_DETECTOR_DETAILS]: ROUTES.DETECTORS,
[ROUTES.EDIT_DETECTOR_RULES]: ROUTES.DETECTORS,
[ROUTES.EDIT_FIELD_MAPPINGS]: ROUTES.DETECTORS,
[ROUTES.RULES_EDIT]: ROUTES.RULES,
- [ROUTES.CORRELATION_RULE_EDIT]: ROUTES.CORRELATION_RULES,
+ // Wazuh: hide Correlation rules edit route.
+ // [ROUTES.CORRELATION_RULE_EDIT]: ROUTES.CORRELATION_RULES,
[ROUTES.DETECTOR_DETAILS]: ROUTES.DETECTORS,
[`${ROUTES.LOG_TYPES}/:logTypeId`]: ROUTES.LOG_TYPES,
- [`${ROUTES.ALERTS}/:detectorId`]: ROUTES.ALERTS,
+ // Wazuh: hide Alerts route.
+ // [`${ROUTES.ALERTS}/:detectorId`]: ROUTES.ALERTS,
[`${ROUTES.FINDINGS}/:detectorId`]: ROUTES.FINDINGS,
[`${ROUTES.THREAT_INTEL_SOURCE_DETAILS}/:sourceId`]: ROUTES.THREAT_INTEL_OVERVIEW,
[ROUTES.THREAT_INTEL_EDIT_SCAN_CONFIG]: ROUTES.THREAT_INTEL_OVERVIEW,
+ [ROUTES.DECODERS_CREATE]: ROUTES.DECODERS,
};
const matchedPath = matchPath(location.pathname, {
@@ -114,7 +122,7 @@ export const DataSourceMenuWrapper: React.FC = ({
}}
/>
{
return dataSourceMenuReadOnly ? (
= ({
path={[
ROUTES.OVERVIEW,
ROUTES.DETECTORS,
- ROUTES.ALERTS,
+ // Wazuh: hide Alerts route.
+ // ROUTES.ALERTS,
ROUTES.FINDINGS,
ROUTES.LOG_TYPES,
ROUTES.RULES,
- ROUTES.CORRELATIONS,
- ROUTES.CORRELATION_RULES,
+ // Wazuh: hide Correlations routes.
+ // ROUTES.CORRELATIONS,
+ // ROUTES.CORRELATION_RULES,
ROUTES.RULES_CREATE,
ROUTES.RULES_IMPORT,
ROUTES.RULES_DUPLICATE,
ROUTES.LOG_TYPES_CREATE,
- ROUTES.CORRELATION_RULE_CREATE,
+ // Wazuh: hide Correlation rules create route.
+ // ROUTES.CORRELATION_RULE_CREATE,
ROUTES.GETTING_STARTED,
ROUTES.ROOT,
]}
diff --git a/public/components/SpaceSelector/SpaceSelector.tsx b/public/components/SpaceSelector/SpaceSelector.tsx
new file mode 100644
index 000000000..54fbf6c96
--- /dev/null
+++ b/public/components/SpaceSelector/SpaceSelector.tsx
@@ -0,0 +1,105 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState } from "react";
+import {
+ EuiButtonGroup,
+ EuiButtonIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiPopover,
+ EuiSpacer,
+ EuiText,
+} from "@elastic/eui";
+import {
+ PLUGIN_VERSION_SHORT,
+ SPACE_SELECTOR_LABEL,
+ SpaceTypes,
+} from "../../../common/constants";
+
+interface SpaceSelectorProps {
+ selectedSpace: string;
+ onSpaceChange: (spaceId: string) => void;
+ isDisabled?: boolean;
+ documentationUrl?: string;
+ allowedSpaces?: string[];
+}
+
+export const SpaceSelector: React.FC = ({
+ selectedSpace,
+ onSpaceChange,
+ isDisabled = false,
+ documentationUrl = `https://documentation.wazuh.com/${PLUGIN_VERSION_SHORT}/user-manual/kvdbs/spaces.html`,
+ allowedSpaces,
+}) => {
+ const visibleSpaceTypes = allowedSpaces
+ ? Object.values(SpaceTypes).filter((st) => allowedSpaces.includes(st.value))
+ : Object.values(SpaceTypes);
+ const [infoPopoverOpen, setInfoPopoverOpen] = useState(false);
+
+ return (
+
+
+
+ {SPACE_SELECTOR_LABEL}
+
+
+
+ ({
+ id: spaceType.value,
+ label: spaceType.label,
+ }))}
+ idSelected={selectedSpace}
+ onChange={onSpaceChange}
+ isDisabled={isDisabled}
+ />
+
+
+ setInfoPopoverOpen(!infoPopoverOpen)}
+ color="primary"
+ />
+ }
+ isOpen={infoPopoverOpen}
+ closePopover={() => setInfoPopoverOpen(false)}
+ anchorPosition="downRight"
+ >
+
+
+ Spaces
+
+
+ {visibleSpaceTypes.map((spaceType) => (
+
+
+
+ {spaceType.label}: {spaceType.description}
+
+
+
+
+ ))}
+
+
+
+ Learn more in the documentation
+
+
+
+
+
+
+
+ );
+};
diff --git a/public/components/SpaceSelector/index.ts b/public/components/SpaceSelector/index.ts
new file mode 100644
index 000000000..328cf3990
--- /dev/null
+++ b/public/components/SpaceSelector/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+export { SpaceSelector } from './SpaceSelector';
diff --git a/public/components/Utility/BadgeGroup.tsx b/public/components/Utility/BadgeGroup.tsx
new file mode 100644
index 000000000..d17d11759
--- /dev/null
+++ b/public/components/Utility/BadgeGroup.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
+import { DEFAULT_EMPTY_DATA } from '../../utils/constants';
+
+interface BadgeGroupProps {
+ label?: string;
+ values: string[] | React.ReactNode;
+ emptyValue?: React.ReactNode;
+}
+
+export const BadgeGroup: React.FC = ({ label, values, emptyValue = null }) => {
+ if (!values?.length) return emptyValue;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+ {values?.length ? (
+ values.map((v, i) => (
+
+ {v}
+
+ ))
+ ) : (
+ {DEFAULT_EMPTY_DATA}
+ )}
+
+
+ );
+};
diff --git a/public/components/Utility/EnabledHealth.tsx b/public/components/Utility/EnabledHealth.tsx
new file mode 100644
index 000000000..dd48e0fc8
--- /dev/null
+++ b/public/components/Utility/EnabledHealth.tsx
@@ -0,0 +1,23 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { EuiHealth } from '@elastic/eui';
+
+interface EnabledHealthProps {
+ enabled: boolean | undefined;
+ 'data-test-subj'?: string;
+}
+
+export const EnabledHealth: React.FC = ({
+ enabled,
+ 'data-test-subj': testSubj,
+}) => (
+
+
+ {enabled !== false ? 'Enabled' : 'Disabled'}
+
+
+);
diff --git a/public/components/Utility/Metadata.tsx b/public/components/Utility/Metadata.tsx
new file mode 100644
index 000000000..18217ac58
--- /dev/null
+++ b/public/components/Utility/Metadata.tsx
@@ -0,0 +1,86 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { EuiLink } from '@elastic/eui';
+import moment from 'moment';
+import { DEFAULT_EMPTY_DATA } from '../../utils/constants';
+import { formatUIDate } from '../../utils/dateFormat';
+
+export type MetadataFieldType = 'raw' | 'text' | 'number' | 'date' | 'url' | 'boolean' | 'boolean_yesno';
+
+export const MetadataFieldRaw: React.FC<{ value: string | number }> = ({ value }) => {
+ return <>{value}>;
+};
+
+export const MetadataFieldText: React.FC<{ value: string | number }> = ({ value }) => {
+ return {String(value)}
;
+};
+
+export const MetadataFieldURL: React.FC<{ value: string | number }> = ({ value }) => {
+ const url = String(value);
+ return value ? (
+
+ {url}
+
+ ) : (
+ DEFAULT_EMPTY_DATA
+ );
+};
+
+export const MetadataFieldBoolean: React.FC<{ value: string | number }> = ({ value }) => {
+ return <>{String(value)}>;
+};
+
+export const MetadataFieldBooleanAsYesNo: React.FC<{
+ value: string | number;
+}> = ({ value }) => {
+ return <>{value ? 'Yes' : 'No'}>;
+};
+
+export const MetadataFieldDate: React.FC<{ value: string | number }> = ({ value }) => {
+ if (!value) {
+ return <>{DEFAULT_EMPTY_DATA}>;
+ }
+ return <>{moment(value).isValid() ? formatUIDate(value) : String(value)}>;
+};
+
+const mapFieldRenderers: {
+ [key in MetadataFieldType]: React.FC<{ value: any }>;
+} = {
+ text: MetadataFieldText,
+ boolean: MetadataFieldBoolean,
+ boolean_yesno: MetadataFieldBooleanAsYesNo,
+ number: MetadataFieldText,
+ date: MetadataFieldDate,
+ url: MetadataFieldURL,
+ raw: MetadataFieldRaw
+};
+
+export const Metadata: React.FC<{
+ type?: MetadataFieldType;
+ value: string | number;
+ label?: React.ReactNode;
+}> = ({ value, label, type = 'text' }) => {
+ return (
+
+
+ {label}
+
+
+ {typeof value === 'undefined' ||
+ value === null ||
+ value === '' ||
+ (Array.isArray(value) && value.length === 0)
+ ? DEFAULT_EMPTY_DATA
+ : Array.isArray(value)
+ ? value.map((v, i) => (
+
{mapFieldRenderers[type]({ value: v })}
+ ))
+ : mapFieldRenderers[type]({ value })}
+
+
+ );
+};
diff --git a/public/components/YamlForm/components/YamlForm.tsx b/public/components/YamlForm/components/YamlForm.tsx
new file mode 100644
index 000000000..ab221f758
--- /dev/null
+++ b/public/components/YamlForm/components/YamlForm.tsx
@@ -0,0 +1,140 @@
+import React, { useRef, useState } from 'react';
+import { EuiCompressedFormRow, EuiCodeEditor, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui';
+import FormFieldHeader from '../../FormFieldHeader';
+import { YamlEditorState, YAML_TYPE } from '../utils/constants';
+
+interface YamlFormProps {
+ type: YAML_TYPE;
+ value: string;
+ change: React.Dispatch;
+ isInvalid: boolean;
+ errors?: string[];
+ parseDebounceMs?: number;
+ onErrors?: (errors: string[] | null) => void;
+ errorSeverity?: ERROR_SEVERITY;
+}
+
+export enum ERROR_SEVERITY {
+ WARNING = 'warning',
+ ERROR = 'error',
+}
+
+const ERROR_COLOR_MAP: Record = {
+ [ERROR_SEVERITY.WARNING]: 'warning',
+ [ERROR_SEVERITY.ERROR]: 'danger',
+};
+
+export const YamlForm: React.FC = ({
+ type,
+ value,
+ change,
+ isInvalid,
+ errors,
+ parseDebounceMs = 500,
+ onErrors,
+ errorSeverity = ERROR_SEVERITY.ERROR,
+}) => {
+ const [state, setState] = useState({
+ errors: null,
+ value: value ?? '',
+ });
+
+ const isFocusedRef = useRef(false);
+
+ const timerRef = useRef(null);
+
+ const onFocus = () => {
+ isFocusedRef.current = true;
+ };
+
+ const tryParseAndNotify = (value: string) => {
+ if (!value || value.trim() === '') {
+ const localErrors = [`${type} cannot be empty`];
+ setState((prev) => ({ ...prev, errors: localErrors }));
+ onErrors?.(localErrors);
+ return;
+ }
+ try {
+ change(value);
+ setState((prev) => ({ ...prev, errors: null }));
+ onErrors?.(null);
+ } catch (err) {
+ const localErrors = ['Invalid YAML'];
+ setState((prev) => ({ ...prev, errors: localErrors }));
+ onErrors?.(localErrors);
+ console.warn(`Security Analytics - ${type} Editor - Yaml load`, err);
+ }
+ };
+
+ const onChangeYaml = (value: string) => {
+ setState((prev) => ({ ...prev, value }));
+ // debounce parse
+ if (timerRef.current) {
+ window.clearTimeout(timerRef.current);
+ }
+ timerRef.current = window.setTimeout(() => {
+ tryParseAndNotify(value);
+ }, parseDebounceMs);
+ };
+
+ const renderErrors = () => {
+ if (state.errors && state.errors.length > 0) {
+ return (
+
+
+ {state.errors.map((error, i) => (
+ {error}
+ ))}
+
+
+ );
+ } else if (isInvalid && errors && errors.length > 0) {
+ return (
+
+
+ {errors.map((error, i) => (
+ {error}
+ ))}
+
+
+ );
+ } else {
+ return null;
+ }
+ };
+
+ return (
+ <>
+ {renderErrors()}
+
+ }
+ fullWidth={true}
+ >
+ <>
+
+
+ Use the YAML editor to define a custom {type}.
+
+
+
+ >
+
+ >
+ );
+};
diff --git a/public/components/YamlForm/index.ts b/public/components/YamlForm/index.ts
new file mode 100644
index 000000000..1396b9711
--- /dev/null
+++ b/public/components/YamlForm/index.ts
@@ -0,0 +1,8 @@
+export {
+ mapYamlToLosslessObject,
+ stringToYamlNode,
+ validateYamlSyntax,
+ normalizeToStringArray,
+} from './utils/helpers';
+export { YamlForm, ERROR_SEVERITY } from './components/YamlForm';
+export { YAML_TYPE, YamlEditorState } from './utils/constants';
diff --git a/public/components/YamlForm/utils/constants.ts b/public/components/YamlForm/utils/constants.ts
new file mode 100644
index 000000000..8ab8a27cb
--- /dev/null
+++ b/public/components/YamlForm/utils/constants.ts
@@ -0,0 +1,10 @@
+export interface YamlEditorState {
+ errors: string[] | null;
+ value?: string;
+}
+
+export enum YAML_TYPE {
+ DECODER = 'Decoder',
+ FILTER = 'Filter',
+ KVDB = 'KVDB',
+}
diff --git a/public/components/YamlForm/utils/helpers.ts b/public/components/YamlForm/utils/helpers.ts
new file mode 100644
index 000000000..969937e61
--- /dev/null
+++ b/public/components/YamlForm/utils/helpers.ts
@@ -0,0 +1,96 @@
+import YAML, { Pair, Scalar, YAMLMap, YAMLSeq } from 'yaml';
+import { LosslessNumber } from 'lossless-json';
+
+/**
+ * Converts a raw user input string into a YAML AST node with proper type metadata.
+ * Preserves float/int/bool/null representations that would otherwise be lost when
+ * passing through JS native types (e.g. "5.0" to !!float 5.0, not int 5).
+ * JSON objects/arrays are parsed as YAML (JSON is valid YAML) and forced to block style.
+ */
+export const stringToYamlNode = (rawValue: string): Scalar | YAMLMap | YAMLSeq => {
+ const trimmed = rawValue.trim();
+ if (trimmed && (trimmed[0] === '{' || trimmed[0] === '[')) {
+ try {
+ const parsed = YAML.parseDocument(trimmed);
+ if (!parsed.errors.length && parsed.contents) {
+ const node = parsed.contents as YAMLMap | YAMLSeq;
+ YAML.visit(node, {
+ Map(_, n) {
+ n.flow = false;
+ },
+ Seq(_, n) {
+ n.flow = false;
+ },
+ Pair(_, n) {
+ if (
+ n.key instanceof Scalar &&
+ (n.key.type === 'QUOTE_DOUBLE' || n.key.type === 'QUOTE_SINGLE')
+ ) {
+ n.key.type = null;
+ }
+ },
+ });
+ return node;
+ }
+ } catch {
+ // not valid YAML
+ }
+ }
+ try {
+ return (YAML.parseDocument(trimmed).contents ?? new Scalar(trimmed)) as
+ | Scalar
+ | YAMLMap
+ | YAMLSeq;
+ } catch {
+ return new Scalar(trimmed);
+ }
+};
+
+export const mapYamlToLosslessObject = (yamlString: string): T => {
+ const yamlObject = YAML.parseDocument(yamlString);
+
+ YAML.visit(yamlObject, {
+ Scalar(_, node) {
+ if (typeof node.value === 'number') {
+ let rawText;
+
+ if (node.range && node.range.length >= 2) {
+ rawText = yamlString.slice(node.range[0], node.range[1]).trim();
+ }
+
+ if (!rawText) {
+ rawText = String(node.value);
+ if (!rawText.includes('.')) rawText += '.0';
+ }
+
+ node.value = new LosslessNumber(rawText);
+ }
+ },
+ });
+
+ return yamlObject.toJS() as T;
+};
+
+/**
+ * Normalizes metadata string arrays from lossless-parsed YAML.
+ * Handles: undefined β [], bare string β [string], and converts any
+ * LosslessNumber or other non-string items to their string representation.
+ */
+export const normalizeToStringArray = (value: unknown[] | string | undefined): string[] => {
+ if (!value) return [];
+ const arr = Array.isArray(value) ? value : [value];
+ return arr.map((item) => {
+ if (typeof item === 'string') return item;
+ if (item instanceof LosslessNumber) return item.toString();
+ return String(item ?? '');
+ });
+};
+
+export const validateYamlSyntax = (yaml: string): string | null => {
+ try {
+ YAML.parse(yaml);
+ return null;
+ } catch (e) {
+ return e instanceof Error ? e.message.split('\n')[0] : 'Invalid YAML syntax';
+ }
+};
diff --git a/public/hooks/useDeleteItems.ts b/public/hooks/useDeleteItems.ts
new file mode 100644
index 000000000..68896299f
--- /dev/null
+++ b/public/hooks/useDeleteItems.ts
@@ -0,0 +1,103 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { MutableRefObject, useCallback, useRef, useState } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { successNotificationToast } from '../utils/helpers';
+
+export const DELETE_ACTION = 'delete' as const;
+export const DELETE_SELECTED_ACTION = 'delete_selected' as const;
+
+export type DeleteItemForAction =
+ | { action: typeof DELETE_ACTION; id: string }
+ | { action: typeof DELETE_SELECTED_ACTION };
+
+interface UseDeleteItemsOptions {
+ deleteOne: (id: string) => Promise;
+ reload: () => Promise | void;
+ notifications: NotificationsStart;
+ entityName: string;
+ entityNamePlural: string;
+ isMountedRef: MutableRefObject;
+}
+
+export function useDeleteItems({
+ deleteOne,
+ reload,
+ notifications,
+ entityName,
+ entityNamePlural,
+ isMountedRef,
+}: UseDeleteItemsOptions) {
+ const deleteOneRef = useRef(deleteOne);
+ const reloadRef = useRef(reload);
+ deleteOneRef.current = deleteOne;
+ reloadRef.current = reload;
+
+ const [itemForAction, setItemForAction] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const confirmDeleteSingle = useCallback(async () => {
+ if (itemForAction?.action !== DELETE_ACTION) return;
+ const { id } = itemForAction;
+ setItemForAction(null);
+
+ const result = await deleteOneRef.current(id);
+ if (result !== undefined) {
+ successNotificationToast(notifications, 'deleted', entityName);
+ reloadRef.current();
+ }
+ }, [itemForAction, notifications, entityName]);
+
+ const confirmDeleteSelected = useCallback(
+ async (selectedItems: Array<{ id: string }>, onSuccess: () => void) => {
+ setIsDeleting(true);
+ try {
+ const deleteResults = await Promise.all(
+ selectedItems.map((item) => deleteOneRef.current(item.id))
+ );
+ const deletedCount = deleteResults.filter((r) => r !== undefined).length;
+ const failedCount = deleteResults.length - deletedCount;
+
+ if (deletedCount > 0) {
+ successNotificationToast(
+ notifications,
+ 'deleted',
+ deletedCount === 1 ? entityName : entityNamePlural
+ );
+ }
+
+ if (failedCount > 0) {
+ notifications.toasts.addWarning({
+ title: `Some ${entityNamePlural} could not be deleted`,
+ text: `${failedCount} ${
+ failedCount !== 1 ? entityNamePlural : entityName
+ } could not be deleted.`,
+ toastLifeTimeMs: 5000,
+ });
+ }
+
+ await reloadRef.current();
+ if (isMountedRef.current) {
+ onSuccess();
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setIsDeleting(false);
+ setItemForAction(null);
+ }
+ }
+ },
+ [notifications, entityName, entityNamePlural, isMountedRef]
+ );
+
+ return {
+ itemForAction,
+ setItemForAction,
+ isDeleting,
+ confirmDeleteSingle,
+ confirmDeleteSelected,
+ };
+}
diff --git a/public/hooks/useLazyFetch.ts b/public/hooks/useLazyFetch.ts
new file mode 100644
index 000000000..b63d665f9
--- /dev/null
+++ b/public/hooks/useLazyFetch.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+
+export function useLazyFetch(
+ fetchFn: (() => Promise) | null,
+ notFoundMessage: string
+): { data: T | undefined; loading: boolean; error: string | undefined } {
+ const [data, setData] = useState(undefined);
+ const [loading, setLoading] = useState(fetchFn !== null);
+ const [error, setError] = useState(undefined);
+
+ useEffect(() => {
+ if (fetchFn === null) {
+ setLoading(false);
+ setData(undefined);
+ setError(undefined);
+ return;
+ }
+ let cancelled = false;
+ setLoading(true);
+ setError(undefined);
+ setData(undefined);
+ fetchFn()
+ .then((result) => {
+ if (cancelled) return;
+ if (result == null) setError(notFoundMessage);
+ else setData(result);
+ })
+ .catch((err) => {
+ if (!cancelled) setError(err?.message ?? 'Failed to load.');
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [fetchFn, notFoundMessage]);
+
+ return { data, loading, error };
+}
diff --git a/public/hooks/useSpaceFilter.ts b/public/hooks/useSpaceFilter.ts
new file mode 100644
index 000000000..3baf8516a
--- /dev/null
+++ b/public/hooks/useSpaceFilter.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useEffect, useMemo } from 'react';
+import { useHistory, useLocation } from 'react-router-dom';
+import { SpaceTypes } from '../../common/constants';
+import { Space } from '../../types';
+
+const SPACE_FILTER_KEY = 'security_analytics_space_filter';
+
+export const useSpaceFilter = () => {
+ const location = useLocation();
+ const history = useHistory();
+
+ const spaceFilter = useMemo(
+ () =>
+ new URLSearchParams(location.search).get('space') ||
+ localStorage?.getItem(SPACE_FILTER_KEY) ||
+ SpaceTypes.STANDARD.value,
+ [location.search]
+ );
+
+ // Add space param to URL if missing
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ if (!params.has('space')) {
+ params.set('space', spaceFilter);
+ history.replace({ ...location, search: params.toString() });
+ }
+ }, [location.pathname]);
+
+ // Persist to localStorage for cross-page navigation fallback
+ useEffect(() => {
+ localStorage?.setItem(SPACE_FILTER_KEY, spaceFilter);
+ }, [spaceFilter]);
+
+ const setSpaceFilter = (id: string) => {
+ const params = new URLSearchParams(location.search);
+ params.set('space', id);
+ history.replace({ ...location, search: params.toString() });
+ localStorage?.setItem(SPACE_FILTER_KEY, id);
+ };
+
+ return [spaceFilter as Space, setSpaceFilter] as const;
+};
diff --git a/public/hooks/useSpaceSelector.tsx b/public/hooks/useSpaceSelector.tsx
new file mode 100644
index 000000000..4b3126d61
--- /dev/null
+++ b/public/hooks/useSpaceSelector.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { SpaceSelector } from '../components/SpaceSelector/SpaceSelector';
+import { useSpaceFilter } from './useSpaceFilter';
+import { Space } from '../../types';
+
+interface UseSpaceSelectorOptions {
+ isDisabled?: boolean;
+ isLoading?: boolean;
+ documentationUrl?: string;
+ onSpaceChange?: (spaceId: string) => void;
+}
+
+export const useSpaceSelector = (
+ options: UseSpaceSelectorOptions = {}
+): { component: React.ReactComponentElement; spaceFilter: Space } => {
+ const { isDisabled, isLoading, documentationUrl, onSpaceChange } = options;
+ const [spaceFilter, setSpaceFilter] = useSpaceFilter();
+ const [isChanging, setIsChanging] = useState(false);
+ const trackPending = isLoading !== undefined;
+ const prevLoadingRef = useRef(!!isLoading);
+
+ useEffect(() => {
+ if (!trackPending) return;
+ if (prevLoadingRef.current && !isLoading) {
+ setIsChanging(false);
+ }
+ prevLoadingRef.current = !!isLoading;
+ }, [isLoading, trackPending]);
+
+ const handleSpaceChange = useCallback(
+ (id: string) => {
+ if (id === spaceFilter) return;
+ if (trackPending) setIsChanging(true);
+ setSpaceFilter(id);
+ onSpaceChange?.(id);
+ },
+ [setSpaceFilter, onSpaceChange, spaceFilter, trackPending]
+ );
+
+ const component = (
+
+ );
+
+ return { component, spaceFilter };
+};
diff --git a/public/hooks/useUrlParamItem.ts b/public/hooks/useUrlParamItem.ts
new file mode 100644
index 000000000..da5ff9fb2
--- /dev/null
+++ b/public/hooks/useUrlParamItem.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useState, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+
+export const useUrlParamItem = (paramName: string) => {
+ const history = useHistory();
+ const [paramId, setParamId] = useState(() =>
+ new URLSearchParams(history.location.search).get(paramName)
+ );
+
+ useEffect(() => {
+ return history.listen((location) => {
+ setParamId(new URLSearchParams(location.search).get(paramName));
+ });
+ }, []);
+
+ const setParam = (id: string) => {
+ const params = new URLSearchParams(history.location.search);
+ params.set(paramName, id);
+ history.push({ ...history.location, search: params.toString() });
+ };
+
+ const clearParam = () => {
+ const params = new URLSearchParams(history.location.search);
+ params.delete(paramName);
+ history.replace({ ...history.location, search: params.toString() });
+ };
+
+ return { paramId, setParam, clearParam };
+};
diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts
index 70ce57263..563b14080 100644
--- a/public/models/interfaces.ts
+++ b/public/models/interfaces.ts
@@ -15,8 +15,14 @@ import {
NotificationsService,
IndexPatternsService,
LogTypeService,
+ IntegrationService,
+ PoliciesService,
+ DecodersService,
+ KVDBsService,
+ FiltersService,
} from '../services';
import CorrelationService from '../services/CorrelationService';
+import LogTestService from '../services/LogTestService';
import MetricsService from '../services/MetricsService';
import ThreatIntelService from '../services/ThreatIntelService';
@@ -32,7 +38,13 @@ export interface BrowserServices {
notificationsService: NotificationsService;
savedObjectsService: ISavedObjectsService;
indexPatternsService: IndexPatternsService;
+ integrationService: IntegrationService;
+ policiesService: PoliciesService;
logTypeService: LogTypeService;
+ decodersService: DecodersService;
+ kvdbsService: KVDBsService;
+ filtersService: FiltersService;
+ logTestService: LogTestService;
metricsService: MetricsService;
threatIntelService: ThreatIntelService;
}
diff --git a/public/pages/Alerts/components/AlertFlyout/AlertFlyout.tsx b/public/pages/Alerts/components/AlertFlyout/AlertFlyout.tsx
index 67e615605..e18675fb8 100644
--- a/public/pages/Alerts/components/AlertFlyout/AlertFlyout.tsx
+++ b/public/pages/Alerts/components/AlertFlyout/AlertFlyout.tsx
@@ -146,7 +146,8 @@ export class AlertFlyout extends React.Component formatRuleType(detector.detector_type),
@@ -203,7 +204,7 @@ export class AlertFlyout extends React.Component
-
+
Alert details
diff --git a/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx
index 45520ab6e..8eb48ad40 100644
--- a/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx
+++ b/public/pages/Alerts/components/CorrelationAlertFlyout/CorrelationAlertFlyout.tsx
@@ -150,7 +150,8 @@ import {
{
...finding,
detector: { _id: finding.detector_id as string, _index: '' },
- ruleName: rule?.title || '',
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ ruleName: rule?.metadata?.title || '',
ruleSeverity: rule?.level === 'critical' ? rule.level : finding['ruleSeverity'] || rule?.level,
},
[...this.state.findingItems, finding],
diff --git a/public/pages/Alerts/containers/Alerts/Alerts.tsx b/public/pages/Alerts/containers/Alerts/Alerts.tsx
index 9c7f1f0ca..e09502e5c 100644
--- a/public/pages/Alerts/containers/Alerts/Alerts.tsx
+++ b/public/pages/Alerts/containers/Alerts/Alerts.tsx
@@ -33,6 +33,7 @@ import {
DEFAULT_DATE_RANGE,
DEFAULT_EMPTY_DATA,
MAX_RECENTLY_USED_TIME_RANGES,
+ THREAT_INTEL_ENABLED,
} from '../../../../utils/constants';
import AlertsService from '../../../../services/AlertsService';
import DetectorService from '../../../../services/DetectorService';
@@ -135,6 +136,11 @@ export class Alerts extends Component {
} = props;
const timeUnits = getChartTimeUnit(dateTimeFilter.startTime, dateTimeFilter.endTime);
const searchParams = new URLSearchParams(props.location.search);
+ const selectedTabFromUrl = searchParams.get('detectionType') as AlertTabId | null;
+ const selectedTabId =
+ selectedTabFromUrl === AlertTabId.ThreatIntel && !THREAT_INTEL_ENABLED
+ ? AlertTabId.DetectionRules
+ : selectedTabFromUrl ?? AlertTabId.DetectionRules;
this.state = {
loading: true,
groupBy: 'status',
@@ -152,7 +158,7 @@ export class Alerts extends Component {
dateFormat: timeUnits.dateFormat,
widgetEmptyMessage: undefined,
widgetEmptyCorrelationMessage: undefined,
- selectedTabId: (searchParams.get('detectionType') as AlertTabId) ?? AlertTabId.DetectionRules,
+ selectedTabId,
threatIntelAlerts: [],
filteredThreatIntelAlerts: [],
};
@@ -322,7 +328,9 @@ export class Alerts extends Component {
break;
case AlertTabId.ThreatIntel:
- this.getThreatIntelAlerts(abortController.signal);
+ if (THREAT_INTEL_ENABLED) {
+ this.getThreatIntelAlerts(abortController.signal);
+ }
break;
}
}
@@ -435,7 +443,7 @@ export class Alerts extends Component {
},
{
field: 'correlation_rule_categories',
- name: 'Log Types',
+ name: 'Integrations', // replace log types to integrations by Wazuh
sortable: false,
dataType: 'string',
render: (correlationRuleCategories: string[]) =>
@@ -534,7 +542,7 @@ export class Alerts extends Component {
}
componentDidMount(): void {
- setBreadcrumbs([BREADCRUMBS.ALERTS]);
+ setBreadcrumbs([BREADCRUMBS.INSIGHTS, BREADCRUMBS.ALERTS]);
this.onRefresh();
}
@@ -1017,24 +1025,25 @@ export class Alerts extends Component {
>
),
},
- {
- id: 'threat-intel',
- name: 'Threat intel',
- content: (
- <>
-
- {/*{this.getAlertsGraph(alerts, loading)}*/}
- {/* */}
-
-
-
- >
- ),
- },
+ // disable threat intel tab for Wazuh
+ // {
+ // id: 'threat-intel',
+ // name: 'Threat intel',
+ // content: (
+ // <>
+ //
+ // {/*{this.getAlertsGraph(alerts, loading)}*/}
+ // {/* */}
+ //
+ //
+ //
+ // >
+ // ),
+ // },
{
id: 'correlations',
name: (
@@ -1106,7 +1115,7 @@ export class Alerts extends Component {
},
]}
>
-
+
diff --git a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap
index f9de702ed..c8cdd3241 100644
--- a/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap
+++ b/public/pages/Alerts/containers/Alerts/__snapshots__/Alerts.test.tsx.snap
@@ -6,7 +6,7 @@ exports[` spec renders the component 1`] = `
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionColumn euiFlexGroup--responsive"
>
spec renders the component 1`] = `
Detection rules
-
-
- Threat intel
-
-
= (props: Correla
}, [DataStore.correlations.getCorrelationRules]);
useEffect(() => {
- if (getUseUpdatedUx()) {
- setBreadcrumbs([BREADCRUMBS.CORRELATION_RULES]);
- } else {
- setBreadcrumbs([BREADCRUMBS.CORRELATIONS, BREADCRUMBS.CORRELATION_RULES]);
- }
+ // Wazuh Customization: Breadcrumbs for Correlation Rules
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.CORRELATION_RULES]);
+
+ // if (getUseUpdatedUx()) {
+ // setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.CORRELATION_RULES]);
+ // } else {
+ // setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.CORRELATION_RULES]);
+ // }
}, [getUseUpdatedUx()]);
useEffect(() => {
@@ -111,7 +114,7 @@ export const CorrelationRules: React.FC = (props: Correla
{isDeleteModalVisible && deleteModal ? deleteModal : null}
-
+
@@ -144,9 +147,10 @@ export const CorrelationRules: React.FC = (props: Correla
}
body={
+ {/* Replace log types with integrations by Wazuh */}
Create a correlation rule based on specified fields to generate correlations
- across all findings between different log types.
+ across all findings between different integrations.
}
diff --git a/public/pages/Correlations/containers/CorrelationsContainer.tsx b/public/pages/Correlations/containers/CorrelationsContainer.tsx
index 673c01244..237363b5f 100644
--- a/public/pages/Correlations/containers/CorrelationsContainer.tsx
+++ b/public/pages/Correlations/containers/CorrelationsContainer.tsx
@@ -149,7 +149,7 @@ export class Correlations extends React.Component {
- setBreadcrumbs([BREADCRUMBS.CORRELATIONS]);
+ setBreadcrumbs([BREADCRUMBS.INSIGHTS, BREADCRUMBS.CORRELATIONS]);
this.updateState(true /* onMount */);
this.props.onMount();
this.fetchCorrelationsTableData();
@@ -766,7 +766,7 @@ export class Correlations extends React.Component
-
+
@@ -784,7 +784,7 @@ export class Correlations extends React.Component
= ({
},
{
field: 'logTypes',
- name: 'Log Types',
+ name: 'Integrations', // Changed from Log Types to Integrations by Wazuh
sortable: true,
render: (logTypes: string[]) => displayBadges(logTypes),
},
diff --git a/public/pages/Correlations/containers/CreateCorrelationRule.tsx b/public/pages/Correlations/containers/CreateCorrelationRule.tsx
index b1d268643..62a346b75 100644
--- a/public/pages/Correlations/containers/CreateCorrelationRule.tsx
+++ b/public/pages/Correlations/containers/CreateCorrelationRule.tsx
@@ -178,7 +178,7 @@ export const CreateCorrelationRule: React.FC = (
const invalidlogType = !query.logType;
if (invalidlogType) {
- error = `Invalid log type for query ${index + 1}`;
+ error = `Invalid integration for query ${index + 1}`; // Changed Log Type to Integration by Wazuh
return true;
}
@@ -590,7 +590,7 @@ export const CreateCorrelationRule: React.FC = (
Log type }
+ label={Integration } // Changed Log Type to Integration by Wazuh
isInvalid={isInvalidInputForQuery('logType')}
error={
(formikErrors.queries?.[queryIdx] as FormikErrors)
@@ -599,7 +599,7 @@ export const CreateCorrelationRule: React.FC = (
>
= (
useEffect(() => {
setBreadcrumbs([
- BREADCRUMBS.CORRELATIONS,
+ BREADCRUMBS.DETECTION,
BREADCRUMBS.CORRELATION_RULES,
BREADCRUMBS.CORRELATIONS_RULE_CREATE(action),
]);
diff --git a/public/pages/Correlations/utils/helpers.tsx b/public/pages/Correlations/utils/helpers.tsx
index 279e7dcb0..fb299a942 100644
--- a/public/pages/Correlations/utils/helpers.tsx
+++ b/public/pages/Correlations/utils/helpers.tsx
@@ -50,7 +50,7 @@ export const getCorrelationRulesTableColumns = (
),
},
{
- name: 'Log types',
+ name: 'Integrations', // Changed from Log Types to Integrations by Wazuh
field: 'logTypes',
render: (logTypes: string, ruleItem: CorrelationRule) => {
const badges = [
@@ -234,14 +234,14 @@ export const displayResourcesBadges = (resources: string[]) => {
export const getCorrelationRulesTableSearchConfig = (): Search => {
return {
box: {
- placeholder: 'Search by rule name, log type',
+ placeholder: 'Search by rule name, integration', // Changed Log Type to Integration by Wazuh
schema: true,
},
filters: [
{
type: 'field_value_selection',
field: 'logTypes',
- name: 'Log Types',
+ name: 'Integrations', // Changed from Log Types to Integrations by Wazuh
multiSelect: 'or',
options: getLogTypeFilterOptions(),
},
diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx b/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx
index 160b3d992..641f29338 100644
--- a/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx
+++ b/public/pages/CreateDetector/components/ConfigureAlerts/containers/ConfigureAlerts.tsx
@@ -83,6 +83,7 @@ export default class ConfigureAlerts extends Component extends Compo
const columns: EuiBasicTableColumn[] = [
{
field: 'ruleFieldName',
- name: 'Detection rule field',
+ name: 'Rule field', // Wazuh: rename 'Detection rule' to 'Rule'
dataType: 'string',
width: '25%',
render: (ruleFieldName: string) => ruleFieldName || DEFAULT_EMPTY_DATA,
diff --git a/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx b/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx
index c6cd1b44d..fcdccde24 100644
--- a/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx
+++ b/public/pages/CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping.tsx
@@ -173,7 +173,8 @@ export default class ConfigureFieldMapping extends Component<
To generate accurate findings, we recommend to review the following field mappings
- between the detection rules fields and the data source fields.
+ between the rules fields and the data source fields.{' '}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
@@ -198,7 +199,8 @@ export default class ConfigureFieldMapping extends Component<
To generate accurate findings, we recommend mapping all the fields of interest in
- your data source to the detection rules fields.
+ your data source to the rules fields.{' '}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
{pendingCount > 0 && (
@@ -373,7 +375,7 @@ export default class ConfigureFieldMapping extends Component<
To perform threat detection the field names from your data source have to be mapped to
- detection rules field names.
+ rules field names. {/* Wazuh: rename 'detection rules' to 'rules' */}
>
}
diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx
index a732655ba..fac16ca7b 100644
--- a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx
+++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules.tsx
@@ -17,7 +17,7 @@ import {
import React, { useMemo, useState } from 'react';
import { DetectionRulesTable } from './DetectionRulesTable';
import { RuleItem, RuleItemInfo } from './types/interfaces';
-import { RuleViewerFlyout } from '../../../../../Rules/components/RuleViewerFlyout/RuleViewerFlyout';
+import { RuleViewerFlyout } from '../../../../../WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout';
import { RuleTableItem } from '../../../../../Rules/utils/helpers';
import { RuleItemInfoBase } from '../../../../../../../types';
import { ROUTES } from '../../../../../../utils/constants';
@@ -68,10 +68,12 @@ export const DetectionRules: React.FC = ({
rulesState.allRules.map((rule) => ({
id: rule._id,
active: rule.enabled,
- description: rule._source.description,
+ // Wazuh: Remove duplicated fields in metadata and root: description.
+ description: rule._source.metadata?.description ?? '',
library: rule.prePackaged ? 'Standard' : 'Custom',
logType: rule._source.category,
- name: rule._source.title,
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ name: rule._source.metadata?.title ?? '',
severity: rule._source.level,
ruleInfo: rule,
})),
@@ -108,13 +110,15 @@ export const DetectionRules: React.FC = ({
- {'Selected detection rules ('}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
+ {'Selected rules ('}
<>{enabledRulesCountDisplay}>
{')'}
- Add or remove detection rules for this detector.
+ Add or remove rules for this detector.{' '}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
}
@@ -140,15 +144,17 @@ export const DetectionRules: React.FC = ({
- No detection rules {detectorType ? 'to display' : 'selected'}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
+ No rules {detectorType ? 'to display' : 'selected'}
}
body={
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
{detectorType
- ? 'There are no applicable detection rules for the selected log type. Consider creating new detection rules.'
- : 'Select a log type to be able to select detection rules.'}
+ ? 'There are no applicable rules for the selected log type. Consider creating new rules.'
+ : 'Select a log type to be able to select rules.'}
}
diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx
index 2a00d0f65..2d266c315 100644
--- a/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx
+++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectionRules/utils/constants.tsx
@@ -7,7 +7,7 @@ import { EuiBasicTableColumn, EuiLink, EuiCompressedSwitch } from '@elastic/eui'
import { capitalizeFirstLetter } from '../../../../../../../utils/helpers';
import React, { ReactNode } from 'react';
import { RuleItem } from '../types/interfaces';
-import { getLogTypeLabel } from '../../../../../../LogTypes/utils/helpers';
+import { getRuleIntegrationTitle } from '../../../../../../WazuhRules/utils/helpers';
export type ActiveToggleOnChangeEvent = React.BaseSyntheticEvent<
React.MouseEvent,
@@ -49,14 +49,14 @@ export const getRulesColumns = (
},
{
field: 'logType',
- name: 'Log type',
+ name: 'Integration',
width: '10%',
sortable: true,
- render: (logType: string) => getLogTypeLabel(logType),
+ render: (_: string, item: RuleItem) => getRuleIntegrationTitle(item.ruleInfo as any),
},
{
field: 'library',
- name: 'Source',
+ name: 'Space',
width: '10%',
render: (library: string) => capitalizeFirstLetter(library),
},
diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx
index f720bca58..62d73da33 100644
--- a/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx
+++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorDataSource/DetectorDataSource.tsx
@@ -77,9 +77,21 @@ export default class DetectorDataSource extends Component<
const res = await getDataSources(this.props.indexService, this.props.notifications);
if (res.ok) {
+ const WAZUH_EVENTS_PREFIX = 'wazuh-events-v5';
+ const filteredDataSources = (res.dataSources as any[])
+ .map((group) => ({
+ ...group,
+ options: (group.options || []).filter(
+ (opt: { label: string; index?: string }) =>
+ opt.label?.startsWith(WAZUH_EVENTS_PREFIX) ||
+ !!opt.index?.startsWith(WAZUH_EVENTS_PREFIX)
+ ),
+ }))
+ .filter((group) => group.options.length > 0);
+
this.setState({
loading: false,
- indexOptions: res.dataSources,
+ indexOptions: filteredDataSources,
});
} else {
this.setState({ loading: false, errorMessage: res.error });
@@ -197,8 +209,9 @@ export default class DetectorDataSource extends Component<
data-test-subj={'define-detector-diff-log-types-warning'}
>
+ {/* Replace log types with integrations by Wazuh */}
To avoid issues with field mappings, we recommend creating separate detectors for
- different log types.
+ different integrations.
>
diff --git a/public/pages/CreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx b/public/pages/CreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx
index 063df3100..c1fc8713f 100644
--- a/public/pages/CreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx
+++ b/public/pages/CreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx
@@ -74,19 +74,21 @@ export default class DetectorType extends Component
- Detection rules
+ Rules {/* Wazuh: rename 'Detection rules' to 'Rules' */}
+ {/* Replace log type with integration by Wazuh */}
- The detection rules are automatically populated based on your selected log type. Threat
- intelligence based detection can be enabled for standard log types.{' '}
+ The rules are automatically populated based on your selected integration. Threat
+ intelligence based detection can be enabled for standard integrations.{' '}
-
+ {/* Replace log type with integration by Wazuh */}
+
}
@@ -96,7 +98,7 @@ export default class DetectorType extends Component
void;
updateDataValidState: (step: DetectorCreationStep, isValid: boolean) => void;
onPageChange: (page: { index: number; size: number }) => void;
onRuleToggle: (changedItem: RuleItem, isActive: boolean) => void;
onAllRulesToggle: (enabled: boolean) => void;
replaceFieldMappings: (mappings: FieldMapping[]) => void;
+ onSpaceChange?: (space: string) => void;
}
interface DefineDetectorState {
detector: Detector;
}
-export default class DefineDetector extends Component {
+export default class DefineDetector extends Component<
+ DefineDetectorProps,
+ DefineDetectorState
+> {
public static contextType?:
| React.Context
| undefined = SecurityAnalyticsContext;
private standardLogTypes = new Set(
- ruleTypes.filter((ruleType) => ruleType.isStandard).map(({ value }) => value)
+ ruleTypes
+ .filter((ruleType) => ruleType.isStandard)
+ .map(({ value }) => value),
);
constructor(props: DefineDetectorProps) {
@@ -75,7 +91,7 @@ export default class DefineDetector extends Component,
prevState: Readonly,
- snapshot?: any
+ snapshot?: any,
) {
if (prevProps.detector !== this.props.detector) {
this.setState({
@@ -84,15 +100,21 @@ export default class DefineDetector extends Component= MIN_NUM_DATA_SOURCES &&
+ detector.inputs[0].detector_input.indices.length >=
+ MIN_NUM_DATA_SOURCES &&
!!detector.schedule.period.interval;
this.props.changeDetector(detector);
- this.props.updateDataValidState(DetectorCreationStep.DEFINE_DETECTOR, isDataValid);
+ this.props.updateDataValidState(
+ DetectorCreationStep.DEFINE_DETECTOR,
+ isDataValid,
+ );
}
onDetectorNameChange = (detectorName: string) => {
@@ -121,8 +143,12 @@ export default class DefineDetector extends Component[]) => {
- const detectorIndices = selectedOptions.map((selectedOption) => selectedOption.label);
+ onDetectorInputIndicesChange = (
+ selectedOptions: EuiComboBoxOptionOption[],
+ ) => {
+ const detectorIndices = selectedOptions.map(
+ (selectedOption) => selectedOption.label,
+ );
const { inputs } = this.state.detector;
const newDetector: Detector = {
@@ -139,7 +165,9 @@ export default class DefineDetector extends Component {
@@ -152,7 +180,7 @@ export default class DefineDetector extends Component ({
...trigger,
detection_types: checked
- ? addDetectionType(trigger, 'threat_intel')
- : removeDetectionType(trigger, 'threat_intel'),
+ ? addDetectionType(trigger, "threat_intel")
+ : removeDetectionType(trigger, "threat_intel"),
}));
const newDetector: Detector = {
@@ -172,7 +200,7 @@ export default class DefineDetector extends Component
-
+
-
+
-
+
-
+
- {this.standardLogTypes.has(detector_type) && (
+ {THREAT_INTEL_ENABLED && this.standardLogTypes.has(detector_type) && (
- A detector dashboard will be automatically created to provide insights for this
- detector.
+ A detector dashboard will be automatically created to provide
+ insights for this detector.
-
+
>
) : null}
-
+
{
- private triggerCounter = 1;
+export default class CreateDetector extends Component<
+ CreateDetectorProps,
+ CreateDetectorState
+> {
+ // Wazuh: hide Configure Alerts step in detector creation wizard.
+ // private triggerCounter = 1;
constructor(props: CreateDetectorProps) {
super(props);
@@ -75,29 +91,41 @@ export default class CreateDetector extends Component,
prevState: Readonly,
- snapshot?: any
+ snapshot?: any,
): void {
if (prevProps.dataSource !== this.props.dataSource) {
this.setState(this.getInitialState());
this.resetDependencies();
- } else if (prevState.detector.detector_type !== this.state.detector.detector_type) {
+ } else if (
+ prevState.detector.detector_type !== this.state.detector.detector_type
+ ) {
this.setupRulesState();
}
}
@@ -136,73 +166,72 @@ export default class CreateDetector extends Component {
- // set detector pending state, this will be used in detector details page
- DataStore.detectors.setState(
- {
- pendingRequests: [fieldsMappingPromise, createDetectorPromise],
- detectorInput: { ...this.state },
- },
- this.props.history
- );
- });
-
- this.props.metrics.detectorMetricsManager.sendMetrics(CreateDetectorSteps.createClicked);
+ const createDetectorPromise =
+ this.props.services.detectorsService.createDetector(detector);
- // navigate to detector details
- this.props.history.push(`${ROUTES.DETECTOR_DETAILS}/${createDetectorRes.response._id}`);
- };
+ // set detector pending state, this will be used in detector details page
+ DataStore.detectors.setState(
+ {
+ pendingRequests: [fieldsMappingPromise, createDetectorPromise],
+ detectorInput: { ...this.state },
+ },
+ this.props.history,
+ );
- onNextClick = () => {
- const { currentStep } = this.state;
- this.setState({ currentStep: currentStep + 1 });
- this.props.setDataSourceMenuReadOnly(true);
- this.props.metrics.detectorMetricsManager.sendMetrics(CreateDetectorSteps.stepTwoInitiated);
- };
+ this.setState({ creatingDetector: false });
- onPreviousClick = () => {
- const { currentStep } = this.state;
- this.setState({ currentStep: currentStep - 1 });
- this.props.setDataSourceMenuReadOnly(false);
- };
+ this.props.metrics.detectorMetricsManager.sendMetrics(
+ CreateDetectorSteps.createClicked,
+ );
- setCurrentStep = (currentStep: DetectorCreationStep) => {
- this.setState({ currentStep });
+ // navigate to detector details
+ this.props.history.push(
+ `${ROUTES.DETECTOR_DETAILS}/${PENDING_DETECTOR_ID}`,
+ );
};
- updateDataValidState = (step: DetectorCreationStep | string, isValid: boolean): void => {
+ // Wazuh: hide Configure Alerts step in detector creation wizard.
+ // onNextClick = () => {
+ // const { currentStep } = this.state;
+ // this.setState({ currentStep: currentStep + 1 });
+ // this.props.setDataSourceMenuReadOnly(true);
+ // this.props.metrics.detectorMetricsManager.sendMetrics(CreateDetectorSteps.stepTwoInitiated);
+ // };
+
+ // onPreviousClick = () => {
+ // const { currentStep } = this.state;
+ // this.setState({ currentStep: currentStep - 1 });
+ // this.props.setDataSourceMenuReadOnly(false);
+ // };
+
+ // setCurrentStep = (currentStep: DetectorCreationStep) => {
+ // this.setState({ currentStep });
+ // };
+
+ updateDataValidState = (
+ step: DetectorCreationStep | string,
+ isValid: boolean,
+ ): void => {
this.setState({
stepDataValid: {
...this.state.stepDataValid,
@@ -211,15 +240,16 @@ export default class CreateDetector extends Component rule.enabled);
- return enabledRules.map((rule) => ({
- id: rule._id,
- name: rule._source.title,
- severity: rule._source.level,
- tags: rule._source.tags.map((tag: { value: string }) => tag.value),
- }));
- }
+ // Wazuh: hide Configure Alerts step in detector creation wizard.
+ // getRulesOptions(): CreateDetectorRulesOptions {
+ // const enabledRules = this.state.rulesState.allRules.filter((rule) => rule.enabled);
+ // return enabledRules.map((rule) => ({
+ // id: rule._id,
+ // name: rule._source.title,
+ // severity: rule._source.level,
+ // tags: rule._source.tags.map((tag: { value: string }) => tag.value),
+ // }));
+ // }
async setupRulesState() {
const { detector_type } = this.state.detector;
@@ -228,16 +258,21 @@ export default class CreateDetector extends Component rule.prePackaged);
- const customRules = allRules.filter((rule) => !rule.prePackaged);
+ const { selectedSpace } = this.state;
+ const spaceRules = allRules.filter((rule) => rule.space === selectedSpace);
+
+ const prePackagedRules = spaceRules.filter((rule) => rule.prePackaged);
+ const customRules = spaceRules.filter((rule) => !rule.prePackaged);
this.setState({
rulesState: {
...this.state.rulesState,
- allRules: customRules.concat(prePackagedRules).map((rule) => ({ ...rule, enabled: true })),
+ allRules: customRules
+ .concat(prePackagedRules)
+ .map((rule) => ({ ...rule, enabled: true })),
page: {
index: 0,
},
@@ -248,7 +283,9 @@ export default class CreateDetector extends Component ({ id: rule._id })),
+ pre_packaged_rules: prePackagedRules.map((rule) => ({
+ id: rule._id,
+ })),
custom_rules: customRules.map((rule) => ({ id: rule._id })),
},
},
@@ -267,9 +304,10 @@ export default class CreateDetector extends Component {
- return `Trigger ${this.triggerCounter++}`;
- };
+ // Wazuh: hide Configure Alerts step in detector creation wizard.
+ // getNextTriggerName = () => {
+ // return `Trigger ${this.triggerCounter++}`;
+ // };
getDetectorWithUpdatedRules(newRules: RuleItemInfo[]) {
return {
@@ -293,9 +331,11 @@ export default class CreateDetector extends Component {
- const ruleIndex = this.state.rulesState.allRules.findIndex((ruleItemInfo) => {
- return ruleItemInfo._id === changedItem.id;
- });
+ const ruleIndex = this.state.rulesState.allRules.findIndex(
+ (ruleItemInfo) => {
+ return ruleItemInfo._id === changedItem.id;
+ },
+ );
if (ruleIndex > -1) {
const newRules: RuleItemInfo[] = [
@@ -311,15 +351,19 @@ export default class CreateDetector extends Component {
- const newRules: RuleItemInfo[] = this.state.rulesState.allRules.map((rule) => ({
- ...rule,
- enabled,
- }));
+ const newRules: RuleItemInfo[] = this.state.rulesState.allRules.map(
+ (rule) => ({
+ ...rule,
+ enabled,
+ }),
+ );
this.setState({
rulesState: {
@@ -328,7 +372,9 @@ export default class CreateDetector extends Component {
@@ -350,21 +396,30 @@ export default class CreateDetector extends Component
+ this.setState({ selectedSpace: space }, () =>
+ this.setupRulesState(),
+ )
+ }
/>
);
- case DetectorCreationStep.CONFIGURE_ALERTS:
- return (
-
- );
+ // Wazuh: hide Configure Alerts step in detector creation wizard.
+ // case DetectorCreationStep.CONFIGURE_ALERTS:
+ // return (
+ //
+ // );
+ default:
+ return null;
}
};
@@ -373,23 +428,24 @@ export default class CreateDetector extends Component stepData.step
- ? 'complete'
+ ? "complete"
: currentStep < stepData.step
- ? 'disabled'
- : undefined,
+ ? "disabled"
+ : undefined,
children: <>>,
}));
}
render() {
const { creatingDetector, currentStep, stepDataValid } = this.state;
- const steps: EuiContainedStepProps[] = this.createStepsMetadata(currentStep);
+ const steps: EuiContainedStepProps[] =
+ this.createStepsMetadata(currentStep);
return (
-
-
+
{this.getStepContent()}
>
-
+ {/* Wazuh: Original Cancel and Create buttons replaced by standardized
+ EuiBottomBar for UI consistency.*/}
+ {/* Wazuh: hide Configure Alerts step in detector creation wizard. */}
+ {/*
- Cancel
+
+ Cancel
+
{currentStep > DetectorCreationStep.DEFINE_DETECTOR && (
@@ -441,7 +502,49 @@ export default class CreateDetector extends Component
)}
-
+
+
+
+ Create detector
+
+
+ */}
+
+
+
+
+
+ Cancel
+
+
+
+
+ Create detector
+
+
+
+
);
}
diff --git a/public/pages/CreateDetector/utils/constants.ts b/public/pages/CreateDetector/utils/constants.ts
index 91dc25f9d..372017db6 100644
--- a/public/pages/CreateDetector/utils/constants.ts
+++ b/public/pages/CreateDetector/utils/constants.ts
@@ -6,15 +6,16 @@
import { DetectorCreationStep } from '../../../../types';
import { DetectorCreationStepInfo } from '../models/interfaces';
-export const createDetectorSteps: Record = {
+export const createDetectorSteps: Partial> = {
[DetectorCreationStep.DEFINE_DETECTOR]: {
title: 'Define detector',
step: 1,
},
- [DetectorCreationStep.CONFIGURE_ALERTS]: {
- title: 'Set up alert triggers',
- step: 2,
- },
+ // Wazuh: hide alert triggers step in detector creation wizard.
+ // [DetectorCreationStep.CONFIGURE_ALERTS]: {
+ // title: 'Set up alert triggers',
+ // step: 2,
+ // },
};
export const PENDING_DETECTOR_ID = 'pending_detector_id';
diff --git a/public/pages/Decoders/components/DecoderDetailsFlyout.tsx b/public/pages/Decoders/components/DecoderDetailsFlyout.tsx
new file mode 100644
index 000000000..92a3c4cd4
--- /dev/null
+++ b/public/pages/Decoders/components/DecoderDetailsFlyout.tsx
@@ -0,0 +1,207 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useMemo, useState } from 'react';
+import {
+ EuiButtonGroup,
+ EuiCallOut,
+ EuiCodeBlock,
+ EuiFlexGrid,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiFormLabel,
+ EuiLoadingContent,
+ EuiModalBody,
+ EuiSmallButtonIcon,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { DecoderDocument, DecoderItem } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import { EnabledHealth } from '../../../components/Utility/EnabledHealth';
+import { Metadata, MetadataFieldType } from '../../../components/Utility/Metadata';
+import { DEFAULT_EMPTY_DATA } from '../../../utils/constants';
+import { BadgeGroup } from '../../../components/Utility/BadgeGroup';
+import { stringify as LosslessStringify } from 'lossless-json';
+import { mapYamlToLosslessObject } from '../../../components/YamlForm';
+import { useLazyFetch } from '../../../hooks/useLazyFetch';
+interface DecoderDetailsFlyoutProps {
+ decoderId: string;
+ space: string;
+ onClose: () => void;
+}
+
+const viewOptions = [
+ {
+ id: 'visual',
+ label: 'Visual',
+ },
+ {
+ id: 'yaml',
+ label: 'YAML',
+ },
+ {
+ id: 'json',
+ label: 'JSON',
+ },
+];
+
+export const DecoderDetailsFlyout: React.FC = ({
+ decoderId,
+ space,
+ onClose,
+}) => {
+ const [selectedView, setSelectedView] = useState(viewOptions[0].id);
+ const fetchDecoder = useCallback(() => DataStore.decoders.getDecoder(decoderId, space), [
+ decoderId,
+ space,
+ ]);
+ const { data: decoder, loading, error } = useLazyFetch(fetchDecoder, 'Decoder not found.');
+
+ const decoderJson = useMemo(() => {
+ if (!decoder) return '';
+ try {
+ const rawYaml = typeof decoder.yaml === 'string' ? decoder.yaml : null;
+ if (rawYaml) {
+ const losslessDoc = mapYamlToLosslessObject(rawYaml);
+ return LosslessStringify(losslessDoc, null, 2) ?? '';
+ }
+ return JSON.stringify(decoder?.document, null, 2);
+ } catch (err) {
+ return JSON.stringify(decoder?.document, null, 2) ?? '';
+ }
+ }, [decoder]);
+
+ const fields: Array<{
+ label: string;
+ value: any;
+ type?: MetadataFieldType;
+ }> = [
+ { label: 'Space', value: decoder?.space },
+ { label: 'Integration', value: decoder?.integrations },
+ { label: 'Title', value: decoder?.document?.metadata?.title },
+ { label: 'ID', value: decoder?.document?.id },
+ { label: 'Author', value: decoder?.document?.metadata?.author },
+ { label: 'Description', value: decoder?.document?.metadata?.description },
+ { label: 'Date', value: decoder?.document?.metadata?.date, type: 'date' },
+ { label: 'Modified', value: decoder?.document?.metadata?.modified, type: 'date' },
+ { label: 'Documentation', value: decoder?.document?.metadata?.documentation },
+ { label: 'References', value: decoder?.document?.metadata?.references, type: 'url' },
+ {
+ label: 'Supports',
+ value: (
+
+ ),
+ type: 'raw',
+ },
+ ];
+
+ const detailsContent = (
+
+ {fields.map(({ label, value, type = 'text' }) => (
+
+ {label}} value={value} type={type} />
+
+ ))}
+
+ );
+
+ const yamlContent = (
+
+ {typeof decoder?.yaml === 'string'
+ ? decoder?.yaml
+ : JSON.stringify(decoder?.yaml, null, 2) ?? ''}
+
+ );
+
+ const jsonContent = (
+
+ {decoderJson}
+
+ );
+
+ const renderContent = () => {
+ if (loading) {
+ return ;
+ }
+ if (error) {
+ return ;
+ }
+ if (!decoder) {
+ return null;
+ }
+ if (selectedView === 'yaml') {
+ return yamlContent;
+ }
+ if (selectedView === 'json') {
+ return jsonContent;
+ }
+ return detailsContent;
+ };
+
+ return (
+
+
+
+
+
+
+ {decoder?.document?.name ? `Decoder details - ${decoder.document.name}` : 'Decoder'}
+
+
+
+
+
+
+
+
+
+
+
+
+ setSelectedView(id)}
+ isDisabled={loading || !!error || !decoder}
+ />
+
+ {decoder && (
+
+
+
+ )}
+
+
+ {renderContent()}
+
+
+
+ );
+};
diff --git a/public/pages/Decoders/containers/DecoderFormPage.tsx b/public/pages/Decoders/containers/DecoderFormPage.tsx
new file mode 100644
index 000000000..e10a448e9
--- /dev/null
+++ b/public/pages/Decoders/containers/DecoderFormPage.tsx
@@ -0,0 +1,374 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { Form, Formik } from 'formik';
+import YAML from 'yaml';
+import { decoderFormDefaultValue } from '../utils/constants';
+import {
+ YamlForm,
+ YAML_TYPE,
+ mapYamlToLosslessObject,
+ ERROR_SEVERITY,
+} from '../../../components/YamlForm';
+import {
+ errorNotificationToast,
+ setBreadcrumbs,
+ successNotificationToast,
+} from '../../../utils/helpers';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiPanel,
+ EuiText,
+ EuiSpacer,
+ EuiButtonGroup,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiToolTip,
+ EuiLoadingSpinner,
+} from '@elastic/eui';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import {
+ IntegrationComboBox,
+ useIntegrationSelector,
+} from '../../../components/IntegrationComboBox';
+import { DecoderDocument } from '../../../../types/Decoders';
+import { DataStore } from '../../../store/DataStore';
+import { RouteComponentProps } from 'react-router-dom';
+import { validateWithJsonSchema } from '../../../utils/jsonSchemaValidation';
+import decoderSchema from '../../../../common/schemas/wazuh-decoders.schema.json';
+
+const editorTypes = [
+ {
+ id: 'yaml',
+ label: 'YAML Editor',
+ },
+];
+
+type DecoderFormPageProps = {
+ notifications: NotificationsStart;
+ history: RouteComponentProps['history'];
+ location?: RouteComponentProps['location'];
+ action: 'create' | 'edit';
+ id?: string;
+ match: { params: { id: string } };
+};
+
+const actionLabels: Record = {
+ create: 'Create',
+ edit: 'Edit',
+};
+
+export const DecoderFormPage: React.FC = (props) => {
+ const { notifications, history, action } = props;
+ const idDecoder = props.match.params.id;
+ const spaceDecoder = new URLSearchParams(props.location?.search).get('space') ?? '';
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedEditorType, setSelectedEditorType] = useState('yaml');
+ const [integrationType, setIntegrationType] = useState('');
+ const [rawDecoder, setRawDecoder] = useState(decoderFormDefaultValue);
+ const [decoder, setDecoder] = useState();
+ const [hasYamlErrors, setHasYamlErrors] = useState(false);
+
+ const {
+ loading: loadingIntegrations,
+ options: integrationTypeOptions,
+ refresh: refreshIntegrations,
+ } = useIntegrationSelector({ notifications });
+
+ useEffect(() => {
+ const fetchDecoder = async () => {
+ if (idDecoder) {
+ setIsLoading(true);
+ try {
+ const response = await DataStore.decoders.getDecoder(idDecoder, spaceDecoder);
+ setRawDecoder(response?.yaml ?? decoderFormDefaultValue);
+ setDecoder(mapYamlToLosslessObject(response?.yaml ?? ''));
+ setIntegrationType(response?.integrations?.[0] || '');
+ setBreadcrumbs([
+ BREADCRUMBS.NORMALIZATION,
+ BREADCRUMBS.DECODERS,
+ BREADCRUMBS.DECODERS_EDIT,
+ { text: response?.document.name },
+ ]);
+ } catch (error) {
+ errorNotificationToast(
+ notifications,
+ 'retrieve',
+ 'decoder',
+ `There was an error retrieving the decoder with id ${idDecoder}.`
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+ if (action === 'edit') {
+ fetchDecoder();
+ }
+ }, [action, idDecoder, notifications]);
+
+ useEffect(() => {
+ if (action === 'create') {
+ setBreadcrumbs([
+ BREADCRUMBS.NORMALIZATION,
+ BREADCRUMBS.DECODERS,
+ BREADCRUMBS.DECODERS_CREATE,
+ ]);
+ }
+ }, [action]);
+
+ const onChange = useCallback((options: Array<{ id?: string }>) => {
+ setIntegrationType(options[0]?.id || '');
+ }, []);
+
+ const onIntegrationCreateSuccess = useCallback(
+ (newOption: { id: string }) => {
+ refreshIntegrations();
+ setIntegrationType(newOption.id);
+ },
+ [refreshIntegrations]
+ );
+
+ const createDecoder = useCallback(
+ async (values: DecoderDocument) => {
+ if (!values || !integrationType) {
+ errorNotificationToast(
+ notifications,
+ 'retrieve',
+ 'decoder',
+ 'Decoder or integration type is missing'
+ );
+ return;
+ }
+
+ try {
+ const result = await DataStore.decoders.createDecoder({
+ document: values,
+ integrationId: integrationType,
+ });
+
+ if (result) {
+ successNotificationToast(
+ notifications,
+ 'create',
+ 'decoder',
+ result.message || `The decoder ${values.name} has been created successfully.`
+ );
+
+ history.push(`${ROUTES.DECODERS}`);
+ }
+ } catch (error: any) {
+ errorNotificationToast(
+ notifications,
+ 'create',
+ 'decoder',
+ error?.message || 'An unexpected error occurred while creating the decoder.'
+ );
+ }
+ },
+ [integrationType, notifications, history]
+ );
+
+ const updateDecoder = useCallback(
+ async (values: DecoderDocument) => {
+ if (!values) {
+ errorNotificationToast(notifications, 'retrieve', 'decoder', 'No decoder to update');
+ return;
+ }
+
+ try {
+ const result = await DataStore.decoders.updateDecoder(idDecoder, {
+ document: values,
+ });
+
+ if (result) {
+ successNotificationToast(
+ notifications,
+ 'update',
+ 'decoder',
+ result.message || `The decoder ${values.name} has been updated successfully.`
+ );
+
+ history.push(`${ROUTES.DECODERS}`);
+ }
+ } catch (error: any) {
+ errorNotificationToast(
+ notifications,
+ 'update',
+ 'decoder',
+ error?.message || 'An unexpected error occurred while updating the decoder.'
+ );
+ }
+ },
+ [notifications, history]
+ );
+
+ const handleOnClick = useCallback(
+ async (values: DecoderDocument) => {
+ if (action === 'create') {
+ await createDecoder(values);
+ } else if (action === 'edit') {
+ await updateDecoder(values);
+ }
+ },
+ [action, createDecoder, updateDecoder]
+ );
+
+ const validateForm = useCallback(
+ (values: { rawDecoder: string }) => {
+ // FIXME: This is making a transformation on each detected change in the yaml form, this could create a lot of overhead
+ let decoder: object;
+ try {
+ decoder = YAML.parse(values.rawDecoder);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message.split('\n')[0] : 'Invalid YAML syntax';
+ return { rawDecoder: msg };
+ }
+ const skippedFields = action === 'create' ? ['id'] : [];
+ return validateWithJsonSchema(decoderSchema, decoder, {
+ skipRequired: skippedFields,
+ });
+ },
+ [action]
+ );
+
+ return (
+ <>
+ {isLoading ? (
+
+
+
+
+
+
+
+ ) : (
+ {
+ setSubmitting(false);
+ handleOnClick(mapYamlToLosslessObject(values.rawDecoder));
+ }}
+ >
+ {(props) => (
+
+ )}
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Decoders/containers/Decoders.tsx b/public/pages/Decoders/containers/Decoders.tsx
new file mode 100644
index 000000000..861625f6d
--- /dev/null
+++ b/public/pages/Decoders/containers/Decoders.tsx
@@ -0,0 +1,377 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+import {
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiButtonIcon,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+ EuiPopover,
+ EuiSmallButton,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiConfirmModal,
+} from '@elastic/eui';
+import { DataStore } from '../../../store/DataStore';
+import { DecoderItem } from '../../../../types';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { formatCellValue, setBreadcrumbs } from '../../../utils/helpers';
+import { buildDecodersSearchQuery } from '../utils/constants';
+import { DecoderDetailsFlyout } from '../components/DecoderDetailsFlyout';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace } from '../../../../common/helpers';
+import { useSpaceSelector } from '../../../hooks/useSpaceSelector';
+import {
+ DELETE_ACTION,
+ DELETE_SELECTED_ACTION,
+ useDeleteItems,
+} from '../../../hooks/useDeleteItems';
+
+const DEFAULT_PAGE_SIZE = 25;
+
+interface DecodersProps {
+ history: RouteComponentProps['history'];
+ notifications: NotificationsStart;
+}
+
+export const Decoders: React.FC = ({ history, notifications }) => {
+ const isMountedRef = useRef(true);
+ const [decoders, setDecoders] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [searchText, setSearchText] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [sortField, setSortField] = useState('document.name');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const { component: spaceSelector, spaceFilter } = useSpaceSelector({
+ isLoading: loading,
+ onSpaceChange: () => setPageIndex(0),
+ });
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [selectedDecoder, setSelectedDecoder] = useState<{
+ id: string;
+ space?: string;
+ } | null>(null);
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ setBreadcrumbs([BREADCRUMBS.NORMALIZATION, BREADCRUMBS.DECODERS]);
+ }, []);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ setAppliedSearch(searchText);
+ setPageIndex(0);
+ }, 300);
+
+ return () => clearTimeout(timeout);
+ }, [searchText]);
+
+ const loadDecoders = useCallback(async () => {
+ setLoading(true);
+ const query = buildDecodersSearchQuery(appliedSearch);
+ const sort = sortField
+ ? [
+ {
+ [sortField]: {
+ order: sortDirection,
+ },
+ },
+ ]
+ : undefined;
+
+ const response = await DataStore.decoders.searchDecoders(
+ {
+ from: pageIndex * pageSize,
+ size: pageSize,
+ sort,
+ query,
+ _source: {
+ includes: [
+ 'document.id',
+ 'document.name',
+ 'document.metadata.title',
+ 'document.metadata.author',
+ 'space',
+ ],
+ },
+ },
+ spaceFilter
+ );
+
+ if (!isMountedRef.current) {
+ return;
+ }
+ setDecoders(response.items);
+ setTotal(response.total);
+ setLoading(false);
+ }, [appliedSearch, pageIndex, pageSize, spaceFilter, sortField, sortDirection]);
+
+ useEffect(() => {
+ loadDecoders();
+ }, [loadDecoders]);
+
+ const {
+ itemForAction,
+ setItemForAction,
+ isDeleting,
+ confirmDeleteSingle,
+ confirmDeleteSelected,
+ } = useDeleteItems({
+ deleteOne: (id) => DataStore.decoders.deleteDecoder(id),
+ reload: loadDecoders,
+ notifications,
+ entityName: 'decoder',
+ entityNamePlural: 'decoders',
+ isMountedRef,
+ });
+
+ const onTableChange = ({ page, sort }: { page: any; sort?: any }) => {
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+ if (sort) {
+ setSortField(sort.field);
+ setSortDirection(sort.direction);
+ }
+ };
+
+ const columns: Array> = useMemo(
+ () => [
+ {
+ field: 'document.name',
+ name: 'Name',
+ sortable: true,
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ field: 'document.metadata.title',
+ name: 'Title',
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ field: 'integrations',
+ name: 'Integration',
+ },
+ {
+ field: 'document.metadata.author',
+ name: 'Author',
+ sortable: true,
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ name: 'Actions',
+ actions: [
+ {
+ name: 'View',
+ description: 'View decoder details',
+ type: 'icon',
+ icon: 'inspect',
+ onClick: (item: DecoderItem) =>
+ setSelectedDecoder({ id: item.document.id, space: item.space }),
+ },
+ {
+ name: 'Edit',
+ description: 'Edit decoder',
+ type: 'icon',
+ icon: 'pencil',
+ onClick: (item: DecoderItem) =>
+ history.push(`${ROUTES.DECODERS_EDIT}/${item.document.id}?space=${item.space}`),
+ available: () => actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.EDIT),
+ },
+ {
+ name: 'Delete',
+ description: 'Delete decoder',
+ type: 'icon',
+ icon: 'trash',
+ onClick: (item: DecoderItem) =>
+ setItemForAction({ action: DELETE_ACTION, id: item.id }),
+ available: () => actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE),
+ },
+ ],
+ },
+ ],
+ [spaceFilter, history]
+ );
+
+ const panels = [
+
+ Create
+ ,
+ {
+ setItemForAction({ action: DELETE_SELECTED_ACTION });
+ setIsPopoverOpen(false);
+ }}
+ disabled={
+ selectedItems.length === 0 || !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE)
+ }
+ toolTipContent={
+ !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE)
+ ? `Cannot delete decoders in the ${spaceFilter} space.`
+ : selectedItems.length === 0
+ ? 'Select decoders to delete'
+ : undefined
+ }
+ >
+ Delete selected ({selectedItems.length})
+ ,
+ ];
+
+ const handlerShowActionsButton = () => setIsPopoverOpen((prevState) => !prevState);
+
+ const actionsButton = (
+
+ Actions
+
+ }
+ isOpen={isPopoverOpen}
+ closePopover={handlerShowActionsButton}
+ panelPaddingSize={'none'}
+ anchorPosition={'downLeft'}
+ data-test-subj={'decodersActionsPopover'}
+ >
+
+
+ );
+
+ return (
+
+ {selectedDecoder && (
+ setSelectedDecoder(null)}
+ />
+ )}
+ {itemForAction?.action === DELETE_ACTION && (
+ setItemForAction(null)}
+ onConfirm={confirmDeleteSingle}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ Are you sure you want to delete this decoder? This action cannot be undone.
+
+ )}
+ {itemForAction?.action === DELETE_SELECTED_ACTION && (
+ setItemForAction(null)}
+ onConfirm={() => confirmDeleteSelected(selectedItems, () => setSelectedItems([]))}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ {`Are you sure you want to delete ${selectedItems.length} decoder${
+ selectedItems.length !== 1 ? 's' : ''
+ }? This action cannot be undone.`}
+
+ )}
+
+
+
+
+
+
+ Decoders
+
+
+ {spaceSelector}
+ {actionsButton}
+
+
+
+
+
+
+
+
+ setSearchText(event.target.value)}
+ isClearable
+ aria-label="Search decoders"
+ />
+
+
+
+ loadDecoders()}
+ />
+
+
+
+
+ true,
+ onSelectionChange: setSelectedItems,
+ }}
+ />
+
+
+
+ );
+};
diff --git a/public/pages/Decoders/hooks/useIntegrationDecoders.ts b/public/pages/Decoders/hooks/useIntegrationDecoders.ts
new file mode 100644
index 000000000..7a92a272c
--- /dev/null
+++ b/public/pages/Decoders/hooks/useIntegrationDecoders.ts
@@ -0,0 +1,129 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { DataStore } from '../../../store/DataStore';
+import { DecoderTableItem } from '../../Integrations/components/IntegrationDecoders';
+import { buildDecodersSearchQuery } from '../utils/constants';
+
+const DECODER_SORT_FIELD_TO_OS: Record = {
+ name: 'document.name',
+ title: 'document.metadata.title',
+ author: 'document.metadata.author',
+};
+
+export interface UseIntegrationDecodersParams {
+ decoderIds: string[];
+ space: string;
+ enabled?: boolean;
+ pageIndex: number;
+ pageSize: number;
+ sortField: string;
+ sortDirection: 'asc' | 'desc';
+ search: string;
+}
+
+export function useIntegrationDecoders({
+ decoderIds,
+ space,
+ enabled = true,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+}: UseIntegrationDecodersParams) {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [reloadTrigger, setReloadTrigger] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+
+ const from = pageIndex * pageSize;
+ const size = pageSize;
+
+ const textQuery = buildDecodersSearchQuery(search);
+ const query = {
+ bool: {
+ must: [textQuery],
+ filter: [{ terms: { 'document.id': decoderIds } }],
+ },
+ };
+
+ const osSortField = DECODER_SORT_FIELD_TO_OS[sortField] ?? sortField;
+ const sort: Array> = [{ [osSortField]: { order: sortDirection } }];
+
+ DataStore.decoders
+ .searchDecoders(
+ {
+ from,
+ size,
+ query,
+ sort,
+ _source: {
+ includes: [
+ 'document.id',
+ 'document.name',
+ 'document.metadata.title',
+ 'document.metadata.author',
+ 'space',
+ ],
+ },
+ },
+ space
+ )
+ .then((response) => {
+ if (!cancelled) {
+ setItems(
+ response.items.map((item) => ({
+ id: item.document?.id,
+ name: item.document?.name,
+ title: item.document?.metadata?.title,
+ author: item.document?.metadata?.author,
+ }))
+ );
+ setTotal(response.total);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setItems([]);
+ setTotal(0);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ decoderIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+ reloadTrigger,
+ ]);
+
+ const refresh = useCallback(() => {
+ setReloadTrigger((prev) => prev + 1);
+ }, []);
+
+ return { items, total, loading, refresh };
+}
diff --git a/public/pages/Decoders/index.ts b/public/pages/Decoders/index.ts
new file mode 100644
index 000000000..24f70642e
--- /dev/null
+++ b/public/pages/Decoders/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+import { Decoders } from './containers/Decoders';
+
+export default Decoders;
diff --git a/public/pages/Decoders/utils/constants.ts b/public/pages/Decoders/utils/constants.ts
new file mode 100644
index 000000000..dd41be946
--- /dev/null
+++ b/public/pages/Decoders/utils/constants.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+const KEYWORD_SEARCH_FIELDS = ['document.name', 'document.metadata.author'];
+
+const TEXT_SEARCH_FIELDS = ['document.metadata.title', 'document.metadata.description'];
+
+const escapeWildcard = (str: string) => str.replace(/[*?]/g, '\\$&');
+
+export const decoderFormDefaultValue: string = `name: decoder//
+enabled: true
+metadata:
+ title: Placeholder Decoder
+ description: This is a placeholder decoder. Please update the fields accordingly.
+ author: User
+ references: []
+ documentation: ''
+ supports: []`;
+
+export const buildDecodersSearchQuery = (searchText: string) => {
+ const trimmed = searchText.trim();
+ if (!trimmed) {
+ return { match_all: {} };
+ }
+
+ return {
+ bool: {
+ should: [
+ ...KEYWORD_SEARCH_FIELDS.map((field) => ({
+ wildcard: {
+ [field]: {
+ value: `*${escapeWildcard(trimmed)}*`,
+ case_insensitive: true,
+ },
+ },
+ })),
+ ...TEXT_SEARCH_FIELDS.map((field) => ({
+ match_phrase: {
+ [field]: trimmed,
+ },
+ })),
+ ],
+ minimum_should_match: 1,
+ },
+ };
+};
diff --git a/public/pages/Detectors/components/AlertTriggerView/AlertTriggerView.tsx b/public/pages/Detectors/components/AlertTriggerView/AlertTriggerView.tsx
index bae763a94..b2e6a8dcb 100644
--- a/public/pages/Detectors/components/AlertTriggerView/AlertTriggerView.tsx
+++ b/public/pages/Detectors/components/AlertTriggerView/AlertTriggerView.tsx
@@ -41,7 +41,8 @@ export const AlertTriggerView: React.FC = ({
const notificationChannel = notificationChannels.find(
(channel) => !!notificationChannelId && channel.config_id === notificationChannelId
);
- const conditionRuleNames = ids.map((ruleId) => rules[ruleId]?._source.title);
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ const conditionRuleNames = ids.map((ruleId) => rules[ruleId]?._source.metadata?.title);
const ruleDetectionTypeEnabled = detection_types.includes('rules');
const threatIntelDetectionTypeEnabled = detection_types.includes('threat_intel');
return (
diff --git a/public/pages/Detectors/components/DetectorBasicDetailsView/DetectorBasicDetailsView.tsx b/public/pages/Detectors/components/DetectorBasicDetailsView/DetectorBasicDetailsView.tsx
index 1b2d36277..d254be0fe 100644
--- a/public/pages/Detectors/components/DetectorBasicDetailsView/DetectorBasicDetailsView.tsx
+++ b/public/pages/Detectors/components/DetectorBasicDetailsView/DetectorBasicDetailsView.tsx
@@ -3,19 +3,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { EuiSmallButton, EuiSpacer, EuiLink, EuiIcon, EuiText, EuiCallOut } from '@elastic/eui';
+import { EuiSmallButton, EuiSpacer, EuiLink, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { ContentPanel } from '../../../../components/ContentPanel';
-import { buildRouteUrl, createTextDetailsGroup, parseSchedule } from '../../../../utils/helpers';
-import moment from 'moment';
-import {
- DEFAULT_EMPTY_DATA,
- logTypesWithDashboards,
- ROUTES,
- THREAT_INTEL_NAV_ID,
-} from '../../../../utils/constants';
+import { createTextDetailsGroup, parseSchedule } from '../../../../utils/helpers';
+// Wazuh: replaced the `moment` import with formatUIDate to honor the
+// `dateFormat`/`dateFormat:tz` advanced settings (upstream imported `moment` here).
+import { formatUIDate } from '../../../../utils/dateFormat';
+import { DEFAULT_EMPTY_DATA, logTypesWithDashboards } from '../../../../utils/constants';
+import { isStandardSource } from '../../../../utils/detectorSource';
import { Detector } from '../../../../../types';
-import { getLogTypeLabel } from '../../../LogTypes/utils/helpers';
+// Wazuh: remove integration title formatting
+// import { getLogTypeLabel } from '../../../LogTypes/utils/helpers';
export interface DetectorBasicDetailsViewProps {
detector: Detector;
@@ -25,6 +24,7 @@ export interface DetectorBasicDetailsViewProps {
last_update_time?: number;
onEditClicked: () => void;
isEditable: boolean;
+ space?: string; // Wazuh
}
export const DetectorBasicDetailsView: React.FC = ({
@@ -36,13 +36,16 @@ export const DetectorBasicDetailsView: React.FC =
dashboardId,
onEditClicked,
isEditable = true,
+ space, // Wazuh
}) => {
- const { name, detector_type, inputs, schedule, threat_intel_enabled } = detector;
+ const { name, detector_type, inputs, schedule } = detector;
const detectorSchedule = parseSchedule(schedule);
- const createdAt = enabled_time ? moment(enabled_time).format('YYYY-MM-DDTHH:mm') : undefined;
- const lastUpdated = last_update_time
- ? moment(last_update_time).format('YYYY-MM-DDTHH:mm')
- : undefined;
+ const isStandardDetector = isStandardSource(detector.source);
+ // Wazuh: format Created at / Last updated time with formatUIDate so they honor the
+ // `dateFormat`/`dateFormat:tz` settings. Upstream used the hardcoded
+ // `moment(...).format('YYYY-MM-DDTHH:mm')`.
+ const createdAt = enabled_time ? formatUIDate(enabled_time) : undefined;
+ const lastUpdated = last_update_time ? formatUIDate(last_update_time) : undefined;
const totalSelected = detector.inputs.reduce((sum, inputObj) => {
return (
sum +
@@ -56,12 +59,17 @@ export const DetectorBasicDetailsView: React.FC =
actions={
isEditable
? [
-
- Edit
- ,
+
+ Edit
+
+ ,
]
: null
}
@@ -70,10 +78,14 @@ export const DetectorBasicDetailsView: React.FC =
{createTextDetailsGroup([
{ label: 'Detector name', content: name },
{
- label: 'Description',
- content: inputs[0].detector_input.description || DEFAULT_EMPTY_DATA,
+ label: 'Integration', // Wazuh: reorganize props
+ content: detector_type, // Wazuh: remove integration title formatting
+ }, // Changed Log Type to Integration by Wazuh
+ {
+ // Wazuh: add space
+ label: 'Space',
+ content: space,
},
- { label: 'Detector schedule', content: detectorSchedule },
])}
{createTextDetailsGroup([
{
@@ -86,7 +98,7 @@ export const DetectorBasicDetailsView: React.FC =
>
),
},
- { label: 'Log type', content: getLogTypeLabel(detector_type.toLowerCase()) },
+ { label: 'Detector schedule', content: detectorSchedule }, // Wazuh: reorganize props
{
label: 'Detector dashboard',
content: dashboardId ? (
@@ -95,38 +107,27 @@ export const DetectorBasicDetailsView: React.FC =
) : !logTypesWithDashboards.has(detector_type) ? (
- 'Not available for this log type'
+ 'Not available for this integration' // Changed Log Type to Integration by Wazuh
) : (
'-'
),
},
])}
{createTextDetailsGroup([
- { label: 'Detection rules', content: totalSelected },
+ { label: 'Rules', content: totalSelected }, // Wazuh: rename 'Detection rules' to 'Rules'
{ label: 'Created at', content: createdAt || DEFAULT_EMPTY_DATA },
- { label: 'Last updated time', content: lastUpdated || DEFAULT_EMPTY_DATA },
+ {
+ label: 'Last updated time',
+ content: lastUpdated || DEFAULT_EMPTY_DATA,
+ },
])}
{createTextDetailsGroup([
- { label: 'Threat intelligence', content: threat_intel_enabled ? 'Enabled' : 'Disabled' },
+ {
+ // Wazuh: reorganize props
+ label: 'Description',
+ content: inputs[0].detector_input.description || DEFAULT_EMPTY_DATA,
+ },
])}
- {threat_intel_enabled && (
-
- To match your data against known indicators of compromise we recommend configuring
- scan using the new{' '}
-
- Threat Intelligence
- {' '}
- platform and disabling threat intelligence in the detector.
-
- }
- />
- )}
{rulesCanFold ? children : null}
);
diff --git a/public/pages/Detectors/components/DetectorBasicDetailsView/__snapshots__/DetectorBasicDetailsView.test.tsx.snap b/public/pages/Detectors/components/DetectorBasicDetailsView/__snapshots__/DetectorBasicDetailsView.test.tsx.snap
index dfe1ee3b8..2105ca8fc 100644
--- a/public/pages/Detectors/components/DetectorBasicDetailsView/__snapshots__/DetectorBasicDetailsView.test.tsx.snap
+++ b/public/pages/Detectors/components/DetectorBasicDetailsView/__snapshots__/DetectorBasicDetailsView.test.tsx.snap
@@ -33,21 +33,25 @@ Object {
-
-
- Edit
+
+ Edit
+
-
-
+
+
@@ -108,7 +112,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Description
+ Integration
- detectorDescription
+ detector_type
@@ -138,7 +142,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Detector schedule
+ Space
@@ -209,7 +213,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Log type
+ Detector schedule
- Detector Type
+ Every 1 minute
@@ -250,7 +254,7 @@ Object {
data-test-subj="text-details-group-content-detector-dashboard"
id="some_html_id"
>
- Not available for this log type
+ Not available for this integration
@@ -276,7 +280,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Detection rules
+ Rules
2
@@ -367,7 +371,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Threat intelligence
+ Description
- Disabled
+ detectorDescription
@@ -418,21 +422,25 @@ Object {
-
-
- Edit
+
+ Edit
+
-
-
+
+
@@ -493,7 +501,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Description
+ Integration
- detectorDescription
+ detector_type
@@ -523,7 +531,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Detector schedule
+ Space
@@ -594,7 +602,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Log type
+ Detector schedule
- Detector Type
+ Every 1 minute
@@ -635,7 +643,7 @@ Object {
data-test-subj="text-details-group-content-detector-dashboard"
id="some_html_id"
>
- Not available for this log type
+ Not available for this integration
@@ -661,7 +669,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Detection rules
+ Rules
2
@@ -752,7 +760,7 @@ Object {
class="euiFormLabel euiFormRow__label"
for="some_html_id"
>
- Threat intelligence
+ Description
- Disabled
+ detectorDescription
diff --git a/public/pages/Detectors/components/DetectorRulesView/DetectorRulesView.tsx b/public/pages/Detectors/components/DetectorRulesView/DetectorRulesView.tsx
index 906f5ed55..31be128f2 100644
--- a/public/pages/Detectors/components/DetectorRulesView/DetectorRulesView.tsx
+++ b/public/pages/Detectors/components/DetectorRulesView/DetectorRulesView.tsx
@@ -5,17 +5,18 @@
import { ContentPanel } from '../../../../components/ContentPanel';
import React, { useContext, useEffect, useState } from 'react';
-import { EuiAccordion, EuiSmallButton, EuiSpacer, EuiText } from '@elastic/eui';
+import { EuiAccordion, EuiSmallButton, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui';
import { RuleItem } from '../../../CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces';
import { SecurityAnalyticsContext } from '../../../../services';
import { RuleInfo } from '../../../../../server/models/interfaces';
import { errorNotificationToast, translateToRuleItems } from '../../../../utils/helpers';
import { NotificationsStart } from 'opensearch-dashboards/public';
import { RulesTable } from '../../../Rules/components/RulesTable/RulesTable';
-import { RuleTableItem } from '../../../Rules/utils/helpers';
-import { RuleViewerFlyout } from '../../../Rules/components/RuleViewerFlyout/RuleViewerFlyout';
+import { RuleTableItem } from '../../../WazuhRules/utils/helpers';
+import { RuleViewerFlyout } from '../../../WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout';
import { DataStore } from '../../../../store/DataStore';
import { Detector } from '../../../../../types';
+import { isStandardSource } from '../../../../utils/detectorSource';
export interface DetectorRulesViewProps {
detector: Detector;
@@ -50,14 +51,20 @@ export const DetectorRulesView: React.FC = (props) => {
const [enabledRuleItems, setEnabledRuleItems] = useState([]);
const [allRuleItems, setAllRuleItems] = useState([]);
const [loading, setLoading] = useState(false);
+ const isStandardDetector = isStandardSource(props.detector.source);
const actions = props.isEditable
? [
- props.onEditClicked(enabledRuleItems, allRuleItems)}
- data-test-subj={'edit-detector-rules'}
+
- Edit
- ,
+ props.onEditClicked(enabledRuleItems, allRuleItems)}
+ isDisabled={isStandardDetector}
+ data-test-subj={'edit-detector-rules'}
+ >
+ Edit
+
+ ,
]
: null;
const saContext = useContext(SecurityAnalyticsContext);
@@ -110,7 +117,7 @@ export const DetectorRulesView: React.FC = (props) => {
});
}, [saContext?.services, props.detector]);
- const getDetectionRulesTitle = () => `View detection rules`;
+ const getDetectionRulesTitle = () => `View rules`; // Wazuh: rename 'View detection rules' to 'View rules'
const onShowRuleDetails = (rule: RuleTableItem) => {
setFlyoutData(() => rule);
diff --git a/public/pages/Detectors/components/DetectorRulesView/__snapshots__/DetectorRulesView.test.tsx.snap b/public/pages/Detectors/components/DetectorRulesView/__snapshots__/DetectorRulesView.test.tsx.snap
index 698b6982f..fa44508b3 100644
--- a/public/pages/Detectors/components/DetectorRulesView/__snapshots__/DetectorRulesView.test.tsx.snap
+++ b/public/pages/Detectors/components/DetectorRulesView/__snapshots__/DetectorRulesView.test.tsx.snap
@@ -63,6 +63,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -201,12 +202,18 @@ exports[` spec renders the component 1`] = `
- Edit
- ,
+
+ Edit
+
+ ,
]
}
title="Active rules (2)"
@@ -280,61 +287,83 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
+
+
+ Edit
+
+
+
+
+
+
+
+
+
@@ -383,7 +412,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -391,7 +420,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -427,14 +456,6 @@ exports[` spec renders the component 1`] = `
"schema": true,
},
"filters": Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -489,21 +510,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
],
}
}
@@ -540,14 +546,6 @@ exports[` spec renders the component 1`] = `
}
filters={
Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -602,21 +600,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -725,14 +708,6 @@ exports[` spec renders the component 1`] = `
spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -829,140 +789,6 @@ exports[` spec renders the component 1`] = `
-
-
- Log type
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_0"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Log type
-
-
-
-
-
-
-
-
-
-
-
spec renders the component 1`] = `
"type": "field_value_selection",
}
}
- index={1}
+ index={0}
onChange={[Function]}
query={
Query {
@@ -1059,7 +885,7 @@ exports[`
spec renders the component 1`] = `
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
- id="field_value_selection_1"
+ id="field_value_selection_0"
isOpen={false}
ownFocus={true}
panelClassName="euiFilterGroup__popoverPanel"
@@ -1067,7 +893,7 @@ exports[`
spec renders the component 1`] = `
>
spec renders the component 1`] = `
-
-
- Source
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_2"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Source
-
-
-
-
-
-
-
-
-
-
-
@@ -1320,7 +1005,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -1328,7 +1013,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -1417,14 +1102,14 @@ exports[` spec renders the component 1`] = `
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_category_2",
- "name": "Log type",
+ "name": "Integration",
"onSort": [Function],
},
Object {
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_source_3",
- "name": "Source",
+ "name": "Space",
"onSort": [Function],
},
]
@@ -1705,15 +1390,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Log type",
+ "innerText": "Integration",
}
}
>
- Log type
+ Integration
@@ -1764,15 +1449,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Source",
+ "innerText": "Space",
}
}
>
- Source
+ Space
diff --git a/public/pages/Detectors/components/UpdateAlertConditions/UpdateAlertConditions.tsx b/public/pages/Detectors/components/UpdateAlertConditions/UpdateAlertConditions.tsx
index a4e0fff88..afdc17fc6 100644
--- a/public/pages/Detectors/components/UpdateAlertConditions/UpdateAlertConditions.tsx
+++ b/public/pages/Detectors/components/UpdateAlertConditions/UpdateAlertConditions.tsx
@@ -103,13 +103,14 @@ export default class UpdateAlertConditions extends Component<
const allRules: { [id: string]: RuleSource } = {};
const rulesOptions = new Set();
+ // Wazuh: Remove duplicated fields in metadata and root: title.
const processRules = (rules: RuleItemInfoBase[]) => {
rules.forEach((hit) => {
allRules[hit._id] = hit._source;
if (enabledRules.has(hit._id)) {
const rule = allRules[hit._id];
rulesOptions.add({
- name: rule.title,
+ name: rule.metadata?.title ?? '',
id: hit._id,
severity: rule.level,
tags: rule.tags.map((tag) => tag.value),
diff --git a/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap b/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap
index f7be91c3c..b9988082b 100644
--- a/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap
+++ b/public/pages/Detectors/components/UpdateAlertConditions/__snapshots__/UpdateAlertConditions.test.tsx.snap
@@ -68,6 +68,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -259,6 +260,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -477,6 +479,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -664,6 +667,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -857,6 +861,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
diff --git a/public/pages/Detectors/components/UpdateBasicDetails/UpdateBasicDetails.tsx b/public/pages/Detectors/components/UpdateBasicDetails/UpdateBasicDetails.tsx
index a74976b3d..b3ac4d04a 100644
--- a/public/pages/Detectors/components/UpdateBasicDetails/UpdateBasicDetails.tsx
+++ b/public/pages/Detectors/components/UpdateBasicDetails/UpdateBasicDetails.tsx
@@ -71,6 +71,7 @@ export const UpdateDetectorBasicDetails: React.FC spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -271,6 +272,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -819,18 +821,7 @@ exports[` spec renders the component 1`] = `
onChange={[Function]}
onCreateOption={[Function]}
onFocus={[Function]}
- options={
- Array [
- Object {
- "label": "Aliases",
- "options": Array [],
- },
- Object {
- "label": "Indices",
- "options": Array [],
- },
- ]
- }
+ options={Array []}
placeholder="Select an input source for the detector."
renderOption={[Function]}
selectedOptions={
@@ -1304,6 +1295,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
diff --git a/public/pages/Detectors/components/UpdateFieldMappings/UpdateFieldMappings.tsx b/public/pages/Detectors/components/UpdateFieldMappings/UpdateFieldMappings.tsx
index cd5b2ff97..a337c8500 100644
--- a/public/pages/Detectors/components/UpdateFieldMappings/UpdateFieldMappings.tsx
+++ b/public/pages/Detectors/components/UpdateFieldMappings/UpdateFieldMappings.tsx
@@ -73,6 +73,7 @@ export default class UpdateFieldMappings extends Component<
detector.detector_type = detector.detector_type.toLowerCase();
setBreadcrumbs([
+ BREADCRUMBS.DETECTION,
BREADCRUMBS.DETECTORS,
BREADCRUMBS.DETECTORS_DETAILS(detectorHit._source.name, detectorHit._id),
BREADCRUMBS.EDIT_DETECTOR_DETAILS,
diff --git a/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx b/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx
index 68d934ad4..b680e5087 100644
--- a/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx
+++ b/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx
@@ -3,7 +3,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { EuiSmallButton, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSpacer,
+ EuiTitle,
+ EuiSmallButton,
+} from '@elastic/eui';
import {
DetectorHit,
SearchDetectorsResponse,
@@ -22,8 +31,8 @@ import {
setBreadcrumbs,
successNotificationToast,
} from '../../../../utils/helpers';
-import { RuleTableItem } from '../../../Rules/utils/helpers';
-import { RuleViewerFlyout } from '../../../Rules/components/RuleViewerFlyout/RuleViewerFlyout';
+import { RuleTableItem } from '../../../WazuhRules/utils/helpers';
+import { RuleViewerFlyout } from '../../../WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout';
import { ContentPanel } from '../../../../components/ContentPanel';
import { DataStore } from '../../../../store/DataStore';
import ReviewFieldMappings from '../ReviewFieldMappings/ReviewFieldMappings';
@@ -65,6 +74,7 @@ export const UpdateDetectorRules: React.FC = (props) =
setDetector(newDetector);
setBreadcrumbs([
+ BREADCRUMBS.DETECTION,
BREADCRUMBS.DETECTORS,
BREADCRUMBS.DETECTORS_DETAILS(detectorHit._source.name, detectorHit._id),
{
@@ -90,15 +100,16 @@ export const UpdateDetectorRules: React.FC = (props) =
const allRules = await DataStore.rules.getAllRules({
'rule.category': [detector.detector_type.toLowerCase()],
});
-
const prePackagedRules = allRules?.filter((rule) => rule.prePackaged);
const prePackagedRuleItems = prePackagedRules?.map((rule) => ({
- name: rule._source.title,
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ name: rule._source.metadata?.title ?? '',
id: rule._id,
severity: rule._source.level,
logType: rule._source.category,
library: 'Standard',
- description: rule._source.description,
+ // Wazuh: Remove duplicated fields in metadata and root: description.
+ description: rule._source.metadata?.description ?? '',
active: enabledRuleIds.includes(rule._id),
ruleInfo: rule,
}));
@@ -106,12 +117,14 @@ export const UpdateDetectorRules: React.FC = (props) =
const customRules = allRules?.filter((rule) => !rule.prePackaged);
const customRuleItems = customRules?.map((rule) => ({
- name: rule._source.title,
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ name: rule._source.metadata?.title ?? '',
id: rule._id,
severity: rule._source.level,
logType: rule._source.category,
library: 'Custom',
- description: rule._source.description,
+ // Wazuh: Remove duplicated fields in metadata and root: description.
+ description: rule._source.metadata?.description ?? '',
active: enabledRuleIds.includes(rule._id),
ruleInfo: rule,
}));
@@ -277,7 +290,7 @@ export const UpdateDetectorRules: React.FC = (props) =
);
return (
-
+
{flyoutData ? (
setFlyoutData(() => null)}
@@ -291,7 +304,8 @@ export const UpdateDetectorRules: React.FC = (props) =
item.active).length
})`}
>
@@ -317,7 +331,9 @@ export const UpdateDetectorRules: React.FC = (props) =
-
+ {/* Wazuh: Original Cancel and Create buttons replaced by standardized
+ EuiBottomBar for UI consistency.*/}
+ {/*
Cancel
@@ -335,7 +351,42 @@ export const UpdateDetectorRules: React.FC = (props) =
Save changes
-
+ */}
+
+
+
+
+
+ Cancel
+
+
+
+
+ Edit detector rules
+
+
+
+
);
diff --git a/public/pages/Detectors/components/UpdateRules/__snapshots__/UpdateDetectorRules.test.tsx.snap b/public/pages/Detectors/components/UpdateRules/__snapshots__/UpdateDetectorRules.test.tsx.snap
index 293b23d23..763efeaa4 100644
--- a/public/pages/Detectors/components/UpdateRules/__snapshots__/UpdateDetectorRules.test.tsx.snap
+++ b/public/pages/Detectors/components/UpdateRules/__snapshots__/UpdateDetectorRules.test.tsx.snap
@@ -3,9 +3,13 @@
exports[`
spec renders the component 1`] = `
Object {
"asFragment": [Function],
- "baseElement":
+ "baseElement":
-
+
@@ -29,7 +33,7 @@ Object {
class="euiText euiText--small"
>
- Detection rules (0)
+ Rules (0)
@@ -263,9 +267,9 @@ Object {
>
- Log type
+ Integration
@@ -281,9 +285,9 @@ Object {
>
- Source
+ Space
@@ -337,54 +341,76 @@ Object {
-
+
+
+
+
+
+
+ Page level controls
+
+
+
+
-
-
-
-
- Cancel
-
-
-
-
-
+
+
+
+
+
+
-
-
-
- Save changes
-
-
-
-
-
+ Edit detector rules
+
+
+
-
+
+
+ There is a new region landmark with page level controls at the end of the document.
+
,
"container":
-
+
@@ -408,7 +434,7 @@ Object {
class="euiText euiText--small"
>
- Detection rules (0)
+ Rules (0)
@@ -642,9 +668,9 @@ Object {
>
- Log type
+ Integration
@@ -660,9 +686,9 @@ Object {
>
- Source
+ Space
@@ -716,47 +742,6 @@ Object {
-
-
-
-
-
- Cancel
-
-
-
-
-
-
-
-
- Save changes
-
-
-
-
-
diff --git a/public/pages/Detectors/components/WazuhUpdateBasicDetails/WazuhUpdateBasicDetails.tsx b/public/pages/Detectors/components/WazuhUpdateBasicDetails/WazuhUpdateBasicDetails.tsx
new file mode 100644
index 000000000..447228f41
--- /dev/null
+++ b/public/pages/Detectors/components/WazuhUpdateBasicDetails/WazuhUpdateBasicDetails.tsx
@@ -0,0 +1,551 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiCallOut,
+ EuiComboBoxOptionOption,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiTitle,
+} from '@elastic/eui';
+import { PeriodSchedule } from '../../../../../models/interfaces';
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import DetectorBasicDetailsForm from '../../../CreateDetector/components/DefineDetector/components/DetectorDetails';
+import DetectorDataSource from '../../../CreateDetector/components/DefineDetector/components/DetectorDataSource';
+import { FieldMappingService, IndexService, SecurityAnalyticsContext } from '../../../../services';
+import { DetectorSchedule } from '../../../CreateDetector/components/DefineDetector/components/DetectorSchedule/DetectorSchedule';
+import { DetectorHit, SearchDetectorsResponse } from '../../../../../server/models/interfaces';
+import { BREADCRUMBS, EMPTY_DEFAULT_DETECTOR, ROUTES } from '../../../../utils/constants';
+import { ServerResponse } from '../../../../../server/models/types';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ errorNotificationToast,
+ getIntegrationOptionsBySpace,
+ setBreadcrumbs,
+ successNotificationToast,
+} from '../../../../utils/helpers';
+import { isDetectorFormValid } from '../../utils/helpers';
+import { FieldMapping, Detector } from '../../../../../types';
+import { ThreatIntelligence } from '../../../CreateDetector/components/DefineDetector/components/ThreatIntelligence/ThreatIntelligence';
+import { PageHeader } from '../../../../components/PageHeader/PageHeader';
+import { dataSourceInfo } from '../../../../services/utils/constants';
+import { SpaceSelector } from '../../../../components/SpaceSelector/SpaceSelector';
+import { SpaceTypes } from '../../../../../common/constants';
+import { IntegrationComboBox } from '../../../../components/IntegrationComboBox';
+import {
+ CreateDetectorRulesState,
+ DetectionRules,
+} from '../../../CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules';
+import {
+ RuleItem,
+ RuleItemInfo,
+} from '../../../CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces';
+import { DataStore } from '../../../../store/DataStore';
+import ConfigureFieldMapping from '../../../CreateDetector/components/ConfigureFieldMapping';
+import { RulesContentUpdateWarning } from '../../../WazuhCreateDetector/components/DefineDetector/components/DetectorType';
+
+export interface WazuhUpdateDetectorBasicDetailsProps
+ extends RouteComponentProps {
+ notifications: NotificationsStart;
+}
+
+export const WazuhUpdateDetectorBasicDetails: React.FC = (
+ props
+) => {
+ const saContext = useContext(SecurityAnalyticsContext);
+ const [detector, setDetector] = useState(
+ (props.location.state?.detectorHit?._source || EMPTY_DEFAULT_DETECTOR) as Detector
+ );
+ const [fieldMappings, setFieldMappings] = useState();
+ const { name, inputs } = detector;
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const description = inputs[0].detector_input.description;
+ const detectorId = props.location.pathname.replace(`${ROUTES.EDIT_DETECTOR_DETAILS}/`, '');
+
+ const [threatIntelEnabledInitially, setThreatIntelEnabledInitially] = useState(false);
+
+ const [selectedSpace, setSelectedSpace] = useState(SpaceTypes.STANDARD.value);
+ const [integrationOptions, setIntegrationOptions] = useState<
+ { id: string; value: string; label: string }[]
+ >([]);
+ const [loadingIntegrations, setLoadingIntegrations] = useState(false);
+ const [integrationTouched, setIntegrationTouched] = useState(false);
+
+ const [rulesState, setRulesState] = useState({
+ page: { index: 0 },
+ allRules: [],
+ });
+ const [loadingRules, setLoadingRules] = useState(false);
+
+ const getSpaceForDetectorType = async (detectorType: string): Promise => {
+ // FIXME: this seems to be broken if there are integration with the same name in custom and standard space
+ const standardOptions = await getIntegrationOptionsBySpace(SpaceTypes.STANDARD.value);
+ if (standardOptions.some((opt) => opt.value === detectorType)) {
+ return SpaceTypes.STANDARD.value;
+ }
+ return SpaceTypes.CUSTOM.value;
+ };
+
+ const loadIntegrationOptions = useCallback(async (space: string) => {
+ setLoadingIntegrations(true);
+ const options = await getIntegrationOptionsBySpace(space);
+ setIntegrationOptions(options);
+ setLoadingIntegrations(false);
+ }, []);
+
+ const loadRules = useCallback(
+ async (
+ detectorType: string,
+ space: string,
+ enabledRuleIds?: string[]
+ ): Promise => {
+ if (!detectorType) {
+ setRulesState({ page: { index: 0 }, allRules: [] });
+ return [];
+ }
+ setLoadingRules(true);
+ const allRules = await DataStore.rules.getAllRules({
+ 'rule.category': [detectorType.toLowerCase()],
+ });
+ const spaceRules = allRules.filter((rule) => rule.space === space);
+ const ruleItems: RuleItemInfo[] = spaceRules.map((rule) => ({
+ ...rule,
+ enabled: enabledRuleIds !== undefined ? enabledRuleIds.includes(rule._id) : true,
+ }));
+ setRulesState({ page: { index: 0 }, allRules: ruleItems });
+ setLoadingRules(false);
+ return ruleItems;
+ },
+ []
+ );
+
+ const getEnabledRuleIds = (det: Detector): string[] => {
+ const prePackaged = det.inputs[0].detector_input.pre_packaged_rules.map((r) => r.id);
+ const custom = det.inputs[0].detector_input.custom_rules.map((r) => r.id);
+ return prePackaged.concat(custom);
+ };
+
+ const buildDetectorWithRules = useCallback(
+ (det: Detector, rules: RuleItemInfo[]): Detector => ({
+ ...det,
+ inputs: [
+ {
+ detector_input: {
+ ...det.inputs[0].detector_input,
+ pre_packaged_rules: rules
+ .filter((r) => r.enabled && r.prePackaged)
+ .map((r) => ({ id: r._id })),
+ custom_rules: rules
+ .filter((r) => r.enabled && !r.prePackaged)
+ .map((r) => ({ id: r._id })),
+ },
+ },
+ ...det.inputs.slice(1),
+ ],
+ }),
+ []
+ );
+
+ useEffect(() => {
+ setThreatIntelEnabledInitially(detector.threat_intel_enabled);
+ }, []);
+
+ useEffect(() => {
+ const getDetector = async () => {
+ const response = (await saContext?.services.detectorsService.getDetectors()) as ServerResponse<
+ SearchDetectorsResponse
+ >;
+ if (response.ok) {
+ const detectorHit = response.response.hits.hits.find(
+ (detectorHit) => detectorHit._id === detectorId
+ ) as DetectorHit;
+ const loadedDetector = detectorHit._source as Detector;
+ setDetector(loadedDetector);
+
+ setBreadcrumbs([
+ BREADCRUMBS.DETECTION,
+ BREADCRUMBS.DETECTORS,
+ BREADCRUMBS.DETECTORS_DETAILS(detectorHit._source.name, detectorHit._id),
+ BREADCRUMBS.EDIT_DETECTOR_DETAILS,
+ ]);
+ props.history.replace({
+ pathname: `${ROUTES.EDIT_DETECTOR_DETAILS}/${detectorId}`,
+ state: {
+ detectorHit: {
+ ...detectorHit,
+ _source: { ...detectorHit._source, ...detectorHit },
+ },
+ },
+ });
+
+ const space = await getSpaceForDetectorType(loadedDetector.detector_type);
+ setSelectedSpace(space);
+ await loadIntegrationOptions(space);
+ await loadRules(loadedDetector.detector_type, space, getEnabledRuleIds(loadedDetector));
+ } else {
+ errorNotificationToast(props.notifications, 'retrieve', 'detector', response.error);
+ }
+ };
+
+ const execute = async () => {
+ setLoading(true);
+ await getDetector();
+ setLoading(false);
+ };
+
+ if (!detector.id?.length) {
+ execute().catch((e) => {
+ errorNotificationToast(props.notifications, 'retrieve', 'detector', e);
+ });
+ } else {
+ getSpaceForDetectorType(detector.detector_type).then(async (space) => {
+ setSelectedSpace(space);
+ await loadIntegrationOptions(space);
+ await loadRules(detector.detector_type, space, getEnabledRuleIds(detector));
+ });
+ }
+ }, [saContext?.services]);
+
+ const updateDetectorState = useCallback(
+ (det: Detector) => {
+ setDetector(det);
+ },
+ [setDetector]
+ );
+
+ const onSpaceChange = useCallback(
+ async (space: string) => {
+ setSelectedSpace(space);
+ const updatedDetector = {
+ ...detector,
+ detector_type: '',
+ inputs: [
+ {
+ detector_input: {
+ ...detector.inputs[0].detector_input,
+ pre_packaged_rules: [],
+ custom_rules: [],
+ },
+ },
+ ...detector.inputs.slice(1),
+ ],
+ };
+ updateDetectorState(updatedDetector);
+ setRulesState({ page: { index: 0 }, allRules: [] });
+ await loadIntegrationOptions(space);
+ },
+ [detector, updateDetectorState, loadIntegrationOptions]
+ );
+
+ const onIntegrationChange = useCallback(
+ async (detectorType: string) => {
+ setIntegrationTouched(true);
+ const baseDetector = { ...detector, detector_type: detectorType };
+ const loadedRules = await loadRules(detectorType, selectedSpace);
+ updateDetectorState(buildDetectorWithRules(baseDetector, loadedRules));
+ },
+ [detector, selectedSpace, updateDetectorState, loadRules, buildDetectorWithRules]
+ );
+
+ const onRuleToggle = useCallback(
+ (changedItem: RuleItem, isActive: boolean) => {
+ const newRules = rulesState.allRules.map((r) =>
+ r._id === changedItem.id ? { ...r, enabled: isActive } : r
+ );
+ setRulesState((prev) => ({ ...prev, allRules: newRules }));
+ updateDetectorState(buildDetectorWithRules(detector, newRules));
+ },
+ [rulesState, detector, updateDetectorState, buildDetectorWithRules]
+ );
+
+ const onAllRulesToggle = useCallback(
+ (enabled: boolean) => {
+ const newRules = rulesState.allRules.map((r) => ({ ...r, enabled }));
+ setRulesState((prev) => ({ ...prev, allRules: newRules }));
+ updateDetectorState(buildDetectorWithRules(detector, newRules));
+ },
+ [rulesState, detector, updateDetectorState, buildDetectorWithRules]
+ );
+
+ const onPageChange = useCallback((page: { index: number; size: number }) => {
+ setRulesState((prev) => ({ ...prev, page: { index: page.index } }));
+ }, []);
+
+ const onDetectorNameChange = useCallback(
+ (detectorName: string) => {
+ updateDetectorState({ ...detector, name: detectorName });
+ },
+ [detector, updateDetectorState]
+ );
+
+ const onDetectorInputDescriptionChange = useCallback(
+ (description: string) => {
+ const { inputs } = detector;
+ updateDetectorState({
+ ...detector,
+ inputs: [
+ {
+ detector_input: {
+ ...inputs[0].detector_input,
+ description: description,
+ },
+ },
+ ...inputs.slice(1),
+ ],
+ });
+ },
+ [detector, updateDetectorState]
+ );
+
+ const onDetectorInputIndicesChange = useCallback(
+ (selectedOptions: EuiComboBoxOptionOption[]) => {
+ const detectorIndices = selectedOptions.map((selectedOption) => selectedOption.label);
+ const { inputs } = detector;
+ updateDetectorState({
+ ...detector,
+ inputs: [
+ {
+ detector_input: {
+ ...inputs[0].detector_input,
+ indices: detectorIndices,
+ },
+ },
+ ...inputs.slice(1),
+ ],
+ });
+ },
+ [detector, updateDetectorState]
+ );
+
+ const onThreatIntelFeedToggle = useCallback(
+ (enabled: boolean) => {
+ updateDetectorState({ ...detector, threat_intel_enabled: enabled });
+ },
+ [detector, updateDetectorState]
+ );
+
+ const onDetectorScheduleChange = useCallback(
+ (schedule: PeriodSchedule) => {
+ updateDetectorState({ ...detector, schedule });
+ },
+ [detector, updateDetectorState]
+ );
+
+ const replaceFieldMappings = useCallback(
+ (mappings: FieldMapping[]) => {
+ setFieldMappings(mappings);
+ },
+ [setFieldMappings]
+ );
+
+ const onCancel = useCallback(() => {
+ props.history.replace({
+ pathname: `${ROUTES.DETECTOR_DETAILS}/${detectorId}`,
+ state: props.location.state,
+ });
+ }, []);
+
+ const onSave = useCallback(async () => {
+ if (!detector.detector_type) {
+ setIntegrationTouched(true);
+ return;
+ }
+
+ setSubmitting(true);
+
+ const updateDetector = async () => {
+ const detectorHit = props.location.state.detectorHit;
+ const updateDetectorRes = await saContext?.services.detectorsService?.updateDetector(
+ detectorHit._id,
+ detector
+ );
+
+ if (updateDetectorRes?.ok) {
+ successNotificationToast(props.notifications, 'updated', 'detector');
+ } else {
+ errorNotificationToast(props.notifications, 'update', 'detector', updateDetectorRes?.error);
+ }
+
+ setSubmitting(false);
+
+ props.history.replace({
+ pathname: `${ROUTES.DETECTOR_DETAILS}/${detectorId}`,
+ state: {
+ detectorHit: {
+ ...detectorHit,
+ _source: { ...detectorHit._source, ...detector },
+ },
+ },
+ });
+ };
+
+ if (fieldMappings?.length) {
+ const createMappingsResponse = await saContext?.services.fieldMappingService?.createMappings(
+ detector.inputs[0].detector_input.indices[0],
+ detector.detector_type.toLowerCase(),
+ fieldMappings
+ );
+
+ if (!createMappingsResponse?.ok) {
+ errorNotificationToast(
+ props.notifications,
+ 'update',
+ 'field mappings',
+ createMappingsResponse?.error
+ );
+ } else {
+ await updateDetector();
+ }
+ } else {
+ await updateDetector();
+ }
+ }, [detector, fieldMappings]);
+
+ const integrationIsInvalid = integrationTouched && !detector.detector_type;
+
+ const isFormValid = isDetectorFormValid(detector);
+
+ return (
+ <>
+
+
+ Edit detector details
+
+
+
+
+
+
+
+
+
+
+ {selectedSpace === SpaceTypes.STANDARD.value && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ onIntegrationChange(e[0]?.value || '')}
+ resourceName="detectors"
+ space={selectedSpace}
+ />
+
+
+
+
+
+
+
+ {/* r.enabled)}
+ replaceFieldMappings={replaceFieldMappings}
+ />
+
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Edit detector
+
+
+
+
+ >
+ );
+};
diff --git a/public/pages/Detectors/components/WazuhUpdateBasicDetails/index.ts b/public/pages/Detectors/components/WazuhUpdateBasicDetails/index.ts
new file mode 100644
index 000000000..45d129e7e
--- /dev/null
+++ b/public/pages/Detectors/components/WazuhUpdateBasicDetails/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export { WazuhUpdateDetectorBasicDetails } from './WazuhUpdateBasicDetails';
diff --git a/public/pages/Detectors/containers/Detector/DetectorDetails.tsx b/public/pages/Detectors/containers/Detector/DetectorDetails.tsx
index 307a025b4..c84ec05f3 100644
--- a/public/pages/Detectors/containers/Detector/DetectorDetails.tsx
+++ b/public/pages/Detectors/containers/Detector/DetectorDetails.tsx
@@ -16,22 +16,25 @@ import {
EuiTabs,
EuiText,
EuiHealth,
+ EuiToolTip,
} from '@elastic/eui';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { BREADCRUMBS, EMPTY_DEFAULT_DETECTOR_HIT, ROUTES } from '../../../../utils/constants';
-import { DetectorHit } from '../../../../../server/models/interfaces';
+import { DetectorHit, DetectorHitWithSpace } from '../../../../../server/models/interfaces';
import { DetectorDetailsView } from '../DetectorDetailsView/DetectorDetailsView';
import { FieldMappingsView } from '../../components/FieldMappingsView/FieldMappingsView';
-import { AlertTriggersView } from '../AlertTriggersView/AlertTriggersView';
+// Wazuh: hide Alert triggers tab in detector details.
+// import { AlertTriggersView } from '../AlertTriggersView/AlertTriggersView';
import { RuleItem } from '../../../CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces';
import { DetectorsService, IndexPatternsService } from '../../../../services';
import { errorNotificationToast, setBreadcrumbs } from '../../../../utils/helpers';
import { NotificationsStart, SimpleSavedObject } from 'opensearch-dashboards/public';
-import { ISavedObjectsService, ServerResponse } from '../../../../../types';
+import { Detector, ISavedObjectsService, ServerResponse } from '../../../../../types';
import { PENDING_DETECTOR_ID } from '../../../CreateDetector/utils/constants';
import { DataStore } from '../../../../store/DataStore';
import { PageHeader } from '../../../../components/PageHeader/PageHeader';
+import { getDetectorSourceLabel, isStandardSource } from '../../../../utils/detectorSource';
export interface DetectorDetailsProps
extends RouteComponentProps<
@@ -54,7 +57,7 @@ export interface DetectorDetailsState {
isActionsMenuOpen: boolean;
selectedTabId: TabId;
selectedTabContent: React.ReactNode;
- detectorHit: DetectorHit;
+ detectorHit: DetectorHitWithSpace; // Wazuh
detectorId: string;
tabs: any[];
loading: boolean;
@@ -65,15 +68,16 @@ export interface DetectorDetailsState {
enum TabId {
DetectorDetails = 'detector-config-tab',
FieldMappings = 'field-mappings-tab',
- AlertTriggers = 'alert-triggers-tab',
+ // Wazuh: hide Alert triggers tab in detector details.
+ // AlertTriggers = 'alert-triggers-tab',
}
export class DetectorDetails extends React.Component {
- private get detectorHit(): DetectorHit {
+ private get detectorHit(): DetectorHitWithSpace {
return this.state.detectorHit;
}
- private set detectorHit(hit: DetectorHit) {
+ private set detectorHit(hit: DetectorHitWithSpace) {
this.setState({ detectorHit: hit });
}
@@ -98,12 +102,13 @@ export class DetectorDetails extends React.Component {
- this.props.history.push({
- pathname: `${ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS}/${this.state.detectorId}`,
- state: { detectorHit: this.detectorHit },
- });
- };
+ // Wazuh: hide Alert triggers tab in detector details.
+ // editAlertTriggers = () => {
+ // this.props.history.push({
+ // pathname: `${ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS}/${this.state.detectorId}`,
+ // state: { detectorHit: this.detectorHit },
+ // });
+ // };
private getTabs() {
const { detectorId, createFailed } = this.state;
@@ -118,6 +123,7 @@ export class DetectorDetails extends React.Component
),
},
- {
- id: TabId.FieldMappings,
- name: 'Field mappings',
- content: (
-
- ),
- },
- {
- id: TabId.AlertTriggers,
- name: 'Alert triggers',
- content: (
-
- ),
- },
+ // Wazuh: hide Alert triggers and Field mappings tabs in detector details.
+ // {
+ // id: TabId.FieldMappings,
+ // name: 'Field mappings',
+ // content: (
+ //
+ // ),
+ // },
+ // {
+ // id: TabId.AlertTriggers,
+ // name: 'Alert triggers',
+ // content: (
+ //
+ // ),
+ // },
];
this.setState({ tabs: tabs, selectedTabContent: tabs[0].content });
}
@@ -191,14 +198,18 @@ export class DetectorDetails extends React.Component {
this.setState({ loading: true });
const { detectorService, notifications } = this.props;
try {
const { detectorId } = this.state;
- const response = await detectorService.getDetectors();
+ // GET by id (read-your-own-writes consistent), avoids eventual-consistency
+ // miss right after creation that occurs with search.
+ const response = await detectorService.getDetectorWithId(detectorId);
if (response.ok) {
- const detector = response.response.hits.hits.find(
- (detectorHit) => detectorHit._id === detectorId
- ) as DetectorHit;
+ const detector = {
+ _id: response.response._id,
+ _index: '',
+ _source: response.response.detector,
+ } as DetectorHit;
+
+ const space = this.getDetectorSpace(detector._source);
this.detectorHit = {
...detector,
@@ -249,6 +275,7 @@ export class DetectorDetails extends React.Component
- View Alerts
- ,
-
- View Findings
- ,
+ // Wazuh: hide "View alerts" action in detector details.
+ //
+ // View Alerts
+ // ,
+ //
+ // View Findings
+ // ,
<>
{this.state.dashboardId ? (
) : null}
>,
- ,
+ // ,
{`${this.detectorHit._source.enabled ? 'Stop' : 'Start'} detector`}
,
- {
- this.closeActionsPopover();
- this.onDelete();
- }}
- data-test-subj={'editButton'}
+ content={isStandardDetector ? 'Only Custom detectors can be deleted.' : undefined}
>
- Delete
- ,
+ {
+ this.closeActionsPopover();
+ this.onDelete();
+ }}
+ data-test-subj={'editButton'}
+ >
+ Delete
+
+ ,
]}
/>
,
];
}
- onViewAlertsClick = () => {
- this.props.history.push(`${ROUTES.ALERTS}/${this.state.detectorId}`);
- };
+ // Wazuh: hide "View alerts" action in detector details.
+ // onViewAlertsClick = () => {
+ // this.props.history.push(`${ROUTES.ALERTS}/${this.state.detectorId}`);
+ // };
onViewFindingsClick = () => {
this.props.history.push(`${ROUTES.FINDINGS}/${this.state.detectorId}`);
@@ -444,7 +479,12 @@ export class DetectorDetails extends React.Component (
this.setState({ selectedTabId: tab.id, selectedTabContent: tab.content })}
+ onClick={() =>
+ this.setState({
+ selectedTabId: tab.id,
+ selectedTabContent: tab.content,
+ })
+ }
isSelected={this.state.selectedTabId === tab.id}
>
{tab.name}
@@ -479,11 +519,15 @@ export class DetectorDetails extends React.Component
{statusText} },
+ {
+ renderComponent: {statusText} ,
+ },
]}
appRightControls={
!creatingDetector && !createFailed
- ? this.createHeaderActions().map((action) => ({ renderComponent: action }))
+ ? this.createHeaderActions().map((action) => ({
+ renderComponent: action,
+ }))
: undefined
}
>
diff --git a/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap b/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap
index d489b5df5..5b421bc8f 100644
--- a/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap
+++ b/public/pages/Detectors/containers/Detector/__snapshots__/DetectorDetails.test.tsx.snap
@@ -68,6 +68,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -213,7 +214,23 @@ exports[` spec renders the component 1`] = `
notifications={
Object {
"toasts": Object {
- "addDanger": [MockFunction],
+ "addDanger": [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "text": [TypeError: detectorService.getDetectorWithId is not a function],
+ "title": "Failed to retrieve detector:",
+ "toastLifeTimeMs": 5000,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
@@ -278,26 +295,7 @@ exports[` spec renders the component 1`] = `
hasFocus={true}
items={
Array [
-
- View Alerts
- ,
-
- View Findings
- ,
,
- ,
spec renders the component 1`] = `
>
Stop detector
,
-
- Delete
- ,
+
+ Delete
+
+ ,
]
}
size="s"
@@ -351,9 +354,7 @@ exports[` spec renders the component 1`] = `
className="euiText euiText--small"
data-test-subj="detector-details-detector-name"
>
-
- detector_name
-
+
@@ -579,46 +580,6 @@ exports[` spec renders the component 1`] = `
-
-
-
- Field mappings
-
-
-
-
-
-
- Alert triggers
-
-
-
spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -874,7 +836,23 @@ exports[` spec renders the component 1`] = `
notifications={
Object {
"toasts": Object {
- "addDanger": [MockFunction],
+ "addDanger": [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "text": [TypeError: detectorService.getDetectorWithId is not a function],
+ "title": "Failed to retrieve detector:",
+ "toastLifeTimeMs": 5000,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
@@ -994,6 +972,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -1144,7 +1123,23 @@ exports[` spec renders the component 1`] = `
notifications={
Object {
"toasts": Object {
- "addDanger": [MockFunction],
+ "addDanger": [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "text": [TypeError: detectorService.getDetectorWithId is not a function],
+ "title": "Failed to retrieve detector:",
+ "toastLifeTimeMs": 5000,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
@@ -1172,12 +1167,18 @@ exports[` spec renders the component 1`] = `
- Edit
- ,
+
+ Edit
+
+ ,
]
}
title="Detector details"
@@ -1251,61 +1252,83 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
+
+
+ Edit
+
+
+
+
+
+
+
+
+
@@ -1414,7 +1437,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Description"
+ label="Integration"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Description
+ Integration
@@ -1442,7 +1465,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- -
+ network
@@ -1476,7 +1499,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Detector schedule"
+ label="Space"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Detector schedule
+ Space
@@ -1504,7 +1527,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Every 1 minute
+ -
@@ -1613,7 +1636,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Log type"
+ label="Detector schedule"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Log type
+ Detector schedule
@@ -1641,7 +1664,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Network
+ Every 1 minute
@@ -1752,7 +1775,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Detection rules"
+ label="Rules"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Detection rules
+ Rules
@@ -1780,7 +1803,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Threat intelligence"
+ label="Description"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Threat intelligence
+ Description
@@ -1968,7 +1991,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Disabled
+ -
@@ -2102,6 +2125,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -2252,7 +2276,23 @@ exports[` spec renders the component 1`] = `
notifications={
Object {
"toasts": Object {
- "addDanger": [MockFunction],
+ "addDanger": [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "text": [TypeError: detectorService.getDetectorWithId is not a function],
+ "title": "Failed to retrieve detector:",
+ "toastLifeTimeMs": 5000,
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ },
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
@@ -2280,12 +2320,18 @@ exports[` spec renders the component 1`] = `
- Edit
- ,
+
+ Edit
+
+ ,
]
}
title="Active rules (0)"
@@ -2359,61 +2405,83 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
+
+
+ Edit
+
+
+
+
+
+
+
+
+
@@ -2462,7 +2530,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -2470,7 +2538,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -2506,14 +2574,6 @@ exports[` spec renders the component 1`] = `
"schema": true,
},
"filters": Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -2568,21 +2628,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
],
}
}
@@ -2619,14 +2664,6 @@ exports[` spec renders the component 1`] = `
}
filters={
Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -2681,21 +2718,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -2804,14 +2826,6 @@ exports[` spec renders the component 1`] = `
spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -2908,140 +2907,6 @@ exports[` spec renders the component 1`] = `
-
-
- Log type
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_0"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Log type
-
-
-
-
-
-
-
-
-
-
-
spec renders the component 1`] = `
"type": "field_value_selection",
}
}
- index={1}
+ index={0}
onChange={[Function]}
query={
Query {
@@ -3138,7 +3003,7 @@ exports[`
spec renders the component 1`] = `
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
- id="field_value_selection_1"
+ id="field_value_selection_0"
isOpen={false}
ownFocus={true}
panelClassName="euiFilterGroup__popoverPanel"
@@ -3146,7 +3011,7 @@ exports[`
spec renders the component 1`] = `
>
spec renders the component 1`] = `
-
-
- Source
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_2"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Source
-
-
-
-
-
-
-
-
-
-
-
@@ -3399,7 +3123,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -3407,7 +3131,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -3496,14 +3220,14 @@ exports[` spec renders the component 1`] = `
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_category_2",
- "name": "Log type",
+ "name": "Integration",
"onSort": [Function],
},
Object {
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_source_3",
- "name": "Source",
+ "name": "Space",
"onSort": [Function],
},
]
@@ -3784,15 +3508,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Log type",
+ "innerText": "Integration",
}
}
>
- Log type
+ Integration
@@ -3843,15 +3567,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Source",
+ "innerText": "Space",
}
}
>
- Source
+ Space
diff --git a/public/pages/Detectors/containers/DetectorDetailsView/DetectorDetailsView.tsx b/public/pages/Detectors/containers/DetectorDetailsView/DetectorDetailsView.tsx
index e3f5cb148..cce5fb04c 100644
--- a/public/pages/Detectors/containers/DetectorDetailsView/DetectorDetailsView.tsx
+++ b/public/pages/Detectors/containers/DetectorDetailsView/DetectorDetailsView.tsx
@@ -21,6 +21,7 @@ export interface DetectorDetailsViewProps {
editBasicDetails: () => void;
editDetectorRules: (enabledRules: RuleItem[], allRuleItems: RuleItem[]) => void;
isEditable?: boolean;
+ space?: string; // Wazuh
}
export interface DetectorDetailsViewState {}
@@ -63,6 +64,7 @@ export class DetectorDetailsView extends React.Component<
dashboardId={dashboardId}
onEditClicked={editBasicDetails}
isEditable={isEditable}
+ space={this.props.space} // Wazuh
>
{rulesCanFold ? detectorRules : null}
diff --git a/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap b/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap
index 19fba1788..37e15032b 100644
--- a/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap
+++ b/public/pages/Detectors/containers/DetectorDetailsView/__snapshots__/DetectorDetailsView.test.tsx.snap
@@ -63,6 +63,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -262,6 +263,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -404,12 +406,18 @@ exports[` spec renders the component 1`] = `
- Edit
- ,
+
+ Edit
+
+ ,
]
}
title="Detector details"
@@ -483,61 +491,83 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
+
+
+ Edit
+
+
+
+
+
+
+
+
+
@@ -648,7 +678,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Description"
+ label="Integration"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Description
+ Integration
@@ -676,7 +706,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- detectorDescription
+ detector_type
@@ -710,7 +740,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Detector schedule"
+ label="Space"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Detector schedule
+ Space
@@ -738,7 +768,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Every 1 minute
+ -
@@ -857,7 +887,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Log type"
+ label="Detector schedule"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Log type
+ Detector schedule
@@ -885,7 +915,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Detector Type
+ Every 1 minute
@@ -960,7 +990,7 @@ exports[` spec renders the component 1`] = `
onBlur={[Function]}
onFocus={[Function]}
>
- Not available for this log type
+ Not available for this integration
@@ -996,7 +1026,7 @@ exports[` spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Detection rules"
+ label="Rules"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Detection rules
+ Rules
@@ -1024,7 +1054,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
spec renders the component 1`] = `
fullWidth={true}
hasChildLabel={true}
hasEmptyLabelSpace={false}
- label="Threat intelligence"
+ label="Description"
labelType="label"
>
spec renders the component 1`] = `
className="euiFormLabel euiFormRow__label"
htmlFor="some_html_id"
>
- Threat intelligence
+ Description
@@ -1212,7 +1242,7 @@ exports[` spec renders the component 1`] = `
className="euiFormRow__fieldWrapper"
>
spec renders the component 1`] = `
>
- Disabled
+ detectorDescription
@@ -1312,6 +1342,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -1454,12 +1485,18 @@ exports[` spec renders the component 1`] = `
- Edit
- ,
+
+ Edit
+
+ ,
]
}
title="Active rules (2)"
@@ -1533,61 +1570,83 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- Edit
-
-
-
-
-
-
-
+
+
+ Edit
+
+
+
+
+
+
+
+
+
@@ -1636,7 +1695,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -1644,7 +1703,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -1680,14 +1739,6 @@ exports[` spec renders the component 1`] = `
"schema": true,
},
"filters": Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -1742,21 +1793,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
],
}
}
@@ -1793,14 +1829,6 @@ exports[` spec renders the component 1`] = `
}
filters={
Array [
- Object {
- "compressed": true,
- "field": "category",
- "multiSelect": "or",
- "name": "Log type",
- "options": Array [],
- "type": "field_value_selection",
- },
Object {
"compressed": true,
"field": "level",
@@ -1855,21 +1883,6 @@ exports[` spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -1978,14 +1991,6 @@ exports[` spec renders the component 1`] = `
spec renders the component 1`] = `
],
"type": "field_value_selection",
},
- Object {
- "compressed": true,
- "field": "source",
- "multiSelect": "or",
- "name": "Source",
- "options": Array [
- Object {
- "value": "Standard",
- },
- Object {
- "value": "Custom",
- },
- ],
- "type": "field_value_selection",
- },
]
}
onChange={[Function]}
@@ -2082,140 +2072,6 @@ exports[` spec renders the component 1`] = `
-
-
- Log type
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_0"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Log type
-
-
-
-
-
-
-
-
-
-
-
spec renders the component 1`] = `
"type": "field_value_selection",
}
}
- index={1}
+ index={0}
onChange={[Function]}
query={
Query {
@@ -2312,7 +2168,7 @@ exports[`
spec renders the component 1`] = `
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
- id="field_value_selection_1"
+ id="field_value_selection_0"
isOpen={false}
ownFocus={true}
panelClassName="euiFilterGroup__popoverPanel"
@@ -2320,7 +2176,7 @@ exports[`
spec renders the component 1`] = `
>
spec renders the component 1`] = `
-
-
- Source
-
- }
- closePopover={[Function]}
- display="inlineBlock"
- hasArrow={true}
- id="field_value_selection_2"
- isOpen={false}
- ownFocus={true}
- panelClassName="euiFilterGroup__popoverPanel"
- panelPaddingSize="none"
- >
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
-
- Source
-
-
-
-
-
-
-
-
-
-
-
@@ -2573,7 +2288,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "category",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
"truncateText": true,
@@ -2581,7 +2296,7 @@ exports[` spec renders the component 1`] = `
},
Object {
"field": "source",
- "name": "Source",
+ "name": "Space",
"sortable": true,
"truncateText": true,
"width": "10%",
@@ -2670,14 +2385,14 @@ exports[` spec renders the component 1`] = `
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_category_2",
- "name": "Log type",
+ "name": "Integration",
"onSort": [Function],
},
Object {
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_source_3",
- "name": "Source",
+ "name": "Space",
"onSort": [Function],
},
]
@@ -2958,15 +2673,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Log type",
+ "innerText": "Integration",
}
}
>
- Log type
+ Integration
@@ -3017,15 +2732,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Source",
+ "innerText": "Space",
}
}
>
- Source
+ Space
diff --git a/public/pages/Detectors/containers/Detectors/Detectors.tsx b/public/pages/Detectors/containers/Detectors/Detectors.tsx
index a276d0a5b..4bcf71b77 100644
--- a/public/pages/Detectors/containers/Detectors/Detectors.tsx
+++ b/public/pages/Detectors/containers/Detectors/Detectors.tsx
@@ -20,6 +20,7 @@ import {
EuiSpacer,
EuiText,
EuiButtonIcon,
+ EuiToolTip,
} from '@elastic/eui';
import { BREADCRUMBS, DEFAULT_EMPTY_DATA, ROUTES } from '../../../../utils/constants';
import DeleteModal from '../../../../components/DeleteModal';
@@ -39,6 +40,7 @@ import { NotificationsStart } from 'opensearch-dashboards/public';
import { Direction } from '@opensearch-project/oui/src/services/sort/sort_direction';
import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types';
import { PageHeader } from '../../../../components/PageHeader/PageHeader';
+import { getDetectorSourceLabel, isStandardSource } from '../../../../utils/detectorSource'; // Wazuh: import functions to handle detector source and space
export interface DetectorsProps extends RouteComponentProps {
detectorService: DetectorsService;
@@ -68,7 +70,7 @@ export default class Detectors extends Component
}
async componentDidMount() {
- setBreadcrumbs([BREADCRUMBS.DETECTORS]);
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.DETECTORS]);
await this.getDetectors();
}
@@ -91,6 +93,7 @@ export default class Detectors extends Component
const detectors = res.response.hits.hits.map((detector) => {
const { custom_rules, pre_packaged_rules } = detector._source.inputs[0].detector_input;
const rulesCount = custom_rules.length + pre_packaged_rules.length;
+
return {
...detector,
detectorName: detector._source.name,
@@ -98,6 +101,7 @@ export default class Detectors extends Component
logType: detector._source.detector_type,
rulesCount: rulesCount,
status: detector._source.enabled ? 'Active' : 'Inactive',
+ space: getDetectorSourceLabel(detector._source.source), // Wazuh: retrieve space from source
};
});
this.setState({ detectorHits: detectors });
@@ -247,11 +251,17 @@ export default class Detectors extends Component
},
{
field: 'logType',
- name: 'Log type',
+ name: 'Integration', // replace log type to integration by Wazuh
sortable: true,
dataType: 'string',
render: (logType: string) => formatRuleType(logType),
},
+ {
+ field: 'space',
+ name: 'Space',
+ sortable: true,
+ dataType: 'string',
+ },
{
field: 'rulesCount',
name: 'Active rules',
@@ -276,22 +286,29 @@ export default class Detectors extends Component
];
const renderActionsLeft = (loading: boolean, selectedItems: DetectorHit[]) => {
+ const hasStandardSelected = selectedItems.some((item) =>
+ isStandardSource(item._source.source)
+ );
return [
- {
- this.closeActionsPopover();
- this.openDeleteModal();
- }}
- data-test-subj={'deleteButton'}
+ content={hasStandardSelected ? 'Only Custom detectors can be deleted.' : undefined}
>
- {selectedItems.length > 0
- ? `Delete ${selectedItems.length} detectors`
- : 'Delete detectors'}
- ,
+ {
+ this.closeActionsPopover();
+ this.openDeleteModal();
+ }}
+ data-test-subj={'deleteButton'}
+ >
+ {selectedItems.length > 0
+ ? `Delete ${selectedItems.length} detectors`
+ : 'Delete detectors'}
+
+ ,
];
};
@@ -332,6 +349,15 @@ export default class Detectors extends Component
];
};
+ // Wazuh: Unique space labels from loaded detectors
+ const spaceOptions = [
+ ...new Set(detectorHits.map((detector) => getDetectorSourceLabel(detector._source.source)))
+ ]
+ .filter((v) => v)
+ .sort()
+ .map((space) => ({ value: space, name: space }));
+ // End Wazuh
+
const search = {
toolsLeft: renderActionsLeft(loadingDetectors, selectedItems),
toolsRight: renderActionsRight(),
@@ -356,11 +382,21 @@ export default class Detectors extends Component
{
type: 'field_value_selection',
field: 'logType',
- name: 'Log type',
+ name: 'Integration', // replace log type to integration by Wazuh
compressed: true,
options: getLogTypeFilterOptions(),
multiSelect: 'or',
} as FieldValueSelectionFilterConfigType,
+ // Wazuh: Added new filter for space
+ {
+ type: 'field_value_selection',
+ field: 'space',
+ name: 'Space',
+ compressed: true,
+ options: spaceOptions,
+ multiSelect: 'or',
+ } as FieldValueSelectionFilterConfigType,
+ // End Wazuh
],
};
@@ -381,7 +417,8 @@ export default class Detectors extends Component
- Threat detectors
+ {/* Wazuh modification: Changed page title to "Detectors" */}
+ Detectors
diff --git a/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap b/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap
index 45e4cb1f2..ac1bbeff9 100644
--- a/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap
+++ b/public/pages/Detectors/containers/Detectors/__snapshots__/Detectors.test.tsx.snap
@@ -75,7 +75,7 @@ exports[` spec renders the component 1`] = `
className="euiText euiText--small"
>
- Threat detectors
+ Detectors
@@ -211,10 +211,16 @@ exports[` spec renders the component 1`] = `
Object {
"dataType": "string",
"field": "logType",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
},
+ Object {
+ "dataType": "string",
+ "field": "space",
+ "name": "Space",
+ "sortable": true,
+ },
Object {
"align": "left",
"dataType": "number",
@@ -301,6 +307,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -425,6 +432,7 @@ exports[` spec renders the component 1`] = `
"lastUpdatedTime": 1,
"logType": "detector_type",
"rulesCount": 2,
+ "space": "Custom",
"status": "Active",
},
]
@@ -481,21 +489,39 @@ exports[` spec renders the component 1`] = `
"compressed": true,
"field": "logType",
"multiSelect": "or",
- "name": "Log type",
+ "name": "Integration",
"options": Array [],
"type": "field_value_selection",
},
+ Object {
+ "compressed": true,
+ "field": "space",
+ "multiSelect": "or",
+ "name": "Space",
+ "options": Array [
+ Object {
+ "name": "Custom",
+ "value": "Custom",
+ },
+ ],
+ "type": "field_value_selection",
+ },
],
"toolsLeft": Array [
-
- Delete detectors
- ,
+
+ Delete detectors
+
+ ,
],
"toolsRight": Array [
spec renders the component 1`] = `
"rulesCount": Object {
"type": "number",
},
+ "space": Object {
+ "type": "string",
+ },
"status": Object {
"type": "string",
},
@@ -600,24 +629,42 @@ exports[` spec renders the component 1`] = `
"compressed": true,
"field": "logType",
"multiSelect": "or",
- "name": "Log type",
+ "name": "Integration",
"options": Array [],
"type": "field_value_selection",
},
+ Object {
+ "compressed": true,
+ "field": "space",
+ "multiSelect": "or",
+ "name": "Space",
+ "options": Array [
+ Object {
+ "name": "Custom",
+ "value": "Custom",
+ },
+ ],
+ "type": "field_value_selection",
+ },
]
}
onChange={[Function]}
toolsLeft={
Array [
-
- Delete detectors
- ,
+
+ Delete detectors
+
+ ,
]
}
toolsRight={
@@ -676,79 +723,99 @@ exports[` spec renders the component 1`] = `
-
-
-
-
-
-
-
- EuiIconMock
-
-
- Delete detectors
-
-
-
-
-
-
-
+
+
+ EuiIconMock
+
+
+ Delete detectors
+
+
+
+
+
+
+
+
+
spec renders the component 1`] = `
"compressed": true,
"field": "logType",
"multiSelect": "or",
- "name": "Log type",
+ "name": "Integration",
"options": Array [],
"type": "field_value_selection",
},
+ Object {
+ "compressed": true,
+ "field": "space",
+ "multiSelect": "or",
+ "name": "Space",
+ "options": Array [
+ Object {
+ "name": "Custom",
+ "value": "Custom",
+ },
+ ],
+ "type": "field_value_selection",
+ },
]
}
onChange={[Function]}
@@ -1040,7 +1120,7 @@ exports[` spec renders the component 1`] = `
"compressed": true,
"field": "logType",
"multiSelect": "or",
- "name": "Log type",
+ "name": "Integration",
"options": Array [],
"type": "field_value_selection",
}
@@ -1078,7 +1158,7 @@ exports[` spec renders the component 1`] = `
onClick={[Function]}
size="s"
>
- Log type
+ Integration
}
closePopover={[Function]}
@@ -1153,10 +1233,149 @@ exports[` spec renders the component 1`] = `
>
- Log type
+ Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Space
+
+ }
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={true}
+ id="field_value_selection_2"
+ isOpen={false}
+ ownFocus={true}
+ panelClassName="euiFilterGroup__popoverPanel"
+ panelPaddingSize="none"
+ >
+
+
+
+
+
+
+
+
+ EuiIconMock
+
+
+
+ Space
@@ -1395,10 +1614,16 @@ exports[` spec renders the component 1`] = `
Object {
"dataType": "string",
"field": "logType",
- "name": "Log type",
+ "name": "Integration",
"render": [Function],
"sortable": true,
},
+ Object {
+ "dataType": "string",
+ "field": "space",
+ "name": "Space",
+ "sortable": true,
+ },
Object {
"align": "left",
"dataType": "number",
@@ -1485,6 +1710,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -1609,6 +1835,7 @@ exports[` spec renders the component 1`] = `
"lastUpdatedTime": 1,
"logType": "detector_type",
"rulesCount": 2,
+ "space": "Custom",
"status": "Active",
},
]
@@ -1754,20 +1981,27 @@ exports[` spec renders the component 1`] = `
"isSortAscending": undefined,
"isSorted": false,
"key": "_data_s_logType_2",
- "name": "Log type",
+ "name": "Integration",
"onSort": [Function],
},
Object {
"isSortAscending": undefined,
"isSorted": false,
- "key": "_data_s_rulesCount_3",
+ "key": "_data_s_space_3",
+ "name": "Space",
+ "onSort": [Function],
+ },
+ Object {
+ "isSortAscending": undefined,
+ "isSorted": false,
+ "key": "_data_s_rulesCount_4",
"name": "Active rules",
"onSort": [Function],
},
Object {
"isSortAscending": undefined,
"isSorted": false,
- "key": "_data_s_lastUpdatedTime_4",
+ "key": "_data_s_lastUpdatedTime_5",
"name": "Last updated time",
"onSort": [Function],
},
@@ -2099,15 +2333,73 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Log type",
+ "innerText": "Integration",
+ }
+ }
+ >
+
+ Integration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- Log type
+ Space
@@ -2118,16 +2410,16 @@ exports[` spec renders the component 1`] = `
spec renders the component 1`] = `
spec renders the component 1`] = `
key="_data_column_logType_detector_id_1_2"
mobileOptions={
Object {
- "header": "Log type",
+ "header": "Integration",
"render": undefined,
}
}
@@ -2398,18 +2690,54 @@ exports[` spec renders the component 1`] = `
- Log type
+ Integration
- -
+ detector_type
+
+
+
+
+
+
+ Space
+
+
+
+ Custom
+
spec renders the component 1`] = `
spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -365,6 +366,7 @@ exports[` spec renders the component 1`] = `
"unit": "MINUTES",
},
},
+ "source": "custom",
"triggers": Array [
Object {
"actions": Array [
@@ -514,7 +516,7 @@ exports[` spec renders the component 1`] = `
Object {
"dataType": "string",
"field": "ruleFieldName",
- "name": "Detection rule field",
+ "name": "Rule field",
"render": [Function],
"width": "25%",
},
@@ -580,7 +582,7 @@ exports[` spec renders the component 1`] = `
Object {
"dataType": "string",
"field": "ruleFieldName",
- "name": "Detection rule field",
+ "name": "Rule field",
"render": [Function],
"width": "25%",
},
@@ -643,7 +645,7 @@ exports[` spec renders the component 1`] = `
"allowNeutralSort": true,
"sort": Object {
"direction": "asc",
- "field": "Detection rule field",
+ "field": "Rule field",
},
}
}
@@ -837,15 +839,15 @@ exports[` spec renders the component 1`] = `
values={
Object {
"description": undefined,
- "innerText": "Detection rule field",
+ "innerText": "Rule field",
}
}
>
- Detection rule field
+ Rule field
diff --git a/public/pages/Detectors/utils/helpers.ts b/public/pages/Detectors/utils/helpers.ts
index 1794f31de..e898ff299 100644
--- a/public/pages/Detectors/utils/helpers.ts
+++ b/public/pages/Detectors/utils/helpers.ts
@@ -4,7 +4,19 @@
*/
import { DetectorHit } from '../../../../server/models/interfaces';
-import { FieldMappingsTableItem } from '../../../../types';
+import { Detector, FieldMappingsTableItem } from '../../../../types';
+import { validateName } from '../../../utils/validation';
+import { MIN_NUM_DATA_SOURCES } from './constants';
+
+/* Wazuh: Add isDetectorFormValid function */
+export function isDetectorFormValid(detector: Detector): boolean {
+ return (
+ validateName(detector.name) &&
+ detector.inputs[0].detector_input.indices.length >= MIN_NUM_DATA_SOURCES &&
+ !!detector.detector_type
+ );
+}
+/* End Wazuh */
export function getDetectorIds(detectors: DetectorHit[]) {
return detectors.map((detector) => detector._id).join(', ');
diff --git a/public/pages/Filters/components/FilterDetailsFlyout.tsx b/public/pages/Filters/components/FilterDetailsFlyout.tsx
new file mode 100644
index 000000000..9881a4084
--- /dev/null
+++ b/public/pages/Filters/components/FilterDetailsFlyout.tsx
@@ -0,0 +1,203 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useMemo, useState } from 'react';
+import {
+ EuiButtonGroup,
+ EuiCallOut,
+ EuiCodeBlock,
+ EuiFlexGrid,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiFormLabel,
+ EuiLoadingContent,
+ EuiModalBody,
+ EuiSmallButtonIcon,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { FilterDocument, FilterItem } from '../../../../types';
+import { Metadata } from '../../../components/Utility/Metadata';
+import { EnabledHealth } from '../../../components/Utility/EnabledHealth';
+import { BadgeGroup } from '../../../components/Utility/BadgeGroup';
+import { DEFAULT_EMPTY_DATA } from '../../../utils/constants';
+import { mapYamlToLosslessObject } from '../../../components/YamlForm';
+import { stringify as LosslessStringify } from 'lossless-json';
+import { checkToFormString } from '../utils/mappers';
+import { DataStore } from '../../../store/DataStore';
+import { useLazyFetch } from '../../../hooks/useLazyFetch';
+
+interface FilterDetailsFlyoutProps {
+ filterId: string;
+ onClose: () => void;
+}
+
+const viewOptions = [
+ {
+ id: 'visual',
+ label: 'Visual',
+ },
+ {
+ id: 'yaml',
+ label: 'YAML',
+ },
+ {
+ id: 'json',
+ label: 'JSON',
+ },
+];
+
+/** Resolve author display: indexer sends string; legacy may send { name } */
+const getAuthorDisplay = (author: string | { name?: string } | undefined): string => {
+ if (!author) return '';
+ if (typeof author === 'string') return author;
+ return author.name ?? '';
+};
+
+export const FilterDetailsFlyout: React.FC = ({ filterId, onClose }) => {
+ const [selectedView, setSelectedView] = useState(viewOptions[0].id);
+ const fetchFilter = useCallback(() => DataStore.filters.getFilter(filterId), [filterId]);
+ const { data: filter, loading, error } = useLazyFetch(fetchFilter, 'Filter not found.');
+
+ const document = filter?.document ?? {
+ id: '',
+ name: '',
+ type: '',
+ check: '',
+ enabled: false,
+ };
+
+ const metadata = document.metadata ?? {};
+ const references = metadata.references ?? [];
+ const supports = metadata.supports ?? [];
+
+ const { filterJson, checkDisplayYaml } = useMemo(() => {
+ const fallbackJson = () => JSON.stringify(filter?.document ?? {}, null, 2);
+ const fallbackCheck = () => checkToFormString(document.check);
+
+ if (!filter) {
+ return { filterJson: '', checkDisplayYaml: fallbackCheck() };
+ }
+
+ const rawYaml = typeof filter.yaml === 'string' ? filter.yaml : '';
+ if (!rawYaml.trim()) {
+ return { filterJson: fallbackJson(), checkDisplayYaml: fallbackCheck() };
+ }
+
+ try {
+ const losslessDoc = mapYamlToLosslessObject(rawYaml);
+ return {
+ filterJson: LosslessStringify(losslessDoc, null, 2) ?? '',
+ checkDisplayYaml: checkToFormString(losslessDoc?.check ?? document.check),
+ };
+ } catch {
+ return { filterJson: fallbackJson(), checkDisplayYaml: fallbackCheck() };
+ }
+ }, [filter, document.check]);
+
+ const fields: Array<{
+ label: string;
+ value: any;
+ type?: 'text' | 'date' | 'url' | 'raw';
+ }> = [
+ { label: 'Name', value: document.name },
+ { label: 'Space', value: filter?.space?.name },
+ { label: 'Type', value: document.type },
+ { label: 'Author', value: getAuthorDisplay(metadata.author) },
+ { label: 'ID', value: document.id || filter?.id },
+ { label: 'Description', value: metadata.description },
+ { label: 'Created', value: metadata.date, type: 'date' },
+ { label: 'Modified', value: metadata.modified, type: 'date' },
+ { label: 'Supports', value: , type: 'raw' },
+ { label: 'Check', value: (
+
+ {checkDisplayYaml}
+
+ ), type: 'raw' },
+ { label: 'Documentation', value: metadata.documentation },
+ { label: 'SHA256', value: filter?.hash?.sha256 },
+ { label: 'References', value: references, type: 'url' },
+ ];
+
+ const renderContent = () => {
+ if (loading) return ;
+ if (error) return ;
+ if (!filter) return null;
+
+ if (selectedView === 'yaml') {
+ return (
+
+ {filter.yaml}
+
+ );
+ }
+ if (selectedView === 'json') {
+ return (
+
+ {filterJson}
+
+ );
+ }
+ return (
+
+ {fields.map(({ label, value, type = 'text' }) => (
+
+ {label}} value={value} type={type} />
+
+ ))}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ {document.name ? `Filter details β ${document.name}` : 'Filter details'}
+
+
+
+
+
+
+
+
+
+
+
+ setSelectedView(id)}
+ isDisabled={loading || !!error || !filter}
+ />
+
+ {filter && (
+
+
+
+ )}
+
+
+ {renderContent()}
+
+
+
+ );
+};
diff --git a/public/pages/Filters/components/FiltersTab.tsx b/public/pages/Filters/components/FiltersTab.tsx
new file mode 100644
index 000000000..223492601
--- /dev/null
+++ b/public/pages/Filters/components/FiltersTab.tsx
@@ -0,0 +1,265 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ EuiSmallButton,
+ EuiCard,
+ EuiConfirmModal,
+ EuiContextMenuItem,
+ EuiContextMenuPanel,
+ EuiInMemoryTable,
+ EuiPopover,
+} from '@elastic/eui';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+import { DataStore } from '../../../store/DataStore';
+import { ROUTES } from '../../../utils/constants';
+import { pluralize } from '../../../utils/helpers';
+import { FiltersAllowedActionsBySpace, SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import {
+ DELETE_ACTION,
+ DELETE_SELECTED_ACTION,
+ useDeleteItems,
+} from '../../../hooks/useDeleteItems';
+import {
+ FilterTableItem,
+ getFiltersTableColumns,
+ getFiltersTableSearchConfig,
+ toFilterTableItem,
+} from '../utils/helpers';
+import { FilterDetailsFlyout } from './FilterDetailsFlyout';
+
+export interface FiltersTabProps {
+ spaceFilter: string;
+ notifications: NotificationsStart;
+ history: RouteComponentProps['history'];
+}
+
+export const FiltersTab: React.FC = ({ spaceFilter, notifications, history }) => {
+ const isMountedRef = useRef(true);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItems, setSelectedItems] = useState([]);
+ const [selectedFilterId, setSelectedFilterId] = useState(null);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ const buildQuery = useCallback(() => {
+ const baseQuery = { match_all: {} };
+ if (spaceFilter) {
+ return {
+ bool: {
+ must: [baseQuery, { term: { 'space.name': spaceFilter } }],
+ },
+ };
+ }
+ return baseQuery;
+ }, [spaceFilter]);
+
+ const fetchFilters = useCallback(async () => {
+ setLoading(true);
+ try {
+ const { items: fetchedItems } = await DataStore.filters.searchFilters({
+ size: 10000,
+ sort: [{ 'document.name': { order: 'asc' } }],
+ query: buildQuery(),
+ _source: {
+ includes: [
+ 'document.name',
+ 'document.metadata.title',
+ 'document.type',
+ 'document.enabled',
+ 'space',
+ ],
+ },
+ });
+ if (isMountedRef.current) {
+ setItems(fetchedItems.map(toFilterTableItem));
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setLoading(false);
+ }
+ }
+ }, [buildQuery]);
+
+ useEffect(() => {
+ fetchFilters();
+ }, [fetchFilters]);
+
+ const isCreateActionAllowed = actionIsAllowedOnSpace(
+ spaceFilter,
+ SPACE_ACTIONS.CREATE,
+ FiltersAllowedActionsBySpace
+ );
+ const isDeleteActionAllowed = actionIsAllowedOnSpace(
+ spaceFilter,
+ SPACE_ACTIONS.DELETE,
+ FiltersAllowedActionsBySpace
+ );
+
+ const {
+ itemForAction,
+ setItemForAction,
+ isDeleting,
+ confirmDeleteSingle,
+ confirmDeleteSelected,
+ } = useDeleteItems({
+ deleteOne: (id) => DataStore.filters.deleteFilter(id),
+ reload: fetchFilters,
+ notifications,
+ entityName: 'filter',
+ entityNamePlural: 'filters',
+ isMountedRef,
+ });
+
+ const onViewDetails = useCallback((id: string) => {
+ setSelectedFilterId(id);
+ }, []);
+
+ const onEdit = useCallback(
+ (id: string) => {
+ history.push(`${ROUTES.FILTERS_EDIT}/${id}`);
+ },
+ [history]
+ );
+
+ const onDelete = useCallback(
+ (id: string) => {
+ setItemForAction({ action: DELETE_ACTION, id });
+ },
+ [setItemForAction]
+ );
+
+ const actionsButton = (
+ setIsPopoverOpen((prev) => !prev)}
+ data-test-subj="filtersActionsButton"
+ >
+ Actions
+
+ }
+ isOpen={isPopoverOpen}
+ closePopover={() => setIsPopoverOpen(false)}
+ panelPaddingSize="none"
+ anchorPosition="downLeft"
+ data-test-subj="filtersActionsPopover"
+ >
+ setIsPopoverOpen(false)}
+ disabled={!isCreateActionAllowed}
+ toolTipContent={
+ !isCreateActionAllowed
+ ? `Filters can only be created in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.CREATE,
+ FiltersAllowedActionsBySpace
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Create
+ ,
+ {
+ setItemForAction({ action: DELETE_SELECTED_ACTION });
+ setIsPopoverOpen(false);
+ }}
+ >
+ Delete selected ({selectedItems.length})
+ ,
+ ]}
+ size="s"
+ />
+
+ );
+
+ return (
+ <>
+
+
+ {selectedFilterId && (
+ setSelectedFilterId(null)}
+ />
+ )}
+
+ {itemForAction?.action === DELETE_ACTION && (
+ setItemForAction(null)}
+ onConfirm={confirmDeleteSingle}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ Are you sure you want to delete this filter? This action cannot be undone.
+
+ )}
+
+ {itemForAction?.action === DELETE_SELECTED_ACTION && (
+ setItemForAction(null)}
+ onConfirm={() => confirmDeleteSelected(selectedItems, () => setSelectedItems([]))}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ isLoading={isDeleting}
+ >
+ {`Are you sure you want to delete ${selectedItems.length} ${pluralize(
+ selectedItems.length,
+ 'filter'
+ )}? This action cannot be undone.`}
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Filters/containers/FilterFormPage.tsx b/public/pages/Filters/containers/FilterFormPage.tsx
new file mode 100644
index 000000000..57752f3a9
--- /dev/null
+++ b/public/pages/Filters/containers/FilterFormPage.tsx
@@ -0,0 +1,538 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiButtonGroup,
+ EuiButtonIcon,
+ EuiCompressedFieldText,
+ EuiCompressedFormRow,
+ EuiCompressedSelect,
+ EuiCompressedSwitch,
+ EuiCompressedTextArea,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
+ EuiPanel,
+ EuiPopover,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+ EuiCodeEditor,
+} from '@elastic/eui';
+import { FormFieldArray } from '../../../components/FormFieldArray';
+import { Form, Formik, FormikErrors } from 'formik';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { DataStore } from '../../../store/DataStore';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import {
+ errorNotificationToast,
+ setBreadcrumbs,
+ successNotificationToast,
+} from '../../../utils/helpers';
+import { useSpaceSelector } from '../../../hooks/useSpaceSelector';
+import { FILTER_NAME_REGEX } from '../../../utils/validation';
+import {
+ FilterFormModel,
+ filterFormDefaultValue,
+ filterYamlFormDefaultValue,
+ mapFilterToForm,
+ mapYamlToForm,
+ mapFormToYaml,
+ parseCheckYaml,
+} from '../utils/mappers';
+import { FILTER_TYPE_OPTIONS } from '../utils/constants';
+import { YamlForm, YAML_TYPE, validateYamlSyntax } from '../../../components/YamlForm';
+
+const FILTER_ACTION = {
+ CREATE: 'create',
+ EDIT: 'edit',
+} as const;
+type FilterAction = typeof FILTER_ACTION[keyof typeof FILTER_ACTION];
+
+const actionLabels: Record = {
+ create: 'Create',
+ edit: 'Edit',
+};
+
+const EDITOR_TYPE = {
+ VISUAL: 'visual',
+ YAML: 'yaml',
+} as const;
+
+type EditorType = typeof EDITOR_TYPE[keyof typeof EDITOR_TYPE];
+
+const editorTypes: Array<{ id: EditorType; label: string }> = [
+ { id: EDITOR_TYPE.VISUAL, label: 'Visual Editor' },
+ { id: EDITOR_TYPE.YAML, label: 'YAML Editor' },
+];
+
+const isSubmitDisabled = (errors: FormikErrors) =>
+ !!(errors.name || errors.type || errors.check || errors.author);
+
+type FilterFormPageProps = {
+ notifications: NotificationsStart;
+ history: RouteComponentProps['history'];
+ action: FilterAction;
+ match: { params: { id?: string } };
+};
+
+const checkHelpPreStyle: React.CSSProperties = { margin: '4px 0 0 0' };
+const checkEditorOptions = { tabSize: 2, useSoftTabs: true, showPrintMargin: false };
+
+const checkHelpText = (
+
+ Expression evaluated to determine if the filter applies (e.g.{' '}
+
$host.os.platform == 'ubuntu') or a list of field/value pairs:
+
{`- host.os.platform: ubuntu\n- host.os.type: linux`}
+
+);
+
+export const FilterFormPage: React.FC = ({
+ notifications,
+ history,
+ action,
+ match,
+}) => {
+ const filterId = match.params.id;
+ const [isLoading, setIsLoading] = useState(false);
+ const [typePopoverOpen, setTypePopoverOpen] = useState(false);
+ const { spaceFilter } = useSpaceSelector();
+ const [selectedEditorType, setSelectedEditorType] = useState(EDITOR_TYPE.VISUAL);
+ const [rawFilter, setRawFilter] = useState(filterYamlFormDefaultValue);
+ const [yamlErrors, setYamlErrors] = useState(null);
+ const [initialValue, setInitialValue] = useState(filterFormDefaultValue);
+
+ useEffect(() => {
+ if (action === FILTER_ACTION.CREATE) {
+ setBreadcrumbs([BREADCRUMBS.FILTERS, BREADCRUMBS.FILTERS_CREATE]);
+ return;
+ }
+ if (!filterId) return;
+ const fetchFilter = async () => {
+ setIsLoading(true);
+ try {
+ const item = await DataStore.filters.getFilter(filterId);
+ if (item?.document) {
+ setInitialValue(mapYamlToForm(item.yaml) ?? mapFilterToForm(item.document));
+ setRawFilter(item.yaml);
+ }
+ setBreadcrumbs([
+ BREADCRUMBS.FILTERS,
+ BREADCRUMBS.FILTERS_EDIT,
+ { text: item?.document?.name || filterId },
+ ]);
+ } catch {
+ errorNotificationToast(
+ notifications,
+ 'retrieve',
+ 'filter',
+ `Could not retrieve filter ${filterId}.`
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchFilter();
+ }, [action, filterId, notifications]);
+
+ const validateForm = useCallback((values: FilterFormModel): FormikErrors => {
+ const errors: FormikErrors = {};
+ const trimmedName = values.name.trim();
+ if (!trimmedName) {
+ errors.name = 'Name is required';
+ } else if (!FILTER_NAME_REGEX.test(trimmedName)) {
+ errors.name = 'Must follow the pattern filter// (e.g. filter/prefilter/0).';
+ }
+ if (!values.type) errors.type = 'Type is required';
+ const checkResult = parseCheckYaml(values.check);
+ if (!checkResult.ok) errors.check = checkResult.error;
+ if (!values.author.trim()) errors.author = 'Author is required';
+ return errors;
+ }, []);
+
+ const handleSubmitForm = useCallback(
+ async (values: FilterFormModel, { setSubmitting }: { setSubmitting: (v: boolean) => void }) => {
+ const resourceYaml =
+ selectedEditorType === EDITOR_TYPE.YAML && rawFilter ? rawFilter : mapFormToYaml(values);
+ const space = spaceFilter || 'draft';
+ try {
+ if (action === FILTER_ACTION.CREATE) {
+ const result = await DataStore.filters.createFilter({ resourceYaml, space });
+ if (result) {
+ successNotificationToast(notifications, 'created', 'filter', result.message);
+ history.push(ROUTES.FILTERS);
+ }
+ } else if (filterId) {
+ const result = await DataStore.filters.updateFilter(filterId, { resourceYaml, space });
+ if (result) {
+ successNotificationToast(notifications, 'updated', 'filter', result.message);
+ history.push(ROUTES.FILTERS);
+ }
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [action, filterId, spaceFilter, notifications, history, selectedEditorType, rawFilter]
+ );
+
+ return (
+ <>
+ {isLoading ? (
+
+
+
+
+
+
+
+ ) : (
+
+ {({
+ values,
+ errors,
+ touched,
+ dirty,
+ isSubmitting,
+ setFieldValue,
+ setFieldTouched,
+ setValues,
+ handleSubmit: formikSubmit,
+ }) => {
+ const onEditorTypeChange = (id: EditorType) => {
+ if (id === EDITOR_TYPE.YAML) {
+ const yaml = dirty || !rawFilter ? mapFormToYaml(values) : rawFilter;
+ setRawFilter(yaml);
+ const syntaxError = validateYamlSyntax(yaml);
+ setYamlErrors(syntaxError ? [syntaxError] : null);
+ } else {
+ if (!validateYamlSyntax(rawFilter)) {
+ setValues(mapYamlToForm(rawFilter));
+ }
+ setYamlErrors(null);
+ }
+ setSelectedEditorType(id);
+ };
+
+ const onYamlChange = (yamlString: string) => {
+ setRawFilter(yamlString);
+ const syntaxError = validateYamlSyntax(yamlString);
+ if (syntaxError) {
+ setYamlErrors([syntaxError]);
+ return;
+ }
+ const formValues = mapYamlToForm(yamlString);
+ setValues(formValues);
+ const fieldErrors = validateForm(formValues);
+ const fieldErrorMessages = Object.values(fieldErrors).filter(Boolean) as string[];
+ setYamlErrors(fieldErrorMessages.length > 0 ? fieldErrorMessages : null);
+ };
+
+ const yamlModeHasErrors =
+ (yamlErrors !== null && yamlErrors.length > 0) || Object.keys(errors).length > 0;
+
+ const yamlFormErrors =
+ yamlErrors && yamlErrors.length > 0
+ ? yamlErrors
+ : Object.values(errors).filter((e): e is string => typeof e === 'string');
+
+ return (
+
+ );
+ }}
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Filters/utils/constants.ts b/public/pages/Filters/utils/constants.ts
new file mode 100644
index 000000000..db261e34b
--- /dev/null
+++ b/public/pages/Filters/utils/constants.ts
@@ -0,0 +1,12 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export const FILTERS_PAGE_SIZE = 25;
+export const FILTERS_SORT_FIELD = 'document.name';
+
+export const FILTER_TYPE_OPTIONS = [
+ { value: 'pre-filter', text: 'Pre-filter' },
+ { value: 'post-filter', text: 'Post-filter' },
+];
diff --git a/public/pages/Filters/utils/helpers.tsx b/public/pages/Filters/utils/helpers.tsx
new file mode 100644
index 000000000..fe3134df4
--- /dev/null
+++ b/public/pages/Filters/utils/helpers.tsx
@@ -0,0 +1,112 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { EuiLink } from '@elastic/eui';
+import { Search } from '@opensearch-project/oui/src/eui_components/basic_table';
+import { FilterItem } from '../../../../types';
+import { FiltersAllowedActionsBySpace, SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace } from '../../../../common/helpers';
+import { FILTER_TYPE_OPTIONS } from './constants';
+
+export interface FilterTableItem {
+ id: string;
+ name: string;
+ type: string;
+ enabled: boolean;
+ spaceName: string;
+}
+
+export const toFilterTableItem = (item: FilterItem): FilterTableItem => ({
+ id: item.id,
+ name: item.document?.metadata?.title ?? item.document?.name ?? '',
+ type: item.document?.type ?? '',
+ enabled: item.document?.enabled ?? false,
+ spaceName: item.space?.name ?? '',
+});
+
+export const getFiltersTableColumns = (
+ spaceFilter: string,
+ onViewDetails: (id: string) => void,
+ onEdit: (id: string) => void,
+ onDelete: (id: string) => void
+) => [
+ {
+ field: 'name',
+ name: 'Name',
+ sortable: true,
+ render: (name: string, row: FilterTableItem) => (
+ onViewDetails(row.id)}>{name}
+ ),
+ },
+ {
+ field: 'type',
+ name: 'Type',
+ sortable: true,
+ },
+ {
+ field: 'enabled',
+ name: 'Enabled',
+ sortable: true,
+ render: (enabled: boolean) => (enabled ? 'Yes' : 'No'),
+ },
+ {
+ name: 'Actions',
+ actions: [
+ {
+ name: 'View',
+ description: 'View filter details',
+ type: 'icon',
+ icon: 'inspect',
+ onClick: (row: FilterTableItem) => onViewDetails(row.id),
+ },
+ {
+ name: 'Edit',
+ description: 'Edit filter',
+ type: 'icon',
+ icon: 'pencil',
+ onClick: (row: FilterTableItem) => onEdit(row.id),
+ available: () =>
+ actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.EDIT, FiltersAllowedActionsBySpace),
+ },
+ {
+ name: 'Delete',
+ description: 'Delete filter',
+ type: 'icon',
+ icon: 'trash',
+ color: 'danger',
+ onClick: (row: FilterTableItem) => onDelete(row.id),
+ available: () =>
+ actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE, FiltersAllowedActionsBySpace),
+ },
+ ],
+ },
+];
+
+export const getFiltersTableSearchConfig = (
+ items: FilterTableItem[],
+ options?: { toolsRight?: React.ReactNode[] }
+): Search => {
+ const types = Array.from(new Set(items.map((item) => item.type).filter(Boolean)));
+
+ return {
+ box: {
+ placeholder: 'Search filters',
+ schema: true,
+ compressed: true,
+ },
+ filters: [
+ {
+ type: 'field_value_selection',
+ field: 'type',
+ name: 'Type',
+ compressed: true,
+ multiSelect: 'or',
+ options: FILTER_TYPE_OPTIONS.map((option) => ({ value: option.value, name: option.text })),
+ },
+ ],
+ toolsRight: options?.toolsRight,
+ };
+};
diff --git a/public/pages/Filters/utils/mappers.ts b/public/pages/Filters/utils/mappers.ts
new file mode 100644
index 000000000..1e0c3d971
--- /dev/null
+++ b/public/pages/Filters/utils/mappers.ts
@@ -0,0 +1,242 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import YAML, { Pair, Scalar, YAMLMap, YAMLSeq } from 'yaml';
+import { LosslessNumber, stringify as LosslessStringify } from 'lossless-json';
+import {
+ FilterCheck,
+ FilterCheckListItem,
+ FilterDocument,
+ FilterResource,
+} from '../../../../types/Filters';
+import {
+ mapYamlToLosslessObject,
+ stringToYamlNode,
+ normalizeToStringArray,
+ validateYamlSyntax,
+} from '../../../components/YamlForm';
+
+export interface FilterFormModel {
+ name: string;
+ type: string;
+ check: string;
+ enabled: boolean;
+ author: string;
+ description: string;
+ documentation: string;
+ references: string[];
+ supports: string[];
+}
+
+export const filterFormDefaultValue: FilterFormModel = {
+ name: '',
+ type: 'pre-filter',
+ check: '',
+ enabled: true,
+ author: '',
+ description: '',
+ documentation: '',
+ references: [],
+ supports: [],
+};
+
+export const filterYamlFormDefaultValue: string = `name: filter//
+type: pre-filter
+check: ''
+enabled: true
+metadata:
+ title: ''
+ author: ''
+ description: ''
+ documentation: ''
+ references: []
+ supports: []`;
+
+
+const isCheckListItem = (value: unknown): value is FilterCheckListItem =>
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+
+const isCheckList = (value: unknown): value is FilterCheckListItem[] =>
+ Array.isArray(value) && value.every(isCheckListItem);
+
+export type FilterCheckParseResult =
+ | { ok: true; value: FilterCheck }
+ | { ok: false; error: string };
+
+/**
+ * Parses the check field from YAML text (expression or list of field/value pairs).
+ * Uses lossless YAML parsing so numeric precision (e.g. 20.0) is preserved in list values.
+ */
+export const parseCheckYaml = (yamlText: string): FilterCheckParseResult => {
+ const text = yamlText ?? '';
+ if (!text.trim()) {
+ return { ok: false, error: 'Check is required' };
+ }
+ const syntaxError = validateYamlSyntax(text);
+ if (syntaxError) {
+ return { ok: false, error: `Invalid YAML: ${syntaxError}` };
+ }
+ let parsed: unknown;
+ try {
+ parsed = mapYamlToLosslessObject(text);
+ } catch (err) {
+ return { ok: false, error: `Invalid YAML: ${(err as Error).message}` };
+ }
+ if (parsed === null || parsed === undefined) {
+ return { ok: false, error: 'Check is required' };
+ }
+ if (typeof parsed === 'string') {
+ return { ok: true, value: parsed };
+ }
+ if (isCheckList(parsed)) {
+ return { ok: true, value: parsed };
+ }
+ if (Array.isArray(parsed)) {
+ return {
+ ok: false,
+ error:
+ 'Each list item must be a field/value pair (e.g. "- host.os.platform: ubuntu"), not a plain value.',
+ };
+ }
+ if (isCheckListItem(parsed)) {
+ const keys = Object.keys(parsed);
+ if (keys.length === 1 && keys[0] === 'check') {
+ return {
+ ok: false,
+ error:
+ 'Do not include the "check:" key; write only its value (an expression or a YAML list).',
+ };
+ }
+ return {
+ ok: false,
+ error:
+ 'Check cannot be a single object. Use an expression string or a YAML list (each item prefixed with "- ").',
+ };
+ }
+ return {
+ ok: false,
+ error: 'Check must be an expression string or a YAML list of field/value items.',
+ };
+};
+
+/**
+ * Converts a yaml check expression value to a string.
+ * Checklist arrays are serialized to block style YAML for display.
+ */
+export const checkToFormString = (check: unknown): string => {
+ if (typeof check === 'string') return check;
+ if (check instanceof LosslessNumber) return check.toString();
+ if (check !== null && check !== undefined) {
+ const jsonStr = LosslessStringify(check);
+ if (!jsonStr) return '';
+ try {
+ const doc = YAML.parseDocument(jsonStr);
+ YAML.visit(doc, {
+ Map(_, n) {
+ (n as YAMLMap).flow = false;
+ },
+ Seq(_, n) {
+ (n as YAMLSeq).flow = false;
+ },
+ Pair(_, n) {
+ const key = (n as Pair).key;
+ if (
+ key instanceof Scalar &&
+ (key.type === 'QUOTE_DOUBLE' || key.type === 'QUOTE_SINGLE')
+ ) {
+ key.type = undefined;
+ }
+ },
+ });
+ return doc.toString({ lineWidth: 0 }).trim();
+ } catch {
+ return jsonStr;
+ }
+ }
+ return '';
+};
+
+export const mapYamlToFilterForm = (yamlObj: any): FilterFormModel => {
+ const author = yamlObj?.metadata?.author;
+ return {
+ name: yamlObj?.name ?? '',
+ type: yamlObj?.type ?? 'pre-filter',
+ check: checkToFormString(yamlObj?.check),
+ enabled: yamlObj?.enabled ?? true,
+ author: typeof author === 'string' ? author : author?.name ?? '',
+ description: yamlObj?.metadata?.description ?? '',
+ documentation: yamlObj?.metadata?.documentation ?? '',
+ references: normalizeToStringArray(yamlObj?.metadata?.references),
+ supports: normalizeToStringArray(yamlObj?.metadata?.supports),
+ };
+};
+
+export const mapFilterToForm = (document: FilterDocument): FilterFormModel => {
+ const author = document.metadata?.author;
+ return {
+ name: document.name ?? '',
+ type: document.type ?? '',
+ check: checkToFormString(document.check),
+ enabled: document.enabled ?? true,
+ author: typeof author === 'string' ? author : author?.name ?? '',
+ description: document.metadata?.description ?? '',
+ documentation: document.metadata?.documentation ?? '',
+ references: document.metadata?.references ?? [],
+ supports: document.metadata?.supports ?? [],
+ };
+};
+
+export const mapFormToFilterResource = (values: FilterFormModel): FilterResource => {
+ const now = new Date().toISOString();
+ return {
+ name: values.name,
+ type: values.type,
+ check: values.check,
+ enabled: values.enabled,
+ metadata: {
+ title: values.name,
+ author: values.author?.trim() ?? '',
+ date: now,
+ modified: now,
+ description: values.description || '',
+ documentation: values.documentation || '',
+ references: values.references,
+ supports: values.supports,
+ },
+ };
+};
+
+/** YAML string to FilterFormModel (uses lossless parsing to preserve float precision). */
+export const mapYamlToForm = (yamlString: string): FilterFormModel => {
+ const parsed = mapYamlToLosslessObject(yamlString);
+ if (!parsed) return filterFormDefaultValue;
+ return mapYamlToFilterForm(parsed);
+};
+
+export const mapFormToYaml = (values: FilterFormModel): string => {
+ const doc = YAML.parseDocument(
+ YAML.stringify(
+ {
+ name: values.name,
+ type: values.type,
+ check: null,
+ enabled: values.enabled,
+ metadata: {
+ title: values.name,
+ author: values.author?.trim() ?? '',
+ description: values.description || '',
+ documentation: values.documentation || '',
+ references: values.references,
+ supports: values.supports,
+ },
+ },
+ { lineWidth: 0 }
+ )
+ );
+
+ doc.set('check', stringToYamlNode(values.check));
+
+ return doc.toString({ lineWidth: 0 });
+};
diff --git a/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx
index 8b3b8e717..264ec3bcc 100644
--- a/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx
+++ b/public/pages/Findings/components/CorrelationsTable/CorrelationsTable.tsx
@@ -156,7 +156,7 @@ export const CorrelationsTable: React.FC = ({
},
{
field: 'logType',
- name: 'Log type',
+ name: 'Integration', // Changed from Log Types to Integrations by Wazuh
sortable: true,
render: (category: string) =>
// TODO: This formatting may need some refactoring depending on the response payload
@@ -202,7 +202,7 @@ export const CorrelationsTable: React.FC = ({
{
type: 'field_value_selection',
field: 'logType',
- name: 'Log type',
+ name: 'Integration', // Changed from Log Types to Integrations by Wazuh
options: Array.from(logTypes).map((type) => ({
value: type,
name: formatRuleType(type),
diff --git a/public/pages/Findings/components/FindingDetailsFlyout.tsx b/public/pages/Findings/components/FindingDetailsFlyout.tsx
index 5562b64c4..e3a39eeef 100644
--- a/public/pages/Findings/components/FindingDetailsFlyout.tsx
+++ b/public/pages/Findings/components/FindingDetailsFlyout.tsx
@@ -29,7 +29,6 @@ import {
EuiTabs,
EuiTab,
EuiLoadingContent,
- EuiLoadingSpinner,
EuiBasicTableColumn,
EuiInMemoryTable,
EuiToolTip,
@@ -43,21 +42,24 @@ import {
} from '../../../utils/helpers';
import { DEFAULT_EMPTY_DATA } from '../../../utils/constants';
import { Query } from '../models/interfaces';
-import { RuleViewerFlyout } from '../../Rules/components/RuleViewerFlyout/RuleViewerFlyout';
+import { RuleViewerFlyout } from '../../WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout';
import { RuleSource } from '../../../../server/models/interfaces';
import { RuleTableItem } from '../../Rules/utils/helpers';
import { CreateIndexPatternForm } from './CreateIndexPatternForm';
import {
- CorrelationFinding,
+ // Wazuh: hide correlations data in finding details flyout.
+ // CorrelationFinding,
FindingDocumentItem,
RuleItemInfoBase,
- FindingItemType,
+ // Wazuh: hide correlations data in finding details flyout.
+ // FindingItemType,
FindingDetailsFlyoutProps,
FlyoutBaseProps,
} from '../../../../types';
import { FindingFlyoutTabId, FindingFlyoutTabs } from '../utils/constants';
import { DataStore } from '../../../store/DataStore';
-import { CorrelationsTable } from './CorrelationsTable/CorrelationsTable';
+// Wazuh: hide Correlations tab in finding details flyout.
+// import { CorrelationsTable } from './CorrelationsTable/CorrelationsTable';
import { getSeverityColor } from '../../Correlations/utils/constants';
import { getLogTypeLabel } from '../../LogTypes/utils/helpers';
@@ -67,10 +69,12 @@ interface FindingDetailsFlyoutState {
indexPatternId?: string;
isCreateIndexPatternModalVisible: boolean;
selectedTab: { id: FindingFlyoutTabId; content: React.ReactNode | null };
- correlatedFindings: CorrelationFinding[];
+ // Wazuh: hide correlations data in finding details flyout.
+ // correlatedFindings: CorrelationFinding[];
allRules: { [id: string]: RuleSource };
loadingIndexPatternId: boolean;
- areCorrelationsLoading: boolean;
+ // Wazuh: hide correlations data in finding details flyout.
+ // areCorrelationsLoading: boolean;
docIdToExpandedRowMap: { [id: string]: JSX.Element };
}
@@ -105,9 +109,11 @@ export default class FindingDetailsFlyout extends Component<
>
),
},
- correlatedFindings: [],
+ // Wazuh: hide correlations data in finding details flyout.
+ // correlatedFindings: [],
loadingIndexPatternId: true,
- areCorrelationsLoading: true,
+ // Wazuh: hide correlations data in finding details flyout.
+ // areCorrelationsLoading: true,
allRules: {},
docIdToExpandedRowMap,
};
@@ -120,44 +126,45 @@ export default class FindingDetailsFlyout extends Component<
this.abortGetFindingsControllers = [];
}
- getCorrelations = async () => {
- const { id, detector } = this.props.finding;
- let allFindings = this.props.findings;
- if (this.props.shouldLoadAllFindings) {
- // if findings come from the alerts fly-out, we need to get all the findings to match those with the correlations
- const abortController = new AbortController();
- this.abortGetFindingsControllers.push(abortController);
- allFindings = await DataStore.findings.getAllFindings(abortController.signal);
- }
-
- DataStore.correlations.getCorrelationRules().then((correlationRules) => {
- DataStore.correlations
- .getCorrelatedFindings(id, detector._source?.detector_type)
- .then((findings) => {
- if (findings?.correlatedFindings.length) {
- let correlatedFindings: any[] = [];
- findings.correlatedFindings.map((finding: CorrelationFinding) => {
- allFindings.map((item: FindingItemType) => {
- if (finding.id === item.id) {
- correlatedFindings.push({
- ...finding,
- correlationRule: correlationRules.find(
- (rule) => finding.rules?.indexOf(rule.id) !== -1
- ),
- });
- }
- });
- });
- this.setState({ correlatedFindings });
- }
- })
- .finally(() => {
- this.setState({
- areCorrelationsLoading: false,
- });
- });
- });
- };
+ // Wazuh: hide correlations data in finding details flyout.
+ // getCorrelations = async () => {
+ // const { id, detector } = this.props.finding;
+ // let allFindings = this.props.findings;
+ // if (this.props.shouldLoadAllFindings) {
+ // // if findings come from the alerts fly-out, we need to get all the findings to match those with the correlations
+ // const abortController = new AbortController();
+ // this.abortGetFindingsControllers.push(abortController);
+ // allFindings = await DataStore.findings.getAllFindings(abortController.signal);
+ // }
+ //
+ // DataStore.correlations.getCorrelationRules().then((correlationRules) => {
+ // DataStore.correlations
+ // .getCorrelatedFindings(id, detector._source?.detector_type)
+ // .then((findings) => {
+ // if (findings?.correlatedFindings.length) {
+ // let correlatedFindings: any[] = [];
+ // findings.correlatedFindings.map((finding: CorrelationFinding) => {
+ // allFindings.map((item: FindingItemType) => {
+ // if (finding.id === item.id) {
+ // correlatedFindings.push({
+ // ...finding,
+ // correlationRule: correlationRules.find(
+ // (rule) => finding.rules?.indexOf(rule.id) !== -1
+ // ),
+ // });
+ // }
+ // });
+ // });
+ // this.setState({ correlatedFindings });
+ // }
+ // })
+ // .finally(() => {
+ // this.setState({
+ // areCorrelationsLoading: false,
+ // });
+ // });
+ // });
+ // };
componentDidMount(): void {
this.getIndexPatternId()
@@ -170,7 +177,8 @@ export default class FindingDetailsFlyout extends Component<
this.setState({ loadingIndexPatternId: false });
});
- this.getCorrelations();
+ // Wazuh: hide correlations data in finding details flyout.
+ // this.getCorrelations();
DataStore.rules.getAllRules().then((rules) => {
const allRules: { [id: string]: RuleSource } = {};
@@ -213,15 +221,16 @@ export default class FindingDetailsFlyout extends Component<
);
};
+ // Wazuh: Remove duplicated fields in metadata and root: title, description.
showRuleDetails = (fullRule: any, ruleId: string) => {
this.setState({
...this.state,
ruleViewerFlyoutData: {
ruleId: ruleId,
- title: fullRule.title,
+ title: fullRule.metadata?.title,
level: fullRule.level,
category: fullRule.category,
- description: fullRule.description,
+ description: fullRule.metadata?.description,
source: fullRule.source,
ruleInfo: {
_source: fullRule,
@@ -258,7 +267,8 @@ export default class FindingDetailsFlyout extends Component<
buttonClassName="euiAccordionForm__button"
buttonContent={
- {fullRule.title}
+ {/* Wazuh: Remove duplicated fields in metadata and root: title. */}
+ {fullRule.metadata?.title ?? DEFAULT_EMPTY_DATA}
Severity: {severity}
@@ -271,11 +281,14 @@ export default class FindingDetailsFlyout extends Component<
+ {/* Wazuh: Remove duplicated fields in metadata and root: title. */}
this.showRuleDetails(fullRule, rule.id)}
- data-test-subj={`finding-details-flyout-${fullRule.title}-details`}
+ data-test-subj={`finding-details-flyout-${
+ fullRule.metadata?.title ?? ''
+ }-details`}
>
- {fullRule.title || DEFAULT_EMPTY_DATA}
+ {fullRule.metadata?.title || DEFAULT_EMPTY_DATA}
@@ -291,7 +304,7 @@ export default class FindingDetailsFlyout extends Component<
{getLogTypeLabel(fullRule.category) || DEFAULT_EMPTY_DATA}
@@ -303,12 +316,16 @@ export default class FindingDetailsFlyout extends Component<
label={'Description'}
data-test-subj={'finding-details-flyout-rule-description'}
>
- {fullRule.description || DEFAULT_EMPTY_DATA}
+ {/* Wazuh: Remove duplicated fields in metadata and root: description */}
+ {fullRule.metadata?.description || DEFAULT_EMPTY_DATA}
-
+
{this.renderTags(rule.tags) || DEFAULT_EMPTY_DATA}
@@ -459,7 +476,10 @@ export default class FindingDetailsFlyout extends Component<
Documents ({relatedDocuments.length})
-
+
{index || DEFAULT_EMPTY_DATA}
@@ -532,26 +552,27 @@ export default class FindingDetailsFlyout extends Component<
private getTabContent(tabId: FindingFlyoutTabId, loadingIndexPatternId = false) {
switch (tabId) {
- case FindingFlyoutTabId.CORRELATIONS:
- const logTypes = new Set();
- const ruleSeverity = new Set();
- Object.values(this.state.allRules).forEach((rule) => {
- logTypes.add(rule.category);
- ruleSeverity.add(rule.level);
- });
-
- return (
-
- );
+ // Wazuh: hide Correlations tab in finding details flyout.
+ // case FindingFlyoutTabId.CORRELATIONS:
+ // const logTypes = new Set();
+ // const ruleSeverity = new Set();
+ // Object.values(this.state.allRules).forEach((rule) => {
+ // logTypes.add(rule.category);
+ // ruleSeverity.add(rule.level);
+ // });
+ //
+ // return (
+ //
+ // );
case FindingFlyoutTabId.DETAILS:
default:
return this.createFindingDetails(loadingIndexPatternId);
@@ -712,7 +733,9 @@ export default class FindingDetailsFlyout extends Component<
});
}}
>
- {tab.id === 'Correlations' ? (
+ {tab.name}
+ {/* Wazuh: hide Correlations tab count in finding details flyout. */}
+ {/* {tab.id === 'Correlations' ? (
<>
{tab.name} (
{this.state.areCorrelationsLoading ? (
@@ -724,7 +747,7 @@ export default class FindingDetailsFlyout extends Component<
>
) : (
tab.name
- )}
+ )} */}
);
})}
diff --git a/public/pages/Findings/components/FindingsTable/FindingsTable.tsx b/public/pages/Findings/components/FindingsTable/FindingsTable.tsx
index 005e32663..d2308e10d 100644
--- a/public/pages/Findings/components/FindingsTable/FindingsTable.tsx
+++ b/public/pages/Findings/components/FindingsTable/FindingsTable.tsx
@@ -25,42 +25,44 @@ import {
renderTime,
} from '../../../../utils/helpers';
import { DEFAULT_EMPTY_DATA } from '../../../../utils/constants';
-import {
- DetectorsService,
- OpenSearchService,
- IndexPatternsService,
- CorrelationService,
-} from '../../../../services';
-import CreateAlertFlyout from '../CreateAlertFlyout';
+import { DetectorsService, OpenSearchService, IndexPatternsService } from '../../../../services';
+// Wazuh: hide Create Alert flow in findings table.
+// import CreateAlertFlyout from '../CreateAlertFlyout';
import { parseAlertSeverityToOption } from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers';
import { RuleSource } from '../../../../../server/models/interfaces';
import { DataStore } from '../../../../store/DataStore';
import { getSeverityColor } from '../../../Correlations/utils/constants';
-import { Finding, FindingItemType, NotificationChannelTypeOptions } from '../../../../../types';
+// Wazuh: hide Create Alert flow in findings table.
+// import { Finding, FindingItemType, NotificationChannelTypeOptions } from '../../../../../types';
+import { FindingItemType } from '../../../../../types';
interface FindingsTableProps extends RouteComponentProps {
detectorService: DetectorsService;
opensearchService: OpenSearchService;
findings: FindingItemType[];
- notificationChannels: NotificationChannelTypeOptions[];
- refreshNotificationChannels: () => void;
+ // Wazuh: hide Create Alert flow in findings table.
+ // notificationChannels: NotificationChannelTypeOptions[];
+ // refreshNotificationChannels: () => void;
loading: boolean;
rules: { [id: string]: RuleSource };
startTime: string;
endTime: string;
onRefresh: () => void;
onFindingsFiltered: (findings: FindingItemType[]) => void;
- hasNotificationsPlugin: boolean;
+ // Wazuh: hide Create Alert flow in findings table.
+ // hasNotificationsPlugin: boolean;
indexPatternsService: IndexPatternsService;
- correlationService: CorrelationService;
+ // Wazuh: hide Create Alert flow in findings table.
+ // correlationService: CorrelationService;
}
interface FindingsTableState {
findingsFiltered: boolean;
filteredFindings: FindingItemType[];
- flyout: object | undefined;
- flyoutOpen: boolean;
- selectedFinding?: Finding;
+ // Wazuh: hide Create Alert flow in findings table.
+ // flyout: object | undefined;
+ // flyoutOpen: boolean;
+ // selectedFinding?: Finding;
widgetEmptyMessage: React.ReactNode | undefined;
}
@@ -70,9 +72,10 @@ export default class FindingsTable extends Component {
- this.setState({ flyout: undefined, flyoutOpen: false, selectedFinding: undefined });
- if (refreshPage) this.props.onRefresh();
- };
+ // Wazuh: hide Create Alert flow in findings table.
+ // closeFlyout = (refreshPage: boolean = false) => {
+ // this.setState({ flyout: undefined, flyoutOpen: false, selectedFinding: undefined });
+ // if (refreshPage) this.props.onRefresh();
+ // };
- renderCreateAlertFlyout = (finding: FindingItemType) => {
- if (this.state.flyoutOpen) this.closeFlyout();
- else {
- const ruleOptions = finding.queries
- .filter(({ id }) => !isThreatIntelQuery(id))
- .map((query) => {
- const rule = this.props.rules[query.id];
- return {
- name: rule.title,
- id: query.id,
- severity: rule.level,
- tags: rule.tags.map((tag: any) => tag.value),
- };
- });
- this.setState({
- flyout: (
-
- ),
- flyoutOpen: true,
- selectedFinding: finding,
- });
- }
- };
+ // Wazuh: hide Create Alert flow in findings table.
+ // renderCreateAlertFlyout = (finding: FindingItemType) => {
+ // if (this.state.flyoutOpen) this.closeFlyout();
+ // else {
+ // const ruleOptions = finding.queries
+ // .filter(({ id }) => !isThreatIntelQuery(id))
+ // .map((query) => {
+ // const rule = this.props.rules[query.id];
+ // return {
+ // name: rule.title,
+ // id: query.id,
+ // severity: rule.level,
+ // tags: rule.tags.map((tag: any) => tag.value),
+ // };
+ // });
+ // this.setState({
+ // flyout: (
+ //
+ // ),
+ // flyoutOpen: true,
+ // selectedFinding: finding,
+ // });
+ // }
+ // };
render() {
const { findings, loading, rules } = this.props;
const {
findingsFiltered,
filteredFindings,
- flyout,
- flyoutOpen,
+ // Wazuh: hide Create Alert flow in findings table.
+ // flyout,
+ // flyoutOpen,
widgetEmptyMessage,
} = this.state;
@@ -217,7 +223,7 @@ export default class FindingsTable extends Component formatRuleType(logType),
@@ -240,17 +246,18 @@ export default class FindingsTable extends Component
),
},
- {
- render: (finding) => (
-
- this.renderCreateAlertFlyout(finding)}
- />
-
- ),
- },
+ // {
+ // Wazuh: hide Create Alert action in findings table.
+ // render: (finding) => (
+ //
+ // this.renderCreateAlertFlyout(finding)}
+ // />
+ //
+ // ),
+ // },
],
},
];
@@ -289,7 +296,7 @@ export default class FindingsTable extends Component ({
value: type,
@@ -304,12 +311,8 @@ export default class FindingsTable extends Component
- {flyoutOpen && flyout}
+ {/* Wazuh: hide Create Alert flyout in findings table. */}
+ {/* {flyoutOpen && flyout} */}
);
}
diff --git a/public/pages/Findings/containers/Findings/Findings.tsx b/public/pages/Findings/containers/Findings/Findings.tsx
index 62df4daff..1fa89184d 100644
--- a/public/pages/Findings/containers/Findings/Findings.tsx
+++ b/public/pages/Findings/containers/Findings/Findings.tsx
@@ -18,40 +18,42 @@ import {
EuiTabbedContent,
} from '@elastic/eui';
import FindingsTable from '../../components/FindingsTable';
-import {
- DetectorsService,
- NotificationsService,
- OpenSearchService,
- IndexPatternsService,
- CorrelationService,
-} from '../../../../services';
+import { DetectorsService, OpenSearchService, IndexPatternsService } from '../../../../services';
+// Wazuh: hide correlations service usage in findings page.
+// import { CorrelationService } from '../../../../services';
+// Wazuh: hide alert-related channels and props from findings page.
+// import { NotificationsService } from '../../../../services';
import {
BREADCRUMBS,
DEFAULT_DATE_RANGE,
DEFAULT_EMPTY_DATA,
FindingTabId,
MAX_RECENTLY_USED_TIME_RANGES,
+ THREAT_INTEL_ENABLED,
} from '../../../../utils/constants';
import { getChartTimeUnit, TimeUnit } from '../../../Overview/utils/helpers';
-import {
- getNotificationChannels,
- parseNotificationChannelsToOptions,
-} from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers';
+// Wazuh: hide alert-related channels and props from findings page.
+// import {
+// getNotificationChannels,
+// parseNotificationChannelsToOptions,
+// } from '../../../CreateDetector/components/ConfigureAlerts/utils/helpers';
import {
createSelectComponent,
errorNotificationToast,
getDuration,
- getIsNotificationPluginInstalled,
setBreadcrumbs,
isThreatIntelQuery,
} from '../../../../utils/helpers';
+// Wazuh: hide alert-related channels and props from findings page.
+// import { getIsNotificationPluginInstalled } from '../../../../utils/helpers';
import { RuleSource } from '../../../../../server/models/interfaces';
import { NotificationsStart } from 'opensearch-dashboards/public';
import { DataStore } from '../../../../store/DataStore';
import { DurationRange } from '@elastic/eui/src/components/date_picker/types';
import {
DataSourceProps,
- FeatureChannelList,
+ // Wazuh: hide alert-related channels and props from findings page.
+ // FeatureChannelList,
DateTimeFilter,
FindingItemType,
DetectorHit,
@@ -65,8 +67,10 @@ import { RuleSeverityValue, RuleSeverityPriority } from '../../../Rules/utils/co
interface FindingsProps extends RouteComponentProps, DataSourceProps {
detectorService: DetectorsService;
- correlationService: CorrelationService;
- notificationsService: NotificationsService;
+ // Wazuh: hide correlations service usage in findings page.
+ // correlationService: CorrelationService;
+ // Wazuh: hide alert-related channels and props from findings page.
+ // notificationsService: NotificationsService;
indexPatternsService: IndexPatternsService;
opensearchService: OpenSearchService;
notifications: NotificationsStart;
@@ -97,7 +101,8 @@ interface FindingsState {
[FindingTabId.DetectionRules]: DetectionRulesFindingsState;
[FindingTabId.ThreatIntel]: ThreatIntelFindingsState;
};
- notificationChannels: FeatureChannelList[];
+ // Wazuh: hide alert-related channels and props from findings page.
+ // notificationChannels: FeatureChannelList[];
recentlyUsedRanges: DurationRange[];
timeUnit: TimeUnit;
dateFormat: string;
@@ -142,11 +147,16 @@ class Findings extends Component {
} = props;
const timeUnits = getChartTimeUnit(dateTimeFilter.startTime, dateTimeFilter.endTime);
const searchParams = new URLSearchParams(props.location.search);
+ const selectedTabFromUrl = searchParams.get('detectionType') as FindingTabId | null;
+ const selectedTabId =
+ selectedTabFromUrl === FindingTabId.ThreatIntel && !THREAT_INTEL_ENABLED
+ ? FindingTabId.DetectionRules
+ : selectedTabFromUrl ?? FindingTabId.DetectionRules;
this.state = {
loading: true,
- notificationChannels: [],
- selectedTabId:
- (searchParams.get('detectionType') as FindingTabId) ?? FindingTabId.DetectionRules,
+ // Wazuh: hide alert-related channels and props from findings page.
+ // notificationChannels: [],
+ selectedTabId,
findingStateByTabId: {
[FindingTabId.DetectionRules]: {
findings: [],
@@ -222,6 +232,8 @@ class Findings extends Component {
componentDidMount = async () => {
setBreadcrumbs([BREADCRUMBS.FINDINGS]);
+ // Wazuh: remove Insights breadcrumb.
+ // setBreadcrumbs([BREADCRUMBS.INSIGHTS, BREADCRUMBS.FINDINGS]);
this.onRefresh();
};
@@ -230,10 +242,11 @@ class Findings extends Component {
}
onRefresh = async () => {
- await this.getNotificationChannels();
+ // Wazuh: hide alert-related channels and props from findings page.
+ // await this.getNotificationChannels();
if (this.state.selectedTabId === FindingTabId.DetectionRules) {
await this.getDetectionRulesFindings();
- } else if (this.state.selectedTabId === FindingTabId.ThreatIntel) {
+ } else if (this.state.selectedTabId === FindingTabId.ThreatIntel && THREAT_INTEL_ENABLED) {
await this.getThreatIntelFindings();
}
// const data = this.generateVisualizationData();
@@ -390,10 +403,11 @@ class Findings extends Component {
}
};
- getNotificationChannels = async () => {
- const channels = await getNotificationChannels(this.props.notificationsService);
- this.setState({ notificationChannels: channels });
- };
+ // Wazuh: hide alert-related channels and props from findings page.
+ // getNotificationChannels = async () => {
+ // const channels = await getNotificationChannels(this.props.notificationsService);
+ // this.setState({ notificationChannels: channels });
+ // };
onTimeChange = ({ start, end }: { start: string; end: string }) => {
let { recentlyUsedRanges } = this.state;
@@ -508,7 +522,8 @@ class Findings extends Component {
render() {
const {
loading,
- notificationChannels,
+ // Wazuh: hide alert-related channels and props from findings page.
+ // notificationChannels,
recentlyUsedRanges,
selectedTabId,
findingStateByTabId,
@@ -557,7 +572,7 @@ class Findings extends Component {
id: FindingTabId.DetectionRules,
name: (
- Detection rules (
+ Rules ( {/* Wazuh: rename 'Detection rules' to 'Rules' */}
{findingStateByTabId[FindingTabId.DetectionRules].filteredFindings.length})
),
@@ -576,39 +591,41 @@ class Findings extends Component {
startTime={dateTimeFilter.startTime}
endTime={dateTimeFilter.endTime}
onRefresh={this.onRefresh}
- notificationChannels={parseNotificationChannelsToOptions(notificationChannels)}
- refreshNotificationChannels={this.getNotificationChannels}
+ // Wazuh: hide alert-related channels and props from findings page.
+ // notificationChannels={parseNotificationChannelsToOptions(notificationChannels)}
+ // refreshNotificationChannels={this.getNotificationChannels}
onFindingsFiltered={this.onFindingsFiltered}
- hasNotificationsPlugin={getIsNotificationPluginInstalled()}
- correlationService={this.props.correlationService}
- />
-
- >
- ),
- },
- {
- id: FindingTabId.ThreatIntel,
- name: (
-
- Threat intel{' '}
- {this.state.selectedTabId === FindingTabId.ThreatIntel
- ? `(${findingStateByTabId[FindingTabId.ThreatIntel].findings.length})`
- : null}
-
- ),
- content: (
- <>
-
- {/*{this.getFindingsGraph(findings, loading)}*/}
- {/* */}
-
-
>
),
},
+ // Threat intel tab is not used by Wazuh
+ // {
+ // id: FindingTabId.ThreatIntel,
+ // name: (
+ //
+ // Threat intel{' '}
+ // {this.state.selectedTabId === FindingTabId.ThreatIntel
+ // ? `(${findingStateByTabId[FindingTabId.ThreatIntel].findings.length})`
+ // : null}
+ //
+ // ),
+ // content: (
+ // <>
+ //
+ // {/*{this.getFindingsGraph(findings, loading)}*/}
+ // {/* */}
+ //
+ //
+ //
+ // >
+ // ),
+ // },
];
const datePicker = (
@@ -624,7 +641,7 @@ class Findings extends Component {
);
return (
-
+
void;
+ };
+}> = ({
+ label,
+ buttonProps,
+ modal: {
+ title,
+ cancelButtonText = 'Cancel',
+ confirmButtonText = 'Confirm',
+ onConfirm,
+ ...modalProps
+ },
+ children,
+}) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+ return (
+ <>
+ {isOpen && (
+
+
+ {title}
+
+ ) : (
+ title
+ )
+ }
+ onCancel={() => setIsOpen(false)}
+ onConfirm={onConfirm}
+ cancelButtonText={cancelButtonText}
+ confirmButtonText={confirmButtonText}
+ buttonColor="primary"
+ defaultFocusedButton="confirm"
+ {...modalProps}
+ >
+ {typeof children === 'function'
+ ? children({ closeModal: () => setIsOpen(false) })
+ : children}
+
+
+ )}
+ setIsOpen(true)}>
+ {label}
+
+ >
+ );
+};
+
+const ButtonComponentByType = {
+ default: EuiButton,
+ icon: EuiButtonIcon,
+ empty: EuiButtonEmpty,
+};
+
+export interface ButtonOpenModalProps {
+ label: string;
+ type: keyof typeof ButtonComponentByType;
+ buttonProps: any;
+ modal: {
+ title: string | React.ReactNode;
+ onConfirm: () => void;
+ };
+}
+
+export const ButtonOpenModal: React.FC = ({
+ label,
+ buttonProps,
+ type,
+ modal: { title, onConfirm, ...modalProps },
+ children,
+}) => {
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ const Button = ButtonComponentByType[type] || ButtonComponentByType.default;
+ return (
+ <>
+ {isOpen && (
+
+ setIsOpen(false)}>
+
+
+ {typeof title === 'string' ? (
+
+ {title}
+
+ ) : (
+ title
+ )}
+
+
+
+ {typeof children === 'function'
+ ? children({ closeModal: () => setIsOpen(false) })
+ : children}
+
+
+
+ )}
+ setIsOpen(true)}>
+ {label}
+
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/CreateIntegrationFlyout.tsx b/public/pages/Integrations/components/CreateIntegrationFlyout.tsx
new file mode 100644
index 000000000..6ae8c12f0
--- /dev/null
+++ b/public/pages/Integrations/components/CreateIntegrationFlyout.tsx
@@ -0,0 +1,138 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useRef, useState } from 'react';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiConfirmModal,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiFlyoutHeader,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiOverlayMask,
+ EuiText,
+} from '@elastic/eui';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { IntegrationItem } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import { successNotificationToast } from '../../../utils/helpers';
+import { IntegrationForm, IntegrationFormHandle } from './IntegrationForm';
+import { defaultIntegration } from '../utils/constants';
+
+const emptyIntegration: IntegrationItem = {
+ ...defaultIntegration,
+ id: '',
+ detectionRulesCount: 0,
+ decodersCount: 0,
+ kvdbsCount: 0,
+};
+
+export interface CreateIntegrationFlyoutProps {
+ notifications: NotificationsStart;
+ onClose: () => void;
+ /** Called with the new integration's id and title after a successful creation */
+ onSuccess: (id: string, title: string) => void;
+}
+
+export const CreateIntegrationFlyout: React.FC = ({
+ notifications,
+ onClose,
+ onSuccess,
+}) => {
+ const formRef = useRef(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [hasChanges, setHasChanges] = useState(false);
+ const [isConfirmCloseOpen, setIsConfirmCloseOpen] = useState(false);
+
+ const onConfirm = useCallback(
+ async (integrationData: IntegrationItem) => {
+ setIsSaving(true);
+ try {
+ const [ok, id] = await DataStore.integrations.createIntegration(integrationData);
+ if (ok) {
+ const title = integrationData.document.metadata?.title ?? '';
+ successNotificationToast(notifications, 'created', `integration ${title}`);
+ onSuccess(id, title);
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [notifications, onSuccess]
+ );
+
+ const handleClose = useCallback(() => {
+ if (hasChanges) {
+ setIsConfirmCloseOpen(true);
+ } else {
+ onClose();
+ }
+ }, [hasChanges, onClose]);
+
+ const onDirtyChange = useCallback((isDirty: boolean) => setHasChanges(isDirty), []);
+
+ return (
+ <>
+
+
+
+ Create integration
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ formRef.current?.submit()}
+ >
+ Create integration
+
+
+
+
+
+
+ {isConfirmCloseOpen && (
+
+ setIsConfirmCloseOpen(false)}
+ cancelButtonText="No, go back"
+ confirmButtonText="Yes, discard changes"
+ buttonColor="danger"
+ >
+ There are unsaved changes. Are you sure you want to close?
+
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/DeleteIntegrationModal.tsx b/public/pages/Integrations/components/DeleteIntegrationModal.tsx
new file mode 100644
index 000000000..bd4f466c6
--- /dev/null
+++ b/public/pages/Integrations/components/DeleteIntegrationModal.tsx
@@ -0,0 +1,138 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiSmallButton,
+ EuiCallOut,
+ EuiConfirmModal,
+ EuiCompressedFieldText,
+ EuiForm,
+ EuiCompressedFormRow,
+ EuiLoadingSpinner,
+ EuiModal,
+ EuiModalBody,
+ EuiModalFooter,
+ EuiModalHeader,
+ EuiModalHeaderTitle,
+ EuiOverlayMask,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import React from 'react';
+import { useState } from 'react';
+import { withGuardAsync } from '../utils/helpers';
+import { DataStore } from '../../../store/DataStore';
+
+export interface DeleteIntegrationModalProps {
+ integrationName: string;
+ integrationId: string;
+ detectionRulesCount?: number;
+ decodersCount?: number;
+ kvdbsCount?: number;
+ loading?: boolean;
+ closeModal: () => void;
+ onConfirm: () => void;
+}
+
+const DELETE_INTEGRATION_ASSOCIATED_ENTITIES_MESSAGE =
+ "Only integrations that don't have associated rules, decoders, or KVDBs can be deleted. Consider editing integration or deleting the associated entities.";
+
+const LoadingModal = ({ closeModal }) => (
+
+
+
+
+
+);
+
+export const DeleteIntegrationModal: React.FC = ({
+ detectionRulesCount = 0,
+ decodersCount = 0,
+ kvdbsCount = 0,
+ integrationName,
+ closeModal,
+ onConfirm,
+}) => {
+ const [confirmDeleteText, setConfirmDeleteText] = useState('');
+ const hasRelatedEntities = detectionRulesCount > 0 || decodersCount > 0 || kvdbsCount > 0;
+ const relatedEntitiesMessage = DataStore.integrations.getRelatedEntitiesMessage({
+ hasRules: detectionRulesCount > 0,
+ hasDecoders: decodersCount > 0,
+ hasKVDBs: kvdbsCount > 0,
+ });
+
+ const onConfirmClick = async () => {
+ await onConfirm();
+ closeModal();
+ };
+
+ return (
+
+ {hasRelatedEntities ? (
+
+
+
+
+ {/* log type replaced by integration */}
+ This integration can't be deleted
+
+
+
+
+
+
+
+ {DELETE_INTEGRATION_ASSOCIATED_ENTITIES_MESSAGE}
+
+
+
+
+ Close
+
+
+
+ ) : (
+
+ Delete integration?
+
+ }
+ onCancel={closeModal}
+ onConfirm={onConfirmClick}
+ cancelButtonText={'Cancel'}
+ confirmButtonText={`Delete integration`}
+ buttonColor={'danger'}
+ defaultFocusedButton="confirm"
+ confirmButtonDisabled={confirmDeleteText != integrationName}
+ >
+
+
+
+ The integration will be permanently deleted. This action is irreversible.
+
+
+
+
+ Type {{integrationName} } to confirm
+
+
+
+ setConfirmDeleteText(e.target.value)}
+ />
+
+
+
+ )}
+
+ );
+};
diff --git a/public/pages/Integrations/components/EditPolicy.tsx b/public/pages/Integrations/components/EditPolicy.tsx
new file mode 100644
index 000000000..09182a693
--- /dev/null
+++ b/public/pages/Integrations/components/EditPolicy.tsx
@@ -0,0 +1,573 @@
+import React, { useCallback, useMemo, useState, useEffect } from 'react';
+import {
+ EuiFlyout,
+ EuiFlyoutHeader,
+ EuiFlyoutBody,
+ EuiFlyoutFooter,
+ EuiText,
+ EuiSwitch,
+ EuiCompressedFormRow,
+ EuiCompressedFieldText,
+ EuiCompressedTextArea,
+ EuiFlexItem,
+ EuiFlexGroup,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiComboBox,
+ EuiPopover,
+ EuiFilterGroup,
+ EuiFilterButton,
+ EuiFilterSelectItem,
+ EuiOverlayMask,
+ EuiConfirmModal,
+ EuiSpacer,
+ EuiHorizontalRule
+} from '@elastic/eui';
+
+import { withPolicyGuard } from './PolicyGuard';
+import { DecoderSource, PolicyDocument, Space } from '../../../../types';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { DataStore } from '../../../store/DataStore';
+import { successNotificationToast } from '../../../utils/helpers';
+import { FormFieldArray } from '../../../components/FormFieldArray';
+import { INTEGRATION_AUTHOR_REGEX, validateName } from '../../../utils/validation';
+import { buildDecodersSearchQuery } from '../../Decoders/utils/constants';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpaceTypeLabel } from '../../../../common/helpers';
+import { ALLOWED_ENRICHMENTS, ENRICHMENT_LABELS, EnrichmentType } from '../constants/enrichments';
+
+const DECODER_SEARCH_SIZE = 25;
+const DELAY_ON_SEARCH = 300; // ms
+
+const EditForm: React.FC<{}> = withPolicyGuard({
+ // Needs the full document on save, but not the integrationsMap
+ includeIntegrationsMap: false,
+})(({
+ policyDocumentData,
+ rootDecoder,
+ notifications,
+ space,
+ onClose,
+ onSuccess,
+ onFlyoutClose,
+ setCanClose,
+}: {
+ policyDocumentData: PolicyDocument;
+ rootDecoder: DecoderSource;
+ notifications: NotificationsStart;
+ space: Space;
+ onClose: () => void;
+ onSuccess: () => void;
+ onFlyoutClose: () => void;
+ setCanClose: (arg0: boolean) => void;
+}) => {
+ const [policyDetails, setPolicyDetails] = useState(policyDocumentData);
+ const [titleError, setTitleError] = useState('');
+ const [authorError, setAuthorError] = useState('');
+ const [decoderList, setDecoderList] = useState>(
+ []
+ );
+
+ const [decoderSearch, setDecoderSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(decoderSearch);
+ }, DELAY_ON_SEARCH);
+
+ return () => clearTimeout(handler);
+ }, [decoderSearch]);
+
+ useEffect(() => {
+ fetchDecoders(debouncedSearch);
+ }, [debouncedSearch]);
+
+ // If the policy has a root decoder set it as the initial value
+ const [selectedDecoder, setSelectedDecoder] = useState<
+ Array<{ label: string; value: DecoderSource }>
+ >(() => {
+ if (rootDecoder?.document) {
+ return [
+ {
+ label: rootDecoder.document.name,
+ value: rootDecoder,
+ },
+ ];
+ }
+ return [];
+ });
+
+ const [selectedEnrichments, setSelectedEnrichments] = useState(
+ () => (policyDocumentData?.enrichments ?? []) as EnrichmentType[]
+ );
+
+ const [isEnrichmentPopoverOpen, setIsEnrichmentPopoverOpen] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Flag to determine if the space allows editing non-enrichments fields
+ const canEditPolicy = actionIsAllowedOnSpace(space, SPACE_ACTIONS.EDIT_POLICY);
+ const canEditToggles =
+ canEditPolicy || actionIsAllowedOnSpace(space, SPACE_ACTIONS.EDIT_POLICY_INDEXING_SETTINGS);
+ const canEditEnrichments = actionIsAllowedOnSpace(space, SPACE_ACTIONS.EDIT_POLICY_ENRICHMENTS);
+
+ const handleEnrichmentToggle = useCallback((value: EnrichmentType) => {
+ setSelectedEnrichments((prev) => {
+ const isActive = prev.includes(value);
+ const nextSelection = isActive ? prev.filter((item) => item !== value) : [...prev, value];
+ setPolicyDetails((prevPolicy) => ({
+ ...prevPolicy,
+ enrichments: nextSelection,
+ }));
+ return nextSelection;
+ });
+ }, []);
+
+ const hasChanges = useMemo(() => {
+ return JSON.stringify(policyDetails) !== JSON.stringify(policyDocumentData);
+ }, [policyDetails, policyDocumentData]);
+
+ useEffect(() => {
+ setCanClose(!hasChanges);
+ }, [hasChanges]);
+
+ const renderTextValue = (value?: string | null) => (
+
+ {value || '-'}
+
+ );
+
+ const renderBooleanValue = (value?: boolean) => (
+
+ {value ? 'yes' : 'no'}
+
+ );
+
+ const updateErrors = (details: PolicyDocument) => {
+ const titleInvalid = !validateName(details.metadata?.title, INTEGRATION_AUTHOR_REGEX);
+ const authorInvalid = !validateName(details.metadata?.author, INTEGRATION_AUTHOR_REGEX);
+ setTitleError(titleInvalid ? 'Invalid title' : '');
+ setAuthorError(authorInvalid ? 'Invalid author' : '');
+
+ return { titleInvalid, authorInvalid };
+ };
+
+ const sanitizatePolicy = (details: PolicyDocument) => {
+ const refs = details.metadata?.references;
+ const references = Array.isArray(refs) ? refs.filter((ref) => String(ref).trim() !== '') : [];
+ return {
+ root_decoder: details.root_decoder,
+ integrations: details.integrations,
+ filters: details.filters ?? [],
+ enrichments: details.enrichments,
+ enabled: details.enabled,
+ index_unclassified_events: details.index_unclassified_events,
+ index_discarded_events: details.index_discarded_events,
+ metadata: {
+ title: details.metadata?.title ?? '',
+ author: details.metadata?.author ?? '',
+ description: details.metadata?.description ?? '',
+ documentation: details.metadata?.documentation ?? '',
+ references,
+ },
+ };
+ };
+
+ const fetchDecoders = async (search: string) => {
+ try {
+ const query = buildDecodersSearchQuery(search);
+ const response = await DataStore.decoders.searchDecoders(
+ {
+ from: 0,
+ size: DECODER_SEARCH_SIZE,
+ sort: [{ ['document.name']: { order: 'asc', unmapped_type: 'keyword' } }],
+ query,
+ _source: { includes: ['document.id', 'document.name'] },
+ },
+ space
+ );
+ setDecoderList(
+ response.items.map((item) => ({
+ label: item?.document?.name ?? item?.document?.id,
+ value: item,
+ }))
+ );
+ } catch {
+ setDecoderList([]);
+ }
+ };
+
+ const onConfirmClicked = useCallback(async () => {
+ if (isSaving) {
+ return;
+ }
+ const { titleInvalid, authorInvalid } = updateErrors(policyDetails);
+
+ if (titleInvalid || authorInvalid) {
+ notifications?.toasts.addDanger({
+ title: `Failed to update`,
+ text: `Fix the marked errors.`,
+ toastLifeTimeMs: 3000,
+ });
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ const payload = sanitizatePolicy(policyDetails);
+ const [ok] = await DataStore.policies.updatePolicy(space, payload);
+
+ if (ok) {
+ successNotificationToast(notifications, 'updated', `[${space}] space`);
+ if (onSuccess) onSuccess();
+ onClose();
+ } else {
+ setIsSaving(false);
+ }
+ } catch {
+ setIsSaving(false);
+ }
+ }, [isSaving, notifications, onClose, onSuccess, policyDetails, space]);
+
+ return (
+ <>
+
+
+ Details
+
+
+
+ {canEditPolicy ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ metadata: {
+ ...policyDetails.metadata,
+ title: e.target.value,
+ },
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderTextValue(policyDetails.metadata?.title)
+ )}
+
+
+ {canEditPolicy ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ metadata: {
+ ...policyDetails.metadata,
+ author: e.target.value,
+ },
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderTextValue(policyDetails.metadata?.author)
+ )}
+
+
+ {'Description - '}
+ optional
+ >
+ }
+ >
+ {canEditPolicy ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ metadata: {
+ ...policyDetails.metadata,
+ description: e.target.value,
+ },
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderTextValue(policyDetails.metadata?.description)
+ )}
+
+
+ {'Documentation - '}
+ optional
+ >
+ }
+ >
+ {canEditPolicy ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ metadata: {
+ ...policyDetails.metadata,
+ documentation: e.target.value,
+ },
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderTextValue(policyDetails.metadata?.documentation)
+ )}
+
+
+
+ Settings
+
+
+
+ {canEditToggles ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ enabled: e.target.checked,
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderBooleanValue(policyDetails.enabled)
+ )}
+
+
+ {canEditPolicy ? (
+ setDecoderSearch(searchValue)}
+ onChange={(selected) => {
+ setSelectedDecoder(selected);
+ const newPolicy = {
+ ...policyDetails,
+ root_decoder: selected.length > 0 ? selected[0].value?.document?.id : '',
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ if (selected.length === 0) {
+ setDecoderSearch('');
+ }
+ }}
+ async
+ />
+ ) : (
+ renderTextValue(rootDecoder?.document?.name)
+ )}
+
+
+
+
+
+ {canEditToggles ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ index_unclassified_events: e.target.checked,
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderBooleanValue(policyDetails.index_unclassified_events)
+ )}
+
+
+
+
+ {canEditToggles ? (
+ {
+ const newPolicy = {
+ ...policyDetails,
+ index_discarded_events: e.target.checked,
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderBooleanValue(policyDetails.index_discarded_events)
+ )}
+
+
+
+
+
+ {'Enrichments - '}
+ optional
+ >
+ }
+ >
+
+ setIsEnrichmentPopoverOpen((prev) => !prev)}
+ isSelected={isEnrichmentPopoverOpen}
+ numFilters={selectedEnrichments.length}
+ hasActiveFilters={selectedEnrichments.length > 0}
+ numActiveFilters={selectedEnrichments.length}
+ isDisabled={!canEditEnrichments}
+ >
+ Select enrichments
+
+ }
+ isOpen={isEnrichmentPopoverOpen}
+ closePopover={() => setIsEnrichmentPopoverOpen(false)}
+ panelPaddingSize="none"
+ >
+
+ {ALLOWED_ENRICHMENTS.map((value) => (
+ handleEnrichmentToggle(value)}
+ >
+ {ENRICHMENT_LABELS[value]}
+
+ ))}
+
+
+
+
+
+ {canEditPolicy ? (
+
+ {'References - '}
+ optional
+ >
+ }
+ values={policyDetails.metadata?.references || []}
+ placeholder="https://example.com/reference"
+ readOnly={false}
+ addButtonLabel="Add reference"
+ onChange={(references) => {
+ const newPolicy = {
+ ...policyDetails,
+ metadata: { ...policyDetails.metadata, references },
+ };
+ setPolicyDetails(newPolicy);
+ updateErrors(newPolicy);
+ }}
+ />
+ ) : (
+ renderTextValue(policyDetails.metadata?.references?.join(', ') ?? '')
+ )}
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+
+ >
+ );
+});
+
+export type EditPolicyProps = {
+ onClose: () => void;
+ onSuccess: () => void;
+ space: Space;
+ notifications: NotificationsStart;
+};
+
+export const EditPolicy: React.FC = ({
+ onClose,
+ onSuccess,
+ space,
+ notifications,
+}) => {
+ const editSpaceTitle = `Edit ${getSpaceTypeLabel(space)}`;
+ const [canClose, setCanClose] = useState(true);
+ const [canNotCloseIsOpen, setCanNotCloseIsOpen] = useState(false);
+ const onFlyoutClose = function () {
+ if (!canClose) {
+ setCanNotCloseIsOpen(true);
+ return;
+ }
+ onClose();
+ };
+
+ return (
+ <>
+
+
+
+ {editSpaceTitle}
+
+
+
+
+ {canNotCloseIsOpen && (
+
+ setCanNotCloseIsOpen(false)}
+ cancelButtonText="No, don't do it"
+ confirmButtonText="Yes, do it"
+ >
+
+ There are unsaved changes. Are you sure you want to proceed?
+
+
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/IntegrationDecoders.tsx b/public/pages/Integrations/components/IntegrationDecoders.tsx
new file mode 100644
index 000000000..7a01fee25
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationDecoders.tsx
@@ -0,0 +1,208 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import {
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiSmallButton,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { ContentPanel } from '../../../components/ContentPanel';
+import { DecoderDetailsFlyout } from '../../Decoders/components/DecoderDetailsFlyout';
+import { formatCellValue } from '../../../utils/helpers';
+import { EuiIcon } from '@elastic/eui';
+import { ROUTES } from '../../../utils/constants';
+import { SpaceTypes, SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import { Space } from '../../../../types';
+import { useIntegrationDecoders } from '../../Decoders/hooks/useIntegrationDecoders';
+
+export interface IntegrationDecodersProps {
+ decoderIds: string[];
+ space: string;
+ enabled: boolean;
+}
+
+export interface DecoderTableItem {
+ id: string;
+ name?: string;
+ title?: string;
+ author?: string;
+}
+
+export const IntegrationDecoders: React.FC = ({
+ decoderIds,
+ space,
+ enabled,
+}) => {
+ const [flyoutDecoderId, setFlyoutDecoderId] = useState(undefined);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(10);
+ const [sortField, setSortField] = useState('name');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [searchText, setSearchText] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+
+ useEffect(() => {
+ const t = setTimeout(() => {
+ setAppliedSearch(searchText);
+ setPageIndex(0);
+ }, 500);
+ return () => clearTimeout(t);
+ }, [searchText]);
+
+ const { items: decoders, total, loading, refresh } = useIntegrationDecoders({
+ decoderIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search: appliedSearch,
+ });
+
+ const isCreateDisabled = !actionIsAllowedOnSpace(space as Space, SPACE_ACTIONS.CREATE);
+
+ const columns: EuiBasicTableColumn[] = useMemo(
+ () => [
+ {
+ field: 'name',
+ name: 'Name',
+ sortable: true,
+ render: (_: string, decoder: DecoderTableItem) => (
+ setFlyoutDecoderId(decoder.id)}>
+ {formatCellValue(decoder?.name)}
+
+ ),
+ },
+ {
+ field: 'title',
+ name: 'Title',
+ sortable: true,
+ render: (_: string, decoder: DecoderTableItem) => formatCellValue(decoder?.title),
+ },
+ {
+ field: 'author',
+ name: 'Author',
+ sortable: true,
+ render: (_: string, decoder: DecoderTableItem) => formatCellValue(decoder?.author),
+ },
+ ],
+ []
+ );
+
+ const closeFlyout = useCallback(() => {
+ setFlyoutDecoderId(undefined);
+ }, []);
+
+ const onTableChange = useCallback(
+ ({
+ page,
+ sort,
+ }: {
+ page?: { index: number; size: number };
+ sort?: { field: string; direction: 'asc' | 'desc' };
+ }) => {
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+ if (sort) {
+ setSortField(sort.field);
+ setSortDirection(sort.direction);
+ }
+ },
+ []
+ );
+
+ const isEmptyState = total === 0 && !loading && !appliedSearch;
+
+ return (
+ <>
+ {flyoutDecoderId && (
+
+ )}
+
+ Refresh]}
+ >
+ {isEmptyState ? (
+
+
+
+ There are no decoders associated with this integration.
+
+
+
+ {space !== SpaceTypes.STANDARD.value && (
+
+ {isCreateDisabled ? (
+
+
+
+ Create decoder
+
+
+
+
+ ) : (
+
+ Create decoder
+
+
+ )}
+
+ )}
+
+ ) : (
+ <>
+
+
+ setSearchText(e.target.value)}
+ compressed
+ fullWidth
+ />
+
+
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/IntegrationDetails.tsx b/public/pages/Integrations/components/IntegrationDetails.tsx
new file mode 100644
index 000000000..c7d9f78eb
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationDetails.tsx
@@ -0,0 +1,78 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { EuiSmallButton, EuiDescriptionList, EuiText } from '@elastic/eui';
+import { ContentPanel } from '../../../components/ContentPanel';
+import React from 'react';
+import { IntegrationItem } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import { IntegrationForm } from './IntegrationForm';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { successNotificationToast, setBreadcrumbs } from '../../../utils/helpers';
+import { actionIsAllowedOnSpace } from '../../../../common/helpers';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { BREADCRUMBS } from '../../../utils/constants';
+
+export interface IntegrationDetailsProps {
+ integrationDetails: IntegrationItem;
+ isEditMode: boolean;
+ notifications: NotificationsStart;
+ setIsEditMode: (isEdit: boolean) => void;
+ setIntegrationDetails: (integration: IntegrationItem) => void;
+ integrationId: string;
+}
+
+export const IntegrationDetails: React.FC = ({
+ integrationDetails,
+ isEditMode,
+ notifications,
+ setIsEditMode,
+ setIntegrationDetails,
+ integrationId,
+}) => {
+ const onUpdateIntegration = async (integrationData: IntegrationItem) => {
+ const success = await DataStore.integrations.updateIntegration(integrationId, integrationData);
+ if (success) {
+ setIntegrationDetails(integrationData);
+ setBreadcrumbs([
+ BREADCRUMBS.INTEGRATIONS,
+ { text: integrationData.document.metadata?.title ?? '' },
+ ]);
+ successNotificationToast(
+ notifications,
+ 'updated',
+ `integration ${integrationData.document.metadata?.title ?? ''}`
+ );
+ setIsEditMode(false);
+ }
+ };
+
+ return (
+
+ Details
+
+ } hideHeaderBorder>
+ {
+ setIsEditMode(false);
+ }}
+ onConfirm={onUpdateIntegration}
+ />
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/public/pages/Integrations/components/IntegrationDetailsTab.tsx b/public/pages/Integrations/components/IntegrationDetailsTab.tsx
new file mode 100644
index 000000000..5dd9797a1
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationDetailsTab.tsx
@@ -0,0 +1,142 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiSmallButton,
+ EuiButton,
+ EuiDescriptionList,
+ EuiCompressedFormRow,
+ EuiCompressedFieldText,
+ EuiSpacer,
+ EuiCompressedTextArea,
+ EuiBottomBar,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButtonEmpty,
+} from '@elastic/eui';
+import { ContentPanel } from '../../../components/ContentPanel';
+import React from 'react';
+import { IntegrationItem } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+
+export interface IntegrationDetailsTabProps {
+ initialIntegrationDetails: IntegrationItem;
+ integrationDetails: IntegrationItem;
+ isEditMode: boolean;
+ setIsEditMode: (isEdit: boolean) => void;
+ setIntegrationDetails: (integration: IntegrationItem) => void;
+ integrationId: string;
+}
+
+export const IntegrationDetailsTab: React.FC = ({
+ initialIntegrationDetails,
+ integrationDetails,
+ isEditMode,
+ setIsEditMode,
+ setIntegrationDetails,
+ integrationId,
+}) => {
+ const onUpdateIntegration = async () => {
+ const success = await DataStore.integrations.updateIntegration(
+ integrationId,
+ integrationDetails
+ );
+ if (success) {
+ setIsEditMode(false);
+ }
+ };
+
+ return (
+ setIsEditMode(true)}>Edit]
+ }
+ >
+
+
+
+ setIntegrationDetails({
+ ...integrationDetails!,
+ document: {
+ ...integrationDetails.document,
+ metadata: {
+ ...integrationDetails.document.metadata,
+ title: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="Enter name for integration" // Replace Log type to Integration by Wazuh
+ disabled={!isEditMode || !!integrationDetails.detectionRulesCount}
+ />
+
+
+
+
+ setIntegrationDetails({
+ ...integrationDetails!,
+ document: {
+ ...integrationDetails.document,
+ metadata: {
+ ...integrationDetails.document.metadata,
+ description: e.target.value,
+ },
+ },
+ })
+ }
+ placeholder="Description of the integration" // Replace Log type to Integration by Wazuh
+ disabled={!isEditMode}
+ />
+
+ {isEditMode ? (
+ <>
+
+
+
+ {
+ setIntegrationDetails(initialIntegrationDetails);
+ setIsEditMode(false);
+ }}
+ >
+ Cancel
+
+
+
+
+ Edit integration
+
+
+
+
+ >
+ ) : null}
+ >
+ ),
+ },
+ ]}
+ />
+
+ );
+};
diff --git a/public/pages/Integrations/components/IntegrationDetectionRules.tsx b/public/pages/Integrations/components/IntegrationDetectionRules.tsx
new file mode 100644
index 000000000..6d3114bf7
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationDetectionRules.tsx
@@ -0,0 +1,234 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import {
+ EuiBadge,
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiComboBox,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiIcon,
+ EuiLink,
+ EuiSmallButton,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { ContentPanel } from '../../../components/ContentPanel';
+import { RuleTableItem } from '../../WazuhRules/utils/helpers';
+import { RuleViewerFlyout } from '../../WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout';
+import { getSeverityColor, getSeverityLabel } from '../../Correlations/utils/constants';
+import { ruleSeverity } from '../../Rules/utils/constants';
+import { ROUTES } from '../../../utils/constants';
+import { SpaceTypes, SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import { Space } from '../../../../types';
+import { useIntegrationRules } from '../../WazuhRules/hooks/useIntegrationRules';
+
+export interface IntegrationDetectionRulesProps {
+ ruleIds: string[];
+ space: string;
+ enabled: boolean;
+}
+
+export const IntegrationDetectionRules: React.FC = ({
+ ruleIds,
+ space,
+ enabled,
+}) => {
+ const [selectedRuleId, setSelectedRuleId] = useState(undefined);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(10);
+ const [sortField, setSortField] = useState('title');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [searchText, setSearchText] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+ const [severityLevels, setSeverityLevels] = useState([]);
+
+ useEffect(() => {
+ const t = setTimeout(() => {
+ setAppliedSearch(searchText);
+ setPageIndex(0);
+ }, 500);
+ return () => clearTimeout(t);
+ }, [searchText]);
+
+ const { items: rules, total, loading: loadingRules, refresh } = useIntegrationRules({
+ ruleIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search: appliedSearch,
+ severityLevels,
+ });
+
+ const closeRuleDetails = useCallback(() => {
+ setSelectedRuleId(undefined);
+ }, []);
+
+ const isCreateDisabled = !actionIsAllowedOnSpace(space as Space, SPACE_ACTIONS.CREATE);
+
+ const columns: EuiBasicTableColumn[] = useMemo(
+ () => [
+ {
+ field: 'title',
+ name: 'Name',
+ sortable: true,
+ truncateText: true,
+ render: (_: string, rule: RuleTableItem) => (
+ setSelectedRuleId(rule.ruleId)}>{rule.title}
+ ),
+ },
+ {
+ field: 'level',
+ name: 'Severity',
+ sortable: true,
+ width: '120px',
+ render: (level: string) => {
+ const { text, background } = getSeverityColor(level);
+ return (
+
+ {getSeverityLabel(level)}
+
+ );
+ },
+ },
+ {
+ field: 'description',
+ name: 'Description',
+ sortable: false,
+ truncateText: true,
+ },
+ ],
+ []
+ );
+
+ const onTableChange = useCallback(
+ ({
+ page,
+ sort,
+ }: {
+ page?: { index: number; size: number };
+ sort?: { field: string; direction: 'asc' | 'desc' };
+ }) => {
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+ if (sort) {
+ setSortField(sort.field);
+ setSortDirection(sort.direction);
+ }
+ },
+ []
+ );
+
+ const severityOptions = ruleSeverity.map((s) => ({ label: s.name, value: s.value }));
+ const selectedSeverityOptions = severityLevels.map((v) => ({
+ label: ruleSeverity.find((s) => s.value === v)?.name ?? v,
+ value: v,
+ }));
+
+ const isEmptyState =
+ total === 0 && !loadingRules && !appliedSearch && severityLevels.length === 0;
+
+ return (
+ <>
+ {selectedRuleId && (
+
+ )}
+ Refresh]}
+ >
+ {isEmptyState ? (
+
+
+
+ {/* By Wazuh */}
+ There are no rules associated with this integration.
+
+
+ {space !== SpaceTypes.STANDARD.value && (
+
+ {isCreateDisabled ? (
+
+
+
+ Create rule
+
+
+
+
+ ) : (
+
+ Create rule
+
+
+ )}
+
+
+ )}
+
+ ) : (
+ <>
+
+
+ setSearchText(e.target.value)}
+ compressed
+ fullWidth
+ />
+
+
+ {
+ setSeverityLevels(opts.map((o) => o.value as string));
+ setPageIndex(0);
+ }}
+ isClearable
+ compressed
+ />
+
+
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/IntegrationForm.tsx b/public/pages/Integrations/components/IntegrationForm.tsx
new file mode 100644
index 000000000..95b5b15e5
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationForm.tsx
@@ -0,0 +1,495 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiToolTip,
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiCompressedFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiCompressedFormRow,
+ EuiCompressedSwitch,
+ EuiSpacer,
+ EuiCompressedSuperSelect,
+ EuiCompressedTextArea,
+ EuiText,
+} from '@elastic/eui';
+import { IntegrationItem } from '../../../../types';
+import React, {
+ useEffect,
+ useRef,
+ useMemo,
+ useCallback,
+ useImperativeHandle,
+ forwardRef,
+} from 'react';
+import {
+ INTEGRATION_AUTHOR_REGEX,
+ LOG_TYPE_NAME_REGEX,
+ validateName,
+} from '../../../utils/validation';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { useState } from 'react';
+import { isEqual } from 'lodash';
+import { getIntegrationCategoryOptions } from '../../../utils/helpers';
+import { FormFieldArray } from '../../../components/FormFieldArray';
+
+interface ReadOnlyFieldProps {
+ value: string | undefined;
+ placeholder?: string;
+ isTextArea?: boolean;
+ noTruncate?: boolean;
+}
+
+const ReadOnlyField: React.FC = ({
+ value,
+ placeholder = '-',
+ isTextArea = false,
+ noTruncate = false,
+}) => (
+
+ {value || placeholder}
+
+);
+
+export interface IntegrationFormHandle {
+ submit: () => void;
+}
+
+export interface IntegrationFormProps {
+ integrationDetails: IntegrationItem;
+ isEditMode: boolean;
+ confirmButtonText: string;
+ notifications: NotificationsStart;
+ onCancel: () => void;
+ onConfirm: (integrationData: IntegrationItem) => void;
+ hideBottomBar?: boolean;
+ onDirtyChange?: (isDirty: boolean) => void;
+}
+
+export const IntegrationForm = forwardRef(
+ function IntegrationForm(
+ { integrationDetails, isEditMode, confirmButtonText, notifications, onCancel, onConfirm, hideBottomBar = false, onDirtyChange },
+ ref
+ ) {
+
+ const hasMountedRef = useRef(false);
+ const initialIntegrationRef = useRef(integrationDetails);
+ const onDirtyChangeRef = useRef(onDirtyChange);
+ useEffect(() => { onDirtyChangeRef.current = onDirtyChange; });
+
+ const showEnabledField = isEditMode && !integrationDetails.id;
+ const [titleError, setTitleError] = useState('');
+ const [categoryError, setCategoryError] = useState('');
+ const [authorError, setAuthorError] = useState('');
+ const [editingIntegration, setEditingIntegration] = useState(integrationDetails);
+
+ useEffect(() => {
+ if (isEditMode) {
+ setEditingIntegration(integrationDetails);
+ }
+ }, [isEditMode, integrationDetails]);
+
+ useEffect(() => {
+ if (!hasMountedRef.current) {
+ hasMountedRef.current = true;
+ return;
+ }
+ const isDirty = !isEqual(editingIntegration, initialIntegrationRef.current);
+ onDirtyChangeRef.current?.(isDirty);
+ }, [editingIntegration]);
+
+ const updateErrors = (details: IntegrationItem) => {
+ const metadata = details.document.metadata;
+ const titleInvalid = !validateName(metadata?.title, LOG_TYPE_NAME_REGEX, false);
+ const authorInvalid = !validateName(metadata?.author, INTEGRATION_AUTHOR_REGEX, false);
+ const categoryInvalid = !details.document.category;
+ setTitleError(titleInvalid ? 'Invalid title' : '');
+ setCategoryError(categoryInvalid ? 'Select category to assign' : '');
+ setAuthorError(authorInvalid ? 'Invalid author' : '');
+
+ return { titleInvalid, categoryInvalid, authorInvalid };
+ };
+ const categoryOptions = useMemo(() => getIntegrationCategoryOptions(), []);
+ const categoryOptionsWithDisabled = useMemo(
+ () =>
+ categoryOptions.map((option) => ({
+ ...option,
+ disabled: !!integrationDetails.detectionRulesCount,
+ })),
+ [categoryOptions, integrationDetails.detectionRulesCount]
+ );
+
+ const getCategoryLabel = useCallback(
+ (categoryValue: string | undefined): string | undefined => {
+ if (!categoryValue) return undefined;
+ const found = categoryOptions.find((opt) => opt.value === categoryValue);
+ return found?.inputDisplay as string | undefined;
+ },
+ [categoryOptions]
+ );
+
+ const onConfirmClicked = useCallback(() => {
+ const { titleInvalid, categoryInvalid, authorInvalid } = updateErrors(editingIntegration);
+
+ if (titleInvalid || categoryInvalid || authorInvalid) {
+ notifications?.toasts.addDanger({
+ title: `Failed to ${confirmButtonText.toLowerCase()}`,
+ text: `Fix the marked errors.`,
+ toastLifeTimeMs: 3000,
+ });
+ return;
+ }
+ onConfirm(editingIntegration);
+ }, [editingIntegration, notifications, confirmButtonText, onConfirm]);
+
+ const onCancelClicked = useCallback(() => {
+ setEditingIntegration(integrationDetails);
+ setTitleError('');
+ setCategoryError('');
+ setAuthorError('');
+ onCancel();
+ }, [integrationDetails, onCancel]);
+
+ useImperativeHandle(ref, () => ({ submit: onConfirmClicked }), [onConfirmClicked]);
+
+ const missingFields: string[] = [];
+ if (!editingIntegration.document.metadata?.title) missingFields.push('Title');
+ if (!editingIntegration.document.category) missingFields.push('Category');
+ if (!editingIntegration.document.metadata?.author) missingFields.push('Author');
+ const isSubmitDisabled = missingFields.length > 0;
+
+ return (
+ <>
+
+
+ {isEditMode ? (
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ title: e.target.value,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ disabled={!!integrationDetails.detectionRulesCount}
+ />
+ ) : (
+
+ )}
+
+
+
+ {isEditMode ? (
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ category: value,
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ disabled={!!integrationDetails.detectionRulesCount}
+ />
+ ) : (
+
+ )}
+
+
+ {isEditMode ? (
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ author: e.target.value,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ disabled={!!integrationDetails.detectionRulesCount}
+ />
+ ) : (
+
+ )}
+
+ {showEnabledField ? (
+ <>
+
+
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ enabled: e.target.checked,
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ data-test-subj="integration_enabled_toggle"
+ />
+
+
+ >
+ ) : (
+
+ )}
+
+ {'Description - '}
+ optional
+ >
+ ) : (
+ 'Description'
+ )
+ }
+ fullWidth={!isEditMode}
+ >
+ {isEditMode ? (
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ description: e.target.value,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ placeholder="Description of the integration"
+ />
+ ) : (
+
+ )}
+
+
+
+ {'Documentation - '}
+ optional
+ >
+ ) : (
+ 'Documentation'
+ )
+ }
+ helpText={isEditMode && 'Must contain 2-100 characters.'}
+ fullWidth={!isEditMode}
+ >
+ {isEditMode ? (
+ {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ documentation: e.target.value,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ disabled={!!integrationDetails.detectionRulesCount}
+ placeholder="Documentation of the integration"
+ />
+ ) : (
+
+ )}
+
+
+ {isEditMode ? (
+
+ {'References - '}
+ optional
+ >
+ }
+ values={editingIntegration?.document?.metadata?.references || []}
+ placeholder="https://example.com/reference"
+ readOnly={false}
+ addButtonLabel="Add reference"
+ onChange={(references) => {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ references,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ />
+ ) : (
+ integrationDetails?.document?.metadata?.references?.length > 0 && (
+ <>
+
+
+
+ {integrationDetails.document.metadata.references.map(
+ (ref: string, index: number) => (
+ {ref || '-'}
+ )
+ )}
+
+
+
+
+ >
+ )
+ )}
+ {isEditMode ? (
+
+ {'Supports - '}
+ optional
+ >
+ }
+ values={editingIntegration?.document?.metadata?.supports || []}
+ readOnly={false}
+ addButtonLabel="Add support"
+ onChange={(supports) => {
+ const newIntegration = {
+ ...editingIntegration!,
+ document: {
+ ...editingIntegration!.document,
+ metadata: {
+ ...editingIntegration!.document.metadata,
+ supports,
+ },
+ },
+ };
+ setEditingIntegration(newIntegration);
+ updateErrors(newIntegration);
+ }}
+ />
+ ) : (
+ (integrationDetails?.document?.metadata?.supports?.length ?? 0) > 0 && (
+ <>
+
+
+
+ {integrationDetails.document.metadata.supports.map(
+ (entry: string, index: number) => (
+ {entry || '-'}
+ )
+ )}
+
+
+
+
+ >
+ )
+ )}
+
+ {isEditMode && !hideBottomBar ? (
+
+
+
+
+ Cancel
+
+
+
+ 0
+ ? (
+
+ Complete the following required fields: {missingFields.join(', ')}
+
+ )
+ : undefined
+ }
+ position="top"
+ delay="regular"
+ display="block"
+ >
+
+ {confirmButtonText}
+
+
+
+
+
+ ) : null}
+ >
+ );
+});
diff --git a/public/pages/Integrations/components/IntegrationKVDBs.tsx b/public/pages/Integrations/components/IntegrationKVDBs.tsx
new file mode 100644
index 000000000..791a652cf
--- /dev/null
+++ b/public/pages/Integrations/components/IntegrationKVDBs.tsx
@@ -0,0 +1,189 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
+import {
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLink,
+ EuiSmallButton,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { ContentPanel } from '../../../components/ContentPanel';
+import { KVDBDetailsFlyout } from '../../KVDBs/components/KVDBDetailsFlyout';
+import { formatCellValue } from '../../../utils/helpers';
+import { EuiIcon } from '@elastic/eui';
+import { ROUTES } from '../../../utils/constants';
+import { KVDBItem, Space } from '../../../../types';
+import { SpaceTypes, SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import { useIntegrationKVDBs } from '../../KVDBs/hooks/useIntegrationKVDBs';
+
+export interface IntegrationKVDBsProps {
+ kvdbIds: string[];
+ space: string;
+ enabled: boolean;
+}
+
+export const IntegrationKVDBs: React.FC = ({ kvdbIds, space, enabled }) => {
+ const [flyoutKvdbId, setFlyoutKvdbId] = useState(undefined);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(10);
+ const [sortField, setSortField] = useState('document.metadata.title');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [searchText, setSearchText] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+
+ useEffect(() => {
+ const t = setTimeout(() => {
+ setAppliedSearch(searchText);
+ setPageIndex(0);
+ }, 500);
+ return () => clearTimeout(t);
+ }, [searchText]);
+
+ const { items: kvdbs, total, loading, refresh } = useIntegrationKVDBs({
+ kvdbIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search: appliedSearch,
+ });
+
+ const isCreateDisabled = !actionIsAllowedOnSpace(space as Space, SPACE_ACTIONS.CREATE);
+
+ const columns: EuiBasicTableColumn[] = useMemo(
+ () => [
+ {
+ field: 'document.metadata.title',
+ name: 'Title',
+ sortable: true,
+ render: (_: string, kvdb: KVDBItem) => (
+ setFlyoutKvdbId(kvdb.id)}>
+ {formatCellValue(kvdb.document?.metadata?.title)}
+
+ ),
+ },
+ {
+ field: 'document.metadata.author',
+ name: 'Author',
+ sortable: true,
+ render: (_: string, kvdb: KVDBItem) => formatCellValue(kvdb.document?.metadata?.author),
+ },
+ ],
+ []
+ );
+
+ const closeFlyout = useCallback(() => {
+ setFlyoutKvdbId(undefined);
+ }, []);
+
+ const onTableChange = useCallback(
+ ({
+ page,
+ sort,
+ }: {
+ page?: { index: number; size: number };
+ sort?: { field: string; direction: 'asc' | 'desc' };
+ }) => {
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+ if (sort) {
+ setSortField(sort.field);
+ setSortDirection(sort.direction);
+ }
+ },
+ []
+ );
+
+ const isEmptyState = total === 0 && !loading && !appliedSearch;
+
+ return (
+ <>
+ {flyoutKvdbId && }
+
+ Refresh]}
+ >
+ {isEmptyState ? (
+
+
+
+ There are no KVDBs associated with this integration.
+
+
+ {/* TO DO: Create KVDB page*/}
+ {space !== SpaceTypes.STANDARD.value && (
+
+ {isCreateDisabled ? (
+
+
+
+ Create KVDBs
+
+
+
+
+ ) : (
+
+ Create KVDBs
+
+
+ )}
+
+ )}
+
+ ) : (
+ <>
+
+
+ setSearchText(e.target.value)}
+ compressed
+ fullWidth
+ />
+
+
+
+
+ >
+ )}
+
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/PolicyGuard.tsx b/public/pages/Integrations/components/PolicyGuard.tsx
new file mode 100644
index 000000000..5564b9ceb
--- /dev/null
+++ b/public/pages/Integrations/components/PolicyGuard.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { EuiText } from '@elastic/eui';
+import { withGuardAsync } from '../utils/helpers';
+import { DataStore } from '../../../store/DataStore';
+import { DecoderSource, PolicyDocument, SearchPolicyOptions, Space } from '../../../../types';
+
+export const withPolicyGuard: (
+ searchPolicyOptions: SearchPolicyOptions,
+ withGuardOptions?: { rerunOn?: (props) => any[]; loadingComponent?: React.FC }
+) => (Component: React.FC) => React.ReactElement = (
+ searchPolicyOptions: SearchPolicyOptions = {},
+ withGuardOptions?: { rerunOn?: (props) => any[]; loadingComponent?: React.FC }
+) =>
+ withGuardAsync(
+ async ({ space }: { space: Space }) => {
+ try {
+ const response = await DataStore.policies.searchPolicies(space, searchPolicyOptions);
+ const item = response.items?.[0] || {};
+
+ const {
+ document: policyDocumentData,
+ space: spaceData,
+ id,
+ ...rest
+ } = item as {
+ document?: PolicyDocument;
+ [key: string]: any;
+ };
+
+ const rootDecoderId = policyDocumentData?.root_decoder;
+ let rootDecoder: DecoderSource | undefined;
+ if (rootDecoderId) {
+ rootDecoder = await DataStore.decoders.getDecoder(rootDecoderId, space);
+ }
+
+ const ok = !Boolean(policyDocumentData);
+ const error = ok ? new Error('Policy data was not found') : null;
+
+ return {
+ ok,
+ data: {
+ policyDocumentData,
+ rootDecoder,
+ policyEnhancedData: rest,
+ error,
+ },
+ };
+ } catch (error) {
+ return { ok: true, data: { error } };
+ }
+ },
+ ({ error }) =>
+ error ? Error loading the policy: {error.message} : null,
+ withGuardOptions?.loadingComponent ?? null,
+ {
+ rerunOn: withGuardOptions?.rerunOn ?? (({ space }) => [space]),
+ }
+ );
diff --git a/public/pages/Integrations/components/PolicyInfoCard.tsx b/public/pages/Integrations/components/PolicyInfoCard.tsx
new file mode 100644
index 000000000..7eaa0c2cc
--- /dev/null
+++ b/public/pages/Integrations/components/PolicyInfoCard.tsx
@@ -0,0 +1,480 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiCard,
+ EuiDescriptionList,
+ EuiDescriptionListDescription,
+ EuiDescriptionListTitle,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHealth,
+ EuiLoadingContent,
+ EuiSpacer,
+ EuiTab,
+ EuiTabs,
+} from '@elastic/eui';
+import { DecoderSource, PolicyDocument, Space } from '../../../../types';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { ENRICHMENT_LABELS, EnrichmentType } from '../constants/enrichments';
+import { formatIntegrationMetadataDate } from '../utils/helpers';
+import { withPolicyGuard } from './PolicyGuard';
+
+const truncateStyle: React.CSSProperties = {
+ display: '-webkit-box',
+ WebkitLineClamp: 4,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ wordBreak: 'break-word',
+};
+
+const fullValueStyle: React.CSSProperties = {
+ display: '-webkit-box',
+ WebkitLineClamp: 'unset',
+ WebkitBoxOrient: 'vertical',
+ overflow: 'visible',
+ textAlign: 'justify',
+};
+
+const renderValue = (
+ value: string | undefined | null,
+ noTruncate: boolean = false
+): React.ReactNode => {
+ if (!value) return '-';
+
+ return (
+
+ {value}
+
+ );
+};
+
+type MetadataField =
+ | 'title'
+ | 'author'
+ | 'description'
+ | 'documentation'
+ | 'references'
+ | 'date'
+ | 'modified';
+
+/** Read metadata fields with fallback to legacy top-level fields (backward compat) */
+const getMetadataValue = (
+ doc: PolicyDocument | undefined,
+ field: MetadataField
+): string | string[] | undefined => {
+ if (!doc) return undefined;
+ const value = doc.metadata?.[field];
+ if (value !== undefined && value !== null) return value;
+ const legacy = doc as unknown as Record;
+ return legacy[field] as string | string[] | undefined;
+};
+
+const POLICY_INFO_TAB = {
+ SETTINGS: 'settings',
+ DETAILS: 'details',
+} as const;
+type PolicyInfoTabId = (typeof POLICY_INFO_TAB)[keyof typeof POLICY_INFO_TAB];
+
+const renderYesNoOrDash = (value: boolean | undefined, hasPolicy: boolean): React.ReactNode => {
+ if (!hasPolicy) return '-';
+ return value ? 'yes' : 'no';
+};
+
+/** EuiSkeletonText is not available in all EUI builds; EuiLoadingContent is used elsewhere in this plugin. */
+const ValueSkeleton: React.FC = () => ;
+
+/** Equal-width flex columns for Settings/Details horizontal rows. */
+const COL: React.CSSProperties = { flex: '1 1 0', minWidth: 0 };
+
+/** Details row 2 vs row 1 (5 cols): Documentation spans Title+Author; Description spans References+Date+Modified. */
+const DETAILS_DOC_COL: React.CSSProperties = { flex: '2 1 0', minWidth: 0 };
+const DETAILS_DESC_COL: React.CSSProperties = { flex: '3 1 0', minWidth: 0 };
+
+const settingsSkeletonRows = (
+ <>
+
+
+
+ Status
+
+
+
+
+
+
+
+ Root decoder
+
+
+
+
+
+
+
+ Index discarded events
+
+
+
+
+
+
+
+ Index unclassified events
+
+
+
+
+
+
+
+
+
+
+ Enrichments
+
+
+
+
+
+
+ >
+);
+
+const detailsSkeletonRows = (
+ <>
+
+
+
+ Title
+
+
+
+
+
+
+
+ Author
+
+
+
+
+
+
+
+ References
+
+
+
+
+
+
+
+ Date
+
+
+
+
+
+
+
+ Modified
+
+
+
+
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+ >
+);
+
+/** Same tab structure as loaded state; placeholders while policy is loading. */
+const PolicyInfoCardSkeleton: React.FC = () => {
+ const [selectedTab, setSelectedTab] = useState(POLICY_INFO_TAB.SETTINGS);
+
+ return (
+
+ setSelectedTab(POLICY_INFO_TAB.SETTINGS)}
+ >
+ Settings
+
+ setSelectedTab(POLICY_INFO_TAB.DETAILS)}
+ >
+ Details
+
+
+ }
+ >
+
+ {selectedTab === POLICY_INFO_TAB.SETTINGS ? settingsSkeletonRows : detailsSkeletonRows}
+
+ );
+};
+
+const renderSettingsPanel = (
+ hasPolicy: boolean,
+ policyDocumentData: PolicyDocument | undefined,
+ rootDecoder: DecoderSource | undefined,
+ enrichmentsDisplay: string
+) => (
+ <>
+
+
+
+ Status
+
+ {!hasPolicy ? (
+ '-'
+ ) : (
+
+ {policyDocumentData?.enabled ? 'Enabled' : 'Disabled'}
+
+ )}
+
+
+
+
+
+ Root decoder
+
+ {renderValue(hasPolicy ? rootDecoder?.document?.name ?? '' : undefined)}
+
+
+
+
+
+ Index discarded events
+
+ {renderYesNoOrDash(policyDocumentData?.index_discarded_events, hasPolicy)}
+
+
+
+
+
+ Index unclassified events
+
+ {renderYesNoOrDash(policyDocumentData?.index_unclassified_events, hasPolicy)}
+
+
+
+
+
+
+
+
+ Enrichments
+ {enrichmentsDisplay}
+
+
+
+ >
+);
+
+const renderDetailsPanel = (
+ hasPolicy: boolean,
+ title: string | string[] | undefined,
+ author: string | string[] | undefined,
+ description: string | string[] | undefined,
+ documentation: string | string[] | undefined,
+ references: string | string[] | undefined,
+ dateStr: string | string[] | undefined,
+ modifiedStr: string | string[] | undefined
+) => (
+ <>
+
+
+
+ Title
+
+ {renderValue(typeof title === 'string' ? title : undefined)}
+
+
+
+
+
+ Author
+
+ {renderValue(typeof author === 'string' ? author : undefined)}
+
+
+
+
+
+ References
+
+ {renderValue(
+ !hasPolicy
+ ? undefined
+ : Array.isArray(references)
+ ? references.join(', ')
+ : ((references as string) ?? '')
+ )}
+
+
+
+
+
+ Date
+
+ {renderValue(
+ !hasPolicy
+ ? undefined
+ : typeof dateStr === 'string'
+ ? formatIntegrationMetadataDate(dateStr) || undefined
+ : undefined
+ )}
+
+
+
+
+
+ Modified
+
+ {renderValue(
+ !hasPolicy
+ ? undefined
+ : typeof modifiedStr === 'string'
+ ? formatIntegrationMetadataDate(modifiedStr) || undefined
+ : undefined
+ )}
+
+
+
+
+
+
+
+
+ Documentation
+
+ {renderValue(typeof documentation === 'string' ? documentation : undefined, true)}
+
+
+
+
+
+ Description
+
+ {renderValue(typeof description === 'string' ? description : undefined, true)}
+
+
+
+
+ >
+);
+
+/** Loaded policy: Settings vs Details tabs; values show "-" when empty. */
+export const PolicyInfoCardLayout: React.FC<{
+ policyDocumentData?: PolicyDocument;
+ rootDecoder?: DecoderSource;
+}> = ({ policyDocumentData, rootDecoder }) => {
+ const [selectedTab, setSelectedTab] = useState(POLICY_INFO_TAB.SETTINGS);
+ const hasPolicy = Boolean(policyDocumentData);
+ const title = getMetadataValue(policyDocumentData, 'title');
+ const documentation = getMetadataValue(policyDocumentData, 'documentation');
+ const author = getMetadataValue(policyDocumentData, 'author');
+ const description = getMetadataValue(policyDocumentData, 'description');
+ const references = getMetadataValue(policyDocumentData, 'references');
+ const dateStr = getMetadataValue(policyDocumentData, 'date');
+ const modifiedStr = getMetadataValue(policyDocumentData, 'modified');
+
+ const enrichmentsDisplay = !hasPolicy
+ ? '-'
+ : policyDocumentData?.enrichments && policyDocumentData.enrichments.length > 0
+ ? policyDocumentData.enrichments
+ .map((e) => ENRICHMENT_LABELS[e as EnrichmentType] ?? e)
+ .join(', ')
+ : '-';
+
+ return (
+
+ setSelectedTab(POLICY_INFO_TAB.SETTINGS)}
+ >
+ Settings
+
+ setSelectedTab(POLICY_INFO_TAB.DETAILS)}
+ >
+ Details
+
+
+ }
+ >
+
+ {selectedTab === POLICY_INFO_TAB.SETTINGS
+ ? renderSettingsPanel(hasPolicy, policyDocumentData, rootDecoder, enrichmentsDisplay)
+ : renderDetailsPanel(
+ hasPolicy,
+ title,
+ author,
+ description,
+ documentation,
+ references,
+ dateStr,
+ modifiedStr
+ )}
+
+ );
+};
+
+const PolicyInfoCardLoading: React.FC = () => ;
+
+export const PolicyInfoCard: React.FC<{}> = withPolicyGuard(
+ {
+ includeIntegrationsMap: false,
+ _source: { excludes: ['document.integrations', 'document.filters'] },
+ },
+ {
+ rerunOn: ({ space, refresh }) => [space, refresh],
+ loadingComponent: PolicyInfoCardLoading,
+ }
+)(({
+ policyDocumentData,
+ rootDecoder,
+ notifications: _notifications,
+ space: _space,
+}: {
+ policyDocumentData: PolicyDocument;
+ rootDecoder: DecoderSource;
+ notifications: NotificationsStart;
+ space: Space;
+ refresh?: number;
+}) => );
diff --git a/public/pages/Integrations/components/PromoteChangeDiff.tsx b/public/pages/Integrations/components/PromoteChangeDiff.tsx
new file mode 100644
index 000000000..25ed13003
--- /dev/null
+++ b/public/pages/Integrations/components/PromoteChangeDiff.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { EuiText, EuiTextColor } from '@elastic/eui';
+import { PromoteOperations } from '../../../../types';
+
+const colorOperationMap: Record = {
+ add: 'success',
+ update: 'accent',
+ remove: 'danger',
+};
+
+export const PromoteChangeDiff: React.FC<{ name: string; operation: PromoteOperations }> = ({
+ name,
+ operation,
+}) => {
+ return (
+
+ {name}{' '}
+ ({operation})
+
+ );
+};
diff --git a/public/pages/Integrations/components/PromoteModal.tsx b/public/pages/Integrations/components/PromoteModal.tsx
new file mode 100644
index 000000000..21f218da3
--- /dev/null
+++ b/public/pages/Integrations/components/PromoteModal.tsx
@@ -0,0 +1,158 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiConfirmModal,
+ EuiCompressedFieldText,
+ EuiForm,
+ EuiCompressedFormRow,
+ EuiOverlayMask,
+ EuiSpacer,
+ EuiText,
+ EuiAccordion,
+} from '@elastic/eui';
+import React, { useState, useMemo } from 'react';
+import { GetPromoteBySpaceResponse, PromoteChangeGroup, PromoteSpaces } from '../../../../types';
+import { getNextSpace } from '../../../../common/helpers';
+import { PromoteChangeDiff } from './PromoteChangeDiff';
+import { PROMOTE_ENTITIES_LABELS, PROMOTE_ENTITIES_ORDER, ROUTES } from '../../../utils/constants';
+import { DataStore } from '../../../store/DataStore';
+import { successNotificationToast } from '../../../utils/helpers';
+import { isRootDecoderRequiementError, RootDecoderRequirement } from './RootDecoderRequirement';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+
+export interface PromoteBySpaceModalProps {
+ promote: GetPromoteBySpaceResponse['response'];
+ closeModal: () => void;
+ space: PromoteSpaces;
+ notifications: NotificationsStart
+ history: RouteComponentProps['history']
+}
+
+const PromoteEntity: React.FC<{
+ label: string;
+ entity: PromoteChangeGroup;
+ data: GetPromoteBySpaceResponse['response'];
+}> = ({ label, data, entity }) => {
+ const memoizedData = useMemo(
+ () =>
+ data.promote.changes[entity].map(({ id, ...rest }) => ({
+ ...rest,
+ id,
+ name: data.available_promotions[entity][id],
+ })),
+ [data.promote.changes[entity]]
+ );
+
+ return (
+
+
+ {memoizedData.map(({ id, name, operation }, i) => (
+
+ ))}
+
+
+ );
+};
+
+const expectedConfirmActionText = 'promote';
+export const PromoteBySpaceModal: React.FC = ({
+ closeModal,
+ promote,
+ space,
+ notifications,
+ history
+}) => {
+ const [confirmActionText, setconfirmActionText] = useState('');
+ const [requireRootDecoderError, setRequireRootDecoderError] = useState(false);
+
+ const onConfirmPromote = async () => {
+ // TODO: generate promote payload based on the selected entities to promote. For now, we are promoting all the entities.
+ const [success, error] = await DataStore.integrations.promoteIntegration({
+ space,
+ changes: promote.promote.changes,
+ });
+ if (success) {
+ successNotificationToast(notifications, 'promoted', `[${space}] space`);
+ history.push(ROUTES.INTEGRATIONS);
+ }else if(isRootDecoderRequiementError(error)){
+ setRequireRootDecoderError(true)
+ }
+ return success;
+ };
+
+ const onConfirmClick = async () => {
+ // Generate promote payload
+ (await onConfirmPromote()) && closeModal();
+
+ };
+
+ const nextSpace = getNextSpace(space);
+
+ return (
+
+
+
+ Promote to {nextSpace} space?
+
+
+ }
+ onCancel={closeModal}
+ onConfirm={onConfirmClick}
+ cancelButtonText={'Cancel'}
+ confirmButtonText={`Promote`}
+ buttonColor={'primary'}
+ defaultFocusedButton="confirm"
+ confirmButtonDisabled={confirmActionText !== expectedConfirmActionText && !requireRootDecoderError}
+ >
+
+
+
+ The entities will be promoted to {nextSpace} . This action is irreversible.
+
+
+
+ {PROMOTE_ENTITIES_ORDER.map((entity) => {
+ const label = PROMOTE_ENTITIES_LABELS[entity];
+ if ((promote?.promote?.changes?.[entity]?.length ?? 0) > 0) {
+ return (
+
+
+
+
+ );
+ }
+ return null;
+ })}
+
+ {
+ requireRootDecoderError && (
+ <>
+ setRequireRootDecoderError(false)}/>
+
+ >
+ )
+ }
+
+
+ Type {{expectedConfirmActionText} } to confirm
+
+
+ setconfirmActionText(e.target.value)}
+ />
+
+
+
+
+ );
+};
diff --git a/public/pages/Integrations/components/Rearrange.tsx b/public/pages/Integrations/components/Rearrange.tsx
new file mode 100644
index 000000000..0b0441d54
--- /dev/null
+++ b/public/pages/Integrations/components/Rearrange.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { EuiDragDropContext, EuiDroppable, EuiDraggable } from '@elastic/eui';
+
+const moveItem = (items: T[], fromIndex: number, toIndex: number): T[] => {
+ const updatedItems = Array.from(items);
+ const [movedItem] = updatedItems.splice(fromIndex, 1);
+ updatedItems.splice(toIndex, 0, movedItem);
+ return updatedItems;
+};
+
+export type RearrangeItemsProps = {
+ items: T[];
+ renderItem: (
+ item: T,
+ index: number,
+ items: T[],
+ other: { hidx: number; lastMovement: number; moveItem: (toIndex: number) => void }
+ ) => React.ReactNode;
+ onChange: (newOrder: T[]) => void;
+ draggableKey?: (item: T, index: number) => string; // Function to generate a unique key for each droppable item
+ droppableProps?: any; // Additional props for EuiDroppable
+ draggableProps?: any; // Additional props for EuiDraggable
+};
+
+export const RearrangeItems = ({
+ items,
+ onChange,
+ renderItem,
+ draggableKey,
+ droppableProps = {},
+ draggableProps = {},
+}: RearrangeItemsProps) => {
+ const [lastMovement, setLastMovement] = React.useState(0);
+
+ const moveItemFromTo = (fromIndex: number, toIndex: number) => {
+ const newItemsOrder = moveItem(items, fromIndex, toIndex);
+ onChange(newItemsOrder);
+ setLastMovement((state) => state + 1);
+ };
+ const onDragEnd = ({ destination, source }) => {
+ if (!destination) return;
+ moveItemFromTo(source.index, destination.index);
+ };
+
+ return (
+
+
+ {items.map((item, idx) => {
+ const key = draggableKey ? draggableKey(item, idx) : `draggable-${idx}`;
+ return (
+
+ {renderItem ? (
+ renderItem(item, idx, items, {
+ hidx: idx + 1,
+ lastMovement,
+ moveItem: (toIndex: number) => {
+ moveItemFromTo(idx, toIndex - 1);
+ },
+ })
+ ) : (
+ {item}
+ )}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/public/pages/Integrations/components/RearrangeIntegrations.tsx b/public/pages/Integrations/components/RearrangeIntegrations.tsx
new file mode 100644
index 000000000..1536b2f19
--- /dev/null
+++ b/public/pages/Integrations/components/RearrangeIntegrations.tsx
@@ -0,0 +1,331 @@
+import React, { useEffect, useState, useMemo } from 'react';
+import {
+ EuiButton,
+ EuiCallOut,
+ EuiConfirmModal,
+ EuiDescriptionListTitle,
+ EuiDescriptionListDescription,
+ EuiFlexGroup,
+ EuiFlexGrid,
+ EuiFlexItem,
+ EuiFieldNumber,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiFlyoutFooter,
+ EuiIcon,
+ EuiOverlayMask,
+ EuiPanel,
+ EuiText,
+ EuiSpacer,
+} from '@elastic/eui';
+import { get } from 'lodash';
+import { compose } from 'redux';
+import { RearrangeItems, RearrangeItemsProps } from './Rearrange';
+import { withPolicyGuard } from './PolicyGuard';
+import { Space } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import { successNotificationToast } from '../../../utils/helpers';
+import { withGuard } from '../utils/helpers';
+
+const RearrageItemAttribute: React.FC<{ title: string; description: any }> = ({ field, value }) => (
+ <>
+
+ {integrationSourceDocumentFieldsUIMapperLabel[field]}
+
+ {value}
+ >
+);
+
+const euiDescriptionListClassName =
+ 'euiDescriptionList euiDescriptionList--row euiDescriptionList--compressed';
+
+const RearrangeItem: RearrangeItemsProps = (
+ item: any,
+ idx,
+ items,
+ { hidx, lastMovement, moveItem }
+) => {
+ const [valueIndex, setValueIndex] = useState(hidx);
+ const moveItemto = (toIndex: number) => {
+ setValueIndex(toIndex);
+ moveItem(toIndex);
+ };
+
+ useEffect(() => {
+ setValueIndex(hidx);
+ }, [lastMovement]);
+
+ return (
+
+
+
+
+
+
+ setValueIndex(e.target.value)}
+ min={1}
+ max={items.length}
+ compressed
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ moveItemto(Number(valueIndex));
+ }
+ }}
+ />
+
+
+
+ {['title', 'category'].map((field) => (
+
+
+
+ ))}
+
+
+
+ {['decoders', 'kvdbs', 'rules'].map((field) => (
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+const integrationSourceDocumentFieldsUI = ['title', 'category', 'decoders', 'kvdbs', 'rules'];
+const integrationSourceDocumentFields = [
+ 'id',
+ 'metadata.title',
+ 'category',
+ 'decoders',
+ 'kvdbs',
+ 'rules',
+];
+
+const integrationSourceDocumentFieldsUIMapperLabel = {
+ title: 'Title',
+ category: 'Category',
+ decoders: 'Decoders',
+ kvdbs: 'KVDBs',
+ rules: 'Rules',
+};
+
+const getArrayLengthMapper = (value: any) => value?.length || 0;
+
+// specify how to map the fields from the document to the values used in the UI, for example we want to show the length of the decoders array instead of the array itself
+const integrationSourceDocumentFieldMapper = {
+ decoders: getArrayLengthMapper,
+ kvdbs: getArrayLengthMapper,
+ rules: getArrayLengthMapper,
+};
+
+// Map from UI field names to actual document paths
+const fieldToDocumentPath: Record = {
+ id: 'document.id',
+ title: 'document.metadata.title',
+ category: 'document.category',
+ decoders: 'document.decoders',
+ kvdbs: 'document.kvdbs',
+ rules: 'document.rules',
+};
+
+// helper function to get the field path for the integration document fields
+function getDocumentField(field: string) {
+ return `document.${field}`;
+}
+
+interface RearrangeIntegrationsViewProps {
+ prependRearrangeItems?: React.ReactNode;
+ policyDocumentData: any;
+ policyEnhancedData: any;
+ space: Space;
+ notifications: any;
+ onConfirm: () => void;
+ setHasModifications: (hasModifications: boolean) => void;
+}
+const RearrangeIntegrationsBody: React.FC = ({
+ policyDocumentData,
+ policyEnhancedData,
+ onConfirm,
+ space,
+ notifications,
+ setHasModifications,
+ prependRearrangeItems = null,
+}) => {
+ const integrations = useMemo(
+ () =>
+ policyDocumentData?.integrations.map((id) => {
+ const integrationData = policyEnhancedData.integrationsMap?.[id] || {};
+
+ const source = Object.fromEntries(
+ integrationSourceDocumentFieldsUI.map((field) => {
+ const documentPath = fieldToDocumentPath[field];
+ const value = get(integrationData, documentPath);
+ return [
+ field,
+ integrationSourceDocumentFieldMapper[field]
+ ? integrationSourceDocumentFieldMapper[field](value)
+ : value,
+ ];
+ })
+ );
+
+ return { id, source };
+ }) || [],
+ [policyDocumentData]
+ );
+ const [rearrangedIntegrations, setRearrangedIntegrations] = useState(integrations);
+
+ const areIntegrationsInOrder = useMemo(() => {
+ return integrations.every(
+ (integration, index) => integration.id === rearrangedIntegrations[index]?.id
+ );
+ }, [integrations, rearrangedIntegrations]);
+
+ useEffect(() => {
+ setHasModifications(areIntegrationsInOrder);
+ }, [areIntegrationsInOrder]);
+
+ const onConfirmEnhanced = async () => {
+ // Re-fetch the current policy just before saving to guard against race conditions
+ // (e.g. an integration deleted in another tab while this flyout was open).
+ const latestPolicy = await DataStore.policies.searchPolicies(space, {});
+ const latestIntegrationIds = new Set(latestPolicy.items[0]?.document?.integrations ?? []);
+
+ // Drop any integration that no longer exists in the latest policy state
+ const validIntegrations = rearrangedIntegrations.filter(({ id }) =>
+ latestIntegrationIds.has(id)
+ );
+
+ // Remove unnecessary fields from payload
+ const {
+ id,
+ metadata: { date, modified, ...payloadMetadata },
+ ...policyPayload
+ } = policyDocumentData;
+
+ const payload = {
+ ...policyPayload,
+ metadata: {
+ ...payloadMetadata,
+ },
+ integrations: validIntegrations.map(({ id }) => id),
+ };
+ const [success] = await DataStore.policies.updatePolicy(space, payload);
+
+ if (success) {
+ successNotificationToast(notifications, 'updated', `[${space}] policy`);
+ onConfirm();
+ }
+ };
+
+ return (
+ <>
+
+ {prependRearrangeItems}
+
+
+
+
+
+
+ Rearrange
+
+
+
+
+ >
+ );
+};
+
+export type RearrangeIntegrationWithDataProps = RearrangeIntegrationsViewProps;
+
+const RearrangeIntegrationWithData: React.FC = compose(
+ withPolicyGuard({
+ includeIntegrationFields: Object.values(fieldToDocumentPath),
+ }),
+ withGuard(
+ (props) => !Boolean(props.policyDocumentData?.integrations?.length),
+ () => (
+
+
+ Add integrations to enable the rearrange functionality.
+
+
+ )
+ )
+)(RearrangeIntegrationsBody);
+
+export type RearrangeIntegrationsProps = RearrangeIntegrationWithDataProps & {
+ onClose: () => void;
+};
+export const RearrangeIntegrations: React.FC = ({
+ onClose,
+ space,
+ notifications,
+}) => {
+ const [canClose, setCanClose] = useState(true);
+ const [canNotCloseIsOpen, setCanNotCloseIsOpen] = useState(false);
+ const onFlyoutClose = function () {
+ if (!canClose) {
+ setCanNotCloseIsOpen(true);
+ return;
+ }
+ onClose();
+ };
+
+ return (
+ <>
+
+
+
+ Rearrange integrations - {space} space
+
+
+
+ Define the order of the integrations in the space.
+
+ >
+ }
+ space={space}
+ onConfirm={onClose}
+ notifications={notifications}
+ setHasModifications={setCanClose}
+ />
+
+ {canNotCloseIsOpen && (
+
+ setCanNotCloseIsOpen(false)}
+ cancelButtonText="No, don't do it"
+ confirmButtonText="Yes, do it"
+ >
+
+ There are unsaved changes. Are you sure you want to proceed?
+
+
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/Integrations/components/RootDecoderRequirement.tsx b/public/pages/Integrations/components/RootDecoderRequirement.tsx
new file mode 100644
index 000000000..ddaddd789
--- /dev/null
+++ b/public/pages/Integrations/components/RootDecoderRequirement.tsx
@@ -0,0 +1,329 @@
+import React, { useState, useEffect } from 'react';
+import { useAsyncActionRunOnStart, withGuardAsync } from '../utils/helpers';
+import { DataStore } from '../../../store/DataStore';
+import {
+ EuiButton,
+ EuiButtonEmpty,
+ EuiButtonIcon,
+ EuiCallOut,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+ EuiSelect,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { DecoderSource, PolicyDocument, UserSpace } from '../../../../types';
+import { ButtonOpenModal, ButtonOpenModalProps } from './Button';
+import { buildDecodersSearchQuery } from '../../Decoders/utils/constants';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { errorNotificationToast, successNotificationToast } from '../../../utils/helpers';
+
+const delayOnSearch = 300; // ms
+const itemsPerPage = 25;
+
+interface SelectRootDecoderFormProps {
+ space: UserSpace;
+ notifications: NotificationsStart;
+ policyDocumentData: PolicyDocument;
+ rootDecoderSource?: DecoderSource;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+const SelectRootDecoderForm: React.FC = ({
+ space,
+ onCancel,
+ notifications,
+ policyDocumentData,
+ onConfirm,
+ rootDecoderSource,
+}) => {
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState(search);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearch(search);
+ }, delayOnSearch);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [search]);
+
+ const [selected, setSelected] = useState(null);
+
+ const action = useAsyncActionRunOnStart<{
+ items: { value: string; label: string };
+ total_items: number;
+ nextFrom: number;
+ search: string;
+ }>(
+ async (dependencies, state) => {
+ const [currentSearch] = dependencies ?? [];
+
+ const isNewSearch = currentSearch !== state.data?.search;
+
+ const prevItems = isNewSearch ? [] : (state.data?.items ?? []);
+ const size = itemsPerPage;
+ const from = isNewSearch ? 0 : (state.data?.nextFrom ?? 0);
+ const query = buildDecodersSearchQuery(currentSearch); // FIXME: this query does not match with the format of the decoders name, it can not find a substring in the name, it needs to be an exact match, we need to change the query builder to make it work with the name field or change the search field to be the keyword version of the name
+ const response = await DataStore.decoders.searchDecoders(
+ {
+ from,
+ size,
+ sort: [
+ {
+ ['document.name']: {
+ order: 'asc',
+ unmapped_type: 'keyword',
+ },
+ },
+ ],
+ query,
+ _source: { includes: ['document.id', 'document.name'] },
+ },
+ space
+ );
+
+ const newItems = response.items.map((item) => ({
+ value: item?.document?.id,
+ label: item?.document?.name ?? item?.document?.id,
+ }));
+ const data = search === currentSearch ? [...(prevItems || []), ...newItems] : newItems;
+
+ return {
+ items: data,
+ total_items: response.total,
+ nextFrom: from + size,
+ search: currentSearch,
+ };
+ },
+ [debouncedSearch],
+ { refreshDataOnPreRun: false }
+ );
+
+ const updatePolicy = async () => {
+ const [success] = await DataStore.policies.updatePolicy(space, {
+ metadata: {
+ title: policyDocumentData.metadata?.title ?? '',
+ author: policyDocumentData.metadata?.author ?? '',
+ description: policyDocumentData.metadata?.description ?? '',
+ documentation: policyDocumentData.metadata?.documentation ?? '',
+ references: policyDocumentData.metadata?.references ?? [],
+ },
+ root_decoder: selected,
+ integrations: policyDocumentData.integrations,
+ filters: policyDocumentData.filters ?? [],
+ enrichments: policyDocumentData.enrichments,
+ enabled: policyDocumentData.enabled,
+ index_unclassified_events: policyDocumentData.index_unclassified_events,
+ index_discarded_events: policyDocumentData.index_discarded_events,
+ });
+ if (success) {
+ successNotificationToast(notifications, 'updated', `[${space}] policy`);
+ onConfirm();
+ } else {
+ errorNotificationToast(notifications, 'updated', `[${space}] policy`);
+ }
+ };
+
+ useEffect(() => {
+ if (action.data?.items && !action.data?.items.some((item) => item.value === selected)) {
+ setSelected(null); // Reset selected value if it's not in the new items list
+ }
+ }, [action?.data]);
+
+ if (action.data?.items) {
+ return (
+ <>
+ setSearch(event.target.value)}
+ isClearable
+ aria-label="Search decoders"
+ />
+
+
+ setSelected(event.target.value)}
+ loading={action.running}
+ value={selected}
+ options={action.data?.items}
+ hasNoInitialSelection
+ append={
+ action.data?.total_items > action.data?.items.length ? (
+
+
+
+ ) : null
+ }
+ />
+
+
+ {rootDecoderSource && (
+ <>
+
+ Current root decoder:{' '}
+
+ {rootDecoderSource.document.name}
+
+
+
+ >
+ )}
+
+
+ {action.data.items.length} of {action.data.total_items} decoders loaded
+
+
+
+
+
+ Cancel
+
+
+
+ Confirm
+
+
+
+ >
+ );
+ }
+ return null;
+};
+
+export type ButtonSelectRootDecoderProps = Omit &
+ SelectRootDecoderFormProps;
+
+export const ButtonSelectRootDecoder: React.FC = ({
+ space,
+ notifications,
+ buttonProps,
+ policyDocumentData,
+ rootDecoderSource,
+ onConfirm,
+ onCancel,
+ type,
+}) => {
+ return (
+ {},
+ }}
+ children={({ closeModal }) => (
+ {
+ closeModal();
+ onCancel?.();
+ }}
+ onConfirm={() => {
+ closeModal();
+ onConfirm?.();
+ }}
+ />
+ )}
+ />
+ );
+};
+type CalloutProps = {
+ check: () => void;
+ rootDecoder: DecoderSource;
+} & ButtonSelectRootDecoderProps;
+const Callout: React.FC = ({
+ space,
+ policyDocumentData,
+ notifications,
+ check,
+ rootDecoder,
+}) => {
+ return (
+
+
+ The promotion of the space requires a root decoder to be defined in the space. Please create
+ and/or select a root decoder.
+
+
+
+ );
+};
+
+export const withRootDecoderRequirementGuard: (Component: React.FC) => React.FC = withGuardAsync(
+ async ({ space, onSuccess }) => {
+ try {
+ const response = await DataStore.policies.searchPolicies(space);
+
+ const policyDocumentData = response.items?.[0]?.document;
+
+ const rootDecoderId = policyDocumentData?.root_decoder;
+ let rootDecoder;
+ if (rootDecoderId) {
+ rootDecoder = await DataStore.decoders.getDecoder(rootDecoderId, space);
+ }
+
+ if (onSuccess && rootDecoder){
+ onSuccess()
+ }
+
+ return {
+ ok: !Boolean(rootDecoder),
+ data: { policyDocumentData, rootDecoder },
+ };
+ } catch (error) {
+ return { ok: false, data: { error } };
+ }
+ },
+ Callout
+);
+
+export const RootDecoderRequirement: React.FC<{space: UserSpace, onSucess?: () => void}> = withRootDecoderRequirementGuard(
+ ({ error }: { error: Error }) => {
+ return error ? Error loading root decoder requirement : null;
+ }
+);
+
+export const withConditionalHOC = (
+ condition: (props: any) => boolean,
+ hoc: (Component: React.FC) => React.FC
+) => {
+ return (Component: React.FC): React.FC => {
+ const EnhancedComponent = hoc(Component);
+ return (props) => {
+ if (condition(props)) {
+ return ;
+ }
+ return ;
+ };
+ };
+};
+
+export function isRootDecoderRequiementError(error){
+ return error.includes('root_decoder') // TODO: change taking into account the real
+}
diff --git a/public/pages/Integrations/constants/enrichments.ts b/public/pages/Integrations/constants/enrichments.ts
new file mode 100644
index 000000000..31ecb86e3
--- /dev/null
+++ b/public/pages/Integrations/constants/enrichments.ts
@@ -0,0 +1,21 @@
+export const ALLOWED_ENRICHMENTS = [
+ 'geo',
+ 'hash_sha256',
+ 'hash_sha1',
+ 'hash_md5',
+ 'url_domain',
+ 'url_full',
+ 'connection',
+] as const;
+
+export type EnrichmentType = typeof ALLOWED_ENRICHMENTS[number];
+
+export const ENRICHMENT_LABELS: Record = {
+ geo: 'Geolocation',
+ connection: 'Connection',
+ url_full: 'URL full',
+ url_domain: 'URL domain',
+ hash_md5: 'Hash MD5',
+ hash_sha1: 'Hash SHA1',
+ hash_sha256: 'Hash SHA256',
+};
diff --git a/public/pages/Integrations/containers/CreateIntegration.tsx b/public/pages/Integrations/containers/CreateIntegration.tsx
new file mode 100644
index 000000000..b18a9eba5
--- /dev/null
+++ b/public/pages/Integrations/containers/CreateIntegration.tsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import { IntegrationForm } from '../components/IntegrationForm';
+import { IntegrationBase, IntegrationItem } from '../../../../types';
+import { defaultIntegration } from '../utils/constants';
+import { RouteComponentProps } from 'react-router-dom';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import { DataStore } from '../../../store/DataStore';
+import { setBreadcrumbs, successNotificationToast } from '../../../utils/helpers';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+
+export interface CreateIntegrationProps extends RouteComponentProps {
+ notifications: NotificationsStart;
+}
+
+export const CreateIntegration: React.FC = ({ history, notifications }) => {
+ const integrationDetails: IntegrationBase = { ...defaultIntegration };
+
+ setBreadcrumbs([BREADCRUMBS.INTEGRATIONS, BREADCRUMBS.INTEGRATIONS_CREATE]);
+
+ const description =
+ 'Create an integration to group and manage rules, decoders, and KVDBs.';
+
+ const onCreateIntegration = async (integrationData: IntegrationItem) => {
+ const [ok] = await DataStore.integrations.createIntegration(integrationData);
+ if (ok) {
+ successNotificationToast(
+ notifications,
+ 'created',
+ `integration ${integrationData.document.metadata?.title ?? ''}`
+ );
+ history.push(ROUTES.INTEGRATIONS);
+ }
+ };
+
+ return (
+
+
+
+ {/* Log Type is replaced with Integration by Wazuh */}
+ Create integration
+
+
+ {description}
+
+
+
+ history.push(ROUTES.INTEGRATIONS)}
+ onConfirm={onCreateIntegration}
+ />
+
+ );
+};
diff --git a/public/pages/Integrations/containers/Integration.tsx b/public/pages/Integrations/containers/Integration.tsx
new file mode 100644
index 000000000..5442141c6
--- /dev/null
+++ b/public/pages/Integrations/containers/Integration.tsx
@@ -0,0 +1,497 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import { RouteComponentProps, useLocation, useParams } from 'react-router-dom';
+import { IntegrationItem, Space } from '../../../../types';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import {
+ EuiSmallButton,
+ EuiDescriptionList,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiHealth,
+ EuiLoadingSpinner,
+ EuiPanel,
+ EuiSpacer,
+ EuiTab,
+ EuiTabs,
+ EuiText,
+ EuiTitle,
+ EuiPopover,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiHorizontalRule,
+} from '@elastic/eui';
+import { DataStore } from '../../../store/DataStore';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import { INTEGRATION_DETAILS_TAB, integrationDetailsTabs } from '../utils/constants';
+import { IntegrationDetails } from '../components/IntegrationDetails';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { IntegrationDetectionRules } from '../components/IntegrationDetectionRules';
+import { IntegrationDecoders } from '../components/IntegrationDecoders';
+import { IntegrationKVDBs } from '../components/IntegrationKVDBs';
+import { DeleteIntegrationModal } from '../components/DeleteIntegrationModal';
+import {
+ errorNotificationToast,
+ setBreadcrumbs,
+ successNotificationToast,
+} from '../../../utils/helpers';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { formatIntegrationMetadataDate } from '../utils/helpers';
+
+export interface IntegrationProps extends RouteComponentProps {
+ notifications: NotificationsStart;
+}
+
+export const Integration: React.FC = ({ notifications, history }) => {
+ const isMountedRef = useRef(true);
+ const { integrationId } = useParams<{ integrationId: string }>();
+ const [selectedTabId, setSelectedTabId] = useState(INTEGRATION_DETAILS_TAB.DETAILS);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
+ const [infoText, setInfoText] = useState(
+ <>
+ Loading details
+
+ >
+ );
+ const [integrationDetails, setIntegrationDetails] = useState(
+ undefined
+ );
+ const [initialIntegrationDetails, setInitialIntegrationDetails] = useState<
+ IntegrationItem | undefined
+ >(undefined);
+
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [togglingEnabled, setTogglingEnabled] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ const getIntegrationDetails = async () => {
+ const details = await DataStore.integrations.getIntegration(integrationId);
+
+ if (!isMountedRef.current) {
+ return;
+ }
+
+ if (!details) {
+ setInfoText('Integration not found!'); // Replace Log Type to Integration by Wazuh
+ return;
+ }
+
+ setBreadcrumbs([BREADCRUMBS.INTEGRATIONS, { text: details.document.metadata?.title ?? '' }]);
+ const integrationItem = {
+ ...details,
+ detectionRulesCount: details.document?.rules?.length ?? 0,
+ decodersCount: details.document.decoders?.length ?? 0,
+ kvdbsCount: details.document.kvdbs?.length ?? 0,
+ };
+ setIntegrationDetails(integrationItem);
+ setInitialIntegrationDetails(integrationItem);
+ };
+
+ getIntegrationDetails();
+ }, [integrationId]);
+
+ const ruleIds = useMemo(() => integrationDetails?.document.rules ?? [], [integrationDetails]);
+ const decoderIds = useMemo(() => integrationDetails?.document.decoders ?? [], [
+ integrationDetails,
+ ]);
+ const kvdbIds = useMemo(() => integrationDetails?.document.kvdbs ?? [], [integrationDetails]);
+
+ const renderTabContent = () => {
+ switch (selectedTabId) {
+ case INTEGRATION_DETAILS_TAB.DECODERS:
+ return (
+
+ );
+ case INTEGRATION_DETAILS_TAB.KVDBS:
+ return (
+
+ );
+ case INTEGRATION_DETAILS_TAB.DETECTION_RULES:
+ return (
+
+ );
+ case INTEGRATION_DETAILS_TAB.DETAILS:
+ default:
+ return (
+
+ );
+ }
+ };
+
+ const deleteIntegration = async () => {
+ const { ok } = await DataStore.integrations.deleteIntegration(integrationDetails!.id);
+
+ if (ok) {
+ successNotificationToast(notifications, 'deleted', 'integration');
+ history.push(ROUTES.INTEGRATIONS);
+ }
+ };
+
+ const toggleActionsMenu = () => {
+ setIsActionsMenuOpen((state) => !state);
+ };
+
+ const closeActionsPopover = () => {
+ setIsActionsMenuOpen(false);
+ };
+
+ const toggleIntegrationEnabled = async (checked: boolean) => {
+ if (!integrationDetails) {
+ return;
+ }
+ setTogglingEnabled(true);
+ const next: IntegrationItem = {
+ ...integrationDetails,
+ document: {
+ ...integrationDetails.document,
+ enabled: checked,
+ },
+ };
+ const success = await DataStore.integrations.updateIntegration(integrationDetails.id, next);
+ if (success) {
+ setIntegrationDetails(next);
+ setInitialIntegrationDetails(next);
+ successNotificationToast(
+ notifications,
+ 'updated',
+ `integration ${next.document.metadata?.title ?? ''}`
+ );
+ }
+ setTogglingEnabled(false);
+ };
+
+ const spaceName = (integrationDetails?.space.name ?? '') as Space;
+ const isCreateDisabled = !actionIsAllowedOnSpace(spaceName, SPACE_ACTIONS.CREATE);
+ const isEditDisabled = !actionIsAllowedOnSpace(spaceName, SPACE_ACTIONS.EDIT);
+ const isDeleteDisabled = !actionIsAllowedOnSpace(spaceName, SPACE_ACTIONS.DELETE);
+
+ const integrationEnabled = integrationDetails?.document.enabled === true;
+
+ const actionsButton = (
+
+ Actions
+
+ }
+ isOpen={isActionsMenuOpen}
+ closePopover={closeActionsPopover}
+ panelPaddingSize={'none'}
+ anchorPosition={'downLeft'}
+ data-test-subj={'integrationsActionsPopover'}
+ >
+ {
+ closeActionsPopover();
+ }}
+ data-test-subj={'createRuleButton'}
+ disabled={isCreateDisabled}
+ toolTipContent={
+ isCreateDisabled
+ ? `Rule can only be created in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.CREATE
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Create rule
+ ,
+ {
+ closeActionsPopover();
+ }}
+ data-test-subj={'createDecoderButton'}
+ disabled={isCreateDisabled}
+ toolTipContent={
+ isCreateDisabled
+ ? `Decoder can only be created in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.CREATE
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Create decoder
+ ,
+ {
+ closeActionsPopover();
+ }}
+ data-test-subj={'createKVDBButton'}
+ disabled={isCreateDisabled}
+ toolTipContent={
+ isCreateDisabled
+ ? `KVDB can only be created in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.CREATE
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Create KVDB
+ ,
+ ,
+ {
+ closeActionsPopover();
+ toggleIntegrationEnabled(!integrationEnabled);
+ }}
+ data-test-subj={'integrationEnableDisableMenuItem'}
+ toolTipContent={
+ isEditDisabled
+ ? `Integration can only be edited in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.EDIT
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ {integrationEnabled ? 'Disable' : 'Enable'}
+ ,
+ {
+ closeActionsPopover();
+ setIsEditMode(true);
+ setSelectedTabId(INTEGRATION_DETAILS_TAB.DETAILS);
+ }}
+ disabled={isEditDisabled}
+ data-test-subj={'editIntegrationButton'}
+ toolTipContent={
+ isEditDisabled
+ ? `Integration can only be edited in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.EDIT
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Edit
+ ,
+ {
+ closeActionsPopover();
+ setShowDeleteModal(true);
+ }}
+ data-test-subj={'deleteIntegrationButton'}
+ disabled={isDeleteDisabled}
+ toolTipContent={
+ isDeleteDisabled
+ ? `Integration can only be deleted in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.DELETE
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Delete
+ ,
+ ]}
+ />
+
+ );
+
+ return !integrationDetails ? (
+
+ {infoText}
+
+ ) : (
+ <>
+ {showDeleteModal && (
+ setShowDeleteModal(false)}
+ onConfirm={deleteIntegration}
+ />
+ )}
+
+ {integrationEnabled ? 'Enabled' : 'Disabled'}
+
+ ),
+ },
+ ]}
+ appRightControls={[{ renderComponent: actionsButton }]}
+ >
+
+
+
+
+
+ {integrationDetails.document.metadata?.title}
+
+
+
+
+ {integrationEnabled ? 'Enabled' : 'Disabled'}
+
+
+
+
+
+
+ {actionsButton}
+
+
+
+
+
+
+
+
+
+
+ {integrationDetails.document.id}
+
+ ),
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {integrationDetailsTabs.map((tab, index) => {
+ return (
+ {
+ setSelectedTabId(tab.id);
+ }}
+ key={index}
+ isSelected={selectedTabId === tab.id}
+ >
+ {tab.name}
+
+ );
+ })}
+
+
+ {renderTabContent()}
+ >
+ );
+};
diff --git a/public/pages/Integrations/containers/IntegrationSummaryPanel.scss b/public/pages/Integrations/containers/IntegrationSummaryPanel.scss
new file mode 100644
index 000000000..5f88e3a77
--- /dev/null
+++ b/public/pages/Integrations/containers/IntegrationSummaryPanel.scss
@@ -0,0 +1,88 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/* 4-column grid. Narrow: ID full width; Date|Modified cols 3β4 aligned with Decoders|KVDBs below. Wide: ID spans cols 1β2; Date|Modified share row with ID. */
+.integration-details-summary-panel {
+ container-type: inline-size;
+ container-name: integration-details-sum;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ column-gap: 24px;
+ row-gap: 16px;
+ align-items: start;
+}
+
+.integration-details-summary-panel__id {
+ grid-row: 1;
+ grid-column: 1 / -1;
+ min-width: 0;
+}
+
+.integration-details-summary-panel__date {
+ grid-row: 2;
+ grid-column: 3;
+}
+
+.integration-details-summary-panel__modified {
+ grid-row: 2;
+ grid-column: 4;
+}
+
+.integration-details-summary-panel__space {
+ grid-row: 3;
+ grid-column: 1;
+}
+
+.integration-details-summary-panel__rules {
+ grid-row: 3;
+ grid-column: 2;
+}
+
+.integration-details-summary-panel__decoders {
+ grid-row: 3;
+ grid-column: 3;
+}
+
+.integration-details-summary-panel__kvdbs {
+ grid-row: 3;
+ grid-column: 4;
+}
+
+@container integration-details-sum (min-width: 768px) {
+ .integration-details-summary-panel__id {
+ grid-row: 1;
+ grid-column: 1 / 3;
+ }
+
+ .integration-details-summary-panel__date {
+ grid-row: 1;
+ grid-column: 3;
+ }
+
+ .integration-details-summary-panel__modified {
+ grid-row: 1;
+ grid-column: 4;
+ }
+
+ .integration-details-summary-panel__space {
+ grid-row: 2;
+ grid-column: 1;
+ }
+
+ .integration-details-summary-panel__rules {
+ grid-row: 2;
+ grid-column: 2;
+ }
+
+ .integration-details-summary-panel__decoders {
+ grid-row: 2;
+ grid-column: 3;
+ }
+
+ .integration-details-summary-panel__kvdbs {
+ grid-row: 2;
+ grid-column: 4;
+ }
+}
diff --git a/public/pages/Integrations/containers/Integrations.tsx b/public/pages/Integrations/containers/Integrations.tsx
new file mode 100644
index 000000000..3e4c77d3b
--- /dev/null
+++ b/public/pages/Integrations/containers/Integrations.tsx
@@ -0,0 +1,577 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useEffect, useState, useRef } from 'react';
+import {
+ EuiSmallButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiInMemoryTable,
+ EuiSpacer,
+ EuiText,
+ EuiCard,
+ EuiContextMenuItem,
+ EuiContextMenuPanel,
+ EuiPopover,
+ EuiConfirmModal,
+ EuiTab,
+ EuiTabs,
+} from '@elastic/eui';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import { OVERVIEW_TAB, OverviewTabId } from '../utils/constants';
+import { DataSourceProps } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import {
+ getIntegrationsTableColumns,
+ getIntegrationsTableSearchConfig,
+ IntegrationTableItem,
+ mapPolicyToIntegrationTableItems,
+ hasRelatedEntity,
+} from '../utils/helpers';
+import { RouteComponentProps } from 'react-router-dom';
+import { useCallback } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { setBreadcrumbs, successNotificationToast } from '../../../utils/helpers';
+import { DeleteIntegrationModal } from '../components/DeleteIntegrationModal';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { PolicyInfoCard } from '../components/PolicyInfoCard';
+import { actionIsAllowedOnSpace, getSpacesAllowAction } from '../../../../common/helpers';
+import { RearrangeIntegrations } from '../components/RearrangeIntegrations';
+import { useSpaceSelector } from '../../../hooks/useSpaceSelector';
+import { EditPolicy } from '../components/EditPolicy';
+import { FiltersTab } from '../../Filters/components/FiltersTab';
+
+export interface IntegrationsProps extends RouteComponentProps, DataSourceProps {
+ notifications: NotificationsStart;
+}
+
+const DELETE_SELECTED_ACTION = 'delete_selected' as const;
+const CLEAR_SPACE_ACTION = 'clear_space' as const;
+
+type ItemForAction =
+ | {
+ item: IntegrationTableItem;
+ action: typeof SPACE_ACTIONS.DELETE;
+ }
+ | {
+ action: typeof SPACE_ACTIONS.REARRANGE_INTEGRATIONS;
+ }
+ | {
+ action: typeof DELETE_SELECTED_ACTION;
+ }
+ | {
+ action: typeof CLEAR_SPACE_ACTION;
+ };
+
+export const Integrations: React.FC = ({
+ history,
+ notifications,
+ dataSource,
+}) => {
+ const isMountedRef = useRef(true);
+ const [integrations, setIntegrations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedItems, setSelectedItems] = useState([]);
+ const [itemForAction, setItemForAction] = useState(null);
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [isOverviewActionsOpen, setIsOverviewActionsOpen] = useState(false);
+ const [isClearingSpace, setIsClearingSpace] = useState(false);
+ const { component: spaceSelector, spaceFilter } = useSpaceSelector({
+ isLoading: loading || isClearingSpace,
+ });
+ const [policyRefresh, setPolicyRefresh] = useState(0);
+ // This trusts the changes in the history location causes a rerender in the componnet
+ const selectedTab =
+ history.location.pathname === ROUTES.FILTERS ? OVERVIEW_TAB.FILTERS : OVERVIEW_TAB.INTEGRATIONS;
+
+ const onTabChange = (tab: OverviewTabId) => {
+ const path = tab === OVERVIEW_TAB.FILTERS ? ROUTES.FILTERS : ROUTES.INTEGRATIONS;
+ history.replace(path + history.location.search);
+ };
+ const loadIntegrations = useCallback(async () => {
+ setLoading(true);
+
+ const policiesResult = await DataStore.policies.searchPolicies(spaceFilter, {
+ includeIntegrationFields: ['document', 'space'],
+ });
+ const policy = policiesResult.items[0];
+ const integrations = mapPolicyToIntegrationTableItems(policy);
+
+ if (!isMountedRef.current) {
+ return;
+ }
+ setIntegrations(integrations);
+ setLoading(false);
+ }, [spaceFilter, dataSource]);
+
+ const deleteIntegration = async (id: string) => {
+ const { ok } = await DataStore.integrations.deleteIntegration(id);
+
+ if (ok) {
+ successNotificationToast(notifications, 'deleted', 'integration');
+ await loadIntegrations();
+ }
+ };
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ setBreadcrumbs([BREADCRUMBS.INTEGRATIONS]);
+ }, []);
+
+ const isCreateActionDisabled = !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.CREATE);
+ const isPromoteActionDisabled = !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.PROMOTE);
+ const isDeleteActionDisabledBySpace = !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE);
+
+ const selectedItemsWithoutRelatedEntities = selectedItems.filter(
+ (item) => !item.rules?.length && !item.decoders?.length && !item.kvdbs?.length
+ );
+ const selectedItemsWithRelatedEntities = selectedItems.filter(
+ (item) => item.rules?.length || item.decoders?.length || item.kvdbs?.length
+ );
+ const selectedItemsWithRelatedEntitiesCount = selectedItemsWithRelatedEntities.length;
+ const selectedItemsRelatedEntitiesMessage = DataStore.integrations.getRelatedEntitiesMessage({
+ hasRules: selectedItemsWithRelatedEntities.some((item) => hasRelatedEntity(item, 'rules')),
+ hasDecoders: selectedItemsWithRelatedEntities.some((item) =>
+ hasRelatedEntity(item, 'decoders')
+ ),
+ hasKVDBs: selectedItemsWithRelatedEntities.some((item) => hasRelatedEntity(item, 'kvdbs')),
+ });
+
+ const isDeleteSelectedActionDisabled =
+ isDeleteActionDisabledBySpace ||
+ selectedItems.length === 0 ||
+ selectedItemsWithoutRelatedEntities.length === 0;
+ const isRearrangeIntegrationsActionDisabled = !actionIsAllowedOnSpace(
+ spaceFilter,
+ SPACE_ACTIONS.REARRANGE_INTEGRATIONS
+ );
+ const canEditSpaceDetails =
+ actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DEFINE_ROOT_DECODER) ||
+ actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.EDIT_POLICY_ENRICHMENTS) ||
+ actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.EDIT_POLICY_INDEXING_SETTINGS);
+ const isEditSpaceDetailsDisabled = !canEditSpaceDetails;
+ const spacesAllowingSpacePolicyEdit = Array.from(
+ new Set([
+ ...getSpacesAllowAction(SPACE_ACTIONS.DEFINE_ROOT_DECODER),
+ ...getSpacesAllowAction(SPACE_ACTIONS.EDIT_POLICY_ENRICHMENTS),
+ ...getSpacesAllowAction(SPACE_ACTIONS.EDIT_POLICY_INDEXING_SETTINGS),
+ ])
+ );
+ const isClearSpaceDisabled = !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.CLEAR_SPACE);
+
+ const clearSpace = useCallback(async () => {
+ setIsClearingSpace(true);
+ try {
+ const ok = await DataStore.policies.deleteSpace(spaceFilter);
+ if (ok) {
+ successNotificationToast(notifications, 'cleared', 'space');
+ setPolicyRefresh((prev) => prev + 1);
+ await loadIntegrations();
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setIsClearingSpace(false);
+ setItemForAction(null);
+ }
+ }
+ }, [spaceFilter, loadIntegrations, notifications]);
+
+ const onEditPolicy = () => {
+ setItemForAction({ action: SPACE_ACTIONS.EDIT_POLICY });
+ setIsPopoverOpen(false);
+ setIsOverviewActionsOpen(false);
+ };
+
+ const deleteSelectedIntegrations = useCallback(async () => {
+ setLoading(true);
+
+ try {
+ const deleteResults = await Promise.all(
+ selectedItemsWithoutRelatedEntities.map(async (item) => {
+ const { ok } = await DataStore.integrations.deleteIntegration(item?.id);
+ return ok;
+ })
+ );
+ const deletedCount = deleteResults.filter(Boolean).length;
+ const failedCount = deleteResults.length - deletedCount;
+
+ if (deletedCount > 0) {
+ successNotificationToast(
+ notifications,
+ 'deleted',
+ deletedCount === 1 ? 'integration' : 'integrations'
+ );
+ }
+
+ if (failedCount > 0) {
+ notifications.toasts.addWarning({
+ title: 'Some integrations could not be deleted',
+ text: `${failedCount} integration${failedCount !== 1 ? 's' : ''} could not be deleted.`,
+ toastLifeTimeMs: 5000,
+ });
+ }
+
+ if (selectedItemsWithRelatedEntitiesCount > 0) {
+ notifications.toasts.addWarning({
+ title: 'Some integrations were skipped',
+ text: `${selectedItemsWithRelatedEntitiesCount} integration${
+ selectedItemsWithRelatedEntitiesCount !== 1 ? 's were' : ' was'
+ } not deleted because ${
+ selectedItemsWithRelatedEntitiesCount !== 1 ? 'they have' : 'it has'
+ } associated ${selectedItemsRelatedEntitiesMessage}.`,
+ toastLifeTimeMs: 5000,
+ });
+ }
+
+ await loadIntegrations();
+ if (isMountedRef.current) {
+ setSelectedItems([]);
+ }
+ } finally {
+ if (isMountedRef.current) {
+ setLoading(false);
+ setItemForAction(null);
+ }
+ }
+ }, [
+ selectedItemsWithoutRelatedEntities,
+ selectedItemsRelatedEntitiesMessage,
+ selectedItemsWithRelatedEntitiesCount,
+ loadIntegrations,
+ notifications,
+ ]);
+
+ const buildActionsPopOver = (
+ id: string,
+ isOpen: boolean,
+ onToggle: () => void,
+ items: React.ReactElement[]
+ ) => (
+
+ Actions
+
+ }
+ isOpen={isOpen}
+ closePopover={onToggle}
+ panelPaddingSize={'none'}
+ anchorPosition={'downLeft'}
+ >
+
+
+ );
+
+ const overviewActionsMenuItems: React.ReactElement[] = [
+
+ Edit
+ ,
+ {
+ setItemForAction({ action: CLEAR_SPACE_ACTION });
+ setIsOverviewActionsOpen(false);
+ }}
+ disabled={isClearSpaceDisabled}
+ toolTipContent={
+ isClearSpaceDisabled
+ ? `Clear space is only available in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.CLEAR_SPACE
+ ).join(', ')}`
+ : undefined
+ }
+ data-test-subj="overviewClearSpace"
+ >
+ Clear space
+ ,
+ ];
+ overviewActionsMenuItems.push(
+ {
+ history.push({ pathname: ROUTES.PROMOTE, search: `?space=${spaceFilter}` });
+ setIsOverviewActionsOpen(false);
+ }}
+ disabled={isPromoteActionDisabled}
+ toolTipContent={
+ isPromoteActionDisabled
+ ? `Integration can only be promoted in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.PROMOTE
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Promote
+
+ );
+
+ const overviewActionsButton = buildActionsPopOver(
+ 'overviewActionsPopover',
+ isOverviewActionsOpen,
+ () => setIsOverviewActionsOpen((prev) => !prev),
+ overviewActionsMenuItems
+ );
+
+ const actionsButton = buildActionsPopOver(
+ 'integrationsActionsPopover',
+ isPopoverOpen,
+ () => setIsPopoverOpen((prev) => !prev),
+ [
+
+ Create
+ ,
+ {
+ setItemForAction({ action: DELETE_SELECTED_ACTION });
+ setIsPopoverOpen(false);
+ }}
+ disabled={isDeleteSelectedActionDisabled}
+ toolTipContent={
+ isDeleteActionDisabledBySpace
+ ? `Integrations can only be deleted in the space: ${getSpacesAllowAction(
+ SPACE_ACTIONS.DELETE
+ ).join(', ')}`
+ : selectedItems.length === 0
+ ? 'Select integrations to delete.'
+ : selectedItemsWithoutRelatedEntities.length === 0
+ ? 'Integrations with associated Rules, Decoders, or KVDBs cannot be deleted.'
+ : selectedItemsWithRelatedEntitiesCount > 0
+ ? `${selectedItemsWithRelatedEntitiesCount} selected integration${
+ selectedItemsWithRelatedEntitiesCount !== 1 ? 's have' : ' has'
+ } associated ${selectedItemsRelatedEntitiesMessage} and will be skipped.`
+ : undefined
+ }
+ >
+ Delete selected ({selectedItems.length})
+ ,
+ {
+ setItemForAction({ action: SPACE_ACTIONS.REARRANGE_INTEGRATIONS });
+ setIsPopoverOpen(false);
+ }}
+ disabled={isRearrangeIntegrationsActionDisabled}
+ toolTipContent={
+ isRearrangeIntegrationsActionDisabled
+ ? `Integration can only be rearranged in the spaces: ${getSpacesAllowAction(
+ SPACE_ACTIONS.REARRANGE_INTEGRATIONS
+ ).join(', ')}`
+ : undefined
+ }
+ >
+ Rearrange
+ ,
+ ]
+ );
+
+ useEffect(() => {
+ loadIntegrations();
+ }, [dataSource, spaceFilter, loadIntegrations]);
+
+ const onSelectionChange = (selectedItems: IntegrationTableItem[]) => {
+ setSelectedItems(selectedItems);
+ };
+
+ const showIntegrationDetails = useCallback(
+ (id: string) => {
+ history.push(`${ROUTES.INTEGRATIONS}/${id}?space=${spaceFilter}`);
+ },
+ [spaceFilter]
+ );
+
+ const createIntegrationAction = (
+ history.push(ROUTES.INTEGRATIONS_CREATE)}>
+ Create integration
+
+ );
+
+ return (
+ <>
+ {itemForAction && (
+ <>
+ {itemForAction.action === SPACE_ACTIONS.DELETE && (
+ setItemForAction(null)}
+ onConfirm={() => deleteIntegration(itemForAction.item.id)}
+ />
+ )}
+ {itemForAction.action === SPACE_ACTIONS.REARRANGE_INTEGRATIONS && (
+ {
+ setItemForAction(null);
+ loadIntegrations();
+ }}
+ notifications={notifications}
+ />
+ )}
+ {itemForAction.action === SPACE_ACTIONS.EDIT_POLICY && (
+ setItemForAction(null)}
+ onSuccess={() => setPolicyRefresh((prevState) => prevState + 1)}
+ />
+ )}
+ >
+ )}
+ {itemForAction?.action === CLEAR_SPACE_ACTION && (
+ setItemForAction(null)}
+ onConfirm={clearSpace}
+ cancelButtonText="Cancel"
+ confirmButtonText="Clear space"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ isLoading={isClearingSpace}
+ >
+
+ This will reset the draft space to its initial state, removing all
+ integrations, rules, decoders, KVDBs, and filters.
+
+ Detectors will not be affected. This action cannot be undone.
+
+ )}
+ {itemForAction?.action === DELETE_SELECTED_ACTION && (
+ setItemForAction(null)}
+ onConfirm={deleteSelectedIntegrations}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+
+ {`Are you sure you want to delete ${
+ selectedItemsWithoutRelatedEntities.length
+ } integration${
+ selectedItemsWithoutRelatedEntities.length !== 1 ? 's' : ''
+ }? This action cannot be undone.`}
+
+ {selectedItemsWithRelatedEntitiesCount > 0 && (
+
+ {`${selectedItemsWithRelatedEntitiesCount} selected integration${
+ selectedItemsWithRelatedEntitiesCount !== 1 ? 's have' : ' has'
+ } associated ${selectedItemsRelatedEntitiesMessage} and will be skipped.`}
+
+ )}
+
+ )}
+
+
+
+
+
+
+ Overview
+
+
+
+ {spaceSelector}
+ {overviewActionsButton}
+
+
+
+
+
+
+
+ onTabChange(OVERVIEW_TAB.INTEGRATIONS)}
+ >
+ Integrations
+
+ onTabChange(OVERVIEW_TAB.FILTERS)}
+ >
+ Filters
+
+
+ }
+ >
+
+ {selectedTab === OVERVIEW_TAB.INTEGRATIONS ? (
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
diff --git a/public/pages/Integrations/containers/PromoteIntegration.tsx b/public/pages/Integrations/containers/PromoteIntegration.tsx
new file mode 100644
index 000000000..91badb0db
--- /dev/null
+++ b/public/pages/Integrations/containers/PromoteIntegration.tsx
@@ -0,0 +1,203 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useMemo } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import {
+ BREADCRUMBS,
+ PROMOTE_ENTITIES_LABELS,
+ PROMOTE_ENTITIES_ORDER,
+ ROUTES,
+} from '../../../utils/constants';
+import { DataStore } from '../../../store/DataStore';
+import { setBreadcrumbs, successNotificationToast } from '../../../utils/helpers';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ EuiButton,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { withGuardAsync } from '../utils/helpers';
+import { PromoteBySpaceModal } from '../components/PromoteModal';
+import { GetPromoteBySpaceResponse, PromoteChangeGroup, PromoteSpaces } from '../../../../types';
+import { SPACE_ACTIONS } from '../../../../common/constants';
+import { compose } from 'redux';
+import { actionIsAllowedOnSpace, getNextSpace } from '../../../../common/helpers';
+import { PromoteChangeDiff } from '../components/PromoteChangeDiff';
+
+export interface PromoteIntegrationProps extends RouteComponentProps {
+ notifications: NotificationsStart;
+}
+
+const PromoteEntity: React.FC<{
+ label: string;
+ entity: PromoteChangeGroup;
+ data: GetPromoteBySpaceResponse['response'];
+}> = ({ label, entity, data }) => {
+ const memoizedData = useMemo(
+ () =>
+ (data.promote?.changes?.[entity] ?? []).map(({ id, ...rest }) => {
+ const strippedId = id.replace(/^\w_/, '');
+ const available = data.available_promotions?.[entity];
+ const name = available?.[id] ?? available?.[strippedId] ?? id; // Prefer metadata.title from available_promotions; fallback to id
+ return { ...rest, id, name };
+ }),
+ [data.promote?.changes?.[entity], entity, data.available_promotions]
+ );
+ return (
+
+
+ {label}
+
+
+
+ {memoizedData.map(({ id, name, operation }, i) => (
+
+ ))}
+
+
+ );
+};
+
+const PromoteBySpace: React.FC<{ space: PromoteSpaces }> = compose(
+ withGuardAsync(
+ async ({ space }) => {
+ try {
+ // Get promotions by space
+ const [ok, data] = await DataStore.integrations.getPromote({ space });
+
+ if (!ok) {
+ return {
+ ok: false,
+ data: { errorPromote: 'Error getting the promote data' },
+ };
+ }
+
+ return {
+ ok: true,
+ data: { promoteData: data },
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ data: {
+ errorPromote: error.message || 'Error getting the promote data',
+ },
+ };
+ }
+ },
+ ({
+ promoteData,
+ space,
+ notifications,
+ history,
+ }: {
+ promoteData: GetPromoteBySpaceResponse['response'];
+ space: PromoteSpaces;
+ notifications: PromoteIntegrationProps['notifications'];
+ }) => {
+ const [modalIsOpen, setModalIsOpen] = useState(false);
+
+ // TODO: add ability to select which entities to promote
+ const hasPromotions = Object.values(promoteData.promote.changes).some(
+ (items) => items.length > 0
+ );
+
+ if (!hasPromotions) {
+ return There is nothing to promote. ;
+ }
+
+ return (
+ <>
+ {modalIsOpen && (
+ setModalIsOpen(false)}
+ promote={promoteData}
+ notifications={notifications}
+ history={history}
+ space={space}
+ >
+ )}
+
+ {PROMOTE_ENTITIES_ORDER.map((entity) => {
+ if ((promoteData?.promote?.changes?.[entity]?.length ?? 0) > 0) {
+ const label = PROMOTE_ENTITIES_LABELS[entity];
+ return (
+
+
+
+
+ );
+ }
+ return null;
+ })}
+
+
+
+
+ setModalIsOpen(true)} fill={true}>
+ Promote
+
+
+
+ >
+ );
+ },
+ EuiLoadingSpinner
+ )
+)(({ errorPromote }) => {
+ return {errorPromote} ;
+});
+
+export const PromoteIntegration: React.FC = ({
+ history,
+ notifications,
+ location,
+}) => {
+ setBreadcrumbs([BREADCRUMBS.INTEGRATIONS, BREADCRUMBS.PROMOTE]);
+
+ const description =
+ 'Promote the integrations, decoders and KVDBs, filters, rules and space changes to another space. Once promoted, they will be available in the another space.';
+
+ const space = new URLSearchParams(location.search).get('space');
+
+ return (
+
+
+
+ {/* Log Type is replaced with Integration by Wazuh */}
+ Promote
+
+
+ {description}
+
+
+
+ {actionIsAllowedOnSpace(space, SPACE_ACTIONS.PROMOTE) ? (
+ <>
+
+ You are promoting the entities from {space} to {getNextSpace(space)} {' '}
+ space.
+
+
+
+ >
+ ) : (
+
+ Invalid space for promotion: {space}
+
+ )}
+
+ );
+};
diff --git a/public/pages/Integrations/utils/constants.ts b/public/pages/Integrations/utils/constants.ts
new file mode 100644
index 000000000..1889bfab4
--- /dev/null
+++ b/public/pages/Integrations/utils/constants.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IntegrationBase } from '../../../../types';
+
+export const OVERVIEW_TAB = {
+ INTEGRATIONS: 'integrations',
+ FILTERS: 'filters',
+} as const;
+
+export type OverviewTabId = typeof OVERVIEW_TAB[keyof typeof OVERVIEW_TAB];
+
+export const INTEGRATION_DETAILS_TAB = {
+ DETAILS: 'details',
+ DETECTION_RULES: 'detection_rules',
+ DECODERS: 'decoders',
+ KVDBS: 'kvdbs',
+} as const;
+
+export type IntegrationDetailsTabId = typeof INTEGRATION_DETAILS_TAB[keyof typeof INTEGRATION_DETAILS_TAB];
+
+export const integrationDetailsTabs = [
+ {
+ id: INTEGRATION_DETAILS_TAB.DETAILS,
+ name: 'Details',
+ },
+ {
+ id: INTEGRATION_DETAILS_TAB.DETECTION_RULES,
+ name: 'Rules',
+ },
+ {
+ id: INTEGRATION_DETAILS_TAB.DECODERS,
+ name: 'Decoders',
+ },
+ {
+ id: INTEGRATION_DETAILS_TAB.KVDBS,
+ name: 'KVDBs',
+ },
+];
+
+export const defaultIntegration: IntegrationBase = {
+ document: {
+ id: '',
+ enabled: true,
+ category: '',
+ metadata: {
+ title: '',
+ author: '',
+ date: '',
+ modified: '',
+ description: '',
+ references: [],
+ documentation: '',
+ supports: [],
+ },
+ tags: null,
+ },
+ space: {
+ name: '',
+ },
+};
diff --git a/public/pages/Integrations/utils/helpers.tsx b/public/pages/Integrations/utils/helpers.tsx
new file mode 100644
index 000000000..c6ddef30d
--- /dev/null
+++ b/public/pages/Integrations/utils/helpers.tsx
@@ -0,0 +1,314 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState, useEffect } from 'react';
+import { EuiLink, EuiPanel } from '@elastic/eui';
+import { Integration } from '../../../../types';
+import { SPACE_ACTIONS, UserSpacesOrder } from '../../../../common/constants';
+import { startCase } from 'lodash';
+import { Search } from '@opensearch-project/oui/src/eui_components/basic_table';
+import { DEFAULT_EMPTY_DATA, integrationCategories } from '../../../utils/constants';
+import { getIntegrationCategoryFilterOptions } from '../../../utils/helpers';
+import { actionIsAllowedOnSpace } from '../../../../common/helpers';
+import { IntegrationBase, PolicyItem } from '../../../../types';
+
+import moment from 'moment';
+import { formatUIDate } from '../../../utils/dateFormat';
+
+/**
+ * Integration/policy metadata dates often arrive as ISO strings; format them
+ * using the configured `dateFormat`/`dateFormat:tz` advanced settings.
+ */
+export const formatIntegrationMetadataDate = (value?: string) => {
+ if (!value?.trim()) return '';
+ const m = moment(value);
+ return m.isValid() ? formatUIDate(value) : value;
+};
+
+const getIntegrationCategoryFilterDisplayName = (value: string): string => {
+ const match = integrationCategories.find((c) => c.value === value);
+ return match?.label ?? startCase(value.replace(/-/g, ' '));
+};
+
+export interface IntegrationTableItem {
+ id: string;
+ title: string;
+ description?: string;
+ category: string;
+ space: string;
+ decoders?: string[];
+ kvdbs?: string[];
+ rules?: any[];
+}
+
+export const mapPolicyToIntegrationTableItems = (
+ policy: PolicyItem | undefined
+): IntegrationTableItem[] => {
+ if (!policy) return [];
+
+ return Object.values(policy.integrationsMap ?? {})
+ .filter((source): source is IntegrationBase & { _id: string } => Boolean(source && source._id))
+ .map((source) => ({
+ id: source._id,
+ title: source.document.metadata?.title ?? '',
+ description: source.document.metadata?.description,
+ category: source.document.category,
+ space: source.space.name,
+ decoders: source.document.decoders,
+ kvdbs: source.document.kvdbs,
+ rules: source.document.rules,
+ }));
+};
+
+export const hasRelatedEntity = (
+ item: IntegrationTableItem,
+ entity: 'rules' | 'decoders' | 'kvdbs'
+): boolean => {
+ return Array.isArray(item[entity]) && (item[entity] as any[]).length > 0;
+};
+
+export const getIntegrationsTableColumns = ({
+ showDetails,
+ setItemForAction,
+}: {
+ showDetails: (id: string) => void;
+ setItemForAction: (options: { item: any; action: typeof SPACE_ACTIONS.DELETE } | null) => void;
+}) => [
+ {
+ field: 'title',
+ name: 'Title',
+ sortable: true,
+ render: (name: string, item: Integration) => {
+ return showDetails(item.id)}>{name} ;
+ },
+ },
+ {
+ field: 'category',
+ name: 'Category',
+ truncateText: false,
+ render: (category: string) => getIntegrationCategoryFilterDisplayName(category ?? ''),
+ },
+ {
+ field: 'rules',
+ name: 'Rules',
+ sortable: true,
+ render: (rules: any[]) => rules?.length ?? 0,
+ },
+ {
+ field: 'decoders',
+ name: 'Decoders',
+ sortable: true,
+ render: (decoders: string[]) => decoders?.length ?? 0,
+ },
+ {
+ field: 'kvdbs',
+ name: 'KVDBs',
+ sortable: true,
+ render: (kvdbs: string[]) => kvdbs?.length ?? 0,
+ },
+ {
+ name: 'Actions',
+ actions: [
+ {
+ name: 'Details',
+ description: 'Show details',
+ type: 'icon',
+ icon: 'inspect',
+ onClick: (item) => {
+ showDetails(item.id);
+ },
+ },
+ {
+ name: 'Remove',
+ description: 'Remove integration',
+ type: 'icon',
+ icon: 'trash',
+ color: 'danger',
+ available: (item) => actionIsAllowedOnSpace(item.space, SPACE_ACTIONS.DELETE),
+ onClick: (item) => {
+ setItemForAction({ item, action: SPACE_ACTIONS.DELETE });
+ },
+ },
+ ],
+ },
+];
+
+export const getIntegrationsTableSearchConfig = (options?: {
+ toolsRight?: React.ReactNode[];
+}): Search => {
+ return {
+ box: {
+ placeholder: 'Search integrations',
+ schema: true,
+ compressed: true,
+ },
+ filters: [
+ {
+ type: 'field_value_selection',
+ field: 'category',
+ name: 'Category',
+ compressed: true,
+ multiSelect: 'or',
+ options: getIntegrationCategoryFilterOptions(false),
+ },
+ ],
+ toolsRight: options?.toolsRight,
+ };
+};
+
+export const withGuardAsync = (
+ condition: (props: any) => Promise<{ ok: boolean; data: any }>,
+ ComponentFulfillsCondition: React.FC,
+ ComponentLoadingResolution: null | React.FC = null,
+ options: { rerunOn?: (props) => any[] }
+) => (WrappedComponent: React.FC) => (props: any) => {
+ const [loading, setLoading] = useState(true);
+ const [fulfillsCondition, setFulfillsCondition] = useState({
+ ok: false,
+ data: {},
+ });
+
+ const execCondition = async () => {
+ try {
+ setLoading(true);
+ setFulfillsCondition({ ok: false, data: {} });
+ setFulfillsCondition(await condition({ ...props, check: execCondition }));
+ } catch (error) {
+ setFulfillsCondition({ ok: false, data: { error } });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const dependenciesRun = options?.rerunOn ? options.rerunOn(props) : [];
+
+ useEffect(() => {
+ execCondition();
+ }, dependenciesRun);
+
+ if (loading) {
+ return ComponentLoadingResolution ? : null;
+ }
+
+ return fulfillsCondition.ok ? (
+
+ ) : (
+
+ );
+};
+
+export const withGuard = (
+ condition: (props: any) => boolean,
+ ComponentFulfillsCondition: React.FC
+) => (WrappedComponent: React.FC) => (props: any) => {
+ return condition(props) ? (
+
+ ) : (
+
+ );
+};
+
+export const withWrapComponent = (WrapComponent, mapWrapComponentProps = () => {}) => (
+ WrappedComponent
+) => (props) => (
+
+
+
+);
+
+export const withModal = (options) =>
+ withWrapComponent(
+ ({
+ paddingSize,
+ hasShadow,
+ hasBorder,
+ borderRadius,
+ grow,
+ panelRef,
+ color,
+ className,
+ 'aria-label': ariaLabel,
+ 'data-test-subj': dataTestSubject,
+ children,
+ }) => {
+ const panelProps = {
+ paddingSize,
+ hasShadow,
+ hasBorder,
+ borderRadius,
+ grow,
+ panelRef,
+ color,
+ className,
+ 'aria-label': ariaLabel,
+ 'data-test-subj': dataTestSubject,
+ children,
+ };
+ return {children} ;
+ },
+ () => options
+ );
+
+export const getNextSpace = (space: string) => {
+ const currentIndex = UserSpacesOrder.indexOf(space);
+ if (currentIndex === -1 || currentIndex === UserSpacesOrder.length - 1) {
+ return null; // No next space available
+ }
+ return UserSpacesOrder[currentIndex + 1];
+};
+
+type useAsyncActionRunOnStartDependenciesReturns = {
+ data: T | null;
+ error: Error | null;
+ running: boolean;
+ run: () => Promise;
+};
+type useAsyncActionRunOnStartAction = (
+ dependencies: any[],
+ state: {
+ data: useAsyncActionRunOnStartDependenciesReturns['data'];
+ error: useAsyncActionRunOnStartDependenciesReturns['error'];
+ running: useAsyncActionRunOnStartDependenciesReturns['running'];
+ }
+) => Promise;
+type useAsyncActionRunOnStartDependencies = any[];
+
+export function useAsyncActionRunOnStart(
+ action: useAsyncActionRunOnStartAction,
+ dependencies: useAsyncActionRunOnStartDependencies = [],
+ { refreshDataOnPreRun }: { refreshDataOnPreRun: boolean } = {
+ refreshDataOnPreRun: true,
+ }
+): useAsyncActionRunOnStartDependenciesReturns {
+ const [running, setRunning] = useState(true);
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ const run = async () => {
+ try {
+ setRunning(true);
+ setError(null);
+ if (refreshDataOnPreRun) {
+ setData(null);
+ }
+ const result = await action(dependencies, { data, error, running });
+ setData(result);
+ } catch (error) {
+ setError(error as Error);
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ useEffect(() => {
+ run();
+ }, [...dependencies]);
+
+ return { data, error, run, running };
+}
diff --git a/public/pages/KVDBs/components/AssetViewer.tsx b/public/pages/KVDBs/components/AssetViewer.tsx
new file mode 100644
index 000000000..2bf3fa60d
--- /dev/null
+++ b/public/pages/KVDBs/components/AssetViewer.tsx
@@ -0,0 +1,82 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+import React, { useMemo } from 'react';
+import { EuiInMemoryTable, EuiCodeBlock } from '@elastic/eui';
+import { sortBy } from 'lodash';
+
+interface AssetViewerProps {
+ content: any;
+}
+
+const columns = [
+ {
+ field: 'key',
+ name: 'Key',
+ width: '30%',
+ },
+ {
+ field: 'value',
+ name: 'Value',
+ },
+];
+
+const normalizeContent = (content: any) => {
+ if (content == null) {
+ return null;
+ }
+
+ if (typeof content === 'string') {
+ try {
+ const parsed = JSON.parse(content);
+ if (parsed && typeof parsed === 'object') {
+ return parsed;
+ }
+ } catch (error) {
+ return content;
+ }
+ return content;
+ }
+
+ return content;
+};
+
+const flattenContent = (content: Record) => {
+ return Object.entries(content).map(([key, value]) => ({
+ key,
+ value: JSON.stringify(value),
+ }));
+};
+
+export const AssetViewer: React.FC = ({ content }) => {
+ const normalized = useMemo(() => normalizeContent(content), [content]);
+
+ if (normalized == null) {
+ return null;
+ }
+
+ if (typeof normalized === 'string') {
+ return (
+
+ {normalized}
+
+ );
+ }
+
+ const tableItems = useMemo(() => sortBy(flattenContent(normalized), 'key'), [normalized]);
+
+ return (
+
+ );
+};
diff --git a/public/pages/KVDBs/components/KVDBContentEditor.tsx b/public/pages/KVDBs/components/KVDBContentEditor.tsx
new file mode 100644
index 000000000..ef76c9b09
--- /dev/null
+++ b/public/pages/KVDBs/components/KVDBContentEditor.tsx
@@ -0,0 +1,128 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import {
+ EuiButtonEmpty,
+ EuiButtonIcon,
+ EuiCompressedFieldText,
+ EuiCompressedTextArea,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { FieldArray, FormikErrors, FormikTouched, useFormikContext } from 'formik';
+import { KVDBFormModel } from '../utils/mappers';
+
+export interface ContentEntry {
+ key: string;
+ value: string;
+}
+
+export const KVDBContentEditor: React.FC = () => {
+ const { values, errors, touched, setFieldValue, setFieldTouched, submitCount } = useFormikContext<
+ KVDBFormModel
+ >();
+
+ const contentErrors = errors.contentEntries as FormikErrors[] | undefined;
+ const contentTouched = touched.contentEntries as FormikTouched[] | undefined;
+
+ return (
+
+ {({ push, remove }) => (
+
+ {values.contentEntries.length === 0 && (
+
+ No content entries yet.
+
+ )}
+ {values.contentEntries.map((entry, index) => {
+ const entryErrors = contentErrors?.[index];
+ const entryTouched = contentTouched?.[index];
+ const afterSubmit = submitCount > 0;
+
+ const showKeyError = (entryTouched?.key || afterSubmit) && !!entryErrors?.key;
+ const showValueError = (entryTouched?.value || afterSubmit) && !!entryErrors?.value;
+
+ const textareaRows = Math.min(Math.max(entry.value.split('\n').length, 1), 8);
+
+ return (
+
+ {index > 0 && }
+
+
+
+
+ setFieldValue(`contentEntries[${index}].key`, e.target.value)
+ }
+ onBlur={() => setFieldTouched(`contentEntries[${index}].key`, true)}
+ isInvalid={showKeyError}
+ fullWidth
+ />
+
+
+
+
+
+
+
+ setFieldValue(`contentEntries[${index}].value`, e.target.value)
+ }
+ onBlur={() => setFieldTouched(`contentEntries[${index}].value`, true)}
+ isInvalid={showValueError}
+ rows={textareaRows}
+ fullWidth
+ />
+
+
+
+
+
+
+
+ remove(index)}
+ />
+
+
+
+
+
+ );
+ })}
+
+
push({ key: '', value: '' })}
+ >
+ Add
+
+
+ )}
+
+ );
+};
diff --git a/public/pages/KVDBs/components/KVDBDetailsFlyout.tsx b/public/pages/KVDBs/components/KVDBDetailsFlyout.tsx
new file mode 100644
index 000000000..af7feb362
--- /dev/null
+++ b/public/pages/KVDBs/components/KVDBDetailsFlyout.tsx
@@ -0,0 +1,166 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useState } from 'react';
+import {
+ EuiAccordion,
+ EuiCallOut,
+ EuiCodeBlock,
+ EuiFlexGrid,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiSpacer,
+ EuiButtonGroup,
+ EuiModalBody,
+ EuiFormLabel,
+ EuiFlexGroup,
+ EuiLoadingContent,
+ EuiText,
+ EuiSmallButtonIcon,
+} from '@elastic/eui';
+import { KVDBItem } from '../../../../types';
+import { AssetViewer } from './AssetViewer';
+import { Metadata, MetadataFieldType } from '../../../components/Utility/Metadata';
+import { BadgeGroup } from '../../../components/Utility/BadgeGroup';
+import { EnabledHealth } from '../../../components/Utility/EnabledHealth';
+import { DEFAULT_EMPTY_DATA } from '../../../utils/constants';
+import { DataStore } from '../../../store/DataStore';
+import { useLazyFetch } from '../../../hooks/useLazyFetch';
+
+interface KVDBDetailsFlyoutProps {
+ kvdbId: string;
+ onClose: () => void;
+}
+
+const VISUAL_VIEW = {
+ VISUAL: 'visual',
+ YAML: 'yaml',
+ JSON: 'json',
+};
+
+const viewOptions = [
+ { id: VISUAL_VIEW.VISUAL, label: 'Visual' },
+ { id: VISUAL_VIEW.YAML, label: 'YAML' },
+ { id: VISUAL_VIEW.JSON, label: 'JSON' },
+];
+
+export const KVDBDetailsFlyout: React.FC = ({ kvdbId, onClose }) => {
+ const [selectedView, setSelectedView] = useState(VISUAL_VIEW.VISUAL);
+ const fetchKvdb = useCallback(() => DataStore.kvdbs.getKVDB(kvdbId), [kvdbId]);
+ const { data: kvdb, loading, error } = useLazyFetch(fetchKvdb, 'KVDB not found.');
+
+ const document = kvdb?.document ?? { id: '' };
+ const metadata = document.metadata;
+
+ const fields: Array<{
+ key: string;
+ label: string;
+ value: any;
+ type?: MetadataFieldType;
+ }> = [
+ { key: 'space', label: 'Space', value: kvdb?.space?.name },
+ { key: 'integration.title', label: 'Integration', value: kvdb?.integration?.title },
+ { key: 'document.metadata.title', label: 'Title', value: metadata?.title },
+ { key: 'document.id', label: 'ID', value: document.id || kvdb?.id },
+ { key: 'document.metadata.author', label: 'Author', value: metadata?.author },
+ { key: 'document.metadata.description', label: 'Description', value: metadata?.description },
+ { key: 'document.metadata.date', label: 'Date', value: metadata?.date, type: 'date' },
+ { key: 'document.metadata.modified', label: 'Modified', value: metadata?.modified, type: 'date' },
+ { key: 'document.metadata.documentation', label: 'Documentation', value: metadata?.documentation },
+ { key: 'document.metadata.references', label: 'References', value: metadata?.references, type: 'url' },
+ { key: 'document.metadata.supports', label: 'Supports', value: , type: 'raw' },
+ ];
+
+ const renderBody = () => {
+ if (loading) return ;
+ if (error) return ;
+ if (!kvdb) return null;
+
+ const visualTab = (
+ <>
+
+ {fields.map(({ key, label, value, type = 'text' }) => (
+
+ {label}} value={value} type={type} />
+
+ ))}
+
+ {document.content && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ );
+
+ const yamlTab = (
+
+ {kvdb.yaml ?? ''}
+
+ );
+
+ const jsonTab = (
+
+ {JSON.stringify(document, null, 2)}
+
+ );
+
+ if (selectedView === VISUAL_VIEW.YAML) return yamlTab;
+ if (selectedView === VISUAL_VIEW.JSON) return jsonTab;
+ return visualTab;
+ };
+
+ return (
+
+
+
+
+
+ {metadata?.title ? `KVDB details - ${metadata.title}` : 'KVDB details'}
+
+
+
+
+
+
+
+
+
+
+
+ setSelectedView(id)}
+ isDisabled={loading || !!error || !kvdb}
+ />
+
+ {kvdb && (
+
+
+
+ )}
+
+
+ {renderBody()}
+
+
+
+ );
+};
diff --git a/public/pages/KVDBs/containers/KVDBFormPage.tsx b/public/pages/KVDBs/containers/KVDBFormPage.tsx
new file mode 100644
index 000000000..cdf25be93
--- /dev/null
+++ b/public/pages/KVDBs/containers/KVDBFormPage.tsx
@@ -0,0 +1,493 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiButtonGroup,
+ EuiCompressedFieldText,
+ EuiCompressedFormRow,
+ EuiCompressedSwitch,
+ EuiCompressedTextArea,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiLoadingSpinner,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { Form, Formik, FormikErrors } from 'formik';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import React, { useCallback, useEffect, useState } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import {
+ IntegrationComboBox,
+ useIntegrationSelector,
+} from '../../../components/IntegrationComboBox';
+import FormFieldHeader from '../../../components/FormFieldHeader';
+import { FormFieldArray } from '../../../components/FormFieldArray';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { DataStore } from '../../../store/DataStore';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import {
+ errorNotificationToast,
+ setBreadcrumbs,
+ successNotificationToast,
+} from '../../../utils/helpers';
+import { ContentEntry, KVDBContentEditor } from '../components/KVDBContentEditor';
+import { YamlForm, YAML_TYPE, validateYamlSyntax } from '../../../components/YamlForm';
+import {
+ kvdbFormDefaultValue,
+ KVDBFormModel,
+ mapFormToYaml,
+ mapYamlToForm,
+} from '../utils/mappers';
+
+const KVDB_ACTION = {
+ CREATE: 'create',
+ EDIT: 'edit',
+} as const;
+
+type KVDBAction = typeof KVDB_ACTION[keyof typeof KVDB_ACTION];
+
+const actionLabels: Record = {
+ create: 'Create',
+ edit: 'Edit',
+};
+
+const EDITOR_TYPE = {
+ VISUAL: 'visual',
+ YAML: 'yaml',
+} as const;
+
+type EditorType = typeof EDITOR_TYPE[keyof typeof EDITOR_TYPE];
+
+const editorTypes: Array<{ id: EditorType; label: string }> = [
+ { id: EDITOR_TYPE.VISUAL, label: 'Visual Editor' },
+ { id: EDITOR_TYPE.YAML, label: 'YAML Editor' },
+];
+
+type KVDBFormPageProps = {
+ notifications: NotificationsStart;
+ history: RouteComponentProps['history'];
+ action: KVDBAction;
+ match: { params: { id?: string } };
+};
+
+export const KVDBFormPage: React.FC = (props) => {
+ const { notifications, history, action } = props;
+ const kvdbId = props.match.params.id;
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedEditorType, setSelectedEditorType] = useState('visual');
+ const [rawKvdb, setRawKvdb] = useState(undefined);
+ const [yamlError, setYamlError] = useState(null);
+ const [integrationType, setIntegrationType] = useState('');
+ const [initialValue, setInitialValue] = useState(kvdbFormDefaultValue);
+
+ const {
+ loading: loadingIntegrations,
+ options: integrationTypeOptions,
+ refresh: refreshIntegrations,
+ } = useIntegrationSelector({ notifications, enabled: action === KVDB_ACTION.CREATE });
+
+ useEffect(() => {
+ if (action !== KVDB_ACTION.EDIT) return;
+
+ const fetchKVDB = async () => {
+ setIsLoading(true);
+ try {
+ const item = await DataStore.kvdbs.getKVDB(kvdbId!);
+ setRawKvdb(item.yaml);
+ setInitialValue(mapYamlToForm(item.yaml));
+ setBreadcrumbs([
+ BREADCRUMBS.NORMALIZATION,
+ BREADCRUMBS.KVDBS,
+ BREADCRUMBS.KVDBS_EDIT,
+ { text: item?.document?.metadata?.title || kvdbId },
+ ]);
+ } catch {
+ errorNotificationToast(
+ notifications,
+ 'retrieve',
+ 'KVDB',
+ `There was an error retrieving the KVDB with id ${kvdbId}.`
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchKVDB();
+ }, [action, kvdbId, notifications]);
+
+ useEffect(() => {
+ if (action !== KVDB_ACTION.CREATE) return;
+ setBreadcrumbs([BREADCRUMBS.NORMALIZATION, BREADCRUMBS.KVDBS, BREADCRUMBS.KVDBS_CREATE]);
+ }, [action]);
+
+ const onIntegrationChange = useCallback((options: Array<{ id?: string }>) => {
+ setIntegrationType(options[0]?.id || '');
+ }, []);
+
+ const onIntegrationCreateSuccess = useCallback(
+ (newOption: { id: string }) => {
+ refreshIntegrations();
+ setIntegrationType(newOption.id);
+ },
+ [refreshIntegrations]
+ );
+
+ const createKVDB = useCallback(
+ async (values: KVDBFormModel) => {
+ const resourceYaml =
+ selectedEditorType === EDITOR_TYPE.YAML && rawKvdb ? rawKvdb : mapFormToYaml(values);
+ const result = await DataStore.kvdbs.createKVDB({
+ resourceYaml,
+ integrationId: integrationType,
+ });
+
+ if (result) {
+ successNotificationToast(
+ notifications,
+ KVDB_ACTION.CREATE,
+ 'KVDB',
+ result.message || `The KVDB "${values.title}" has been created successfully.`
+ );
+ history.push(ROUTES.KVDBS);
+ }
+ },
+ [integrationType, notifications, history, selectedEditorType, rawKvdb]
+ );
+
+ const updateKVDB = useCallback(
+ async (values: KVDBFormModel) => {
+ if (!kvdbId) return;
+
+ const resourceYaml =
+ selectedEditorType === EDITOR_TYPE.YAML && rawKvdb ? rawKvdb : mapFormToYaml(values);
+ const result = await DataStore.kvdbs.updateKVDB(kvdbId, { resourceYaml });
+
+ if (result) {
+ successNotificationToast(
+ notifications,
+ 'update',
+ 'KVDB',
+ result.message || `The KVDB "${values.title}" has been updated successfully.`
+ );
+ history.push(ROUTES.KVDBS);
+ }
+ },
+ [kvdbId, notifications, history, selectedEditorType, rawKvdb]
+ );
+
+ const handleSubmit = useCallback(
+ async (values: KVDBFormModel) => {
+ if (action === KVDB_ACTION.CREATE) {
+ await createKVDB(values);
+ } else if (action === KVDB_ACTION.EDIT) {
+ await updateKVDB(values);
+ }
+ },
+ [action, createKVDB, updateKVDB]
+ );
+
+ const validateForm = useCallback(
+ (values: KVDBFormModel) => {
+ const errors: FormikErrors = {};
+
+ if (!values.title.trim()) {
+ errors.title = 'Title is required';
+ } else if (/\s/.test(values.title)) {
+ errors.title = 'Title must not contain spaces';
+ }
+
+ if (!values.author.trim()) {
+ errors.author = 'Author is required';
+ }
+
+ const keyCounts: Record = {};
+ values.contentEntries.forEach(({ key }) => {
+ const k = key.trim();
+ if (k) keyCounts[k] = (keyCounts[k] ?? 0) + 1;
+ });
+
+ const contentErrors = values.contentEntries.map(
+ (entry): FormikErrors => {
+ const entryErrors: FormikErrors = {};
+
+ if (entry.key.trim() && keyCounts[entry.key.trim()] > 1) {
+ entryErrors.key = 'Duplicate key';
+ }
+
+ const trimmed = entry.value.trim();
+ if (trimmed[0] === '{' || trimmed[0] === '[') {
+ try {
+ JSON.parse(trimmed);
+ } catch {
+ entryErrors.value = 'Invalid JSON';
+ }
+ }
+
+ return entryErrors;
+ }
+ );
+
+ if (contentErrors.some((e) => Object.keys(e).length > 0)) {
+ errors.contentEntries = contentErrors as any;
+ }
+
+ return errors;
+ },
+ [selectedEditorType]
+ );
+
+ const handleSubmitForm = async (
+ values: KVDBFormModel,
+ { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void }
+ ) => {
+ try {
+ await handleSubmit(values);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const isSubmitDisabled = (errors: FormikErrors) => {
+ const fieldErrorsExist = Object.keys(errors).length > 0;
+ if (action === KVDB_ACTION.CREATE && !integrationType) return true;
+ if (selectedEditorType === EDITOR_TYPE.YAML) return yamlError !== null || fieldErrorsExist;
+ return fieldErrorsExist;
+ };
+
+ const getSubmitTooltip = (errors: FormikErrors) => {
+ const messages: string[] = [];
+ if (action === KVDB_ACTION.CREATE && !integrationType) {
+ messages.push('Select an integration to proceed');
+ }
+ if (selectedEditorType === EDITOR_TYPE.YAML && yamlError) {
+ messages.push('Please fix the errors in the YAML editor to proceed');
+ } else if (errors.title || errors.author) {
+ messages.push('Please fix the errors in the form to proceed');
+ }
+ return messages.length > 0 ? messages.join('. ') : undefined;
+ };
+
+ return (
+ <>
+ {isLoading ? (
+
+
+
+
+
+
+
+ ) : (
+
+ {(formikProps) => (
+
+ )}
+
+ )}
+ >
+ );
+};
diff --git a/public/pages/KVDBs/containers/KVDBs.tsx b/public/pages/KVDBs/containers/KVDBs.tsx
new file mode 100644
index 000000000..4e97f7f44
--- /dev/null
+++ b/public/pages/KVDBs/containers/KVDBs.tsx
@@ -0,0 +1,365 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiConfirmModal,
+ EuiContextMenuItem,
+ EuiContextMenuPanel,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiPopover,
+ EuiSearchBar,
+ EuiSmallButton,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+import { KVDBItem } from '../../../../types';
+import { DataStore } from '../../../store/DataStore';
+import { BREADCRUMBS, DEFAULT_EMPTY_DATA, ROUTES } from '../../../utils/constants';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { formatCellValue, setBreadcrumbs } from '../../../utils/helpers';
+import { KVDBS_PAGE_SIZE, KVDBS_SEARCH_SCHEMA, KVDBS_SORT_FIELD } from '../utils/constants';
+import { KVDBDetailsFlyout } from '../components/KVDBDetailsFlyout';
+import { SPACE_ACTIONS, SpaceTypes } from '../../../../common/constants';
+import { actionIsAllowedOnSpace } from '../../../../common/helpers';
+import { useSpaceSelector } from '../../../hooks/useSpaceSelector';
+import {
+ DELETE_ACTION,
+ DELETE_SELECTED_ACTION,
+ useDeleteItems,
+} from '../../../hooks/useDeleteItems';
+
+interface KVDBsProps extends RouteComponentProps {
+ notifications: NotificationsStart;
+}
+
+export const KVDBs: React.FC = ({ history, notifications }) => {
+ const isMountedRef = useRef(true);
+ const [items, setItems] = useState([]);
+ const [totalItemCount, setTotalItemCount] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(KVDBS_PAGE_SIZE);
+ const [sortField, setSortField] = useState(KVDBS_SORT_FIELD);
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const [searchQuery, setSearchQuery] = useState(null);
+ const [selectedKVDBId, setSelectedKVDBId] = useState(null);
+ const { component: spaceSelector, spaceFilter } = useSpaceSelector({
+ isLoading: loading,
+ onSpaceChange: () => setPageIndex(0),
+ });
+ const [actionsPopoverOpen, setActionsPopoverOpen] = useState(false);
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ const isCreateActionDisabled = !actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.CREATE);
+ const isDeleteActionAllowed = actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ setBreadcrumbs([BREADCRUMBS.NORMALIZATION, BREADCRUMBS.KVDBS]);
+ }, []);
+
+ const buildQuery = useCallback(() => {
+ let query = searchQuery ? EuiSearchBar.Query.toESQuery(searchQuery) : { match_all: {} };
+ if (!query || Object.keys(query).length === 0) {
+ query = { match_all: {} };
+ }
+
+ if (spaceFilter) {
+ query = {
+ bool: {
+ must: [query, { term: { 'space.name': spaceFilter } }],
+ },
+ };
+ }
+
+ return query;
+ }, [searchQuery, spaceFilter]);
+
+ const fetchKVDBs = useCallback(async () => {
+ setLoading(true);
+ const sort = sortField ? [{ [sortField]: { order: sortDirection } }] : undefined;
+
+ try {
+ const response = await DataStore.kvdbs.searchKVDBs({
+ from: pageIndex * pageSize,
+ size: pageSize,
+ sort,
+ query: buildQuery(),
+ track_total_hits: true,
+ _source: {
+ includes: ['document.id', 'document.metadata.title', 'document.metadata.author', 'space'],
+ },
+ });
+
+ setItems(response.items);
+ setTotalItemCount(response.total);
+ } finally {
+ setLoading(false);
+ }
+ }, [pageIndex, pageSize, sortField, sortDirection, buildQuery]);
+
+ useEffect(() => {
+ fetchKVDBs();
+ }, [fetchKVDBs]);
+
+ const {
+ itemForAction,
+ setItemForAction,
+ isDeleting,
+ confirmDeleteSingle,
+ confirmDeleteSelected,
+ } = useDeleteItems({
+ deleteOne: (id) => DataStore.kvdbs.deleteKVDB(id),
+ reload: fetchKVDBs,
+ notifications,
+ entityName: 'KVDB',
+ entityNamePlural: 'KVDBs',
+ isMountedRef,
+ });
+
+ const onTableChange = ({ page, sort }: any) => {
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+
+ if (sort) {
+ setSortField(sort.field || KVDBS_SORT_FIELD);
+ setSortDirection(sort.direction || 'asc');
+ }
+ };
+
+ const onSearchChange = ({ query }: { query: any }) => {
+ setSearchQuery(query);
+ setPageIndex(0);
+ };
+
+ const pagination = useMemo(
+ () => ({
+ pageIndex,
+ pageSize,
+ totalItemCount,
+ pageSizeOptions: [10, 25, 50, 100],
+ }),
+ [pageIndex, pageSize, totalItemCount]
+ );
+
+ const sorting = useMemo(
+ () => ({
+ sort: {
+ field: sortField,
+ direction: sortDirection,
+ },
+ }),
+ [sortField, sortDirection]
+ );
+
+ const menuItems = [
+
+ Create
+ ,
+ {
+ setItemForAction({ action: DELETE_SELECTED_ACTION });
+ setActionsPopoverOpen(false);
+ }}
+ disabled={selectedItems.length === 0 || !isDeleteActionAllowed}
+ toolTipContent={
+ !isDeleteActionAllowed
+ ? `Cannot delete KVDBs in the ${spaceFilter} space.`
+ : selectedItems.length === 0
+ ? 'Select KVDBs to delete'
+ : undefined
+ }
+ >
+ Delete selected ({selectedItems.length})
+ ,
+ ];
+
+ const columns: Array> = useMemo(
+ () => [
+ {
+ field: 'document.metadata.title',
+ name: 'Title',
+ sortable: true,
+ dataType: 'string',
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ field: 'integration.title',
+ name: 'Integration',
+ dataType: 'string',
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ field: 'document.metadata.author',
+ name: 'Author',
+ sortable: true,
+ render: (value: string) => formatCellValue(value),
+ },
+ {
+ name: 'Actions',
+ align: 'right',
+ actions: [
+ {
+ name: 'View',
+ description: 'View KVDB details',
+ type: 'icon',
+ icon: 'inspect',
+ onClick: (item: KVDBItem) => setSelectedKVDBId(item.id),
+ },
+ {
+ name: 'Edit',
+ description: 'Edit KVDB',
+ type: 'icon',
+ icon: 'pencil',
+ onClick: (item: KVDBItem) => history.push(`${ROUTES.KVDBS_EDIT}/${item.id}`),
+ available: () => actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.EDIT),
+ },
+ {
+ name: 'Delete',
+ description: 'Delete KVDB',
+ type: 'icon',
+ icon: 'trash',
+ color: 'danger',
+ onClick: (item: KVDBItem) => setItemForAction({ action: DELETE_ACTION, id: item.id }),
+ available: () => actionIsAllowedOnSpace(spaceFilter, SPACE_ACTIONS.DELETE),
+ },
+ ],
+ },
+ ],
+ [spaceFilter, history]
+ );
+
+ return (
+
+ {selectedKVDBId && (
+ setSelectedKVDBId(null)} />
+ )}
+ {itemForAction?.action === DELETE_ACTION && (
+ setItemForAction(null)}
+ onConfirm={confirmDeleteSingle}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ Are you sure you want to delete this KVDB? This action cannot be undone.
+
+ )}
+ {itemForAction?.action === DELETE_SELECTED_ACTION && (
+ setItemForAction(null)}
+ onConfirm={() => confirmDeleteSelected(selectedItems, () => setSelectedItems([]))}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ {`Are you sure you want to delete ${selectedItems.length} KVDB${
+ selectedItems.length !== 1 ? 's' : ''
+ }? This action cannot be undone.`}
+
+ )}
+
+
+
+
+
+ KVDBs
+
+
+ {spaceSelector}
+
+ setActionsPopoverOpen((prev) => !prev)}
+ data-test-subj="kvdbsActionsButton"
+ >
+ Actions
+
+ }
+ isOpen={actionsPopoverOpen}
+ closePopover={() => setActionsPopoverOpen(false)}
+ panelPaddingSize="none"
+ anchorPosition="downLeft"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Refresh
+
+
+
+
+ item.document?.id || item.id}
+ noItemsMessage="No KVDBs to display"
+ selection={{
+ selectable: () => true,
+ onSelectionChange: setSelectedItems,
+ }}
+ />
+
+
+
+ );
+};
diff --git a/public/pages/KVDBs/hooks/useIntegrationKVDBs.ts b/public/pages/KVDBs/hooks/useIntegrationKVDBs.ts
new file mode 100644
index 000000000..864c40d93
--- /dev/null
+++ b/public/pages/KVDBs/hooks/useIntegrationKVDBs.ts
@@ -0,0 +1,122 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { DataStore } from '../../../store/DataStore';
+import { KVDBItem } from '../../../../types';
+import { buildKVDBsSearchQuery } from '../utils/constants';
+
+export interface UseIntegrationKVDBsParams {
+ kvdbIds: string[];
+ space: string;
+ enabled?: boolean;
+ pageIndex: number;
+ pageSize: number;
+ sortField: string;
+ sortDirection: 'asc' | 'desc';
+ search: string;
+}
+
+export function useIntegrationKVDBs({
+ kvdbIds,
+ space,
+ enabled = true,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+}: UseIntegrationKVDBsParams) {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [reloadTrigger, setReloadTrigger] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+
+ const from = pageIndex * pageSize;
+ const size = pageSize;
+
+ const textQuery = buildKVDBsSearchQuery(search);
+
+ const filterClauses: any[] = [{ terms: { 'document.id': kvdbIds } }];
+ if (space) {
+ filterClauses.push({ term: { 'space.name': space } });
+ }
+
+ const query = {
+ bool: {
+ must: [textQuery],
+ filter: filterClauses,
+ },
+ };
+
+ const effectiveSortField = sortField || 'document.metadata.title';
+ const sort: Array> = [{ [effectiveSortField]: { order: sortDirection } }];
+
+ DataStore.kvdbs
+ .searchKVDBs(
+ {
+ from,
+ size,
+ query,
+ sort,
+ track_total_hits: true,
+ _source: {
+ includes: [
+ 'document.id',
+ 'document.metadata.title',
+ 'document.metadata.author',
+ 'space',
+ ],
+ },
+ },
+ { skipIntegrationMap: true }
+ )
+ .then((response) => {
+ if (!cancelled) {
+ setItems(response.items);
+ setTotal(response.total);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setItems([]);
+ setTotal(0);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ kvdbIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+ reloadTrigger,
+ ]);
+
+ const refresh = useCallback(() => {
+ setReloadTrigger((prev) => prev + 1);
+ }, []);
+
+ return { items, total, loading, refresh };
+}
diff --git a/public/pages/KVDBs/utils/constants.ts b/public/pages/KVDBs/utils/constants.ts
new file mode 100644
index 000000000..1dacfa0e5
--- /dev/null
+++ b/public/pages/KVDBs/utils/constants.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export const KVDBS_PAGE_SIZE = 25;
+export const KVDBS_SORT_FIELD = 'document.metadata.title';
+
+const KEYWORD_SEARCH_FIELDS = ['document.metadata.title', 'document.metadata.author'];
+
+const escapeWildcard = (str: string) => str.replace(/[*?]/g, '\\$&');
+
+export const buildKVDBsSearchQuery = (searchText: string) => {
+ const trimmed = searchText.trim();
+ if (!trimmed) {
+ return { match_all: {} };
+ }
+
+ return {
+ bool: {
+ should: KEYWORD_SEARCH_FIELDS.map((field) => ({
+ wildcard: {
+ [field]: {
+ value: `*${escapeWildcard(trimmed)}*`,
+ case_insensitive: true,
+ },
+ },
+ })),
+ minimum_should_match: 1,
+ },
+ };
+};
+
+export const KVDBS_SEARCH_SCHEMA = {
+ strict: true,
+ fields: {
+ 'document.metadata.author': {
+ type: 'string',
+ },
+ 'document.metadata.date': {
+ type: 'date',
+ },
+ 'document.enabled': {
+ type: 'boolean',
+ },
+ 'document.id': {
+ type: 'string',
+ },
+ 'document.metadata.references': {
+ type: 'string',
+ },
+ 'document.metadata.title': {
+ type: 'string',
+ },
+ },
+};
diff --git a/public/pages/KVDBs/utils/mappers.ts b/public/pages/KVDBs/utils/mappers.ts
new file mode 100644
index 000000000..fb6c8c200
--- /dev/null
+++ b/public/pages/KVDBs/utils/mappers.ts
@@ -0,0 +1,100 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import YAML, { Pair, Scalar, YAMLMap } from 'yaml';
+import { LosslessNumber, stringify as LosslessStringify } from 'lossless-json';
+import { KVDBMetadata, KVDBResource } from '../../../../types/KVDBs';
+import { ContentEntry } from '../components/KVDBContentEditor';
+import { mapYamlToLosslessObject, stringToYamlNode, normalizeToStringArray } from '../../../components/YamlForm';
+
+export interface KVDBFormModel {
+ title: string;
+ author: string;
+ description: string;
+ documentation: string;
+ references: string[];
+ supports: string[];
+ enabled: boolean;
+ contentEntries: ContentEntry[];
+}
+
+export const kvdbFormDefaultValue: KVDBFormModel = {
+ title: '',
+ author: '',
+ description: '',
+ documentation: '',
+ references: [],
+ supports: [],
+ enabled: true,
+ contentEntries: [],
+};
+
+/** Extracts shared metadata fields from a KVDBMetadata object into form fields. */
+const metadataToFormFields = (metadata: KVDBMetadata | undefined) => ({
+ title: metadata?.title || '',
+ author: metadata?.author || '',
+ description: metadata?.description || '',
+ documentation: metadata?.documentation || '',
+ references: normalizeToStringArray(metadata?.references),
+ supports: normalizeToStringArray(metadata?.supports),
+});
+
+/** Converts any content value to a display string for a form entry. */
+const contentValueToString = (value: unknown): string => {
+ if (value instanceof LosslessNumber) return value.toString();
+ if (typeof value === 'string') return value;
+ return LosslessStringify(value, null, 2);
+};
+
+/** Converts a content object into form entries. */
+const contentToEntries = (content: Record | undefined): ContentEntry[] => {
+ if (!content || typeof content !== 'object' || Array.isArray(content)) return [];
+ return Object.entries(content).map(([key, value]) => ({
+ key,
+ value: contentValueToString(value),
+ }));
+};
+
+/** Form model to YAML string (to persist). */
+export const mapFormToYaml = (values: KVDBFormModel): string => {
+ const doc = YAML.parseDocument(
+ YAML.stringify(
+ {
+ metadata: {
+ title: values.title,
+ author: values.author,
+ description: values.description,
+ documentation: values.documentation,
+ references: values.references,
+ supports: values.supports,
+ },
+ enabled: values.enabled,
+ },
+ { lineWidth: 0 }
+ )
+ );
+
+ const contentMap = new YAMLMap();
+ for (const { key, value } of values.contentEntries) {
+ const trimmedKey = key.trim();
+ if (!trimmedKey) continue;
+ contentMap.add(new Pair(new Scalar(trimmedKey), stringToYamlNode(value)));
+ }
+
+ doc.set('content', contentMap);
+
+ return doc.toString({ lineWidth: 0 });
+};
+
+/** YAML string to form model */
+export const mapYamlToForm = (yamlStr: string): KVDBFormModel => {
+ const parsed = mapYamlToLosslessObject(yamlStr);
+ if (!parsed) return kvdbFormDefaultValue;
+ return {
+ ...metadataToFormFields(parsed.metadata),
+ enabled: parsed.enabled ?? true,
+ contentEntries: contentToEntries(parsed.content),
+ };
+};
diff --git a/public/pages/LogTest/components/LogTestForm.tsx b/public/pages/LogTest/components/LogTestForm.tsx
new file mode 100644
index 000000000..7e3ea881e
--- /dev/null
+++ b/public/pages/LogTest/components/LogTestForm.tsx
@@ -0,0 +1,175 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiFieldText,
+ EuiTextArea,
+ EuiAccordion,
+ EuiSpacer,
+ EuiSelect,
+ EuiTitle,
+} from '@elastic/eui';
+import { LogTestTraceLevel } from '../../../../types';
+import { MetadataEntry } from '../utils';
+import { MetadataFieldsEditor } from './MetadataFieldsEditor';
+
+const TRACE_LEVEL_OPTIONS: Array<{ value: LogTestTraceLevel; text: string }> = [
+ { value: 'NONE', text: 'None' },
+ { value: 'ASSET_ONLY', text: 'Asset only' },
+ { value: 'ALL', text: 'All' },
+];
+
+export interface LogTestIntegrationOption {
+ id: string;
+ label: string;
+}
+
+export interface LogTestFormData {
+ queue: number | undefined;
+ location: string;
+ event: string;
+ traceLevel: LogTestTraceLevel;
+ space: string;
+ metadataFields: MetadataEntry[];
+ integration: string;
+}
+
+export interface LogTestFormErrors {
+ queue?: string;
+ event?: string;
+ space?: string;
+ integration?: string;
+}
+
+export interface LogTestFormProps {
+ formData: LogTestFormData;
+ errors: LogTestFormErrors;
+ onFormChange: (field: keyof LogTestFormData, value: any) => void;
+ onMetadataFieldsChange: (fields: MetadataEntry[]) => void;
+ integrationOptions: LogTestIntegrationOption[];
+ disabled?: boolean;
+}
+
+export const LogTestForm: React.FC = ({
+ formData,
+ errors,
+ onFormChange,
+ onMetadataFieldsChange,
+ integrationOptions,
+ disabled = false,
+}) => {
+
+ const integrationSelectOptions = [
+ { value: '', text: 'Select an integration' },
+ ...integrationOptions.map((option) => ({
+ value: option.id,
+ text: option.label,
+ })),
+ ];
+
+ return (
+ <>
+
+ Normalization
+
+
+
+
+
+ {'Location - '}
+ optional
+ >
+ }
+ fullWidth
+ >
+ onFormChange('location', e.target.value)}
+ placeholder="/var/log/auth.log"
+ disabled={disabled}
+ fullWidth
+ />
+
+
+
+
+ onFormChange('traceLevel', e.target.value as LogTestTraceLevel)}
+ disabled={disabled}
+ fullWidth
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ Detection
+
+
+
+
+
+ {'Integration - '}
+ optional
+ >
+ }
+ fullWidth
+ >
+ onFormChange('integration', e.target.value)}
+ disabled={disabled || integrationSelectOptions.length <= 1}
+ fullWidth
+ />
+
+
+
+
+
+
+
+ Log event
+
+
+
+ onFormChange('event', e.target.value)}
+ rows={6}
+ isInvalid={!!errors.event}
+ disabled={disabled}
+ fullWidth
+ />
+
+ >
+ );
+};
diff --git a/public/pages/LogTest/components/LogTestResult.tsx b/public/pages/LogTest/components/LogTestResult.tsx
new file mode 100644
index 000000000..3be2bbcb2
--- /dev/null
+++ b/public/pages/LogTest/components/LogTestResult.tsx
@@ -0,0 +1,443 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useMemo, useState } from 'react';
+import {
+ EuiText,
+ EuiCodeBlock,
+ EuiSpacer,
+ EuiAccordion,
+ EuiBadge,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiCallOut,
+ EuiTabs,
+ EuiTab,
+ EuiLink,
+} from '@elastic/eui';
+import {
+ LogTestResponse,
+ LogTestAssetTrace,
+ LogTestNormalizationResult,
+ LogTestDetectionResult,
+ LogTestDetectionRuleMatch,
+ LogTestValidationError,
+} from '../../../../types';
+
+export interface LogTestResultProps {
+ result: LogTestResponse;
+ onRuleClick?: (ruleId: string) => void;
+}
+
+const AssetTraceItem: React.FC<{ trace: LogTestAssetTrace; index: number }> = ({
+ trace,
+ index,
+}) => {
+ return (
+
+
+
+ {trace.success ? 'Success' : 'Failed'}
+
+
+
+
+ {trace.asset}
+
+
+
+ }
+ paddingSize='s'
+ >
+ {trace.traces && trace.traces.length > 0 ? (
+
+ {trace.traces.join('\n')}
+
+ ) : (
+
+ No trace details available
+
+ )}
+
+ );
+};
+
+const ValidationErrorItem: React.FC<{
+ error: LogTestValidationError;
+ index: number;
+}> = ({ error, index }) => {
+ const listItems = Object.entries(error)
+ .filter(([, value]) => value != null)
+ .map(([key, value]) => ({
+ title: {key} ,
+ description: String(value),
+ }));
+
+ return (
+
+ {error.path}
+
+ }
+ paddingSize='none'
+ >
+
+
+
+ {listItems.map(({ title, description }, i) => (
+
+
+ {title}
+
+ {description}
+
+ ))}
+
+
+
+
+ );
+};
+
+function getLevelBadgeColor(level: string): string {
+ switch (level.toLowerCase()) {
+ case 'critical':
+ return 'danger';
+ case 'high':
+ return 'danger';
+ case 'medium':
+ return 'warning';
+ case 'low':
+ return 'default';
+ default:
+ return 'hollow';
+ }
+}
+
+const DetectionMatchItem: React.FC<{
+ match: LogTestDetectionRuleMatch;
+ index: number;
+ onRuleClick?: (ruleId: string) => void;
+}> = ({ match, index, onRuleClick }) => {
+ const { rule, matched_conditions } = match;
+
+ return (
+
+
+
+ {rule.level}
+
+
+
+
+ {onRuleClick ? (
+ onRuleClick!(rule.id)}>
+ {rule.title}
+
+ ) : (
+ {rule.title}
+ )}
+
+
+
+ }
+ paddingSize='s'
+ >
+
+ ID: {rule.id}
+
+
+ {rule.tags && rule.tags.length > 0 && (
+ <>
+
+ {rule.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ >
+ )}
+ {matched_conditions && matched_conditions.length > 0 && (
+ <>
+
+ Matched conditions:
+
+
+
+ {matched_conditions.map((condition, i) => (
+
+
+ {condition}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+};
+
+const NormalizationSection: React.FC<{ data: LogTestNormalizationResult }> = ({
+ data,
+}) => {
+ const formattedOutput = useMemo(() => {
+ if (data?.output) {
+ return JSON.stringify(data.output, null, 2);
+ }
+ return null;
+ }, [data?.output]);
+
+ const hasAssetTraces = data?.asset_traces && data.asset_traces.length > 0;
+ const hasValidation = data?.validation != null;
+
+ if (data.status === 'error') {
+ return (
+
+ {data.error?.message ?? 'An unexpected error occurred during normalization.'}
+
+ );
+ }
+
+ return (
+ <>
+ {formattedOutput ? (
+
+
+ {formattedOutput}
+
+
+ ) : (
+
+ The logtest did not return any output.
+
+ )}
+
+ {hasAssetTraces && (
+ <>
+
+
+ Asset Traces
+
+ }
+ paddingSize='s'
+ >
+
+
+ {data.asset_traces!.map((trace, index) => (
+
+ {index > 0 && }
+
+
+ ))}
+
+
+ >
+ )}
+
+ {hasValidation && !data.validation!.valid && (
+ <>
+
+
+
+
+ Validation
+
+
+
+ Failed
+
+
+ }
+ paddingSize='s'
+ >
+ {data.validation!.errors.length > 0 && (
+ <>
+
+
+ {data.validation!.errors.map((error, index) => (
+
+ {index > 0 && }
+
+
+ ))}
+
+ >
+ )}
+
+ >
+ )}
+ >
+ );
+};
+
+const DetectionSection: React.FC<{
+ data: LogTestDetectionResult;
+ onRuleClick?: (ruleId: string) => void;
+}> = ({ data, onRuleClick, }) => {
+ if (data.status === 'skipped') {
+ return (
+
+ {data.reason || 'Detection was skipped.'}
+
+ );
+ }
+
+ if (data.status === 'error') {
+ return (
+
+ {data.reason || 'Detection failed due to an unexpected error.'}
+
+ );
+ }
+
+ const matches = data.matches ?? [];
+
+ if (matches.length === 0) {
+ return (
+
+
+ {data.rules_evaluated != null
+ ? `${data.rules_evaluated} rules evaluated, 0 matched.`
+ : 'No detection rules matched the log event.'}
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {data.rules_evaluated} rules evaluated,{' '}
+ {data.rules_matched} matched
+
+
+
+
+ {matches.map((match, index) => (
+
+ {index > 0 && }
+
+
+ ))}
+
+ >
+ );
+};
+
+type ResultTab = 'normalization' | 'detection';
+
+export const LogTestResult: React.FC = ({ result, onRuleClick }) => {
+ const [selectedTab, setSelectedTab] = useState('normalization');
+ const normalization = result?.message?.normalization;
+ const detection = result?.message?.detection;
+
+ return (
+ <>
+
+
+
+ Test Result
+
+
+
+
+ {result?.status}
+
+
+
+
+
+
+
+ setSelectedTab('normalization')}
+ >
+ Normalization
+
+ setSelectedTab('detection')}
+ >
+ Detection
+
+
+
+
+
+ {selectedTab === 'normalization' &&
+ (normalization ? (
+
+ ) : (
+
+ The logtest did not return normalization results.
+
+ ))}
+
+ {selectedTab === 'detection' &&
+ (detection ? (
+
+ ) : (
+
+ The logtest did not return detection results.
+
+ ))}
+ >
+ );
+};
diff --git a/public/pages/LogTest/components/MetadataFieldsEditor.tsx b/public/pages/LogTest/components/MetadataFieldsEditor.tsx
new file mode 100644
index 000000000..f8a6a71c0
--- /dev/null
+++ b/public/pages/LogTest/components/MetadataFieldsEditor.tsx
@@ -0,0 +1,142 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React from 'react';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiComboBox,
+ EuiFieldText,
+ EuiFieldNumber,
+ EuiButtonIcon,
+ EuiButton,
+ EuiSpacer,
+} from '@elastic/eui';
+import { METADATA_FIELDS } from '../constants';
+import { MetadataEntry } from '../utils';
+
+const UNIQUE_METADATA_FIELDS = (() => {
+ const seen = new Set();
+ return METADATA_FIELDS.filter((f) => {
+ if (seen.has(f.key)) {
+ return false;
+ }
+ seen.add(f.key);
+ return true;
+ });
+})();
+
+export interface MetadataFieldsEditorProps {
+ entries: MetadataEntry[];
+ onChange: (entries: MetadataEntry[]) => void;
+ disabled?: boolean;
+}
+
+export const MetadataFieldsEditor: React.FC = ({
+ entries,
+ onChange,
+ disabled = false,
+}) => {
+ const selectedKeys = entries.map((e) => e.key);
+ const allFieldsSelected =
+ selectedKeys.filter(Boolean).length >= UNIQUE_METADATA_FIELDS.length;
+
+ const handleKeyChange = (index: number, newKey: string) => {
+ const updated = entries.map((entry, i) => (i === index ? { key: newKey, value: '' } : entry));
+ onChange(updated);
+ };
+
+ const handleValueChange = (index: number, newValue: string) => {
+ const updated = entries.map((entry, i) =>
+ i === index ? { ...entry, value: newValue } : entry
+ );
+ onChange(updated);
+ };
+
+ const handleRemove = (index: number) => {
+ onChange(entries.filter((_, i) => i !== index));
+ };
+
+ const handleAdd = () => {
+ onChange([...entries, { key: '', value: '' }]);
+ };
+
+ return (
+ <>
+ {entries.map((entry, index) => {
+ const fieldDef = UNIQUE_METADATA_FIELDS.find((f) => f.key === entry.key);
+ const isNumber = fieldDef?.type === 'number';
+
+ const availableOptions = UNIQUE_METADATA_FIELDS.filter(
+ (f) => !selectedKeys.includes(f.key) || f.key === entry.key
+ ).map((f) => ({ label: f.key, value: f.key }));
+
+ const selectedOption = entry.key ? [{ label: entry.key, value: entry.key }] : [];
+
+ return (
+
+ {index > 0 && }
+
+
+ {
+ const opt = selected[0];
+ handleKeyChange(index, opt?.value ?? opt?.label ?? '');
+ }}
+ placeholder="Select a field..."
+ isDisabled={disabled}
+ isClearable={false}
+ compressed
+ />
+
+
+ {isNumber ? (
+ handleValueChange(index, e.target.value)}
+ disabled={disabled || !entry.key}
+ placeholder="Value"
+ compressed
+ fullWidth
+ />
+ ) : (
+ handleValueChange(index, e.target.value)}
+ disabled={disabled || !entry.key}
+ placeholder="Value"
+ compressed
+ fullWidth
+ />
+ )}
+
+
+ handleRemove(index)}
+ disabled={disabled}
+ />
+
+
+
+ );
+ })}
+ {entries.length > 0 && }
+
+ Add field
+
+ >
+ );
+};
diff --git a/public/pages/LogTest/constants.ts b/public/pages/LogTest/constants.ts
new file mode 100644
index 000000000..de50ff9d5
--- /dev/null
+++ b/public/pages/LogTest/constants.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type MetadataFieldType = 'string' | 'number' | 'ip' | 'geo_point';
+
+export interface LogTestMetadataFieldDef {
+ key: string;
+ type: MetadataFieldType;
+}
+
+export const METADATA_FIELDS: LogTestMetadataFieldDef[] = [
+ { key: 'wazuh.agent.build.original', type: 'string' },
+ { key: 'wazuh.agent.ephemeral_id', type: 'string' },
+ { key: 'wazuh.agent.groups', type: 'string' },
+ { key: 'wazuh.agent.host.architecture', type: 'string' },
+ { key: 'wazuh.agent.host.boot.id', type: 'string' },
+ { key: 'wazuh.agent.host.cpu.cores', type: 'number' },
+ { key: 'wazuh.agent.host.cpu.name', type: 'string' },
+ { key: 'wazuh.agent.host.cpu.speed', type: 'number' },
+ { key: 'wazuh.agent.host.cpu.usage', type: 'number' },
+ { key: 'wazuh.agent.host.disk.read.bytes', type: 'number' },
+ { key: 'wazuh.agent.host.disk.write.bytes', type: 'number' },
+ { key: 'wazuh.agent.host.domain', type: 'string' },
+ { key: 'wazuh.agent.host.geo.city_name', type: 'string' },
+ { key: 'wazuh.agent.host.geo.continent_code', type: 'string' },
+ { key: 'wazuh.agent.host.geo.continent_name', type: 'string' },
+ { key: 'wazuh.agent.host.geo.country_iso_code', type: 'string' },
+ { key: 'wazuh.agent.host.geo.country_name', type: 'string' },
+ { key: 'wazuh.agent.host.geo.location', type: 'geo_point' },
+ { key: 'wazuh.agent.host.geo.name', type: 'string' },
+ { key: 'wazuh.agent.host.geo.postal_code', type: 'string' },
+ { key: 'wazuh.agent.host.geo.region_iso_code', type: 'string' },
+ { key: 'wazuh.agent.host.geo.region_name', type: 'string' },
+ { key: 'wazuh.agent.host.geo.timezone', type: 'string' },
+ { key: 'wazuh.agent.host.hostname', type: 'string' },
+ { key: 'wazuh.agent.host.id', type: 'string' },
+ { key: 'wazuh.agent.host.ip', type: 'ip' },
+ { key: 'wazuh.agent.host.mac', type: 'string' },
+ { key: 'wazuh.agent.host.memory.free', type: 'number' },
+ { key: 'wazuh.agent.host.memory.total', type: 'number' },
+ { key: 'wazuh.agent.host.memory.used.percentage', type: 'number' },
+ { key: 'wazuh.agent.host.name', type: 'string' },
+ { key: 'wazuh.agent.host.network.egress.bytes', type: 'number' },
+ { key: 'wazuh.agent.host.network.egress.drops', type: 'number' },
+ { key: 'wazuh.agent.host.network.egress.errors', type: 'number' },
+ { key: 'wazuh.agent.host.network.egress.packets', type: 'number' },
+ { key: 'wazuh.agent.host.network.egress.queue', type: 'number' },
+ { key: 'wazuh.agent.host.network.ingress.bytes', type: 'number' },
+ { key: 'wazuh.agent.host.network.ingress.drops', type: 'number' },
+ { key: 'wazuh.agent.host.network.ingress.errors', type: 'number' },
+ { key: 'wazuh.agent.host.network.ingress.packets', type: 'number' },
+ { key: 'wazuh.agent.host.network.ingress.queue', type: 'number' },
+ { key: 'wazuh.agent.host.os.family', type: 'string' },
+ { key: 'wazuh.agent.host.os.full', type: 'string' },
+ { key: 'wazuh.agent.host.os.kernel', type: 'string' },
+ { key: 'wazuh.agent.host.os.name', type: 'string' },
+ { key: 'wazuh.agent.host.os.platform', type: 'string' },
+ { key: 'wazuh.agent.host.os.type', type: 'string' },
+ { key: 'wazuh.agent.host.os.version', type: 'string' },
+ { key: 'wazuh.agent.host.pid_ns_ino', type: 'string' },
+ { key: 'wazuh.agent.host.risk.calculated_level', type: 'string' },
+ { key: 'wazuh.agent.host.risk.calculated_score', type: 'number' },
+ { key: 'wazuh.agent.host.risk.calculated_score_norm', type: 'number' },
+ { key: 'wazuh.agent.host.risk.static_level', type: 'string' },
+ { key: 'wazuh.agent.host.risk.static_score', type: 'number' },
+ { key: 'wazuh.agent.host.risk.static_score_norm', type: 'number' },
+ { key: 'wazuh.agent.host.type', type: 'string' },
+ { key: 'wazuh.agent.host.uptime', type: 'number' },
+ { key: 'wazuh.agent.id', type: 'string' },
+ { key: 'wazuh.agent.name', type: 'string' },
+ { key: 'wazuh.agent.type', type: 'string' },
+ { key: 'wazuh.agent.version', type: 'string' },
+ { key: 'wazuh.cluster.name', type: 'string' },
+ { key: 'wazuh.cluster.node', type: 'string' },
+ { key: 'wazuh.protocol.location', type: 'string' },
+ { key: 'wazuh.protocol.queue', type: 'number' },
+ { key: 'wazuh.schema.version', type: 'string' },
+];
diff --git a/public/pages/LogTest/containers/LogTest.tsx b/public/pages/LogTest/containers/LogTest.tsx
new file mode 100644
index 000000000..c1953434e
--- /dev/null
+++ b/public/pages/LogTest/containers/LogTest.tsx
@@ -0,0 +1,292 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ EuiCallOut,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiButton,
+ EuiHorizontalRule,
+} from '@elastic/eui';
+import { RouteComponentProps } from 'react-router-dom';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { PageHeader } from '../../../components/PageHeader/PageHeader';
+import { SpaceSelector } from '../../../components/SpaceSelector/SpaceSelector';
+import { errorNotificationToast, setBreadcrumbs } from '../../../utils/helpers';
+import { BREADCRUMBS, ROUTES } from '../../../utils/constants';
+import { DataStore } from '../../../store/DataStore';
+import { SpaceTypes } from '../../../../common/constants';
+import { LogTestResponse } from '../../../../types';
+import {
+ LogTestForm,
+ LogTestFormData,
+ LogTestFormErrors,
+ LogTestIntegrationOption,
+} from '../components/LogTestForm';
+import { LogTestResult } from '../components/LogTestResult';
+import { MetadataEntry, buildMetadataObject } from '../utils';
+import { getApplication } from '../../../services/utils/constants';
+import { DETECTION_RULE_NAV_ID } from '../../../utils/constants';
+
+const LOG_TEST_SPACE_OPTIONS = [
+ SpaceTypes.TEST.value,
+ SpaceTypes.CUSTOM.value,
+ SpaceTypes.STANDARD.value,
+];
+
+const INITIAL_FORM_DATA: LogTestFormData = {
+ queue: undefined,
+ location: '',
+ event: '',
+ traceLevel: 'NONE',
+ space: SpaceTypes.STANDARD.value,
+ metadataFields: [],
+ integration: '',
+};
+
+const INITIAL_ERRORS: LogTestFormErrors = {};
+
+const INITIAL_SPACE_OPTIONS = [
+ { id: SpaceTypes.TEST.value },
+ { id: SpaceTypes.CUSTOM.value },
+ { id: SpaceTypes.STANDARD.value },
+];
+
+interface SpaceCacheEntry {
+ enabled: boolean;
+ integrations: LogTestIntegrationOption[];
+}
+
+type SpaceCache = Record;
+
+interface LogTestProps extends RouteComponentProps {
+ notifications?: NotificationsStart;
+}
+
+export const LogTest: React.FC = ({ notifications, history }) => {
+ const [formData, setFormData] = useState(INITIAL_FORM_DATA);
+ const [errors, setErrors] = useState(INITIAL_ERRORS);
+ const [isLoading, setIsLoading] = useState(false);
+ const [testResult, setTestResult] = useState(null);
+ const [spaceCache, setSpaceCache] = useState({});
+
+ useEffect(() => {
+ setBreadcrumbs([BREADCRUMBS.LOG_TEST]);
+ }, []);
+
+ const loadSpaceCache = useCallback(async (): Promise => {
+ const entries = await Promise.all(
+ INITIAL_SPACE_OPTIONS.map((option) =>
+ DataStore.policies
+ .searchPolicies(option.id, {
+ includeIntegrationFields: ['document.id', 'document.metadata', 'document.enabled'],
+ })
+ .then((response): [string, SpaceCacheEntry] => {
+ const policy = response.items[0];
+ const integrations: LogTestIntegrationOption[] = Object.values(
+ policy?.integrationsMap ?? {}
+ )
+ .filter((i) => i.document?.enabled)
+ .map((i) => ({
+ id: i.document?.id,
+ label: i.document?.metadata?.title ?? i.document?.id,
+ }));
+ return [
+ option.id,
+ {
+ enabled: !!policy && policy.document?.enabled !== false,
+ integrations,
+ },
+ ];
+ })
+ .catch((error): [string, SpaceCacheEntry] => {
+ console.error(`Security Analytics - LogTest - searchPolicies (${option.id}):`, error);
+ errorNotificationToast(notifications, 'retrieve', 'policies', error);
+ return [option.id, { enabled: false, integrations: [] }];
+ })
+ )
+ );
+
+ const cache: SpaceCache = Object.fromEntries(entries);
+ setSpaceCache(cache);
+ return cache;
+ }, [notifications]);
+
+ useEffect(() => {
+ loadSpaceCache();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ setFormData((prev) => (prev.integration ? { ...prev, integration: '' } : prev));
+ }, [formData.space]);
+
+ const disabledSpaces = useMemo(
+ () => INITIAL_SPACE_OPTIONS.filter((o) => spaceCache[o.id]?.enabled === false).map((o) => o.id),
+ [spaceCache]
+ );
+
+ const integrationOptions = useMemo(
+ () => spaceCache[formData.space]?.integrations ?? [],
+ [spaceCache, formData.space]
+ );
+
+ const validateForm = useCallback((): boolean => {
+ const newErrors: LogTestFormErrors = {};
+
+ if (!formData.event.trim()) {
+ newErrors.event = 'Log event is required';
+ }
+
+ if (!formData.space) {
+ newErrors.space = 'Space is required';
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ }, [formData]);
+
+ const handleExecuteLogTest = async () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ const result = await DataStore.logTests.executeLogTest({
+ document: {
+ queue: 1, // temporary hardcoded queue value
+ location: String(formData.location ?? '').trim(),
+ event: formData.event.trim(),
+ trace_level: formData.traceLevel,
+ metadata: buildMetadataObject(formData.metadataFields),
+ space: formData.space,
+ integration: formData.integration || undefined,
+ },
+ });
+
+ setIsLoading(false);
+
+ if (result.success && result.data) {
+ setTestResult(result.data);
+ }
+ };
+
+ const handleFormChange = useCallback(
+ (field: keyof LogTestFormData, value: any) => {
+ setFormData((prev) => ({ ...prev, [field]: value }));
+
+ // clears error when user starts typing
+ if (errors[field as keyof LogTestFormErrors]) {
+ setErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[field as keyof LogTestFormErrors];
+ return newErrors;
+ });
+ }
+ },
+ [errors]
+ );
+
+ const handleMetadataFieldsChange = useCallback((fields: MetadataEntry[]) => {
+ setFormData((prev) => ({ ...prev, metadataFields: fields }));
+ }, []);
+
+ const handleClearSession = useCallback(() => {
+ setFormData(INITIAL_FORM_DATA);
+ setErrors(INITIAL_ERRORS);
+ setTestResult(null);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ Log Test
+
+
+
+ handleFormChange('space', id)}
+ isDisabled={isLoading}
+ allowedSpaces={LOG_TEST_SPACE_OPTIONS}
+ />
+
+
+
+
+
+
+
+
+
+ {disabledSpaces.includes(formData.space) && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ {isLoading ? 'Testing...' : 'Test'}
+
+
+
+
+
+ Clear session
+
+
+
+
+ {testResult && (
+ <>
+
+
+ getApplication().navigateToApp(DETECTION_RULE_NAV_ID, {
+ path: `#${ROUTES.RULES}?ruleId=${ruleId}&space=${formData.space}`,
+ })
+ }
+ />
+ >
+ )}
+
+
+
+ );
+};
diff --git a/public/pages/LogTest/utils.ts b/public/pages/LogTest/utils.ts
new file mode 100644
index 000000000..8814ff959
--- /dev/null
+++ b/public/pages/LogTest/utils.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METADATA_FIELDS } from './constants';
+
+export interface MetadataEntry {
+ key: string;
+ value: string;
+}
+
+/** Converts a flat array of {key, value} dotted path entries into a nested object.
+ * Number-typed fields are cast to number. Empty keys/values are skipped.
+ * e.g. [{ key: 'agent.id', value: '1' }] => { agent: { id: 1 } }
+ */
+export function buildMetadataObject(
+ entries: MetadataEntry[]
+): Record {
+ const result: Record = {};
+
+ for (const { key, value } of entries) {
+ if (!key || value === '') continue;
+
+ const parts = key.split('.');
+ let cursor = result;
+
+ for (let i = 0; i < parts.length - 1; i++) {
+ if (!cursor[parts[i]] || typeof cursor[parts[i]] !== 'object') {
+ cursor[parts[i]] = {};
+ }
+ cursor = cursor[parts[i]];
+ }
+
+ const leaf = parts[parts.length - 1];
+ const fieldDef = METADATA_FIELDS.find((f) => f.key === key);
+ cursor[leaf] = fieldDef?.type === 'number' ? Number(value) : value;
+ }
+
+ return result;
+}
diff --git a/public/pages/LogTypes/components/DeleteLogTypeModal.tsx b/public/pages/LogTypes/components/DeleteLogTypeModal.tsx
index 7c4650c3e..197bf8434 100644
--- a/public/pages/LogTypes/components/DeleteLogTypeModal.tsx
+++ b/public/pages/LogTypes/components/DeleteLogTypeModal.tsx
@@ -62,14 +62,15 @@ export const DeleteLogTypeModal: React.FC = ({
- This log type can't be deleted
+ {/* log type replaced by integration */}
+ This integration can't be deleted
1 ? 'rules' : 'rule'
}.`}
iconType={'iInCircle'}
@@ -78,8 +79,8 @@ export const DeleteLogTypeModal: React.FC = ({
- Only log types that donβt have any associated rules can be deleted. Consider editing
- log type or deleting associated detection rules.
+ Only integrations that donβt have any associated rules can be deleted. Consider editing
+ integration or deleting associated detection rules.
@@ -91,11 +92,11 @@ export const DeleteLogTypeModal: React.FC = ({
) : (
Delete log type? }
+ title={Delete integration? }
onCancel={closeModal}
onConfirm={onConfirmClick}
cancelButtonText={'Cancel'}
- confirmButtonText={`Delete log type`}
+ confirmButtonText={`Delete integration`}
buttonColor={'danger'}
defaultFocusedButton="confirm"
confirmButtonDisabled={confirmDeleteText != logTypeName}
@@ -103,7 +104,7 @@ export const DeleteLogTypeModal: React.FC = ({
- The log type will be permanently deleted. This action is irreversible.
+ The integration will be permanently deleted. This action is irreversible.
diff --git a/public/pages/LogTypes/components/LogTypeDetails.tsx b/public/pages/LogTypes/components/LogTypeDetails.tsx
index ee04f9fec..824a58f85 100644
--- a/public/pages/LogTypes/components/LogTypeDetails.tsx
+++ b/public/pages/LogTypes/components/LogTypeDetails.tsx
@@ -32,7 +32,8 @@ export const LogTypeDetails: React.FC = ({
const onUpdateLogType = async () => {
const success = await DataStore.logTypes.updateLogType(logTypeDetails);
if (success) {
- successNotificationToast(notifications, 'updated', `log type ${logTypeDetails.name}`);
+ // Replace Log Type to Integration by Wazuh
+ successNotificationToast(notifications, 'updated', `integration ${logTypeDetails.name}`);
setIsEditMode(false);
}
};
@@ -51,7 +52,7 @@ export const LogTypeDetails: React.FC = ({
type="column"
listItems={[
{
- title: 'Log type',
+ title: 'Integration', // Replace Log type to Integration by Wazuh
description: (
= ({
type="column"
listItems={[
{
- title: 'Log type',
+ title: 'Integration', // Replace Log type to Integration by Wazuh
description: (
<>
@@ -64,7 +64,7 @@ export const LogTypeDetailsTab: React.FC = ({
name: e.target.value,
})
}
- placeholder="Enter name for log type"
+ placeholder="Enter name for integration" // Replace Log type to Integration by Wazuh
disabled={!isEditMode || !!logTypeDetails.detectionRulesCount}
/>
@@ -78,7 +78,7 @@ export const LogTypeDetailsTab: React.FC = ({
description: e.target.value,
})
}
- placeholder="Description of the log type"
+ placeholder="Description of the integration" // Replace Log type to Integration by Wazuh
disabled={!isEditMode}
/>
diff --git a/public/pages/LogTypes/components/LogTypeDetectionRules.tsx b/public/pages/LogTypes/components/LogTypeDetectionRules.tsx
index b67cbbefd..778fcb4a3 100644
--- a/public/pages/LogTypes/components/LogTypeDetectionRules.tsx
+++ b/public/pages/LogTypes/components/LogTypeDetectionRules.tsx
@@ -38,7 +38,8 @@ export const LogTypeDetectionRules: React.FC = ({
- There are no detection rules associated with this log type.
+ {/* By Wazuh */}
+ There are no detection rules associated with this integration.
diff --git a/public/pages/LogTypes/components/LogTypeForm.tsx b/public/pages/LogTypes/components/LogTypeForm.tsx
index 8a96e0976..17a1d6e8d 100644
--- a/public/pages/LogTypes/components/LogTypeForm.tsx
+++ b/public/pages/LogTypes/components/LogTypeForm.tsx
@@ -112,7 +112,7 @@ export const LogTypeForm: React.FC = ({
setLogTypeDetails(newLogType);
updateErrors(newLogType);
}}
- placeholder="Description of the log type"
+ placeholder="Description of the integration" // Replace Log Type with Integration by Wazuh
readOnly={!isEditMode}
/>
diff --git a/public/pages/LogTypes/containers/CreateLogType.tsx b/public/pages/LogTypes/containers/CreateLogType.tsx
index d24478fed..1d0ec9ce0 100644
--- a/public/pages/LogTypes/containers/CreateLogType.tsx
+++ b/public/pages/LogTypes/containers/CreateLogType.tsx
@@ -28,18 +28,19 @@ export const CreateLogType: React.FC = ({ history, notificat
if (getUseUpdatedUx()) {
setBreadcrumbs([BREADCRUMBS.LOG_TYPES, BREADCRUMBS.LOG_TYPE_CREATE]);
} else {
- setBreadcrumbs([BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES, BREADCRUMBS.LOG_TYPE_CREATE]);
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES, BREADCRUMBS.LOG_TYPE_CREATE]);
}
}, [getUseUpdatedUx()]);
const description =
- 'Create log type to categorize and identify detection rules for your data sources.';
+ 'Create integration to categorize and identify detection rules for your data sources.'; // Replace Log Type is replaced with Integration by Wazuh
return (
- Create log type
+ {/* Log Type is replaced with Integration by Wazuh */}
+ Create integration
{description}
@@ -49,14 +50,14 @@ export const CreateLogType: React.FC = ({ history, notificat
history.push(ROUTES.LOG_TYPES)}
onConfirm={async () => {
const success = await DataStore.logTypes.createLogType(logTypeDetails);
if (success) {
- successNotificationToast(notifications, 'created', `log type ${logTypeDetails.name}`);
+ successNotificationToast(notifications, 'created', `integration ${logTypeDetails.name}`); // Replace Log Type to Integration by Wazuh
history.push(ROUTES.LOG_TYPES);
}
}}
diff --git a/public/pages/LogTypes/containers/LogType.tsx b/public/pages/LogTypes/containers/LogType.tsx
index b4441a46b..238d14850 100644
--- a/public/pages/LogTypes/containers/LogType.tsx
+++ b/public/pages/LogTypes/containers/LogType.tsx
@@ -90,11 +90,11 @@ export const LogType: React.FC = ({ notifications, history }) => {
const details = await DataStore.logTypes.getLogType(logTypeId);
if (!details) {
- setInfoText('Log type not found!');
+ setInfoText('Integration not found!'); // Replace Log Type to Integration by Wazuh
return;
}
- setBreadcrumbs([BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES, { text: details.name }]);
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES, { text: details.name }]);
const logTypeItem = { ...details, detectionRulesCount: details.detectionRules.length };
updateRules(logTypeItem, logTypeItem);
};
@@ -134,10 +134,10 @@ export const LogType: React.FC = ({ notifications, history }) => {
const deleteLogType = async () => {
const deleteSucceeded = await DataStore.logTypes.deleteLogType(logTypeDetails!.id);
if (deleteSucceeded) {
- successNotificationToast(notifications, 'deleted', 'log type');
+ successNotificationToast(notifications, 'deleted', 'integration'); // Replace Log Type to Integration by Wazuh
history.push(ROUTES.LOG_TYPES);
} else {
- errorNotificationToast(notifications, 'delete', 'log type');
+ errorNotificationToast(notifications, 'delete', 'integration'); // Replace Log Type to Integration by Wazuh
}
};
@@ -193,8 +193,7 @@ export const LogType: React.FC = ({ notifications, history }) => {
listItems={[
{
title: 'Source',
- description:
- logTypeDetails.source === 'Sigma' ? 'Standard' : logTypeDetails.source,
+ description: logTypeDetails.source,
},
]}
/>
diff --git a/public/pages/LogTypes/containers/LogTypes.tsx b/public/pages/LogTypes/containers/LogTypes.tsx
index 88f3a6a62..742713543 100644
--- a/public/pages/LogTypes/containers/LogTypes.tsx
+++ b/public/pages/LogTypes/containers/LogTypes.tsx
@@ -43,7 +43,8 @@ export const LogTypes: React.FC = ({ history, notifications, data
const deleteLogType = async (id: string) => {
const deleteSucceeded = await DataStore.logTypes.deleteLogType(id);
if (deleteSucceeded) {
- successNotificationToast(notifications, 'deleted', 'log type');
+ // Replace Log Type to Integration by Wazuh
+ successNotificationToast(notifications, 'deleted', 'integration');
getLogTypes();
}
};
@@ -52,7 +53,7 @@ export const LogTypes: React.FC = ({ history, notifications, data
if (getUseUpdatedUx()) {
setBreadcrumbs([BREADCRUMBS.LOG_TYPES]);
} else {
- setBreadcrumbs([BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES]);
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.DETECTORS, BREADCRUMBS.LOG_TYPES]);
}
}, [getUseUpdatedUx()]);
@@ -72,9 +73,10 @@ export const LogTypes: React.FC = ({ history, notifications, data
setDeletionDetails({ detectionRulesCount: rules.length });
};
+ // Replace Log Type to Integration by Wazuh
const createLogTypeAction = (
history.push(ROUTES.LOG_TYPES_CREATE)}>
- Create log type
+ Create integration
);
@@ -96,10 +98,11 @@ export const LogTypes: React.FC = ({ history, notifications, data
- Log types
+ {/* Replace Log Types to Integrations by Wazuh */}
+ Integrations
- Log types describe the data sources to which the detection rules are meant to be
+ Integrations describe the data sources to which the detection rules are meant to be
applied.
diff --git a/public/pages/LogTypes/utils/helpers.tsx b/public/pages/LogTypes/utils/helpers.tsx
index 6545eda1e..a026cce65 100644
--- a/public/pages/LogTypes/utils/helpers.tsx
+++ b/public/pages/LogTypes/utils/helpers.tsx
@@ -63,7 +63,7 @@ export const getLogTypesTableColumns = (
export const getLogTypesTableSearchConfig = (): Search => {
return {
box: {
- placeholder: 'Search log types',
+ placeholder: 'Search integrations',
schema: true,
compressed: true,
},
diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx
index fc56a0fd7..bba4e8163 100644
--- a/public/pages/Main/Main.tsx
+++ b/public/pages/Main/Main.tsx
@@ -14,8 +14,6 @@ import {
EuiTitle,
EuiSpacer,
EuiGlobalToastList,
- EuiFlexGroup,
- EuiFlexItem,
} from '@elastic/eui';
import { Toast } from '@opensearch-project/oui/src/eui_components/toast/global_toast_list';
import { AppMountParameters, CoreStart } from 'opensearch-dashboards/public';
@@ -26,31 +24,61 @@ import {
ROUTES,
dataSourceObservable,
OS_NOTIFICATION_PLUGIN,
+ THREAT_INTEL_ENABLED,
+ // Wazuh: hide Overview app in navigation.
+ // OVERVIEW_NAV_ID,
+ FINDINGS_NAV_ID,
+ // Wazuh: hide Alerts app in navigation.
+ // THREAT_ALERTS_NAV_ID,
+ // Wazuh: hide Correlations app in navigation.
+ // CORRELATIONS_NAV_ID,
+ DETECTORS_NAV_ID,
+ DETECTION_RULE_NAV_ID,
+ // Wazuh: hide Correlation rules app in navigation.
+ // CORRELATIONS_RULE_NAV_ID,
+ LOG_TYPES_NAV_ID,
+ INTEGRATIONS_NAV_ID,
+ DECODERS_NAV_ID,
+ KVDBS_NAV_ID,
+ OVERVIEW_NAV_ID,
+ LOG_TEST_NAV_ID,
} from '../../utils/constants';
import { CoreServicesConsumer } from '../../components/core_services';
import Findings from '../Findings';
import Detectors from '../Detectors';
-import Overview from '../Overview';
+// import Overview from '../Overview';
import CreateDetector from '../CreateDetector/containers/CreateDetector';
-import Alerts from '../Alerts';
+// Wazuh: hide Alerts app and routes.
+// import Alerts from "../Alerts";
import { DetectorDetails } from '../Detectors/containers/Detector/DetectorDetails';
-import { UpdateDetectorBasicDetails } from '../Detectors/components/UpdateBasicDetails/UpdateBasicDetails';
+import { WazuhUpdateDetectorBasicDetails as UpdateDetectorBasicDetails } from '../Detectors/components/WazuhUpdateBasicDetails';
import { UpdateDetectorRules } from '../Detectors/components/UpdateRules/UpdateRules';
import UpdateFieldMappings from '../Detectors/components/UpdateFieldMappings/UpdateFieldMappings';
-import UpdateAlertConditions from '../Detectors/components/UpdateAlertConditions/UpdateAlertConditions';
-import { Rules } from '../Rules/containers/Rules/Rules';
-import { CreateRule } from '../Rules/containers/CreateRule/CreateRule';
-import { EditRule } from '../Rules/containers/EditRule/EditRule';
-import { ImportRule } from '../Rules/containers/ImportRule/ImportRule';
-import { DuplicateRule } from '../Rules/containers/DuplicateRule/DuplicateRule';
+// Wazuh: hide Alert triggers edit route.
+// import UpdateAlertConditions from "../Detectors/components/UpdateAlertConditions/UpdateAlertConditions";
+// Wazuh: use wazuh rules
+// import { Rules } from '../Rules/containers/Rules/Rules';
+// import { CreateRule } from '../Rules/containers/CreateRule/CreateRule';
+// import { EditRule } from '../Rules/containers/EditRule/EditRule';
+// import { ImportRule } from '../Rules/containers/ImportRule/ImportRule';
+// import { DuplicateRule } from '../Rules/containers/DuplicateRule/DuplicateRule';
+import { Rules, CreateRule, EditRule } from '../WazuhRules';
import Callout, { ICalloutProps } from './components/Callout';
import { DataStore } from '../../store/DataStore';
-import { CreateCorrelationRule } from '../Correlations/containers/CreateCorrelationRule';
-import { CorrelationRules } from '../Correlations/containers/CorrelationRules';
-import { Correlations } from '../Correlations/containers/CorrelationsContainer';
-import { LogTypes } from '../LogTypes/containers/LogTypes';
-import { LogType } from '../LogTypes/containers/LogType';
-import { CreateLogType } from '../LogTypes/containers/CreateLogType';
+// Wazuh: hide Correlations and Correlation rules routes.
+// import { CreateCorrelationRule } from "../Correlations/containers/CreateCorrelationRule";
+// import { CorrelationRules } from "../Correlations/containers/CorrelationRules";
+// import { Correlations } from "../Correlations/containers/CorrelationsContainer";
+// import { LogTypes } from "../LogTypes/containers/LogTypes";
+// import { LogType } from "../LogTypes/containers/LogType";
+// import { CreateLogType } from "../LogTypes/containers/CreateLogType";
+import { Integrations } from '../Integrations/containers/Integrations';
+import { Integration } from '../Integrations/containers/Integration';
+import { CreateIntegration } from '../Integrations/containers/CreateIntegration';
+import Decoders from '../Decoders';
+import { KVDBs } from '../KVDBs/containers/KVDBs';
+import { KVDBFormPage } from '../KVDBs/containers/KVDBFormPage';
+import { LogTest } from '../LogTest/containers/LogTest';
import {
DataSourceContextType,
DateTimeFilter,
@@ -62,7 +90,7 @@ import { DataSourceManagementPluginSetup } from '../../../../../src/plugins/data
import { DataSourceMenuWrapper } from '../../components/MDS/DataSourceMenuWrapper';
import { DataSourceOption } from 'src/plugins/data_source_management/public/components/data_source_menu/types';
import { DataSourceContext, DataSourceContextConsumer } from '../../services/DataSourceContext';
-import { dataSourceInfo, getUseUpdatedUx } from '../../services/utils/constants';
+import { dataSourceInfo, getApplication, getUseUpdatedUx } from '../../services/utils/constants';
import { ThreatIntelOverview } from '../ThreatIntel/containers/Overview/ThreatIntelOverview';
import { AddThreatIntelSource } from '../ThreatIntel/containers/AddThreatIntelSource/AddThreatIntelSource';
import { ThreatIntelScanConfigForm } from '../ThreatIntel/containers/ScanConfiguration/ThreatIntelScanConfigForm';
@@ -73,21 +101,35 @@ import {
getPlugins,
setIsNotificationPluginInstalled,
} from '../../utils/helpers';
-import { GettingStartedContent } from '../Overview/components/GettingStarted/GettingStartedContent';
+// import { GettingStartedContent } from '../Overview/components/GettingStarted/GettingStartedContent';
import { BrowserServices } from '../../models/interfaces';
import { CHANNEL_TYPES } from '../CreateDetector/components/ConfigureAlerts/utils/constants';
+import { PromoteIntegration } from '../Integrations/containers/PromoteIntegration';
+import { DecoderFormPage } from '../Decoders/containers/DecoderFormPage';
+import { FilterFormPage } from '../Filters/containers/FilterFormPage';
enum Navigation {
SecurityAnalytics = 'Security Analytics',
- Findings = 'Findings',
+ // Wazuh: hide Findings navigation items.
+ // Findings = 'Findings',
Detectors = 'Detectors',
- Rules = 'Detection rules',
+ Rules = 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'
Overview = 'Overview',
- Alerts = 'Alerts',
- Correlations = 'Correlations',
- CorrelationRules = 'Correlation rules',
- LogTypes = 'Log types',
- ThreatIntel = 'Threat Intelligence',
+ // Wazuh: hide Alerts/Correlations navigation items.
+ // Alerts = "Alerts",
+ // Correlations = "Correlations",
+ // CorrelationRules = "Correlation rules",
+ LogTypes = 'Integrations', // Replace Log Types to Integrations by Wazuh
+ Integrations = 'Integrations', // Integrations by Wazuh
+ // Removed Threat Intel from side nav by Wazuh
+ // Wazuh: hide Alerts/Correlations navigation items.
+ // Insights = "Insights",
+ Detection = 'Detection',
+ // Wazuh
+ Normalization = 'Normalization',
+ Decoders = 'Decoders',
+ KVDBS = 'KVDBs',
+ LogTest = 'Log test',
}
/**
@@ -102,12 +144,21 @@ const HIDDEN_NAV_ROUTES: string[] = [
ROUTES.EDIT_DETECTOR_DETAILS,
ROUTES.EDIT_DETECTOR_RULES,
ROUTES.EDIT_FIELD_MAPPINGS,
- ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS,
+ // Wazuh: hide Alert triggers edit route in nav.
+ // ROUTES.EDIT_DETECTOR_ALERT_TRIGGERS,
`${ROUTES.LOG_TYPES}/.+`,
ROUTES.LOG_TYPES_CREATE,
+ ROUTES.INTEGRATIONS_CREATE,
+ ROUTES.PROMOTE,
ROUTES.THREAT_INTEL_ADD_CUSTOM_SOURCE,
ROUTES.THREAT_INTEL_CREATE_SCAN_CONFIG,
ROUTES.THREAT_INTEL_EDIT_SCAN_CONFIG,
+ ROUTES.DECODERS_CREATE,
+ ROUTES.DECODERS_EDIT,
+ ROUTES.KVDBS_CREATE,
+ ROUTES.KVDBS_EDIT,
+ ROUTES.FILTERS_CREATE,
+ ROUTES.FILTERS_EDIT,
];
interface MainProps extends RouteComponentProps {
@@ -134,14 +185,25 @@ interface MainState {
*/
const navItemIdByRoute: { [route: string]: Navigation } = {
- [ROUTES.OVERVIEW]: Navigation.Overview,
- [ROUTES.FINDINGS]: Navigation.Findings,
- [ROUTES.ALERTS]: Navigation.Alerts,
+ // [ROUTES.OVERVIEW]: Navigation.Overview,
+ // Wazuh: hide Findings route mapping.
+ // [ROUTES.FINDINGS]: Navigation.Findings,
+ // Wazuh: hide Alerts route mapping.
+ // [ROUTES.ALERTS]: Navigation.Alerts,
[ROUTES.DETECTORS]: Navigation.Detectors,
[ROUTES.RULES]: Navigation.Rules,
- [ROUTES.THREAT_INTEL_OVERVIEW]: Navigation.ThreatIntel,
+ // Wazuh: hide Log types and add Wazuh integrations route mapping.
+ // [ROUTES.LOG_TYPES]: Navigation.LogTypes,
+ [ROUTES.INTEGRATIONS]: Navigation.Overview,
+ [ROUTES.FILTERS]: Navigation.Overview,
+ [ROUTES.DECODERS]: Navigation.Decoders,
+ [ROUTES.KVDBS]: Navigation.KVDBS,
+ [ROUTES.LOG_TEST]: Navigation.LogTest,
};
+// Wazuh
+const generateAppPath = (path: string) => `#${path}`;
+
export default class Main extends Component {
constructor(props: MainProps) {
super(props);
@@ -168,7 +230,7 @@ export default class Main extends Component {
this.state = {
getStartedDismissedOnce: false,
- selectedNavItemId: Navigation.Overview,
+ selectedNavItemId: Navigation.Overview, // Navigation.Integrations
dateTimeFilter: defaultDateTimeFilter,
showFlyoutData: null,
/**
@@ -256,6 +318,7 @@ export default class Main extends Component {
updateSelectedNavItem() {
const navItemId = this.getCurrentRouteId();
+
if (navItemId) {
this.setState({ selectedNavItemId: navItemId });
}
@@ -324,110 +387,224 @@ export default class Main extends Component {
);
},
items: [
+ /****
{
name: Navigation.Overview,
id: Navigation.Overview,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Overview });
- history.push(ROUTES.OVERVIEW);
+ // this.setState({ selectedNavItemId: Navigation.Overview });
+ // history.push(ROUTES.OVERVIEW);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu
+ getApplication().navigateToApp(OVERVIEW_NAV_ID, {
+ path: generateAppPath(ROUTES.OVERVIEW),
+ });
},
isSelected: selectedNavItemId === Navigation.Overview,
},
+ */
+ ...(THREAT_INTEL_ENABLED
+ ? [
+ {
+ name: Navigation.ThreatIntel,
+ id: Navigation.ThreatIntel,
+ onClick: () => {
+ this.setState({
+ selectedNavItemId: Navigation.ThreatIntel,
+ });
+ history.push(ROUTES.THREAT_INTEL_OVERVIEW);
+ },
+ isSelected: selectedNavItemId === Navigation.ThreatIntel,
+ },
+ ]
+ : []),
{
- name: Navigation.Findings,
- id: Navigation.Findings,
- onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Findings });
- history.push(ROUTES.FINDINGS);
- },
- isSelected: selectedNavItemId === Navigation.Findings,
- },
- {
- name: Navigation.Alerts,
- id: Navigation.Alerts,
+ name: Navigation.Overview,
+ id: Navigation.Overview,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Alerts });
- history.push(ROUTES.ALERTS);
+ // this.setState({ selectedNavItemId: Navigation.Integrations });
+ // history.push(ROUTES.INTEGRATIONS);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ getApplication().navigateToApp(INTEGRATIONS_NAV_ID, {
+ path: generateAppPath(ROUTES.INTEGRATIONS),
+ });
+ if (history.location.pathname !== ROUTES.INTEGRATIONS) {
+ history.push(ROUTES.INTEGRATIONS);
+ }
},
- isSelected: selectedNavItemId === Navigation.Alerts,
+ isSelected: selectedNavItemId === Navigation.Overview,
},
+ // Wazuh: hide Findings nav item.
+ // {
+ // name: Navigation.Findings,
+ // id: Navigation.Findings,
+ // onClick: () => {
+ // // this.setState({ selectedNavItemId: Navigation.Findings });
+ // // history.push(ROUTES.FINDINGS);
+ // // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ // getApplication().navigateToApp(FINDINGS_NAV_ID, {
+ // path: generateAppPath(ROUTES.FINDINGS),
+ // });
+ // },
+ // isSelected: selectedNavItemId === Navigation.Findings,
+ // },
+ // Wazuh: hide Insights category and Alerts/Correlations nav items.
+ // {
+ // name: Navigation.Insights,
+ // id: Navigation.Insights,
+ // forceOpen: true,
+ // items: [
+ // {
+ // name: Navigation.Findings,
+ // id: Navigation.Findings,
+ // onClick: () => {
+ // // this.setState({ selectedNavItemId: Navigation.Findings });
+ // // history.push(ROUTES.FINDINGS);
+ // // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ // getApplication().navigateToApp(FINDINGS_NAV_ID, {path: generateAppPath(ROUTES.FINDINGS)});
+ // },
+ // isSelected: selectedNavItemId === Navigation.Findings,
+ // },
+ // {
+ // name: Navigation.Alerts,
+ // id: Navigation.Alerts,
+ // onClick: () => {
+ // // this.setState({ selectedNavItemId: Navigation.Alerts });
+ // // history.push(ROUTES.ALERTS);
+ // // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ // getApplication().navigateToApp(THREAT_ALERTS_NAV_ID, {path: generateAppPath(ROUTES.ALERTS)});
+ // },
+ // isSelected: selectedNavItemId === Navigation.Alerts,
+ // },
+ // {
+ // name: Navigation.Correlations,
+ // id: Navigation.Correlations,
+ // onClick: () => {
+ // // this.setState({ selectedNavItemId: Navigation.Correlations });
+ // // history.push(ROUTES.CORRELATIONS);
+ // // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ // getApplication().navigateToApp(CORRELATIONS_NAV_ID, {path: generateAppPath(ROUTES.CORRELATIONS)});
+ // },
+ // isSelected: selectedNavItemId === Navigation.Correlations,
+ // },
+ // ],
+ // },
+ /**** Wazuh Replace LogTypes entry for Wazuh integrations
{
- name: Navigation.ThreatIntel,
- id: Navigation.ThreatIntel,
+ name: Navigation.LogTypes,
+ id: Navigation.LogTypes,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.ThreatIntel });
- history.push(ROUTES.THREAT_INTEL_OVERVIEW);
+ // this.setState({ selectedNavItemId: Navigation.LogTypes });
+ // history.push(ROUTES.LOG_TYPES);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ getApplication().navigateToApp(LOG_TYPES_NAV_ID, {
+ path: generateAppPath(ROUTES.LOG_TYPES),
+ });
},
- isSelected: selectedNavItemId === Navigation.ThreatIntel,
+ isSelected: selectedNavItemId === Navigation.LogTypes,
},
+ */
{
- name: Navigation.Detectors,
- id: Navigation.Detectors,
- onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Detectors });
- history.push(ROUTES.DETECTORS);
- },
+ name: Navigation.Normalization,
+ id: Navigation.Normalization,
forceOpen: true,
- isSelected: selectedNavItemId === Navigation.Detectors,
items: [
{
- name: Navigation.Rules,
- id: Navigation.Rules,
+ name: Navigation.Decoders,
+ id: Navigation.Decoders,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Rules });
- history.push(ROUTES.RULES);
+ // this.setState({ selectedNavItemId: Navigation.Decoders });
+ // history.push(ROUTES.DECODERS);
+ getApplication().navigateToApp(DECODERS_NAV_ID, {
+ path: generateAppPath(ROUTES.DECODERS),
+ });
},
- isSelected: selectedNavItemId === Navigation.Rules,
+ isSelected: selectedNavItemId === Navigation.Decoders,
},
{
- name: Navigation.LogTypes,
- id: Navigation.LogTypes,
+ name: Navigation.KVDBS,
+ id: Navigation.KVDBS,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.LogTypes });
- history.push(ROUTES.LOG_TYPES);
+ // this.setState({ selectedNavItemId: Navigation.KVDBS });
+ // history.push(ROUTES.KVDBS);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu
+ getApplication().navigateToApp(KVDBS_NAV_ID, {
+ path: generateAppPath(ROUTES.KVDBS),
+ });
},
- isSelected: selectedNavItemId === Navigation.LogTypes,
+ isSelected: selectedNavItemId === Navigation.KVDBS,
},
],
+ // onClick: () => {
+ // /* WORKAROUND: redirect to Normalization app registered by wazuh plugin.
+ // This view should be moved to this plugin.
+ // */
+
+ // // this.setState({ selectedNavItemId: Navigation.Normalization });
+ // // history.push(ROUTES.NORMALIZATION);
+ // },
+ // isSelected: selectedNavItemId === Navigation.Normalization,
},
{
- name: Navigation.Correlations,
- id: Navigation.Correlations,
- onClick: () => {
- this.setState({ selectedNavItemId: Navigation.Correlations });
- history.push(ROUTES.CORRELATIONS);
- },
- renderItem: (props: any) => {
- return (
-
-
- {
- this.setState({ selectedNavItemId: Navigation.Correlations });
- history.push(ROUTES.CORRELATIONS);
- }}
- >
- {props.children}
-
-
-
- );
- },
- isSelected: selectedNavItemId === Navigation.Correlations,
+ name: Navigation.Detection,
+ id: Navigation.Detection,
forceOpen: true,
items: [
{
- name: Navigation.CorrelationRules,
- id: Navigation.CorrelationRules,
+ name: Navigation.Detectors,
+ id: Navigation.Detectors,
onClick: () => {
- this.setState({ selectedNavItemId: Navigation.CorrelationRules });
- history.push(ROUTES.CORRELATION_RULES);
+ // this.setState({ selectedNavItemId: Navigation.Detectors });
+ // history.push(ROUTES.DETECTORS);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ getApplication().navigateToApp(DETECTORS_NAV_ID, {
+ path: generateAppPath(ROUTES.DETECTORS),
+ });
+ // This fixes a navigation problem when viewing the detector details and using the sidebar menu to navigate to Detectors
+ if (history.location.pathname !== ROUTES.DETECTORS) {
+ history.push(ROUTES.DETECTORS);
+ }
},
- isSelected: selectedNavItemId === Navigation.CorrelationRules,
+ isSelected: selectedNavItemId === Navigation.Detectors,
},
+ {
+ name: Navigation.Rules,
+ id: Navigation.Rules,
+ onClick: () => {
+ // this.setState({ selectedNavItemId: Navigation.Rules });
+ // history.push(ROUTES.RULES);
+ // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ getApplication().navigateToApp(DETECTION_RULE_NAV_ID, {
+ path: generateAppPath(ROUTES.RULES),
+ });
+ },
+ isSelected: selectedNavItemId === Navigation.Rules,
+ },
+ // Wazuh: hide Correlation rules nav item.
+ // {
+ // name: Navigation.CorrelationRules,
+ // id: Navigation.CorrelationRules,
+ // onClick: () => {
+ // // this.setState({
+ // // selectedNavItemId: Navigation.CorrelationRules,
+ // // });
+ // // history.push(ROUTES.CORRELATION_RULES);
+ // // Wazuh: navigate to app so this is highlighted in the sidebar menu (old)
+ // getApplication().navigateToApp(CORRELATIONS_RULE_NAV_ID, {path: generateAppPath(ROUTES.CORRELATION_RULES)});
+ // },
+ // isSelected: selectedNavItemId === Navigation.CorrelationRules,
+ // },
],
},
+ {
+ name: Navigation.LogTest,
+ id: Navigation.LogTest,
+ onClick: () => {
+ getApplication().navigateToApp(LOG_TEST_NAV_ID, {
+ path: generateAppPath(ROUTES.LOG_TEST),
+ });
+ },
+ isSelected: selectedNavItemId === Navigation.LogTest,
+ },
],
},
];
@@ -451,6 +628,9 @@ export default class Main extends Component {
dataSourceMenuReadOnly,
} = this.state;
const sideNav: EuiSideNavItemType<{ style: any }>[] = this.getSideNavItems();
+ const isDecodersRoute = !!pathname.match(new RegExp(`^${ROUTES.DECODERS}`));
+ const showDataSourceMenu = multiDataSourceEnabled && !isDecodersRoute;
+ const shouldBlockForDataSource = dataSourceLoading && !isDecodersRoute;
const dataSourceContextValue: DataSourceContextType = {
dataSource: selectedDataSource,
setDataSource: this.onDataSourceSelected,
@@ -471,7 +651,7 @@ export default class Main extends Component {
{(_dataSource: DataSourceContextType | null) =>
_dataSource && (
<>
- {multiDataSourceEnabled && (
+ {showDataSourceMenu && (
{
dataSourceFilterFn={dataSourceFilterFn}
/>
)}
- {!dataSourceLoading && services && (
+ {!shouldBlockForDataSource && services && (
{/* Hide side navigation bar when on any HIDDEN_NAV_ROUTES pages. */}
{!HIDDEN_NAV_ROUTES.some((route) => pathname.match(route)) &&
@@ -507,10 +687,8 @@ export default class Main extends Component {
{...props}
setDateTimeFilter={this.setDateTimeFilter}
dateTimeFilter={this.state.dateTimeFilter}
- correlationService={services.correlationsService}
opensearchService={services.opensearchService}
detectorService={services.detectorsService}
- notificationsService={services.notificationsService}
indexPatternsService={services.indexPatternsService}
notifications={core?.notifications}
dataSource={selectedDataSource}
@@ -548,9 +726,8 @@ export default class Main extends Component {
path={ROUTES.RULES}
render={(props: RouteComponentProps) => (
)}
/>
@@ -564,39 +741,23 @@ export default class Main extends Component {
/>
)}
/>
+ {/* Wazuh: use rule id in route, instead of state */}
) => {
- if (!props.location.state?.ruleItem) {
- props.history.replace(ROUTES.RULES);
- return (
-
- );
- }
-
- return (
-
- );
- }}
+ path={`${ROUTES.RULES_EDIT}/:id`}
+ render={(props: RouteComponentProps<{ id: string }>) => (
+
+ )}
/>
- ) => {
if (!props.location.state?.ruleItem) {
props.history.replace(ROUTES.RULES);
return (
);
}
@@ -619,8 +780,8 @@ export default class Main extends Component {
notifications={core?.notifications}
/>
)}
- />
- */}
+ {/* (
{
/>
)}
/>
- )}
- (
)}
- />
+ /> */}
) => (
@@ -706,21 +882,41 @@ export default class Main extends Component {
/>
)}
/>
- ) => (
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => (
)}
- />
- */}
+ {/* Wazuh: hide Correlations and Correlation rules routes. */}
+ {/* ) => (
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => (
{
/>
) => (
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => (
)}
/>
) => (
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => (
)}
/>
) => {
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => {
return (
this.setState({
- selectedNavItemId: Navigation.Correlations,
+ selectedNavItemId:
+ Navigation.Correlations,
})
}
- dateTimeFilter={this.state.dateTimeFilter}
- setDateTimeFilter={this.setDateTimeFilter}
+ dateTimeFilter={
+ this.state.dateTimeFilter
+ }
+ setDateTimeFilter={
+ this.setDateTimeFilter
+ }
dataSource={selectedDataSource}
notifications={core?.notifications}
/>
);
}}
- />
+ /> */}
+ {/* Wazuh: hide Log Types routes. */}
+ {/*
) => (
-
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => (
+
)}
/>
) => {
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => {
return (
{
/>
) => {
+ render={(
+ props: RouteComponentProps<
+ any,
+ any,
+ any
+ >,
+ ) => {
return (
{
/>
);
}}
+ /> */}
+ ) => (
+
+ )}
/>
{
+ path={`${ROUTES.INTEGRATIONS}`}
+ render={(props: RouteComponentProps) => {
return (
-
);
}}
/>
{
+ path={ROUTES.FILTERS_CREATE}
+ render={(props: RouteComponentProps) => (
+
+ )}
+ />
+
+ ) => (
+
+ )}
+ />
+ ) => {
return (
-
);
}}
/>
) => {
return (
-
);
}}
/>
) => {
return (
-
);
}}
/>
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ ) => (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ ) => (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+ {THREAT_INTEL_ENABLED && (
+ <>
+ {
+ return (
+
+ );
+ }}
+ />
+ {
+ return (
+
+ );
+ }}
+ />
+ ) => {
+ return (
+
+ );
+ }}
+ />
+ ) => {
+ return (
+
+ );
+ }}
+ />
+ >
+ )}
diff --git a/public/pages/Overview/components/GettingStarted/GettingStartedContent.tsx b/public/pages/Overview/components/GettingStarted/GettingStartedContent.tsx
index f23b58e11..31522c314 100644
--- a/public/pages/Overview/components/GettingStarted/GettingStartedContent.tsx
+++ b/public/pages/Overview/components/GettingStarted/GettingStartedContent.tsx
@@ -12,9 +12,9 @@ import {
DETECTION_RULE_NAV_ID,
FINDINGS_NAV_ID,
ROUTES,
- THREAT_ALERTS_NAV_ID,
- THREAT_INTEL_NAV_ID,
- CORRELATIONS_RULE_NAV_ID,
+ // Wazuh: hide Alerts and Correlation rules steps.
+ // THREAT_ALERTS_NAV_ID,
+ // CORRELATIONS_RULE_NAV_ID,
} from '../../../../utils/constants';
import { RouteComponentProps } from 'react-router-dom';
import { GetStartedStep } from './GetStartedStep';
@@ -52,7 +52,9 @@ export const GettingStartedContent: React.FC = ({
children: (
= ({
/>
),
},
- {
+ /* {
title: 'Set up threat intelligence analytics',
children: (
= ({
]}
/>
),
- },
+ }, */
{
title: 'Discover security findings',
children: (
@@ -107,23 +109,24 @@ export const GettingStartedContent: React.FC = ({
/>
),
},
- {
- title: 'View security alerts',
- children: (
- {
- onStepClicked();
- onActionClick(THREAT_ALERTS_NAV_ID, ROUTES.ALERTS);
- },
- },
- ]}
- />
- ),
- },
+ // Wazuh: hide View alerts step.
+ // {
+ // title: 'View security alerts',
+ // children: (
+ // {
+ // onStepClicked();
+ // onActionClick(THREAT_ALERTS_NAV_ID, ROUTES.ALERTS);
+ // },
+ // },
+ // ]}
+ // />
+ // ),
+ // },
{
title: 'Create custom rules for detectors',
children: (
@@ -141,23 +144,24 @@ export const GettingStartedContent: React.FC = ({
/>
),
},
- {
- title: 'Set up correlation rules',
- children: (
- {
- onStepClicked();
- onActionClick(CORRELATIONS_RULE_NAV_ID, ROUTES.CORRELATION_RULES);
- },
- },
- ]}
- />
- ),
- },
+ // Wazuh: hide Correlation rules step.
+ // {
+ // title: 'Set up correlation rules',
+ // children: (
+ // {
+ // onStepClicked();
+ // onActionClick(CORRELATIONS_RULE_NAV_ID, ROUTES.CORRELATION_RULES);
+ // },
+ // },
+ // ]}
+ // />
+ // ),
+ // },
],
[onStepClicked]
);
diff --git a/public/pages/Overview/components/Widgets/RecentAlertsWidget.tsx b/public/pages/Overview/components/Widgets/RecentAlertsWidget.tsx
index 6ea5e8646..655400fdd 100644
--- a/public/pages/Overview/components/Widgets/RecentAlertsWidget.tsx
+++ b/public/pages/Overview/components/Widgets/RecentAlertsWidget.tsx
@@ -1,76 +1,79 @@
+// Wazuh: hide Recent Alerts widget from Overview.
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
+ *
+ * Original implementation retained for future upgrades:
+ *
+ * import { EuiBasicTableColumn, EuiSmallButton } from '@elastic/eui';
+ * import { ROUTES, SortDirection } from '../../../../utils/constants';
+ * import React, { useEffect, useState } from 'react';
+ * import { TableWidget } from './TableWidget';
+ * import { WidgetContainer } from './WidgetContainer';
+ * import { getAlertSeverityBadge, getEuiEmptyPrompt, renderTime } from '../../../../utils/helpers';
+ * import { OverviewAlertItem } from '../../../../../types';
+ *
+ * const columns: EuiBasicTableColumn[] = [
+ * {
+ * field: 'time',
+ * name: 'Time',
+ * sortable: true,
+ * align: 'left',
+ * render: renderTime,
+ * },
+ * {
+ * field: 'triggerName',
+ * name: 'Alert Trigger Name',
+ * sortable: false,
+ * align: 'left',
+ * },
+ * {
+ * field: 'severity',
+ * name: 'Alert severity',
+ * sortable: true,
+ * align: 'left',
+ * render: (severity: string) => getAlertSeverityBadge(severity),
+ * },
+ * ];
+ *
+ * export interface RecentAlertsWidgetProps {
+ * items: OverviewAlertItem[];
+ * loading?: boolean;
+ * }
+ *
+ * export const RecentAlertsWidget: React.FC = ({
+ * items,
+ * loading = false,
+ * }) => {
+ * const [alertItems, setAlertItems] = useState([]);
+ *
+ * useEffect(() => {
+ * items.sort((a, b) => {
+ * const timeA = new Date(a.time).getTime();
+ * const timeB = new Date(b.time).getTime();
+ * return timeB - timeA;
+ * });
+ * setAlertItems(items.slice(0, 20));
+ * }, [items]);
+ *
+ * const actions = React.useMemo(
+ * () => [View Alerts ],
+ * []
+ * );
+ *
+ * return (
+ *
+ * {alertItems.length === 0 ? (
+ * getEuiEmptyPrompt('No recent alerts.')
+ * ) : (
+ *
+ * )}
+ *
+ * );
+ * };
*/
-
-import { EuiBasicTableColumn, EuiSmallButton } from '@elastic/eui';
-import { ROUTES, SortDirection } from '../../../../utils/constants';
-import React, { useEffect, useState } from 'react';
-import { TableWidget } from './TableWidget';
-import { WidgetContainer } from './WidgetContainer';
-import { getAlertSeverityBadge, getEuiEmptyPrompt, renderTime } from '../../../../utils/helpers';
-import { OverviewAlertItem } from '../../../../../types';
-
-const columns: EuiBasicTableColumn[] = [
- {
- field: 'time',
- name: 'Time',
- sortable: true,
- align: 'left',
- render: renderTime,
- },
- {
- field: 'triggerName',
- name: 'Alert Trigger Name',
- sortable: false,
- align: 'left',
- },
- {
- field: 'severity',
- name: 'Alert severity',
- sortable: true,
- align: 'left',
- render: (severity: string) => getAlertSeverityBadge(severity),
- },
-];
-
-export interface RecentAlertsWidgetProps {
- items: OverviewAlertItem[];
- loading?: boolean;
-}
-
-export const RecentAlertsWidget: React.FC = ({
- items,
- loading = false,
-}) => {
- const [alertItems, setAlertItems] = useState([]);
-
- useEffect(() => {
- items.sort((a, b) => {
- const timeA = new Date(a.time).getTime();
- const timeB = new Date(b.time).getTime();
- return timeB - timeA;
- });
- setAlertItems(items.slice(0, 20));
- }, [items]);
-
- const actions = React.useMemo(
- () => [View Alerts ],
- []
- );
-
- return (
-
- {alertItems.length === 0 ? (
- getEuiEmptyPrompt('No recent alerts.')
- ) : (
-
- )}
-
- );
-};
diff --git a/public/pages/Overview/components/Widgets/RecentFindingsWidget.tsx b/public/pages/Overview/components/Widgets/RecentFindingsWidget.tsx
index 96edb94b0..c8718bd86 100644
--- a/public/pages/Overview/components/Widgets/RecentFindingsWidget.tsx
+++ b/public/pages/Overview/components/Widgets/RecentFindingsWidget.tsx
@@ -67,7 +67,9 @@ export const RecentFindingsWidget: React.FC = ({
}, []);
return (
-
+
+ {' '}
+ {/* Wazuh: rename 'detection rule' to 'rule' */}
{findingItems.length === 0 ? (
getEuiEmptyPrompt('No recent findings.')
) : (
diff --git a/public/pages/Overview/components/Widgets/Summary.tsx b/public/pages/Overview/components/Widgets/Summary.tsx
index 80b9287a5..6ed209999 100644
--- a/public/pages/Overview/components/Widgets/Summary.tsx
+++ b/public/pages/Overview/components/Widgets/Summary.tsx
@@ -16,35 +16,28 @@ import {
import React, { useCallback, useEffect, useState } from 'react';
import { WidgetContainer } from './WidgetContainer';
import { summaryGroupByOptions } from '../../utils/constants';
-import { TimeUnit } from '../../utils/helpers';
import { createSelectComponent } from '../../../../utils/helpers';
import { ROUTES } from '../../../../utils/constants';
-import { OverviewAlertItem, OverviewFindingItem } from '../../../../../types';
+import { OverviewFindingItem } from '../../../../../types';
+// Wazuh: hide alerts in summary widget.
+// import { OverviewAlertItem } from '../../../../../types';
import { getUseUpdatedUx } from '../../../../services/utils/constants';
// import { createBarAndLineChartWrapper } from '../../../../utils/chartUtils';
export interface SummaryProps {
findings: OverviewFindingItem[];
- alerts: OverviewAlertItem[];
- loading?: boolean;
- startTime: string;
- endTime: string;
- timeUnit: TimeUnit;
+ // Wazuh: hide alerts in summary widget.
+ // alerts: OverviewAlertItem[];
}
export const SUMMARY_VIEW_CHART = 'summary-view';
-export const Summary: React.FC = ({
- alerts,
- findings,
- startTime,
- endTime,
- loading = false,
-}) => {
+export const Summary: React.FC = ({ findings }) => {
const [groupBy, setGroupBy] = useState('');
- const [alertsVisData, setAlertsVisData] = useState([]);
- const [findingsVisData, setFindingsVisData] = useState([]);
- const [activeAlerts, setActiveAlerts] = useState(undefined);
+ // Wazuh: hide alerts in summary widget.
+ // const [alertsVisData, setAlertsVisData] = useState([]);
+ // Wazuh: hide alerts in summary widget.
+ // const [activeAlerts, setActiveAlerts] = useState(undefined);
const [totalFindings, setTotalFindings] = useState(undefined);
const onGroupByChange = useCallback((event) => {
@@ -66,35 +59,38 @@ export const Summary: React.FC = ({
);
useEffect(() => {
- const alertsVisData: any[] = [];
- let activeAlerts = 0;
- alerts.forEach((alert) => {
- if (!alert.acknowledged) {
- activeAlerts++;
- }
+ // Wazuh: hide alerts in summary widget.
+ // const alertsVisData: any[] = [];
+ // let activeAlerts = 0;
+ // alerts.forEach((alert) => {
+ // if (!alert.acknowledged) {
+ // activeAlerts++;
+ // }
+ //
+ // alertsVisData.push({
+ // time: alert.time,
+ // alert: 1,
+ // finding: 0,
+ // logType: alert.logType,
+ // });
+ // });
- alertsVisData.push({
- time: alert.time,
- alert: 1,
- finding: 0,
- logType: alert.logType,
- });
- });
+ // Wazuh: hide alerts in summary widget.
+ // const findingsVisData: any[] = [];
+ // findings.forEach((finding) => {
+ // findingsVisData.push({
+ // time: finding.time,
+ // alert: 0,
+ // finding: 1,
+ // logType: finding.logType,
+ // });
+ // });
- const findingsVisData: any[] = [];
- findings.forEach((finding) => {
- findingsVisData.push({
- time: finding.time,
- alert: 0,
- finding: 1,
- logType: finding.logType,
- });
- });
-
- setActiveAlerts(activeAlerts);
+ // setActiveAlerts(activeAlerts);
setTotalFindings(findings.length);
- setAlertsVisData(alertsVisData);
- setFindingsVisData(findingsVisData);
+ // Wazuh: hide alerts in summary widget.
+ // setAlertsVisData(alertsVisData);
+ // setFindingsVisData(findingsVisData);
}, [alerts, findings]);
// useEffect(() => {
@@ -129,17 +125,20 @@ export const Summary: React.FC = ({
);
return (
-
+
+ {/* Wazuh: hide alerts in summary title. */}
+ {/* */}
{!getUseUpdatedUx() && (
- {activeAlerts === 0 && totalFindings === 0 ? null : (
+ {totalFindings === 0 ? null : (
- {createStatComponent(
+ {/* Wazuh: hide alerts in summary widget. */}
+ {/* {createStatComponent(
'Total active alerts',
{ url: ROUTES.ALERTS, color: 'danger' },
activeAlerts
- )}
+ )} */}
{createStatComponent(
'Total findings',
{ url: ROUTES.FINDINGS, color: 'primary' },
@@ -150,11 +149,13 @@ export const Summary: React.FC = ({
)}
- {activeAlerts === 0 && totalFindings === 0 ? (
+ {totalFindings === 0 ? (
- No alerts and findings found
+ No findings found
+ {/* Wazuh: hide alerts in empty prompt copy. */}
+ {/* No alerts and findings found */}
}
body={
diff --git a/public/pages/Overview/components/Widgets/TopRulesWidget.tsx b/public/pages/Overview/components/Widgets/TopRulesWidget.tsx
index 1b0407831..00a7daf1c 100644
--- a/public/pages/Overview/components/Widgets/TopRulesWidget.tsx
+++ b/public/pages/Overview/components/Widgets/TopRulesWidget.tsx
@@ -35,9 +35,11 @@ export const TopRulesWidget: React.FC = ({ findings, loadin
}, [findings]);
return (
-
+
+ {' '}
+ {/* Wazuh: rename 'detection rules' to 'rules' */}
{findings.length === 0 ? (
- getEuiEmptyPrompt('No findings with detection rules.')
+ getEuiEmptyPrompt('No findings with rules.') // Wazuh: rename 'detection rules' to 'rules'
) : (
diff --git a/public/pages/Overview/containers/Overview/Overview.tsx b/public/pages/Overview/containers/Overview/Overview.tsx
index d2fc4d690..d2610dbd3 100644
--- a/public/pages/Overview/containers/Overview/Overview.tsx
+++ b/public/pages/Overview/containers/Overview/Overview.tsx
@@ -4,13 +4,10 @@
*/
import {
- EuiSmallButtonEmpty,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
- EuiPopover,
EuiSmallButton,
- EuiCard,
EuiPanel,
EuiStat,
EuiText,
@@ -24,20 +21,21 @@ import {
ROUTES,
} from '../../../../utils/constants';
import { CoreServicesContext } from '../../../../../public/components/core_services';
-import { RecentAlertsWidget } from '../../components/Widgets/RecentAlertsWidget';
+// Wazuh: hide Recent Alerts widget from Overview.
+// import { RecentAlertsWidget } from '../../components/Widgets/RecentAlertsWidget';
import { RecentFindingsWidget } from '../../components/Widgets/RecentFindingsWidget';
import { OverviewViewModelActor } from '../../models/OverviewViewModel';
import { SecurityAnalyticsContext } from '../../../../services';
-import { Summary } from '../../components/Widgets/Summary';
-import { TopRulesWidget } from '../../components/Widgets/TopRulesWidget';
-import { GettingStartedContent } from '../../components/GettingStarted/GettingStartedContent';
-import { getChartTimeUnit, TimeUnit } from '../../utils/helpers';
+// Wazuh: hide Summary widget (alerts-focused).
+// import { Summary } from '../../components/Widgets/Summary';
import { OverviewProps, OverviewState, OverviewViewModel } from '../../../../../types';
import { setBreadcrumbs } from '../../../../utils/helpers';
import { PageHeader } from '../../../../components/PageHeader/PageHeader';
-import { getOverviewStatsProps, getOverviewsCardsProps } from '../../utils/constants';
+import { getOverviewStatsProps } from '../../utils/constants';
+// Wazuh: hide overview getting started cards.
+// import { getOverviewsCardsProps } from '../../utils/constants';
import { getChrome, getUseUpdatedUx } from '../../../../services/utils/constants';
-import { RecentThreatIntelFindingsWidget } from '../../components/Widgets/RecentThreatIntelFindingsWidget';
+// import { RecentThreatIntelFindingsWidget } from '../../components/Widgets/RecentThreatIntelFindingsWidget';
import { useObservable } from 'react-use';
import { SECURITY_ANALYTICS_USE_CASE_ID } from '../../../../../../../src/core/public';
@@ -48,25 +46,28 @@ export const Overview: React.FC
= (props) => {
endTime: DEFAULT_DATE_RANGE.end,
},
} = props;
-
- const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ // Getting started section hidden by wazuh
+ // const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [initialLoadingFinished, setInitialLoadingFinished] = useState(false);
const [state, setState] = useState({
groupBy: 'finding',
overviewViewModel: {
detectors: [],
findings: [],
- alerts: [],
+ // Wazuh: hide alerts and correlations from overview view model.
+ // alerts: [],
threatIntelFindings: [],
- correlations: 0,
+ // Wazuh: hide alerts and correlations from overview view model.
+ // correlations: 0,
},
});
const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([DEFAULT_DATE_RANGE]);
const [loading, setLoading] = useState(true);
- const timeUnits = getChartTimeUnit(dateTimeFilter.startTime, dateTimeFilter.endTime);
- const [timeUnit, setTimeUnit] = useState(timeUnits.timeUnit);
+ // Wazuh: hide Summary widget (alerts-focused).
+ // const timeUnits = getChartTimeUnit(dateTimeFilter.startTime, dateTimeFilter.endTime);
+ // const [timeUnit, setTimeUnit] = useState(timeUnits.timeUnit);
const context = useContext(CoreServicesContext);
const saContext = useContext(SecurityAnalyticsContext);
@@ -104,11 +105,16 @@ export const Overview: React.FC = (props) => {
const isSecurityAnalyticsUseCase = currentNavGroup?.id === SECURITY_ANALYTICS_USE_CASE_ID;
useEffect(() => {
+ // Breadcrumbs set to 'Overview' for Wazuh
setBreadcrumbs(
- isSecurityAnalyticsUseCase
- ? [BREADCRUMBS.OVERVIEW]
- : [{ ...BREADCRUMBS.OVERVIEW, text: 'Security Analytics overview' }]
- );
+ [BREADCRUMBS.OVERVIEW]
+ )
+
+ // setBreadcrumbs(
+ // isSecurityAnalyticsUseCase
+ // ? [BREADCRUMBS.OVERVIEW]
+ // : [{ ...BREADCRUMBS.OVERVIEW, text: 'Security Analytics overview' }]
+ // );
overviewViewModelActor.registerRefreshHandler(updateState, true /* allowPartialResults */);
overviewViewModelActor.registerRefreshHandler(
onLoadingComplete,
@@ -138,15 +144,15 @@ export const Overview: React.FC = (props) => {
};
}, [dateTimeFilter.startTime, dateTimeFilter.endTime]);
- useEffect(() => {
- if (
- !props.getStartedDismissedOnce &&
- initialLoadingFinished &&
- state.overviewViewModel.detectors.length === 0
- ) {
- setIsPopoverOpen(true);
- }
- }, [initialLoadingFinished, state.overviewViewModel, props.getStartedDismissedOnce]);
+ // useEffect(() => {
+ // if (
+ // !props.getStartedDismissedOnce &&
+ // initialLoadingFinished &&
+ // state.overviewViewModel.detectors.length === 0
+ // ) {
+ // setIsPopoverOpen(true);
+ // }
+ // }, [initialLoadingFinished, state.overviewViewModel, props.getStartedDismissedOnce]);
const onTimeChange = async ({ start, end }: { start: string; end: string }) => {
let usedRanges = recentlyUsedRanges.filter(
@@ -157,13 +163,15 @@ export const Overview: React.FC = (props) => {
usedRanges = usedRanges.slice(0, MAX_RECENTLY_USED_TIME_RANGES);
const endTime = start === end ? DEFAULT_DATE_RANGE.end : end;
- const timeUnits = getChartTimeUnit(start, endTime);
+ // Wazuh: hide Summary widget (alerts-focused).
+ // const timeUnits = getChartTimeUnit(start, endTime);
props.setDateTimeFilter &&
props.setDateTimeFilter({
startTime: start,
endTime: endTime,
});
+ // Wazuh: hide Summary widget (alerts-focused).
setTimeUnit(timeUnits.timeUnit);
setRecentlyUsedRanges(usedRanges);
};
@@ -186,18 +194,18 @@ export const Overview: React.FC = (props) => {
};
}, [props.dataSource]);
- const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen);
+ // const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen);
- const closePopover = () => {
- setIsPopoverOpen(false);
- props.onGetStartedDismissed();
- };
+ // const closePopover = () => {
+ // setIsPopoverOpen(false);
+ // props.onGetStartedDismissed();
+ // };
- const button = (
-
- Getting started
-
- );
+ // const button = (
+ //
+ // Getting started
+ //
+ // );
const datePicker = (
= (props) => {
);
- const gettingStartedBadgeControl = (
-
-
-
- );
+ // const gettingStartedBadgeControl = (
+ //
+ //
+ //
+ // );
const overviewStats = {
- alerts: state.overviewViewModel.alerts.filter((a) => !a.acknowledged).length,
- correlations: state.overviewViewModel.correlations,
+ // Wazuh: hide alerts and correlations from overview stats.
+ // alerts: state.overviewViewModel.alerts.filter((a) => !a.acknowledged).length,
+ // correlations: state.overviewViewModel.correlations,
ruleFindings: state.overviewViewModel.findings.length,
threatIntelFindings: state.overviewViewModel.threatIntelFindings.length,
};
@@ -255,14 +264,14 @@ export const Overview: React.FC = (props) => {
{ renderComponent: createDetectorAction },
]}
>
-
+
Overview
- {gettingStartedBadgeControl}
+ {/* {gettingStartedBadgeControl} */}
{datePicker}
{createDetectorAction}
@@ -272,7 +281,9 @@ export const Overview: React.FC = (props) => {
<>
- {getOverviewsCardsProps().map((p, idx) => (
+ {/* Wazuh hides overview cards */}
+ {/*
+ {getOverviewsCardsProps().map((p, idx) => (
= (props) => {
titleSize="s"
/>
- ))}
+ ))} */}
@@ -298,26 +309,28 @@ export const Overview: React.FC = (props) => {
>
)}
- {/**/}
- {/* */}
- {/* */}
-
+ {/* Wazuh: hide Summary widget (alerts-focused). */}
+ {/*
+
+ */}
-
+ {/* Wazuh: hide Recent Alerts widget from Overview. */}
+ {/* */}
{/* */}
-
+ /> */}
diff --git a/public/pages/Overview/models/OverviewViewModel.ts b/public/pages/Overview/models/OverviewViewModel.ts
index d9d8397ae..b80407414 100644
--- a/public/pages/Overview/models/OverviewViewModel.ts
+++ b/public/pages/Overview/models/OverviewViewModel.ts
@@ -11,10 +11,12 @@ import { errorNotificationToast, getDuration, isThreatIntelQuery } from '../../.
import dateMath from '@elastic/datemath';
import moment from 'moment';
import { DataStore } from '../../../store/DataStore';
+import { THREAT_INTEL_ENABLED } from '../../../utils/constants';
import {
DetectorHit,
Finding,
- OverviewAlertItem,
+ // Wazuh: hide alerts and correlations in overview view model.
+ // OverviewAlertItem,
OverviewFindingItem,
OverviewViewModel,
OverviewViewModelRefreshHandler,
@@ -25,9 +27,11 @@ export class OverviewViewModelActor {
private overviewViewModel: OverviewViewModel = {
detectors: [],
findings: [],
- alerts: [],
+ // Wazuh: hide alerts and correlations in overview view model.
+ // alerts: [],
threatIntelFindings: [],
- correlations: 0,
+ // Wazuh: hide alerts and correlations in overview view model.
+ // correlations: 0,
};
private partialUpdateHandlers: OverviewViewModelRefreshHandler[] = [];
private fullUpdateHandlers: OverviewViewModelRefreshHandler[] = [];
@@ -141,40 +145,46 @@ export class OverviewViewModelActor {
this.overviewViewModel.findings = this.filterChartDataByTime(findingItems);
}
- private async updateAlerts(signal: AbortSignal) {
- let alertItems: OverviewAlertItem[] = [];
- const duration = getDuration({
- startTime: this.startTime,
- endTime: this.endTime,
- });
+ // Wazuh: hide alerts in overview view model.
+ // private async updateAlerts(signal: AbortSignal) {
+ // let alertItems: OverviewAlertItem[] = [];
+ // const duration = getDuration({
+ // startTime: this.startTime,
+ // endTime: this.endTime,
+ // });
+ //
+ // try {
+ // for (let detector of this.overviewViewModel.detectors) {
+ // const id = detector._id;
+ // const detectorAlerts = await DataStore.alerts.getAlertsByDetector(
+ // id,
+ // detector._source.name,
+ // signal,
+ // duration
+ // );
+ // const detectorAlertItems: OverviewAlertItem[] = detectorAlerts.map((alert) => ({
+ // id: alert.id,
+ // severity: alert.severity,
+ // time: alert.last_notification_time,
+ // triggerName: alert.trigger_name,
+ // logType: detector._source.detector_type,
+ // acknowledged: !!alert.acknowledged_time,
+ // }));
+ // alertItems = alertItems.concat(detectorAlertItems);
+ // }
+ // } catch (e: any) {
+ // errorNotificationToast(this.notifications, 'retrieve', 'alerts', e);
+ // }
+ //
+ // this.overviewViewModel.alerts = this.filterChartDataByTime(alertItems);
+ // }
- try {
- for (let detector of this.overviewViewModel.detectors) {
- const id = detector._id;
- const detectorAlerts = await DataStore.alerts.getAlertsByDetector(
- id,
- detector._source.name,
- signal,
- duration
- );
- const detectorAlertItems: OverviewAlertItem[] = detectorAlerts.map((alert) => ({
- id: alert.id,
- severity: alert.severity,
- time: alert.last_notification_time,
- triggerName: alert.trigger_name,
- logType: detector._source.detector_type,
- acknowledged: !!alert.acknowledged_time,
- }));
- alertItems = alertItems.concat(detectorAlertItems);
- }
- } catch (e: any) {
- errorNotificationToast(this.notifications, 'retrieve', 'alerts', e);
+ private async updateThreatIntelFindings(signal: AbortSignal) {
+ if (!THREAT_INTEL_ENABLED) {
+ this.overviewViewModel.threatIntelFindings = [];
+ return;
}
- this.overviewViewModel.alerts = this.filterChartDataByTime(alertItems);
- }
-
- private async updateThreatIntelFindings(signal: AbortSignal) {
let tIFindings: ThreatIntelFinding[] = [];
const duration = getDuration({
startTime: this.startTime,
@@ -193,24 +203,25 @@ export class OverviewViewModelActor {
);
}
- private async updateCorrelationsCount() {
- let count = 0;
- const duration = getDuration({
- startTime: this.startTime,
- endTime: this.endTime,
- });
-
- try {
- count = await DataStore.correlations.getCorrelationsCountInWindow(
- duration.startTime.toString(),
- duration.endTime.toString()
- );
- } catch (e: any) {
- errorNotificationToast(this.notifications, 'retrieve', 'correlation count', e);
- }
-
- this.overviewViewModel.correlations = count;
- }
+ // Wazuh: hide correlations count in overview view model.
+ // private async updateCorrelationsCount() {
+ // let count = 0;
+ // const duration = getDuration({
+ // startTime: this.startTime,
+ // endTime: this.endTime,
+ // });
+ //
+ // try {
+ // count = await DataStore.correlations.getCorrelationsCountInWindow(
+ // duration.startTime.toString(),
+ // duration.endTime.toString()
+ // );
+ // } catch (e: any) {
+ // errorNotificationToast(this.notifications, 'retrieve', 'correlation count', e);
+ // }
+ //
+ // this.overviewViewModel.correlations = count;
+ // }
public getOverviewViewModel() {
return this.overviewViewModel;
@@ -248,18 +259,20 @@ export class OverviewViewModelActor {
await this.updateFindings(signal);
this.updateResults(this.partialUpdateHandlers, false);
},
- async (signal: AbortSignal) => {
- await this.updateAlerts(signal);
- this.updateResults(this.partialUpdateHandlers, false);
- },
+ // Wazuh: hide alerts in overview refresh flow.
+ // async (signal: AbortSignal) => {
+ // await this.updateAlerts(signal);
+ // this.updateResults(this.partialUpdateHandlers, false);
+ // },
async (signal: AbortSignal) => {
await this.updateThreatIntelFindings(signal);
this.updateResults(this.partialUpdateHandlers, false);
},
- async (_signal: AbortSignal) => {
- await this.updateCorrelationsCount();
- this.updateResults(this.partialUpdateHandlers, false);
- },
+ // Wazuh: hide correlations in overview refresh flow.
+ // async (_signal: AbortSignal) => {
+ // await this.updateCorrelationsCount();
+ // this.updateResults(this.partialUpdateHandlers, false);
+ // },
],
signal
);
diff --git a/public/pages/Overview/utils/constants.ts b/public/pages/Overview/utils/constants.ts
index 071e6e595..ddb1fd412 100644
--- a/public/pages/Overview/utils/constants.ts
+++ b/public/pages/Overview/utils/constants.ts
@@ -9,6 +9,7 @@ import {
DETECTORS_NAV_ID,
GET_STARTED_NAV_ID,
THREAT_INTEL_NAV_ID,
+ THREAT_INTEL_ENABLED,
} from '../../../utils/constants';
import { getApplication } from '../../../services/utils/constants';
@@ -19,71 +20,85 @@ export const summaryGroupByOptions = [
export const moreLink = 'https://opensearch.org/docs/latest/security-analytics/';
-export const getOverviewsCardsProps = (): EuiCardProps[] => [
- {
- icon: React.createElement(EuiIcon, { type: 'rocket', size: "l", color: "primary" }),
- title: '',
- description: 'Configure Security Analytics tools and components to get started.',
- onClick: () => {
- getApplication().navigateToApp(GET_STARTED_NAV_ID);
- },
- footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Get started guide'),
- className: 'usecaseOverviewGettingStartedCard',
- },
- {
- icon: React.createElement(EuiIcon, { type: 'compass', size: "l", color: "primary" }),
- title: '',
- description: 'Explore data to uncover and discover insights.',
- onClick: () => {
- getApplication().navigateToApp('discover');
+export const getOverviewsCardsProps = (): EuiCardProps[] => {
+ const cards: EuiCardProps[] = [
+ {
+ icon: React.createElement(EuiIcon, { type: 'rocket', size: 'l', color: 'primary' }),
+ title: '',
+ description: 'Configure Security Analytics tools and components to get started.',
+ onClick: () => {
+ getApplication().navigateToApp(GET_STARTED_NAV_ID);
+ },
+ footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Get started guide'),
+ className: 'usecaseOverviewGettingStartedCard',
},
- footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Discover'),
- className: 'usecaseOverviewGettingStartedCard',
- },
- {
- icon: React.createElement(EuiIcon, { type: 'pulse', size: "l", color: "primary" }),
- title: '',
- description: 'Identify security threats in your log data with detection rules.',
- onClick: () => {
- getApplication().navigateToApp(DETECTORS_NAV_ID);
+ {
+ icon: React.createElement(EuiIcon, { type: 'compass', size: 'l', color: 'primary' }),
+ title: '',
+ description: 'Explore data to uncover and discover insights.',
+ onClick: () => {
+ getApplication().navigateToApp('discover');
+ },
+ footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Discover'),
+ className: 'usecaseOverviewGettingStartedCard',
},
- footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Threat detection'),
- className: 'usecaseOverviewGettingStartedCard',
- },
- {
- icon: React.createElement(EuiIcon, { type: 'radar', size: "l", color: "primary" }),
- title: '',
- description: 'Scan your log data for malicious actors from known indicators of compromise.',
- onClick: () => {
- getApplication().navigateToApp(THREAT_INTEL_NAV_ID);
+ {
+ icon: React.createElement(EuiIcon, { type: 'pulse', size: 'l', color: 'primary' }),
+ title: '',
+ description: 'Identify security threats in your log data with rules.', // Wazuh: rename 'detection rules' to 'rules'
+ onClick: () => {
+ getApplication().navigateToApp(DETECTORS_NAV_ID);
+ },
+ footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Threat detection'),
+ className: 'usecaseOverviewGettingStartedCard',
},
- footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Threat intelligence'),
- className: 'usecaseOverviewGettingStartedCard',
- },
-];
+ ];
+
+ if (THREAT_INTEL_ENABLED) {
+ cards.push({
+ icon: React.createElement(EuiIcon, { type: 'radar', size: 'l', color: 'primary' }),
+ title: '',
+ description: 'Scan your log data for malicious actors from known indicators of compromise.',
+ onClick: () => {
+ getApplication().navigateToApp(THREAT_INTEL_NAV_ID);
+ },
+ footer: React.createElement(EuiTextColor, { color: 'subdued' }, 'Threat intelligence'),
+ className: 'usecaseOverviewGettingStartedCard',
+ });
+ }
+
+ return cards;
+};
export const getOverviewStatsProps = ({
- alerts,
- correlations,
+ // Wazuh: hide alerts and correlations from overview stats.
+ // alerts,
+ // correlations,
ruleFindings,
threatIntelFindings,
}: any): EuiStatProps[] => {
- return [
- {
- title: alerts,
- description: 'Total active threat alerts',
- },
- {
- title: correlations,
- description: 'Correlations',
- },
+ const stats: EuiStatProps[] = [
+ // Wazuh: hide alerts and correlations from overview stats.
+ // {
+ // title: alerts,
+ // description: 'Total active threat alerts',
+ // },
+ // {
+ // title: correlations,
+ // description: 'Correlations',
+ // },
{
title: ruleFindings,
- description: 'Detection rule findings',
+ description: 'Rule findings', // Wazuh: rename 'Detection rule' to 'Rule'
},
- {
+ ];
+
+ if (THREAT_INTEL_ENABLED) {
+ stats.push({
title: threatIntelFindings,
description: 'Threat intel findings',
- },
- ];
+ });
+ }
+
+ return stats;
};
diff --git a/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx
index 4073f771a..d62187766 100644
--- a/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx
+++ b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx
@@ -16,11 +16,13 @@ import {
EuiText,
EuiButtonGroup,
} from '@elastic/eui';
+import { EnabledHealth } from '../../../../components/Utility/EnabledHealth';
import { DEFAULT_EMPTY_DATA } from '../../../../utils/constants';
import React, { useState } from 'react';
import { RuleContentYamlViewer } from './RuleContentYamlViewer';
import { RuleItemInfoBase } from '../../../../../types';
import { getLogTypeLabel } from '../../../LogTypes/utils/helpers';
+import { getSeverityBadge } from '../../../../utils/helpers';
export interface RuleContentViewerProps {
rule: RuleItemInfoBase;
@@ -35,6 +37,10 @@ const editorTypes = [
id: 'yaml',
label: 'YAML',
},
+ {
+ id: 'json',
+ label: 'JSON',
+ },
];
export const RuleContentViewer: React.FC = ({
@@ -64,10 +70,13 @@ export const RuleContentViewer: React.FC = ({
Rule Name
- {ruleData.title}
+
+ {ruleData.title}
+
- Log Type
+ {/* Replace Log type to Integration by Wazuh */}
+ Integration
{getLogTypeLabel(ruleData.category)}
@@ -84,7 +93,7 @@ export const RuleContentViewer: React.FC = ({
- Last Updated
+ Modified
{ruleData.last_update_time}
@@ -97,7 +106,7 @@ export const RuleContentViewer: React.FC = ({
- Source
+ Space
{prePackaged ? 'Standard' : 'Custom'}
{prePackaged ? (
@@ -120,7 +129,7 @@ export const RuleContentViewer: React.FC = ({
Rule level
- {ruleData.level}
+ {getSeverityBadge(ruleData.level)}
@@ -193,9 +202,7 @@ export const RuleContentViewer: React.FC = ({
{ruleData.false_positives.length > 0 ? (
ruleData.false_positives.map((falsepositive: any, i: number) => (
-
- {falsepositive.value}
-
+ {falsepositive.value}
))
@@ -225,6 +232,11 @@ export const RuleContentViewer: React.FC = ({
)}
+ {selectedEditorType === 'json' && (
+
+ {JSON.stringify(ruleData, null, 2)}
+
+ )}
);
};
diff --git a/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap
index 5bb740d21..c201daf6b 100644
--- a/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap
+++ b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap
@@ -68,6 +68,30 @@ exports[` spec renders the component 1`] = `
+
+
+
+
+ JSON
+
+
+
spec renders the component 1`] = `
- Log Type
+ Integration
spec renders the component 1`] = `
- Last Updated
+ Modified
spec renders the component 1`] = `
- Source
+ Space
spec renders the component 1`] = `
>
Rule level
-
- high
+
+
+
+
+ High
+
+
+
diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx
index 6486a68f5..e02ff4e55 100644
--- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx
+++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx
@@ -16,6 +16,7 @@ import {
EuiFlexItem,
EuiCompressedFormRow,
EuiCompressedFieldText,
+ EuiCompressedFieldNumber,
EuiCompressedComboBox,
EuiPanel,
EuiCompressedRadioGroup,
@@ -87,8 +88,18 @@ const detectionModifierOptions = [
{ value: 'endswith', label: 'endswith' },
{ value: 'startswith', label: 'startswith' },
{ value: 'cidr', label: 'cidr' },
+ { value: 'base64offset', label: 'base64offset' },
+ { value: 'wide', label: 'wide' },
+ { value: 'windash', label: 'windash' },
+ { value: 're', label: 're' },
+ { value: 'lt', label: 'lt' },
+ { value: 'lte', label: 'lte' },
+ { value: 'gt', label: 'gt' },
+ { value: 'gte', label: 'gte' },
];
+const NUMERIC_MODIFIERS = new Set(['lt', 'lte', 'gt', 'gte']);
+
const defaultDetectionObj: DetectionObject = {
condition: 'Selection_1',
selections: [
@@ -257,7 +268,13 @@ export class DetectionVisualEditor extends React.Component<
selectionMaps = datum.values[0] || '';
} else if (datum.field) {
const key = `${datum.field}${datum.modifier ? `|${datum.modifier}` : ''}`;
- selectionMaps[key] = datum.values;
+ if (NUMERIC_MODIFIERS.has(datum.modifier ?? '')) {
+ const raw = datum.values[0];
+ const parsed = Number(raw);
+ selectionMaps[key] = !isNaN(parsed) ? parsed : raw;
+ } else {
+ selectionMaps[key] = datum.values;
+ }
} else {
selectionMaps = datum.values;
}
@@ -288,8 +305,15 @@ export class DetectionVisualEditor extends React.Component<
if ('values' in data) {
const valueId = `value_${selIdx}_${idx}`;
delete errors.fields[valueId];
- if (data.values.length === 1 && !data.values[0]) {
+ if (data.values.length === 1 && !String(data.values[0]).trim()) {
errors.fields[valueId] = 'Value is required';
+ } else if (NUMERIC_MODIFIERS.has(data.modifier ?? '')) {
+ const raw = String(data.values[0]).trim();
+ const isValidNumber = !isNaN(Number(raw)) && raw !== '';
+ if (!isValidNumber) {
+ errors.fields[valueId] =
+ 'This modifier requires a numeric value (e.g. 42, 3.14)';
+ }
}
errors.touched[valueId] = true;
}
@@ -383,7 +407,7 @@ export class DetectionVisualEditor extends React.Component<
private validateCondition = (value: string) => {
const { errors } = this.state;
- value = value.trim();
+ value = String(value).trim(); // Wazuh: ensure value is string
delete errors.fields['condition'];
if (!value) {
errors.fields['condition'] = 'Condition is required';
@@ -554,6 +578,7 @@ export class DetectionVisualEditor extends React.Component<
const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx);
const fieldName = `field_${selectionIdx}_${idx}`;
const valueId = `value_${selectionIdx}_${idx}`;
+ const isNumeric = NUMERIC_MODIFIERS.has(datum.modifier ?? '');
return (
{idx > 0 && }
@@ -632,19 +657,45 @@ export class DetectionVisualEditor extends React.Component<
- {
- this.updateDatumInState(selectionIdx, idx, {
- selectedRadioId: id as SelectionMapValueRadioId,
- });
- }}
- />
-
-
+ {/* Hide Value/List toggle for numeric modifiers β they only accept a scalar */}
+ {!isNumeric && (
+ <>
+ {
+ this.updateDatumInState(selectionIdx, idx, {
+ selectedRadioId: id as SelectionMapValueRadioId,
+ });
+ }}
+ />
+
+ >
+ )}
- {datum.selectedRadioId?.includes('list') ? (
+ {isNumeric ? (
+
+ {
+ this.updateDatumInState(selectionIdx, idx, {
+ values: [e.target.value, ...datum.values.slice(1)],
+ });
+ }}
+ onBlur={(e) => {
+ this.updateDatumInState(selectionIdx, idx, {
+ values: [e.target.value, ...datum.values.slice(1)],
+ });
+ }}
+ value={datum.values[0] as string}
+ />
+
+ ) : datum.selectedRadioId?.includes('list') ? (
<>
@@ -823,7 +874,7 @@ export class DetectionVisualEditor extends React.Component<
mode="yaml"
width="600px"
height="50px"
- value={this.state.detectionObj.condition}
+ value={String(this.state.detectionObj.condition)}
onChange={(value) => this.updateCondition(value)}
onBlur={(e) => {
this.updateCondition(this.state.detectionObj.condition);
@@ -887,4 +938,4 @@ export class DetectionVisualEditor extends React.Component<
);
}
-}
+}
\ No newline at end of file
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.scss b/public/pages/Rules/components/RuleEditor/RuleEditorForm.scss
index 993d3a614..903a19a76 100644
--- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.scss
+++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.scss
@@ -1,7 +1,7 @@
.rule-editor-form {
.field-text-array-remove {
- padding-top: 10px;
+ // padding-top: 10px; // Wazuh: remove padding as it's misaligned with the input field
margin-left: 0;
}
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx
index 74ca7584b..663520db8 100644
--- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx
+++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx
@@ -147,9 +147,9 @@ export const RuleEditorForm: React.FC = ({
}
if (!values.logType) {
- errors.logType = 'Log type is required';
+ errors.logType = 'Integration is required'; // Replace Log type to Integration by Wazuh
} else if (!ruleTypes.some((type) => type.value === values.logType)) {
- errors.logType = `Invalid log type`;
+ errors.logType = `Invalid integration`; // Replace Log type to Integration by Wazuh
}
if (!values.detection) {
@@ -175,11 +175,11 @@ export const RuleEditorForm: React.FC = ({
if (!validateTags(values.tags)) {
errors.tags = `Tags must start with '${TAGS_PREFIX}'`;
}
-
return errors;
}}
onSubmit={(values, { setSubmitting }) => {
- if (isDetectionInvalid) {
+ // Wazuh: fixed to prevent submission when it's visual editor to yaml works correctly
+ if (isDetectionInvalid && selectedEditorType === 'visual') {
return;
}
@@ -338,7 +338,8 @@ export const RuleEditorForm: React.FC = ({
- Log type
+ {/* Replace Log type to Integration by Wazuh */}
+ Integration
}
isInvalid={
@@ -350,7 +351,7 @@ export const RuleEditorForm: React.FC = ({
isInvalid={
(validateOnMount || props.touched.logType) && !!props.errors?.logType
}
- placeholder="Select a log type"
+ placeholder="Select an integration" // Replace Log type to Integration by Wazuh
data-test-subj={'rule_type_dropdown'}
options={logTypeOptions}
singleSelection={{ asPlainText: true }}
@@ -364,7 +365,7 @@ export const RuleEditorForm: React.FC = ({
? [
{
value: props.values.logType,
- label: getLogTypeLabel(props.values.logType),
+ label: getLogTypeLabel(String(props.values.logType)),
},
]
: []
@@ -374,7 +375,7 @@ export const RuleEditorForm: React.FC = ({
Manage
@@ -441,7 +442,7 @@ export const RuleEditorForm: React.FC = ({
onBlur={props.handleBlur('status')}
selectedOptions={
props.values.status
- ? [{ value: props.values.status, label: props.values.status }]
+ ? [{ value: props.values.status, label: String(props.values.status) }]
: []
}
/>
diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorContainer.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorContainer.test.tsx.snap
index afc8fceac..8b68b0579 100644
--- a/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorContainer.test.tsx.snap
+++ b/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorContainer.test.tsx.snap
@@ -766,7 +766,7 @@ exports[` spec renders the component 1`] = `
size="s"
>
- Log type
+ Integration
}
@@ -796,7 +796,7 @@ exports[` spec renders the component 1`] = `
className="euiText euiText--small"
>
- Log type
+ Integration
@@ -817,7 +817,7 @@ exports[` spec renders the component 1`] = `
onChange={[Function]}
onFocus={[Function]}
options={Array []}
- placeholder="Select a log type"
+ placeholder="Select an integration"
selectedOptions={Array []}
singleSelection={
Object {
@@ -850,7 +850,7 @@ exports[` spec renders the component 1`] = `
onFocus={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
- placeholder="Select a log type"
+ placeholder="Select an integration"
rootId={[Function]}
searchValue=""
selectedOptions={Array []}
@@ -893,7 +893,7 @@ exports[` spec renders the component 1`] = `
- Select a log type
+ Select an integration
spec renders the component 1`] = `
"label": "cidr",
"value": "cidr",
},
+ Object {
+ "label": "base64offset",
+ "value": "base64offset",
+ },
+ Object {
+ "label": "wide",
+ "value": "wide",
+ },
+ Object {
+ "label": "windash",
+ "value": "windash",
+ },
+ Object {
+ "label": "re",
+ "value": "re",
+ },
+ Object {
+ "label": "lt",
+ "value": "lt",
+ },
+ Object {
+ "label": "lte",
+ "value": "lte",
+ },
+ Object {
+ "label": "gt",
+ "value": "gt",
+ },
+ Object {
+ "label": "gte",
+ "value": "gte",
+ },
]
}
selectedOptions={
diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorForm.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorForm.test.tsx.snap
index a4ee03999..a91ac0119 100644
--- a/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorForm.test.tsx.snap
+++ b/public/pages/Rules/components/RuleEditor/__snapshots__/RuleEditorForm.test.tsx.snap
@@ -742,7 +742,7 @@ exports[` spec renders the component 1`] = `
size="s"
>
- Log type
+ Integration
}
@@ -772,7 +772,7 @@ exports[` spec renders the component 1`] = `
className="euiText euiText--small"
>
- Log type
+ Integration
@@ -793,7 +793,7 @@ exports[` spec renders the component 1`] = `
onChange={[Function]}
onFocus={[Function]}
options={Array []}
- placeholder="Select a log type"
+ placeholder="Select an integration"
selectedOptions={Array []}
singleSelection={
Object {
@@ -826,7 +826,7 @@ exports[` spec renders the component 1`] = `
onFocus={[Function]}
onOpenListClick={[Function]}
onRemoveOption={[Function]}
- placeholder="Select a log type"
+ placeholder="Select an integration"
rootId={[Function]}
searchValue=""
selectedOptions={Array []}
@@ -869,7 +869,7 @@ exports[` spec renders the component 1`] = `
- Select a log type
+ Select an integration
spec renders the component 1`] = `
"label": "cidr",
"value": "cidr",
},
+ Object {
+ "label": "base64offset",
+ "value": "base64offset",
+ },
+ Object {
+ "label": "wide",
+ "value": "wide",
+ },
+ Object {
+ "label": "windash",
+ "value": "windash",
+ },
+ Object {
+ "label": "re",
+ "value": "re",
+ },
+ Object {
+ "label": "lt",
+ "value": "lt",
+ },
+ Object {
+ "label": "lte",
+ "value": "lte",
+ },
+ Object {
+ "label": "gt",
+ "value": "gt",
+ },
+ Object {
+ "label": "gte",
+ "value": "gte",
+ },
]
}
selectedOptions={
diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
index bc9fe566f..fe3fe335f 100644
--- a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
+++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import React, { useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import { load } from 'js-yaml';
import { EuiCompressedFormRow, EuiCodeEditor, EuiLink, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui';
import FormFieldHeader from '../../../../../../components/FormFieldHeader';
@@ -19,6 +19,7 @@ export interface YamlRuleEditorComponentProps {
change: React.Dispatch;
isInvalid: boolean;
errors?: string[];
+ parseDebounceMs?: number; // Wazuh: added onFocus to warning erros on real time typing
}
export interface YamlEditorState {
@@ -31,6 +32,7 @@ export const YamlRuleEditorComponent: React.FC = (
change,
isInvalid,
errors,
+ parseDebounceMs = 500,
}) => {
const yamlObject = mapRuleToYamlObject(rule);
@@ -39,28 +41,63 @@ export const YamlRuleEditorComponent: React.FC = (
value: mapYamlObjectToYamlString(yamlObject),
});
- const onChange = (value: string) => {
- setState((prevState) => ({ ...prevState, value }));
- };
+ // Wazuh: display warning erros on real time typing
+ const timerRef = useRef(null);
+
+ // track whether the user currently has focus in the editor
+ const isFocusedRef = useRef(false);
- const onBlur = () => {
- if (!state.value) {
- setState((prevState) => ({ ...prevState, errors: ['Rule cannot be empty'] }));
+ // update local editor value when parent rule changes, BUT only if editor is NOT focused
+ useEffect(() => {
+ // Wazuh: display warning erros on real time typing
+ const newYaml = mapYamlObjectToYamlString(mapRuleToYamlObject(rule));
+ setState((s) => {
+ if (isFocusedRef.current) {
+ return s;
+ }
+ // only update if the external YAML truly differs from the current editor value
+ if (s.value === newYaml) {
+ return s;
+ }
+ return { ...s, value: newYaml };
+ });
+ }, [rule]);
+
+ const tryParseAndNotify = (value: string) => {
+ if (!value || value.trim() === '') {
+ setState((prev) => ({ ...prev, errors: ['Rule cannot be empty'] }));
return;
}
try {
- const yamlObject = load(state.value);
-
- const rule = mapYamlObjectToRule(yamlObject);
+ const yamlObject = load(value);
+ const parsedRule = mapYamlObjectToRule(yamlObject);
+ change(parsedRule);
+ setState((prev) => ({ ...prev, errors: null }));
+ } catch (err) {
+ setState((prev) => ({ ...prev, errors: ['Invalid YAML'] }));
+ console.warn('Security Analytics - Rule Editor - Yaml load', err);
+ }
+ };
- change(rule);
+ const onChange = (value: string) => {
+ setState((prev) => ({ ...prev, value }));
+ // debounce parse
+ if (timerRef.current) {
+ window.clearTimeout(timerRef.current);
+ }
+ timerRef.current = window.setTimeout(() => {
+ tryParseAndNotify(value);
+ }, parseDebounceMs);
+ };
- setState((prevState) => ({ ...prevState, errors: null }));
- } catch (error) {
- setState((prevState) => ({ ...prevState, errors: ['Invalid YAML'] }));
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ };
+ }, []);
- console.warn('Security Analytics - Rule Eritor - Yaml load', error);
- }
+ const onFocus = () => {
+ isFocusedRef.current = true;
};
const renderErrors = () => {
@@ -109,7 +146,7 @@ export const YamlRuleEditorComponent: React.FC = (
width="100%"
value={state.value}
onChange={onChange}
- onBlur={onBlur}
+ onFocus={onFocus} // Wazuh: display warning erros on real time typing
data-test-subj={'rule_yaml_editor'}
/>
>
diff --git a/public/pages/Rules/components/RuleEditor/mappers.ts b/public/pages/Rules/components/RuleEditor/mappers.ts
index 94480cfcc..2a24ebe0d 100644
--- a/public/pages/Rules/components/RuleEditor/mappers.ts
+++ b/public/pages/Rules/components/RuleEditor/mappers.ts
@@ -8,6 +8,8 @@ import { getLogTypeFromLogSource } from '../../utils/helpers';
import { RuleEditorFormModel, ruleEditorStateDefaultValue } from './RuleEditorFormModel';
export const mapFormToRule = (formState: RuleEditorFormModel): Rule => {
+ const references = formState.references.map((ref) => ({ value: ref }));
+ const refs = references.map((r) => r.value);
return {
id: formState.id,
category: formState.logType,
@@ -15,7 +17,7 @@ export const mapFormToRule = (formState: RuleEditorFormModel): Rule => {
description: formState.description,
status: formState.status,
author: formState.author,
- references: formState.references.map((ref) => ({ value: ref })),
+ references,
tags: formState.tags.map((tag) => ({ value: tag })),
log_source: formState.log_source,
detection: formState.detection,
@@ -23,29 +25,38 @@ export const mapFormToRule = (formState: RuleEditorFormModel): Rule => {
false_positives: formState.falsePositives.map((falsePositive) => ({
value: falsePositive,
})),
+ metadata: {
+ title: formState.name,
+ author: formState.author,
+ description: formState.description,
+ references: refs,
+ documentation: '',
+ supports: [],
+ },
};
};
export const mapRuleToForm = (rule: Rule): RuleEditorFormModel => {
- // get category from log_source
const logType = rule.category || getLogTypeFromLogSource(rule.log_source);
+ const title = rule.metadata?.title ?? rule.title;
+ const description = rule.metadata?.description ?? rule.description;
+ const author = rule.metadata?.author ?? rule.author;
+ const refs = rule.metadata?.references ?? rule.references?.map((r) => r.value) ?? [];
return {
id: rule.id,
log_source: rule.log_source,
logType: logType || '',
- name: rule.title,
- description: rule.description,
+ name: title,
+ description,
status: rule.status,
- author: rule.author,
- references: rule.references
- ? rule.references.map((ref) => ref.value)
- : ruleEditorStateDefaultValue.references,
- tags: rule.tags ? rule.tags.map((tag) => tag.value) : ruleEditorStateDefaultValue.tags,
+ author,
+ references: refs.length ? refs : ruleEditorStateDefaultValue.references,
+ tags: rule.tags?.length ? rule.tags.map((tag) => tag.value) : ruleEditorStateDefaultValue.tags,
detection: rule.detection,
level: rule.level,
- falsePositives: rule.false_positives
- ? rule.false_positives.map((falsePositive) => falsePositive.value)
+ falsePositives: rule.false_positives?.length
+ ? rule.false_positives.map((fp) => fp.value)
: ruleEditorStateDefaultValue.falsePositives,
};
};
diff --git a/public/pages/Rules/components/RulesTable/__snapshots__/RulesTable.test.tsx.snap b/public/pages/Rules/components/RulesTable/__snapshots__/RulesTable.test.tsx.snap
index b32277ed8..90f6b33a1 100644
--- a/public/pages/Rules/components/RulesTable/__snapshots__/RulesTable.test.tsx.snap
+++ b/public/pages/Rules/components/RulesTable/__snapshots__/RulesTable.test.tsx.snap
@@ -46,36 +46,6 @@ Object {
-
-
-
- EuiIconMock
-
-
- Log type
-
-
-
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
- Source
-
-
-
-
-
-
@@ -266,9 +206,9 @@ Object {
>
- Log type
+ Integration
@@ -292,9 +232,9 @@ Object {
>
- Source
+ Space
@@ -386,36 +326,6 @@ Object {
-
-
-
- EuiIconMock
-
-
- Log type
-
-
-
-
-
-
-
-
-
-
-
- EuiIconMock
-
-
- Source
-
-
-
-
-
-
@@ -606,9 +486,9 @@ Object {
>
- Log type
+ Integration
@@ -632,9 +512,9 @@ Object {
>
- Source
+ Space
diff --git a/public/pages/Rules/containers/Rules/Rules.tsx b/public/pages/Rules/containers/Rules/Rules.tsx
index b0c79b81b..70973f649 100644
--- a/public/pages/Rules/containers/Rules/Rules.tsx
+++ b/public/pages/Rules/containers/Rules/Rules.tsx
@@ -52,7 +52,7 @@ export const Rules: React.FC = (props) => {
}, [DataStore.rules.getAllRules]);
useEffect(() => {
- setBreadcrumbs([BREADCRUMBS.RULES]);
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.RULES]);
}, []);
useEffect(() => {
@@ -106,7 +106,7 @@ export const Rules: React.FC = (props) => {
renderComponent: action,
}))}
>
-
+
diff --git a/public/pages/Rules/utils/helpers.tsx b/public/pages/Rules/utils/helpers.tsx
index b92201ea3..d634d327f 100644
--- a/public/pages/Rules/utils/helpers.tsx
+++ b/public/pages/Rules/utils/helpers.tsx
@@ -70,7 +70,7 @@ export const getRulesTableColumns = (
},
category: {
field: 'category',
- name: 'Log type',
+ name: 'Integration', // replace log type to integration by Wazuh
sortable: true,
width: '10%',
truncateText: true,
@@ -78,7 +78,7 @@ export const getRulesTableColumns = (
},
source: {
field: 'source',
- name: 'Source',
+ name: 'Space', // Wazuh: replace to Space
sortable: true,
width: '10%',
truncateText: true,
@@ -110,14 +110,6 @@ export const getRulesTableSearchConfig = (): Search => {
compressed: true,
},
filters: [
- {
- type: 'field_value_selection',
- field: 'category',
- name: 'Log type',
- compressed: true,
- multiSelect: 'or',
- options: getLogTypeFilterOptions(),
- },
{
type: 'field_value_selection',
field: 'level',
@@ -126,16 +118,6 @@ export const getRulesTableSearchConfig = (): Search => {
multiSelect: 'or',
options: ruleSeverity,
},
- {
- type: 'field_value_selection',
- field: 'source',
- name: 'Source',
- compressed: true,
- multiSelect: 'or',
- options: ruleSource.map((source: string) => ({
- value: source,
- })),
- },
],
};
};
diff --git a/public/pages/ThreatIntel/components/ThreatIntelOverviewActions/ThreatIntelOverviewActions.tsx b/public/pages/ThreatIntel/components/ThreatIntelOverviewActions/ThreatIntelOverviewActions.tsx
index 95f427741..14e5439d5 100644
--- a/public/pages/ThreatIntel/components/ThreatIntelOverviewActions/ThreatIntelOverviewActions.tsx
+++ b/public/pages/ThreatIntel/components/ThreatIntelOverviewActions/ThreatIntelOverviewActions.tsx
@@ -7,9 +7,12 @@ import React, { useState } from 'react';
import { EuiSmallButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { StatusWithIndicator } from '../../../../components/Utility/StatusWithIndicator';
import { RouteComponentProps } from 'react-router-dom';
-import { AlertTabId, FindingTabId, ROUTES } from '../../../../utils/constants';
+import { FindingTabId, ROUTES } from '../../../../utils/constants';
+// Wazuh: hide alerts actions from threat intel overview.
+// import { AlertTabId } from '../../../../utils/constants';
import { ThreatIntelScanConfig } from '../../../../../types';
-import { ConfigureThreatIntelScanStep } from '../../utils/constants';
+// Wazuh: hide alert triggers setup in threat intel overview.
+// import { ConfigureThreatIntelScanStep } from '../../utils/constants';
export interface ThreatIntelOverviewActionsProps {
sourceCount: number;
@@ -32,7 +35,8 @@ export const ThreatIntelOverviewActions: React.FC {
const scanIsSetup = !!scanConfig;
const scanRunning = scanIsSetup && scanConfig.enabled;
- const alertTriggerSetup = scanIsSetup && scanConfig.triggers.length > 0;
+ // Wazuh: hide alert triggers setup in threat intel overview.
+ // const alertTriggerSetup = scanIsSetup && scanConfig.triggers.length > 0;
const [togglingScan, setTogglingScan] = useState(false);
let status: React.ReactNode = null;
@@ -63,35 +67,35 @@ export const ThreatIntelOverviewActions: React.FC {
- history.push({
- pathname: ROUTES.THREAT_INTEL_EDIT_SCAN_CONFIG,
- state: {
- scanConfig,
- step: ConfigureThreatIntelScanStep.SetupAlertTriggers,
- },
- });
- },
- });
- } else {
- actions.push({
- label: 'View alerts',
- disabled: false,
- fill: false,
- onClick: () => {
- history.push({
- pathname: ROUTES.ALERTS,
- search: `?detectionType=${AlertTabId.ThreatIntel}`,
- });
- },
- });
- }
+ // Wazuh: hide alert triggers setup and View alerts actions.
+ // if (!alertTriggerSetup) {
+ // actions.push({
+ // label: 'Set up alerts',
+ // disabled: false,
+ // fill: true,
+ // onClick: () => {
+ // history.push({
+ // pathname: ROUTES.THREAT_INTEL_EDIT_SCAN_CONFIG,
+ // state: {
+ // scanConfig,
+ // step: ConfigureThreatIntelScanStep.SetupAlertTriggers,
+ // },
+ // });
+ // },
+ // });
+ // } else {
+ // actions.push({
+ // label: 'View alerts',
+ // disabled: false,
+ // fill: false,
+ // onClick: () => {
+ // history.push({
+ // pathname: ROUTES.ALERTS,
+ // search: `?detectionType=${AlertTabId.ThreatIntel}`,
+ // });
+ // },
+ // });
+ // }
} else {
status = statusByScanState['stopped'];
actions.push({
diff --git a/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx
new file mode 100644
index 000000000..39d8fb593
--- /dev/null
+++ b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/DetectorType.tsx
@@ -0,0 +1,169 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { Component } from 'react';
+import { EuiCompressedFormRow, EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import {
+ CreateDetectorRulesState,
+ DetectionRules,
+} from '../../../../../CreateDetector/components/DefineDetector/components/DetectionRules/DetectionRules';
+import { RuleItem } from '../../../../../CreateDetector/components/DefineDetector/components/DetectionRules/types/interfaces';
+import { ConfigureFieldMappingProps } from '../../../../../CreateDetector/components/ConfigureFieldMapping/containers/ConfigureFieldMapping';
+import { getIntegrationOptionsBySpace } from '../../../../../../utils/helpers';
+import { SpaceSelector } from '../../../../../../components/SpaceSelector/SpaceSelector';
+import { SpaceTypes } from '../../../../../../../common/constants';
+import { RulesContentUpdateWarning } from '.';
+import { IntegrationComboBox } from '../../../../../../components/IntegrationComboBox';
+
+interface DetectorTypeProps {
+ detectorType: string;
+ rulesState: CreateDetectorRulesState;
+ configureFieldMappingProps: ConfigureFieldMappingProps;
+ loadingRules?: boolean;
+ // Wazuh: add selectedSpace and to props to update the space in forms
+ selectedSpace?: string;
+ onDetectorTypeChange: (detectorType: string) => void;
+ onPageChange: (page: { index: number; size: number }) => void;
+ onRuleToggle: (changedItem: RuleItem, isActive: boolean) => void;
+ onAllRulesToggle: (enabled: boolean) => void;
+ /** Notifies the parent when the selected space changes so it can re-filter rules */
+ onSpaceChange?: (space: string) => void;
+}
+
+interface DetectorTypeState {
+ fieldTouched: boolean;
+ selectedSpace: string;
+ detectorTypeOptions: { id: string; value: string; label: string }[];
+}
+
+export default class DetectorType extends Component {
+ constructor(props: DetectorTypeProps) {
+ super(props);
+
+ this.state = {
+ fieldTouched: false,
+ // Wazuh: initialize from prop so the space is restored correctly when the
+ // form is re-opened after a failed creation (e.g. via "Review detector
+ // configuration"). Without this, the selector always defaults to standard
+ // regardless of the integration that was originally selected.
+ selectedSpace: props.selectedSpace ?? SpaceTypes.STANDARD.value,
+ detectorTypeOptions: [],
+ };
+ }
+
+ async componentDidMount(): Promise {
+ await this.loadOptionsForSpace(this.state.selectedSpace);
+ }
+
+ private async loadOptionsForSpace(space: string): Promise {
+ const options = await getIntegrationOptionsBySpace(space);
+ this.setState({ detectorTypeOptions: options });
+ }
+
+ onSpaceChange = async (space: string): Promise => {
+ this.setState({ selectedSpace: space });
+ // Clear the currently selected integration when switching spaces
+ this.props.onDetectorTypeChange('');
+ // Notify parent so it can re-fetch rules filtered by the new space
+ this.props.onSpaceChange?.(space);
+ await this.loadOptionsForSpace(space);
+ };
+
+ onChange = (detectorType: string) => {
+ this.setState({ fieldTouched: true });
+ this.props.onDetectorTypeChange(detectorType);
+ };
+
+ isInvalid = () => {
+ const { fieldTouched } = this.state;
+ return fieldTouched && !(this.getErrorMessage().length < 1);
+ };
+
+ getErrorMessage = () => {
+ const { detectorType } = this.props;
+ const { detectorTypeOptions } = this.state;
+ if (detectorType.length < 1) return 'Select a detector type.';
+ // Validate against the currently loaded options for the selected space
+ if (
+ detectorTypeOptions.length > 0 &&
+ !detectorTypeOptions.some((opt) => opt.value === detectorType)
+ ) {
+ return 'Unsupported detector type.';
+ }
+ return '';
+ };
+
+ render() {
+ const { detectorType } = this.props;
+ const { selectedSpace, detectorTypeOptions } = this.state;
+
+ return (
+ <>
+
+ Rules
+
+
+
+ The rules are automatically populated based on your selected integration. Threat
+ intelligence based detection can be enabled for standard integrations.{' '}
+
+
+
+
+ {selectedSpace === SpaceTypes.STANDARD.value && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+ {
+ this.onChange(e[0]?.value || '');
+ }}
+ resourceName="detectors"
+ data-test-subj="integration_dropdown"
+ space={selectedSpace}
+ />
+
+
+
+
+
+
+ {/*
+
+ */}
+ >
+ );
+ }
+}
diff --git a/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/RulesContentUpdateWarning.tsx b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/RulesContentUpdateWarning.tsx
new file mode 100644
index 000000000..a1baee603
--- /dev/null
+++ b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/RulesContentUpdateWarning.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { EuiCallOut } from '@elastic/eui';
+
+export const RulesContentUpdateWarning: React.FC = () => {
+ return (
+
+
+ Standard space rules may change when content is updated, which could affect your Detector configuration.
+
+
+ );
+};
diff --git a/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/index.ts b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/index.ts
new file mode 100644
index 000000000..e04f42921
--- /dev/null
+++ b/public/pages/WazuhCreateDetector/components/DefineDetector/components/DetectorType/index.ts
@@ -0,0 +1,9 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import DetectorType from "./DetectorType";
+export { RulesContentUpdateWarning } from "./RulesContentUpdateWarning";
+
+export default DetectorType;
diff --git a/public/pages/WazuhRules/components/RuleContentViewer/RuleContentViewer.tsx b/public/pages/WazuhRules/components/RuleContentViewer/RuleContentViewer.tsx
new file mode 100644
index 000000000..1f0d65ff4
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleContentViewer/RuleContentViewer.tsx
@@ -0,0 +1,235 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ EuiBadge,
+ EuiButtonGroup,
+ EuiCodeBlock,
+ EuiCompressedFormRow,
+ EuiFlexGrid,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormLabel,
+ EuiLink,
+ EuiModalBody,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import { EnabledHealth } from '../../../../components/Utility/EnabledHealth';
+import React, { useState } from 'react';
+import { DEFAULT_EMPTY_DATA } from '../../../../utils/constants';
+import { RuleItemInfoBase } from '../../../../../types';
+import { Metadata, MetadataFieldType } from '../../../../components/Utility/Metadata';
+import { BadgeGroup } from '../../../../components/Utility/BadgeGroup';
+import { getSeverityBadge } from '../../../../utils/helpers';
+import { RuleContentYamlViewer } from './RuleContentYamlViewer';
+import { MITRE_SECTIONS, parseMitreYml } from '../../utils/mitre';
+import { COMPLIANCE_FRAMEWORKS, COMPLIANCE_KEYS, parseComplianceYml } from '../../utils/compliance';
+import { load } from 'js-yaml';
+
+function safeLoadYaml(yamlStr: string): unknown {
+ try {
+ return load(yamlStr);
+ } catch {
+ return yamlStr;
+ }
+}
+
+export interface RuleContentViewerProps {
+ rule: RuleItemInfoBase;
+}
+
+const editorTypes = [
+ { id: 'visual', label: 'Visual' },
+ { id: 'yaml', label: 'YAML' },
+ { id: 'json', label: 'JSON' },
+];
+
+export const RuleContentViewer: React.FC = ({
+ rule: { prePackaged, _source: ruleData, _id: ruleId, integration, space },
+}) => {
+ if (!ruleData.id) {
+ ruleData.id = ruleId;
+ }
+ const [selectedEditorType, setSelectedEditorType] = useState('visual');
+
+ const mitreData = parseMitreYml(ruleData.mitre);
+ const hasMitre = MITRE_SECTIONS.some((s) => mitreData[s.field].length > 0);
+
+ const complianceData = parseComplianceYml(ruleData.compliance);
+ const hasCompliance = COMPLIANCE_KEYS.some((k) => complianceData[k].length > 0);
+
+ const metadataFields: Array<{
+ label: string;
+ value: any;
+ type?: MetadataFieldType;
+ }> = [
+ { label: 'Space', value: space },
+ { label: 'Integration', value: integration?.document?.metadata?.title },
+ { label: 'Title', value: ruleData.metadata?.title },
+ { label: 'ID', value: ruleData.id },
+ { label: 'Author', value: ruleData.metadata?.author },
+ { label: 'Description', value: ruleData.metadata?.description },
+ { label: 'Date', value: ruleData.metadata?.date, type: 'date' },
+ { label: 'Modified', value: ruleData.metadata?.modified, type: 'date' },
+ { label: 'Rule level', value: getSeverityBadge(ruleData.level), type: 'raw' },
+ { label: 'Rule status', value: ruleData.status },
+ { label: 'Documentation', value: ruleData.metadata?.documentation },
+ {
+ label: 'Supports',
+ value: ,
+ type: 'raw',
+ },
+ { label: 'References', value: ruleData.metadata?.references?.map((r: any) => r), type: 'url' },
+ ];
+
+ return (
+
+
+
+ setSelectedEditorType(id)}
+ />
+
+
+
+
+
+
+ {selectedEditorType === 'visual' && (
+ <>
+
+ {metadataFields.map(({ label, value, type = 'text' }) => (
+
+ {label}} value={value} type={type} />
+
+ ))}
+
+ {hasMitre && (
+ <>
+
+ MITRE ATT&CK
+
+
+ {MITRE_SECTIONS.map((section) => {
+ const entries = mitreData[section.field];
+ if (!entries.length) return null;
+ return (
+
+ (e.name ? `${e.id} - ${e.name}` : e.id))}
+ />
+
+ );
+ })}
+
+ >
+ )}
+ {hasCompliance && (
+ <>
+
+ Compliance
+
+
+ {COMPLIANCE_FRAMEWORKS.map((framework) =>
+ complianceData[framework.key].length > 0 ? (
+
+
+
+ ) : null
+ )}
+
+ >
+ )}
+
+ Tags
+
+ {ruleData.tags.length > 0 ? (
+
+ {ruleData.tags.map((tag: { value: string }, i: number) => (
+
+
+ {tag.value.match(/attack\.t[0-9]+/) ? (
+
+ {tag.value}
+
+ ) : (
+ tag.value
+ )}
+
+
+ ))}
+
+ ) : (
+ {DEFAULT_EMPTY_DATA}
+ )}
+
+ False positive cases
+
+ {ruleData.false_positives.length > 0 ? (
+ ruleData.false_positives.map((falsepositive: any, i: number) => (
+
+ {falsepositive.value}
+
+ ))
+ ) : (
+
{DEFAULT_EMPTY_DATA}
+ )}
+
+
+
+
+ {ruleData.detection}
+
+
+ >
+ )}
+ {selectedEditorType === 'yaml' && (
+
+
+
+ )}
+ {selectedEditorType === 'json' && (
+
+ {JSON.stringify(
+ {
+ ...ruleData,
+ detection: ruleData.detection ? safeLoadYaml(ruleData.detection) : undefined,
+ mitre: ruleData.mitre ? safeLoadYaml(ruleData.mitre) : undefined,
+ compliance: ruleData.compliance ? safeLoadYaml(ruleData.compliance) : undefined,
+ },
+ null,
+ 2
+ )}
+
+ )}
+
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleContentViewer/RuleContentYamlViewer.tsx b/public/pages/WazuhRules/components/RuleContentViewer/RuleContentYamlViewer.tsx
new file mode 100644
index 000000000..0c2b2b678
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleContentViewer/RuleContentYamlViewer.tsx
@@ -0,0 +1,24 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { EuiCodeBlock } from '@elastic/eui';
+import React from 'react';
+import { mapRuleToYamlObject, mapYamlObjectToYamlString } from '../../utils/mappers';
+import { Rule } from '../../../../../types';
+
+export interface RuleContentYamlViewerProps {
+ rule: Rule;
+}
+
+export const RuleContentYamlViewer: React.FC = ({ rule }) => {
+ const yamlObject = mapRuleToYamlObject(rule);
+ const ruleYaml = mapYamlObjectToYamlString(yamlObject);
+
+ return (
+
+ {ruleYaml}
+
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/RuleEditorContainer.tsx b/public/pages/WazuhRules/components/RuleEditor/RuleEditorContainer.tsx
new file mode 100644
index 000000000..828274484
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/RuleEditorContainer.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { ROUTES } from '../../../../utils/constants';
+import { EuiSpacer } from '@elastic/eui';
+import { RuleEditorFormModel, ruleEditorStateDefaultValue } from './RuleEditorFormModel';
+import { mapFormToRule, mapRuleToForm } from './mappers';
+import { RuleEditorForm, VisualRuleEditorProps } from './RuleEditorForm';
+import { validateRule } from '../../utils/helpers';
+import { successNotificationToast } from '../../../../utils/helpers';
+import { DataStore } from '../../../../store/DataStore';
+import { Rule } from '../../../../../types';
+
+export interface RuleEditorProps {
+ title: string;
+ subtitleData?: VisualRuleEditorProps['subtitleData'];
+ rule?: Rule;
+ history: RouteComponentProps['history'];
+ notifications?: NotificationsStart;
+ mode: 'create' | 'edit';
+ validateOnMount?: boolean;
+}
+
+export interface VisualEditorFormErrorsState {
+ nameError: string | null;
+ descriptionError: string | null;
+ authorError: string | null;
+}
+
+export const RuleEditorContainer: React.FC = ({
+ history,
+ notifications,
+ title,
+ rule,
+ mode,
+ validateOnMount,
+ subtitleData,
+}) => {
+ const initialRuleValue = rule
+ ? { ...mapRuleToForm(rule) }
+ : ruleEditorStateDefaultValue;
+
+ const onSubmit = async (values: RuleEditorFormModel, integrationId: string) => {
+ // Wazuh: added integrationId param
+ const submitingRule = mapFormToRule(values);
+ if (!validateRule(submitingRule, notifications!, 'create')) {
+ return;
+ }
+
+ let result;
+ if (mode === 'edit') {
+ if (!rule) {
+ console.error('No rule id found');
+ return;
+ }
+ result = await DataStore.rules.updateRule(rule?.id, submitingRule);
+ } else {
+ result = await DataStore.rules.createRule(submitingRule, integrationId);
+ }
+
+ if (result) {
+ successNotificationToast(
+ notifications!,
+ mode === 'create' ? 'create' : 'update',
+ 'rule',
+ result.message ??
+ `The rule ${values.metadata.title} has been ${
+ mode === 'create' ? 'created' : 'updated'
+ } successfully.`
+ );
+ history.replace(ROUTES.RULES);
+ }
+ };
+
+ const goToRulesList = useCallback(() => {
+ history.replace(ROUTES.RULES);
+ }, [history]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/WazuhRules/components/RuleEditor/RuleEditorForm.tsx
new file mode 100644
index 000000000..af91875d0
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/RuleEditorForm.tsx
@@ -0,0 +1,792 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState } from 'react';
+import { Formik, Form, FormikErrors } from 'formik';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ EuiBottomBar,
+ EuiButton,
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiCompressedFormRow,
+ EuiCompressedFieldText,
+ EuiCompressedTextArea,
+ EuiSpacer,
+ EuiAccordion,
+ EuiCompressedComboBox,
+ EuiButtonGroup,
+ EuiText,
+ EuiTitle,
+ EuiPanel,
+ EuiLink,
+ EuiSwitch,
+ EuiToolTip,
+} from '@elastic/eui';
+import { FieldTextArray } from '../../../Rules/components/RuleEditor/components/FieldTextArray';
+import { ruleSeverity, ruleStatus } from '../../../Rules/utils/constants';
+import {
+ AUTHOR_REGEX,
+ RULE_DESCRIPTION_REGEX,
+ RULE_NAME_REGEX,
+ detectionRuleNameError,
+ detectionRuleDescriptionError,
+ validateDescription,
+ validateName,
+} from '../../../../utils/validation';
+import { RuleEditorFormModel } from './RuleEditorFormModel';
+import { FormSubmissionErrorToastNotification } from '../../../Rules/components/RuleEditor/FormSubmitionErrorToastNotification';
+import { YamlRuleEditorComponent } from './components/YamlRuleEditorComponent/YamlRuleEditorComponent';
+import { mapFormToRule, mapRuleToForm } from './mappers';
+import { DetectionVisualEditor } from '../../../Rules/components/RuleEditor/DetectionVisualEditor';
+import { MitreVisualEditor } from './components/MitreVisualEditor/MitreVisualEditor';
+import { MITRE_SECTIONS } from '../../utils/mitre';
+import { ComplianceVisualEditor } from './components/ComplianceVisualEditor/ComplianceVisualEditor';
+import { getSeverityLabel } from '../../../Correlations/utils/constants';
+import { PageHeader } from '../../../../components/PageHeader/PageHeader';
+import { TopNavControlLinkData } from '../../../../../../../src/plugins/navigation/public';
+import {
+ IntegrationComboBox,
+ useIntegrationSelector,
+} from '../../../../components/IntegrationComboBox';
+
+export interface VisualRuleEditorProps {
+ initialValue: RuleEditorFormModel;
+ notifications?: NotificationsStart;
+ validateOnMount?: boolean;
+ submit: (values: RuleEditorFormModel, integrationId: string) => void;
+ cancel: () => void;
+ mode: 'create' | 'edit';
+ title: string;
+ subtitleData?: { description: string; links?: TopNavControlLinkData };
+}
+
+const editorTypes = [
+ {
+ id: 'visual',
+ label: 'Visual Editor',
+ },
+ {
+ id: 'yaml',
+ label: 'YAML Editor',
+ },
+];
+
+export const TAGS_PREFIX = 'attack.';
+
+export const RuleEditorForm: React.FC = ({
+ initialValue,
+ notifications,
+ submit,
+ cancel,
+ mode,
+ title,
+ validateOnMount,
+ subtitleData,
+}) => {
+ const [selectedEditorType, setSelectedEditorType] = useState('visual');
+ const [isDetectionInvalid, setIsDetectionInvalid] = useState(false);
+ const [integrationId, setIntegrationId] = useState('');
+
+ const hasAdditionalDetails =
+ initialValue.tags.length > 0 ||
+ initialValue.metadata.references.length > 0 ||
+ initialValue.metadata.supports.length > 0 ||
+ !!initialValue.metadata.documentation ||
+ initialValue.falsePositives.length > 0;
+
+ const {
+ loading: loadingIntegrations,
+ options: integrationOptions,
+ refresh: refreshIntegrations,
+ } = useIntegrationSelector({ notifications: notifications!, enabled: mode === 'create' });
+
+ const validateTags = (fields: string[]) => {
+ let isValid = true;
+ let tag;
+ for (let i = 0; i < fields.length; i++) {
+ tag = fields[i];
+ if (tag.length && !(tag.startsWith(TAGS_PREFIX) && tag.length > TAGS_PREFIX.length)) {
+ isValid = false;
+ break;
+ }
+ }
+
+ return isValid;
+ };
+
+ return (
+ {
+ const errors: FormikErrors = {};
+
+ if (!values.metadata.title) {
+ errors.metadata = { ...(errors.metadata ?? {}), title: 'Rule name is required' };
+ } else if (!validateName(values.metadata.title, RULE_NAME_REGEX)) {
+ errors.metadata = { ...(errors.metadata ?? {}), title: 'Invalid rule name.' };
+ }
+
+ if (
+ values.metadata.description &&
+ !validateDescription(values.metadata.description, RULE_DESCRIPTION_REGEX)
+ ) {
+ errors.metadata = {
+ ...(errors.metadata ?? {}),
+ description: detectionRuleDescriptionError,
+ };
+ }
+
+ if (!values.integration) {
+ errors.integration = 'Integration is required';
+ }
+
+ if (!values.detection) {
+ errors.detection = 'Detection is required';
+ }
+
+ if (!values.level) {
+ errors.level = 'Rule level is required';
+ } else if (!ruleSeverity.some((sev) => sev.value === values.level)) {
+ errors.level = `Invalid rule level. Should be one of critical, high, medium, low, informational`;
+ }
+
+ if (!validateName(values.metadata.author, AUTHOR_REGEX)) {
+ errors.metadata = { ...(errors.metadata ?? {}), author: 'Invalid author.' };
+ }
+
+ if (!values.status) {
+ errors.status = 'Rule status is required';
+ } else if (!ruleStatus.includes(values.status)) {
+ errors.status = `Invalid rule status. Should be one of experimental, test, stable`;
+ }
+
+ if (!validateTags(values.tags)) {
+ errors.tags = `Tags must start with '${TAGS_PREFIX}'`;
+ }
+
+ const mitreErrors: Partial> = {};
+ for (const section of MITRE_SECTIONS) {
+ if (values.mitre[section.field].some((e) => !e.id || !e.name)) {
+ mitreErrors[section.field] = 'Both ID and name are required for each entry.';
+ }
+ }
+ if (Object.keys(mitreErrors).length > 0) {
+ errors.mitre = mitreErrors as FormikErrors['mitre'];
+ }
+
+ return errors;
+ }}
+ onSubmit={(values, { setSubmitting }) => {
+ if (isDetectionInvalid && selectedEditorType === 'visual') {
+ return;
+ }
+ setSubmitting(false);
+ submit(values, integrationId);
+ }}
+ >
+ {(props) => {
+ const onIntegrationCreateSuccess = (newOption: {
+ id: string;
+ value: string;
+ label: string;
+ }) => {
+ refreshIntegrations();
+ setIntegrationId(newOption.id);
+ props.setFieldValue('integration', newOption.value ?? newOption.label ?? '', true);
+ props.setFieldTouched('integration', true, false);
+ };
+
+ return (
+
+ }
+ isInvalid={
+ (validateOnMount || props.touched.metadata?.title) &&
+ !!props.errors?.metadata?.title
+ }
+ error={props.errors?.metadata?.title}
+ helpText={detectionRuleNameError}
+ >
+ {
+ props.handleChange('metadata.title')(e);
+ }}
+ onBlur={props.handleBlur('metadata.title')}
+ value={props.values.metadata.title}
+ />
+
+
+
+
+
+ Description
+ - optional
+
+ }
+ isInvalid={
+ (validateOnMount || props.touched.metadata?.description) &&
+ !!props.errors?.metadata?.description
+ }
+ error={props.errors?.metadata?.description}
+ >
+ {
+ props.handleChange('metadata.description')(e.target.value);
+ }}
+ onBlur={props.handleBlur('metadata.description')}
+ value={props.values.metadata.description}
+ placeholder={'Detects ...'}
+ />
+
+
+
+
+
+ Author
+
+ }
+ helpText="Combine multiple authors separated with a comma"
+ isInvalid={
+ (validateOnMount || props.touched.metadata?.author) &&
+ !!props.errors?.metadata?.author
+ }
+ error={props.errors?.metadata?.author}
+ >
+ {
+ props.handleChange('metadata.author')(e);
+ }}
+ onBlur={props.handleBlur('metadata.author')}
+ value={props.values.metadata.author}
+ />
+
+
+
+
+
+
+ Details
+
+
+
+
+ {mode === 'create' ? (
+ {
+ const option = selected[0] ?? null;
+ setIntegrationId(option?.id ?? '');
+ props.setFieldValue(
+ 'integration',
+ option?.value ?? option?.label ?? '',
+ true
+ );
+ props.setFieldTouched('integration', true, false);
+ }}
+ />
+ ) : (
+
+ Integration
+
+ }
+ >
+
+
+ )}
+
+
+
+
+ Rule level (severity)
+
+ }
+ isInvalid={(validateOnMount || props.touched.level) && !!props.errors?.level}
+ error={props.errors.level}
+ >
+ ({ label: name, value }))}
+ singleSelection={{ asPlainText: true }}
+ onChange={(e) => props.handleChange('level')(e[0]?.value ?? '')}
+ onBlur={props.handleBlur('level')}
+ selectedOptions={
+ props.values.level
+ ? [
+ {
+ value: props.values.level,
+ label: getSeverityLabel(props.values.level),
+ },
+ ]
+ : []
+ }
+ />
+
+
+
+
+
+ Rule Status
+
+ }
+ isInvalid={(validateOnMount || props.touched.status) && !!props.errors?.status}
+ error={props.errors.status}
+ >
+ ({ value: type, label: type }))}
+ singleSelection={{ asPlainText: true }}
+ onChange={(e) => props.handleChange('status')(e[0]?.value ?? '')}
+ onBlur={props.handleBlur('status')}
+ selectedOptions={
+ props.values.status
+ ? [{ value: props.values.status, label: String(props.values.status) }]
+ : []
+ }
+ />
+
+
+
+
+
+ Enabled
+
+ }
+ >
+ props.setFieldValue('enabled', e.target.checked)}
+ data-test-subj={'rule_enabled_toggle'}
+ />
+
+
+
+
+
+
+ Detection
+
+
+
+ Define the detection criteria for the rule
+
+
+
+
+ {
+ if (isInvalid) {
+ props.errors.detection = 'Invalid detection entries';
+ } else {
+ delete props.errors.detection;
+ }
+
+ setIsDetectionInvalid(isInvalid);
+ }}
+ onChange={(detection: string) => {
+ props.handleChange('detection')(detection);
+ }}
+ />
+
+
+
+ props.values.mitre[s.field]?.length > 0
+ )}
+ buttonContent={
+ <>
+ MITRE ATT&CK - optional
+
+ {props.errors.mitre
+ ? 'Some entries are incomplete. Both ID and name are required.'
+ : 'Map this rule to MITRE ATT&CK tactics, techniques and subtechniques.'}
+
+ >
+ }
+ paddingSize="l"
+ >
+
+ props.setFieldValue('mitre', state)}
+ />
+
+
+
+
+
+ Compliance - optional
+
+ Map this rule to compliance frameworks (PCI DSS, GDPR, HIPAA, etc.).
+
+ >
+ }
+ paddingSize="l"
+ >
+
+ props.setFieldValue('compliance', value)}
+ />
+
+
+
+
+
+
+ Additional details - optional
+ >
+ }
+ paddingSize="l"
+ >
+
+
+
+ Tags
+ - optional
+
+
+
+
+
+ Tag
+
+ >
+ }
+ addButtonName="Add tag"
+ fields={props.values.tags}
+ error={props.errors.tags}
+ isInvalid={(validateOnMount || props.touched.tags) && !!props.errors.tags}
+ onChange={(tags) => {
+ props.touched.tags = true;
+ props.setFieldValue('tags', tags);
+ }}
+ data-test-subj={'rule_tags_field'}
+ />
+
+
+
+ References
+ - optional
+
+
+
+
+
+ URL
+
+ >
+ }
+ addButtonName="Add URL"
+ fields={props.values.metadata.references}
+ error={props.errors?.metadata?.references}
+ isInvalid={
+ (validateOnMount || props.touched.metadata?.references) &&
+ !!props.errors?.metadata?.references
+ }
+ onChange={(references) => {
+ props.setFieldTouched('metadata.references', true, false);
+ props.setFieldValue('metadata.references', references);
+ }}
+ data-test-subj={'rule_references_field'}
+ />
+
+
+
+ Supports
+ - optional
+
+
+
+
+
+ Support
+
+ >
+ }
+ addButtonName="Add support"
+ fields={props.values.metadata.supports}
+ error={props.errors?.metadata?.supports}
+ isInvalid={
+ (validateOnMount || props.touched.metadata?.supports) &&
+ !!props.errors?.metadata?.supports
+ }
+ onChange={(supports) => {
+ props.setFieldTouched('metadata.supports', true, false);
+ props.setFieldValue('metadata.supports', supports);
+ }}
+ data-test-subj={'rule_supports_field'}
+ />
+
+
+ Documentation
+ - optional
+
+ }
+ >
+ {
+ props.setFieldValue('metadata.documentation', e.target.value);
+ props.setFieldTouched('metadata.documentation', true, false);
+ }}
+ onBlur={props.handleBlur('metadata.documentation')}
+ />
+
+
+
+
+ False positive cases
+ - optional
+
+ >
+ }
+ addButtonName="Add false positive"
+ fields={props.values.falsePositives}
+ error={props.errors.falsePositives}
+ isInvalid={
+ (validateOnMount || props.touched.falsePositives) &&
+ !!props.errors?.falsePositives
+ }
+ onChange={(falsePositives) => {
+ props.touched.falsePositives = true;
+ props.setFieldValue('falsePositives', falsePositives);
+ }}
+ data-test-subj={'rule_falsePositives_field'}
+ />
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+ {mode === 'create' && !integrationId
+ ? 'Select an integration to enable creating the rule'
+ : ''}
+
+
+ {Object.keys(props.errors).length > 0
+ ? 'Please fix the errors in the form to proceed'
+ : ''}
+
+ >
+ }
+ position="top"
+ >
+ 0
+ }
+ onClick={() => props.handleSubmit()}
+ data-test-subj={'submit_rule_form_button'}
+ >
+ {mode === 'create' ? 'Create rule' : 'Edit rule'}
+
+
+
+
+
+
+ );
+ }}
+
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/RuleEditorFormModel.ts b/public/pages/WazuhRules/components/RuleEditor/RuleEditorFormModel.ts
new file mode 100644
index 000000000..4586335b6
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/RuleEditorFormModel.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { ruleStatus } from '../../../Rules/utils/constants';
+import { MitreState, EMPTY_MITRE } from '../../utils/mitre';
+
+export interface RuleEditorFormModel {
+ id: string;
+ log_source: { product?: string; category?: string; service?: string };
+ integration: string;
+ status: string;
+ tags: string[];
+ detection: string;
+ level: string;
+ falsePositives: string[];
+ mitre: MitreState;
+ compliance: string;
+ enabled: boolean;
+ metadata: {
+ title: string;
+ author: string;
+ description: string;
+ references: string[];
+ supports: string[];
+ documentation: string;
+ };
+}
+
+export const ruleEditorStateDefaultValue: RuleEditorFormModel = {
+ id: '',
+ log_source: {},
+ integration: '',
+ status: ruleStatus[0],
+ tags: [],
+ detection: '',
+ level: '',
+ falsePositives: [],
+ mitre: EMPTY_MITRE,
+ compliance: '',
+ enabled: true,
+ metadata: {
+ title: '',
+ author: '',
+ description: '',
+ references: [],
+ supports: [],
+ documentation: '',
+ },
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/components/ComplianceVisualEditor/ComplianceVisualEditor.tsx b/public/pages/WazuhRules/components/RuleEditor/components/ComplianceVisualEditor/ComplianceVisualEditor.tsx
new file mode 100644
index 000000000..f7b12e7f1
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/components/ComplianceVisualEditor/ComplianceVisualEditor.tsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui';
+import { FieldTextArray } from '../../../../../Rules/components/RuleEditor/components/FieldTextArray';
+import {
+ ComplianceKey,
+ ComplianceState,
+ ComplianceFramework,
+ COMPLIANCE_FRAMEWORKS,
+ parseComplianceYml,
+ dumpComplianceYml,
+} from '../../../../utils/compliance';
+
+export interface ComplianceVisualEditorProps {
+ complianceYml: string;
+ onChange: (yml: string) => void;
+}
+
+const makeLabel = (framework: ComplianceFramework) => (
+ <>
+
+
+
+ {framework.label}
+ - optional
+
+
+
+
+ >
+);
+
+export const ComplianceVisualEditor: React.FC = ({
+ complianceYml,
+ onChange,
+}) => {
+ const [state, setState] = useState(() => parseComplianceYml(complianceYml));
+
+ const handleChange = (key: ComplianceKey) => (items: string[]) => {
+ const newState = { ...state, [key]: items };
+ setState(newState);
+ onChange(dumpComplianceYml(newState));
+ };
+
+ return (
+ <>
+ {COMPLIANCE_FRAMEWORKS.map((framework, idx) => (
+
+
+
+ ))}
+ >
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/components/MitreVisualEditor/MitreVisualEditor.tsx b/public/pages/WazuhRules/components/RuleEditor/components/MitreVisualEditor/MitreVisualEditor.tsx
new file mode 100644
index 000000000..9344ca6bf
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/components/MitreVisualEditor/MitreVisualEditor.tsx
@@ -0,0 +1,137 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useState } from 'react';
+import {
+ EuiCompressedFieldText,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSmallButton,
+ EuiSmallButtonIcon,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+} from '@elastic/eui';
+import { MitreEntry, MitreState, MITRE_SECTIONS } from '../../../../utils/mitre';
+
+export interface MitreVisualEditorProps {
+ mitre: MitreState;
+ onChange: (state: MitreState) => void;
+}
+
+export const MitreVisualEditor: React.FC = ({ mitre, onChange }) => {
+ const [state, setState] = useState(mitre);
+
+ const isEntryInvalid = (entry: MitreEntry) => !!entry.id !== !!entry.name;
+
+ const update = (newState: MitreState) => {
+ setState(newState);
+ onChange(newState);
+ };
+
+ const handleChange = (
+ field: keyof MitreState,
+ idx: number,
+ key: keyof MitreEntry,
+ value: string
+ ) => {
+ update({
+ ...state,
+ [field]: state[field].map((entry, i) => (i === idx ? { ...entry, [key]: value } : entry)),
+ });
+ };
+
+ const handleAdd = (field: keyof MitreState) => {
+ update({ ...state, [field]: [...state[field], { id: '', name: '' }] });
+ };
+
+ const handleRemove = (field: keyof MitreState, idx: number) => {
+ update({ ...state, [field]: state[field].filter((_, i) => i !== idx) });
+ };
+
+ return (
+ <>
+ {MITRE_SECTIONS.map((section, sectionIdx) => (
+
+ {sectionIdx > 0 && }
+
+
+ {section.title} - optional
+
+
+
+ {state[section.field].length > 0 && (
+ <>
+
+
+
+
+ ID
+
+
+
+
+ Name
+
+
+
+
+ >
+ )}
+
+ {state[section.field].map((entry, idx) => {
+ const invalid = isEntryInvalid(entry);
+ return (
+
+
+
+
+ handleChange(section.field, idx, 'id', e.target.value)}
+ data-test-subj={`mitre_${section.field}_id_${idx}`}
+ />
+
+
+ handleChange(section.field, idx, 'name', e.target.value)}
+ data-test-subj={`mitre_${section.field}_name_${idx}`}
+ />
+
+
+
+ handleRemove(section.field, idx)}
+ />
+
+
+
+ {invalid && (
+
+ Both ID and name are required.
+
+ )}
+
+ );
+ })}
+
+
+
+ handleAdd(section.field)}>
+ {section.addButtonName}
+
+
+ ))}
+ >
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx b/public/pages/WazuhRules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
new file mode 100644
index 000000000..45ff5f88d
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
@@ -0,0 +1,143 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useEffect, useState, useRef } from 'react';
+import { load } from 'js-yaml';
+import {
+ EuiCallOut,
+ EuiCodeEditor,
+ EuiCompressedFormRow,
+ EuiLink,
+ EuiSpacer,
+ EuiText,
+} from '@elastic/eui';
+import FormFieldHeader from '../../../../../../components/FormFieldHeader';
+import {
+ mapRuleToYamlObject,
+ mapYamlObjectToRule,
+ mapYamlObjectToYamlString,
+} from '../../../../utils/mappers';
+import { Rule } from '../../../../../../../types';
+
+export interface YamlRuleEditorComponentProps {
+ rule: Rule;
+ change: React.Dispatch;
+ isInvalid: boolean;
+ errors?: string[];
+ parseDebounceMs?: number;
+}
+
+export interface YamlEditorState {
+ errors: string[] | null;
+ value?: string;
+}
+
+export const YamlRuleEditorComponent: React.FC = ({
+ rule,
+ change,
+ isInvalid,
+ errors,
+ parseDebounceMs = 500,
+}) => {
+ const yamlObject = mapRuleToYamlObject(rule);
+
+ const [state, setState] = useState({
+ errors: null,
+ value: mapYamlObjectToYamlString(yamlObject),
+ });
+
+ const timerRef = useRef(null);
+ const isFocusedRef = useRef(false);
+
+ useEffect(() => {
+ const newYaml = mapYamlObjectToYamlString(mapRuleToYamlObject(rule));
+ setState((s) => {
+ if (isFocusedRef.current) return s;
+ if (s.value === newYaml) return s;
+ return { ...s, value: newYaml };
+ });
+ }, [rule]);
+
+ const tryParseAndNotify = (value: string) => {
+ if (!value || value.trim() === '') {
+ setState((prev) => ({ ...prev, errors: ['Rule cannot be empty'] }));
+ return;
+ }
+ try {
+ const yamlObj = load(value);
+ const parsedRule = mapYamlObjectToRule(yamlObj);
+ change(parsedRule);
+ setState((prev) => ({ ...prev, errors: null }));
+ } catch (err) {
+ setState((prev) => ({ ...prev, errors: ['Invalid YAML'] }));
+ console.warn('Security Analytics - Rule Editor - Yaml load', err);
+ }
+ };
+
+ const onChange = (value: string) => {
+ setState((prev) => ({ ...prev, value }));
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ timerRef.current = window.setTimeout(() => {
+ tryParseAndNotify(value);
+ }, parseDebounceMs);
+ };
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) window.clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ const onFocus = () => {
+ isFocusedRef.current = true;
+ };
+
+ const renderErrors = () => {
+ const callout = (errs: string[]) => (
+
+
+ {errs.map((error, i) => (
+ {error}
+ ))}
+
+
+ );
+
+ if (state.errors && state.errors.length > 0) return callout(state.errors);
+ if (isInvalid && errors && errors.length > 0) return callout(errors);
+ return null;
+ };
+
+ return (
+ <>
+ {renderErrors()}
+
+ }
+ fullWidth
+ >
+ <>
+
+
+ Use the YAML editor to define a sigma rule. See{' '}
+
+ Sigma specification
+ {' '}
+ for rule structure and schema.
+
+
+
+ >
+
+ >
+ );
+};
diff --git a/public/pages/WazuhRules/components/RuleEditor/mappers.ts b/public/pages/WazuhRules/components/RuleEditor/mappers.ts
new file mode 100644
index 000000000..8578e0915
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleEditor/mappers.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { Rule } from '../../../../../types';
+import { getLogTypeFromLogSource } from '../../utils/helpers';
+import { RuleEditorFormModel, ruleEditorStateDefaultValue } from './RuleEditorFormModel';
+import { parseMitreYml, dumpMitreYml } from '../../utils/mitre';
+
+export const mapFormToRule = (formState: RuleEditorFormModel): Rule => {
+ const logSource = { ...(formState.log_source ?? {}) };
+ if (!logSource.product && formState.integration) {
+ logSource.product = formState.integration;
+ }
+
+ const title = formState.metadata.title;
+ const description = formState.metadata.description;
+ const author = formState.metadata.author;
+ const references = formState.metadata.references;
+ const documentation = formState.metadata.documentation;
+ const supports = formState.metadata.supports;
+
+ const rule: any = {
+ id: formState.id,
+ category: formState.integration,
+ status: formState.status,
+ tags: formState.tags.map((tag) => ({ value: tag })),
+ log_source: logSource,
+ detection: formState.detection,
+ level: formState.level,
+ false_positives: formState.falsePositives.map((falsePositive) => ({
+ value: falsePositive,
+ })),
+ metadata: {
+ title,
+ author,
+ description,
+ references,
+ documentation,
+ supports,
+ },
+ mitre: dumpMitreYml(formState.mitre),
+ compliance: formState.compliance,
+ enabled: formState.enabled,
+ };
+
+ return rule as Rule;
+};
+
+export const mapRuleToForm = (rule: Rule): RuleEditorFormModel => {
+ const logType = rule.category || getLogTypeFromLogSource(rule.log_source);
+
+ const metadataTitle = rule.metadata?.title;
+ const metadataDescription = rule.metadata?.description;
+ const metadataAuthor = rule.metadata?.author;
+ const metadataReferences = rule.metadata?.references;
+ const metadataDocumentation = rule.metadata?.documentation;
+ const metadataSupports = rule.metadata?.supports;
+
+ return {
+ id: rule.id,
+ log_source: rule.log_source,
+ integration: logType || '',
+ status: rule.status,
+ tags: rule.tags ? rule.tags.map((tag) => tag.value) : ruleEditorStateDefaultValue.tags,
+ detection: rule.detection,
+ level: rule.level,
+ falsePositives: rule.false_positives?.length
+ ? rule.false_positives.map((fp) => fp.value)
+ : ruleEditorStateDefaultValue.falsePositives,
+ mitre: parseMitreYml(rule.mitre || ''),
+ compliance: rule.compliance,
+ enabled: rule.enabled ?? true,
+ metadata: {
+ title: metadataTitle ?? ruleEditorStateDefaultValue.metadata.title,
+ description: metadataDescription ?? ruleEditorStateDefaultValue.metadata.description,
+ author: metadataAuthor ?? ruleEditorStateDefaultValue.metadata.author,
+ references: metadataReferences ?? ruleEditorStateDefaultValue.metadata.references,
+ supports: metadataSupports ?? ruleEditorStateDefaultValue.metadata.supports,
+ documentation: metadataDocumentation ?? ruleEditorStateDefaultValue.metadata.documentation,
+ },
+ };
+};
diff --git a/public/pages/WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout.tsx b/public/pages/WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout.tsx
new file mode 100644
index 000000000..9e1ab2bb0
--- /dev/null
+++ b/public/pages/WazuhRules/components/RuleViewerFlyout/RuleViewerFlyout.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback } from 'react';
+import {
+ EuiCallOut,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFlyout,
+ EuiFlyoutBody,
+ EuiFlyoutHeader,
+ EuiLoadingContent,
+ EuiSmallButtonIcon,
+ EuiText,
+} from '@elastic/eui';
+import { RuleItemInfoBase } from '../../../../../types';
+import { RuleTableItem } from '../../utils/helpers';
+import { RuleContentViewer } from '../RuleContentViewer/RuleContentViewer';
+import { DataStore } from '../../../../store/DataStore';
+import { useLazyFetch } from '../../../../hooks/useLazyFetch';
+
+export interface RuleViewerFlyoutProps {
+ ruleTableItem?: RuleTableItem;
+ ruleId?: string;
+ space?: string;
+ hideFlyout: () => void;
+}
+
+export const RuleViewerFlyout: React.FC = ({
+ ruleTableItem,
+ ruleId,
+ space,
+ hideFlyout,
+}) => {
+ // If ruleId and space are provided, it means the flyout was opened from a rule item that doesn't have full rule info, so we need to fetch it.
+ const shouldFetchRule = Boolean(ruleId && space);
+ const fetchRule = useCallback(() => DataStore.rules.getRule(ruleId!, space!), [ruleId, space]);
+ const { data: fetchedRule, loading, error } = useLazyFetch(
+ shouldFetchRule ? fetchRule : null,
+ 'Rule not found.'
+ );
+
+ const resolvedRule = shouldFetchRule ? fetchedRule : ruleTableItem?.ruleInfo;
+ const title = shouldFetchRule ? fetchedRule?._source?.metadata?.title : ruleTableItem?.title;
+
+ const renderBody = () => {
+ if (loading) return ;
+ if (error) return ;
+ if (!resolvedRule) return null;
+ return ;
+ };
+
+ return (
+
+
+
+
+
+ {title ? `Rule details - ${title}` : 'Rule details'}
+
+
+
+
+
+
+
+ {renderBody()}
+
+ );
+};
diff --git a/public/pages/WazuhRules/containers/CreateRule/CreateRule.tsx b/public/pages/WazuhRules/containers/CreateRule/CreateRule.tsx
new file mode 100644
index 000000000..947a93fe6
--- /dev/null
+++ b/public/pages/WazuhRules/containers/CreateRule/CreateRule.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { BrowserServices } from '../../../../models/interfaces';
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
+import React, { useEffect } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { BREADCRUMBS } from '../../../../utils/constants';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { setRulesRelatedBreadCrumb } from '../../utils/helpers';
+import { setBreadcrumbs } from '../../../../utils/helpers';
+
+export interface CreateRuleProps {
+ services: BrowserServices;
+ history: RouteComponentProps['history'];
+ notifications?: NotificationsStart;
+}
+
+export const CreateRule: React.FC = ({ history, services, notifications }) => {
+ useEffect(() => {
+ setRulesRelatedBreadCrumb(BREADCRUMBS.RULES_CREATE, setBreadcrumbs);
+ });
+
+ return (
+
+ );
+};
diff --git a/public/pages/WazuhRules/containers/EditRule/EditRule.tsx b/public/pages/WazuhRules/containers/EditRule/EditRule.tsx
new file mode 100644
index 000000000..361011acd
--- /dev/null
+++ b/public/pages/WazuhRules/containers/EditRule/EditRule.tsx
@@ -0,0 +1,81 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
+import React, { useEffect, useState } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
+import { BREADCRUMBS, ROUTES } from '../../../../utils/constants';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { setRulesRelatedBreadCrumb } from '../../utils/helpers';
+import { Rule } from '../../../../../types';
+import { errorNotificationToast, setBreadcrumbs } from '../../../../utils/helpers';
+import { getUseUpdatedUx } from '../../../../services/utils/constants';
+import { DataStore } from '../../../../store/DataStore';
+import { SpaceTypes } from '../../../../../common/constants';
+
+export interface EditRuleProps extends RouteComponentProps<{ id: string }> {
+ notifications?: NotificationsStart;
+}
+
+export const EditRule: React.FC = ({ history, match, notifications }) => {
+ const ruleId = match.params.id;
+ const [rule, setRule] = useState(undefined);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchRule = async () => {
+ setIsLoading(true);
+ try {
+ const result = await DataStore.rules.searchRules(
+ { query: { ids: { values: [ruleId] } }, size: 1 },
+ SpaceTypes.DRAFT.value
+ );
+ const item = result.items[0];
+ if (!item) {
+ history.replace(ROUTES.RULES);
+ return;
+ }
+
+ if (!getUseUpdatedUx()) {
+ setRulesRelatedBreadCrumb(BREADCRUMBS.RULES_EDIT, setBreadcrumbs);
+ } else {
+ setBreadcrumbs([BREADCRUMBS.RULE_EDIT_DETAILS(item._source.metadata?.title ?? '-')]);
+ }
+
+ setRule(item._source);
+ } catch {
+ errorNotificationToast(notifications, 'retrieve', 'rule');
+ history.replace(ROUTES.RULES);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchRule();
+ }, [ruleId, history, notifications]);
+
+ if (isLoading || !rule) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/public/pages/WazuhRules/containers/Rules/Rules.tsx b/public/pages/WazuhRules/containers/Rules/Rules.tsx
new file mode 100644
index 000000000..32ed09c38
--- /dev/null
+++ b/public/pages/WazuhRules/containers/Rules/Rules.tsx
@@ -0,0 +1,404 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RouteComponentProps } from 'react-router-dom';
+import {
+ EuiBadge,
+ EuiBasicTable,
+ EuiBasicTableColumn,
+ EuiButtonIcon,
+ EuiFieldSearch,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiText,
+ EuiToolTip,
+ EuiPopover,
+ EuiSmallButton,
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiConfirmModal,
+} from '@elastic/eui';
+import { DataStore } from '../../../../store/DataStore';
+import { RuleItemInfoBase } from '../../../../../types';
+import { BREADCRUMBS, ROUTES } from '../../../../utils/constants';
+import { PageHeader } from '../../../../components/PageHeader/PageHeader';
+import { setBreadcrumbs } from '../../../../utils/helpers';
+import { buildRulesSearchQuery } from '../../utils/constants';
+import { RuleTableItem } from '../../utils/helpers';
+import { getSeverityColor, getSeverityLabel } from '../../../Correlations/utils/constants';
+import { RuleViewerFlyout } from '../../components/RuleViewerFlyout/RuleViewerFlyout';
+import { SpaceTypes } from '../../../../../common/constants';
+import { useSpaceSelector } from '../../../../hooks/useSpaceSelector';
+import {
+ DELETE_ACTION,
+ DELETE_SELECTED_ACTION,
+ useDeleteItems,
+} from '../../../../hooks/useDeleteItems';
+import { useUrlParamItem } from '../../../../hooks/useUrlParamItem';
+
+const DEFAULT_PAGE_SIZE = 25;
+
+const SORT_FIELD_TO_OS: Record = {
+ title: 'document.metadata.title',
+ level: 'document.level',
+ category: 'document.logsource.category',
+};
+
+interface RulesProps {
+ history: RouteComponentProps['history'];
+ notifications: NotificationsStart;
+}
+
+const toRuleTableItem = (rule: RuleItemInfoBase): RuleTableItem => ({
+ title: rule._source.metadata?.title ?? '',
+ level: rule._source.level,
+ category: rule._source.category,
+ source: rule.prePackaged ? 'Standard' : 'Custom',
+ description: rule._source.metadata?.description ?? '',
+ ruleInfo: rule,
+ ruleId: rule._id,
+ integration: rule.integration,
+});
+
+export const Rules: React.FC = ({ history, notifications }) => {
+ const isMountedRef = useRef(true);
+ const [allRules, setAllRules] = useState([]);
+ const [totalRules, setTotalRules] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [searchText, setSearchText] = useState('');
+ const [appliedSearch, setAppliedSearch] = useState('');
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [sortField, setSortField] = useState('title');
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
+ const { component: spaceSelector, spaceFilter } = useSpaceSelector({
+ isLoading: loading,
+ onSpaceChange: () => setPageIndex(0),
+ });
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const [selectedItems, setSelectedItems] = useState([]);
+
+ const { paramId: selectedRuleId, setParam, clearParam } = useUrlParamItem('ruleId');
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ setBreadcrumbs([BREADCRUMBS.DETECTION, BREADCRUMBS.RULES]);
+ }, []);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ setAppliedSearch(searchText);
+ setPageIndex(0);
+ }, 300);
+ return () => clearTimeout(timeout);
+ }, [searchText]);
+
+ const loadRules = useCallback(async () => {
+ setLoading(true);
+ const query = buildRulesSearchQuery(appliedSearch);
+ const osField = SORT_FIELD_TO_OS[sortField];
+ const sort = osField ? [{ [osField]: { order: sortDirection } }] : undefined;
+ const response = await DataStore.rules.searchRules(
+ {
+ query,
+ from: pageIndex * pageSize,
+ size: pageSize,
+ sort,
+ _source: {
+ includes: [
+ 'document.id',
+ 'document.metadata.title',
+ 'document.level',
+ 'document.logsource.category',
+ 'document.logsource.product',
+ 'document.metadata.description',
+ 'space',
+ ],
+ },
+ },
+ spaceFilter
+ );
+
+ if (!isMountedRef.current) return;
+
+ setAllRules(response.items.map(toRuleTableItem));
+ setTotalRules(response.total);
+ setSelectedItems([]);
+ setLoading(false);
+ }, [appliedSearch, spaceFilter, pageIndex, pageSize, sortField, sortDirection]);
+
+ useEffect(() => {
+ loadRules();
+ }, [loadRules]);
+
+ const {
+ itemForAction,
+ setItemForAction,
+ isDeleting,
+ confirmDeleteSingle,
+ confirmDeleteSelected,
+ } = useDeleteItems({
+ deleteOne: async (id) => {
+ const ok = await DataStore.rules.deleteRule(id);
+ return ok ? ok : undefined;
+ },
+ reload: loadRules,
+ notifications,
+ entityName: 'rule',
+ entityNamePlural: 'rules',
+ isMountedRef,
+ });
+
+ const onTableChange = ({ page, sort }: { page?: any; sort?: any }) => {
+ if (sort) {
+ setSortField(sort.field);
+ setSortDirection(sort.direction);
+ setPageIndex(0);
+ }
+ if (page) {
+ setPageIndex(page.index);
+ setPageSize(page.size);
+ }
+ };
+
+ const hideFlyout = (refreshRules?: boolean) => {
+ clearParam();
+ if (refreshRules) loadRules();
+ };
+
+ const columns: Array> = useMemo(
+ () => [
+ {
+ field: 'title',
+ name: 'Name',
+ sortable: true,
+ truncateText: true,
+ },
+ {
+ field: 'level',
+ name: 'Severity',
+ sortable: true,
+ render: (level: string) => {
+ const { text, background } = getSeverityColor(level);
+ return (
+
+ {getSeverityLabel(level)}
+
+ );
+ },
+ },
+ {
+ field: 'category',
+ name: 'Integration',
+ sortable: false,
+ render: (_: any, row: RuleTableItem) => {
+ return row.integration?.document?.metadata?.title || '-';
+ },
+ },
+ {
+ field: 'description',
+ name: 'Description',
+ sortable: false,
+ truncateText: true,
+ },
+ {
+ name: 'Actions',
+ actions: [
+ {
+ name: 'View',
+ description: 'View rule details',
+ type: 'icon',
+ icon: 'inspect',
+ onClick: (item: RuleTableItem) => setParam(item.ruleId),
+ },
+ {
+ name: 'Edit',
+ description: 'Edit rule',
+ type: 'icon',
+ icon: 'pencil',
+ onClick: (item: RuleTableItem) => history.push(`${ROUTES.RULES_EDIT}/${item.ruleId}`),
+ available: () => spaceFilter === SpaceTypes.DRAFT.value,
+ },
+ {
+ name: 'Delete',
+ description: 'Delete rule',
+ type: 'icon',
+ icon: 'trash',
+ onClick: (item: RuleTableItem) =>
+ setItemForAction({ action: DELETE_ACTION, id: item.ruleId }),
+ available: () => spaceFilter === SpaceTypes.DRAFT.value,
+ },
+ ],
+ },
+ ],
+ [history, spaceFilter]
+ );
+
+ const isDraftSpace = spaceFilter === SpaceTypes.DRAFT.value;
+
+ const panels = [
+
+ Create
+ ,
+ {
+ setItemForAction({ action: DELETE_SELECTED_ACTION });
+ setIsPopoverOpen(false);
+ }}
+ disabled={selectedItems.length === 0 || !isDraftSpace}
+ toolTipContent={
+ !isDraftSpace
+ ? `Cannot delete rules in the ${spaceFilter} space.`
+ : selectedItems.length === 0
+ ? 'Select rules to delete'
+ : undefined
+ }
+ >
+ Delete selected ({selectedItems.length})
+ ,
+ ];
+
+ const actionsButton = (
+ setIsPopoverOpen((prev) => !prev)}
+ data-test-subj={'rulesActionsButton'}
+ >
+ Actions
+
+ }
+ isOpen={isPopoverOpen}
+ closePopover={() => setIsPopoverOpen(false)}
+ panelPaddingSize={'none'}
+ anchorPosition={'downLeft'}
+ data-test-subj={'rulesActionsPopover'}
+ >
+
+
+ );
+
+ return (
+
+ {selectedRuleId && (
+
+ )}
+ {itemForAction?.action === DELETE_ACTION && (
+ setItemForAction(null)}
+ onConfirm={confirmDeleteSingle}
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ Are you sure you want to delete this rule? This action cannot be undone.
+
+ )}
+ {itemForAction?.action === DELETE_SELECTED_ACTION && (
+ setItemForAction(null)}
+ onConfirm={() =>
+ confirmDeleteSelected(
+ selectedItems.map((item) => ({ id: item.ruleId })),
+ () => setSelectedItems([])
+ )
+ }
+ cancelButtonText="Cancel"
+ confirmButtonText="Delete"
+ buttonColor="danger"
+ defaultFocusedButton="cancel"
+ >
+ {`Are you sure you want to delete ${selectedItems.length} rule${
+ selectedItems.length !== 1 ? 's' : ''
+ }? This action cannot be undone.`}
+
+ )}
+
+
+
+
+
+
+ Rules
+
+
+ {spaceSelector}
+ {actionsButton}
+
+
+
+
+
+
+
+
+ setSearchText(event.target.value)}
+ isClearable
+ aria-label="Search rules"
+ />
+
+
+
+ loadRules()}
+ />
+
+
+
+
+ true,
+ onSelectionChange: setSelectedItems,
+ }}
+ />
+
+
+
+ );
+};
diff --git a/public/pages/WazuhRules/containers/Rules/index.ts b/public/pages/WazuhRules/containers/Rules/index.ts
new file mode 100644
index 000000000..9ee0a15db
--- /dev/null
+++ b/public/pages/WazuhRules/containers/Rules/index.ts
@@ -0,0 +1,6 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export { Rules } from './Rules';
diff --git a/public/pages/WazuhRules/hooks/useIntegrationRules.ts b/public/pages/WazuhRules/hooks/useIntegrationRules.ts
new file mode 100644
index 000000000..4cf3c2e82
--- /dev/null
+++ b/public/pages/WazuhRules/hooks/useIntegrationRules.ts
@@ -0,0 +1,138 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { DataStore } from '../../../store/DataStore';
+import { RuleItemInfoBase } from '../../../../types';
+import { RuleTableItem } from '../utils/helpers';
+import { buildRulesSearchQuery } from '../utils/constants';
+
+const SORT_FIELD_TO_OS: Record = {
+ title: 'document.metadata.title',
+ level: 'document.level',
+ category: 'document.logsource.category',
+};
+
+const toRuleTableItem = (rule: RuleItemInfoBase): RuleTableItem => ({
+ title: rule._source.metadata?.title ?? '',
+ level: rule._source.level,
+ category: rule._source.category,
+ source: rule.prePackaged ? 'Standard' : 'Custom',
+ description: rule._source.metadata?.description ?? '',
+ ruleInfo: rule,
+ ruleId: rule._id,
+});
+
+export interface UseIntegrationRulesParams {
+ ruleIds: string[];
+ space: string;
+ enabled?: boolean;
+ pageIndex: number;
+ pageSize: number;
+ sortField: string;
+ sortDirection: 'asc' | 'desc';
+ search: string;
+ severityLevels?: string[];
+}
+
+export function useIntegrationRules({
+ ruleIds,
+ space,
+ enabled = true,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+ severityLevels,
+}: UseIntegrationRulesParams) {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [reloadTrigger, setReloadTrigger] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+
+ const from = pageIndex * pageSize;
+ const size = pageSize;
+
+ const textQuery = buildRulesSearchQuery(search);
+ const mustClauses: any[] = [textQuery];
+ const filterClauses: any[] = [{ terms: { 'document.id': ruleIds } }];
+ if (severityLevels && severityLevels.length > 0) {
+ filterClauses.push({ terms: { 'document.level': severityLevels } });
+ }
+ const query = { bool: { must: mustClauses, filter: filterClauses } };
+
+ const osSortField = SORT_FIELD_TO_OS[sortField] ?? sortField;
+ const sort: Array> = [{ [osSortField]: { order: sortDirection } }];
+
+ DataStore.rules
+ .searchRules(
+ {
+ from,
+ size,
+ query,
+ sort,
+ _source: {
+ includes: [
+ 'document.id',
+ 'document.metadata.title',
+ 'document.level',
+ 'document.logsource.category',
+ 'document.logsource.product',
+ 'document.metadata.description',
+ 'space',
+ ],
+ },
+ },
+ space
+ )
+ .then((response) => {
+ if (!cancelled) {
+ setItems(response.items.map(toRuleTableItem));
+ setTotal(response.total);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setItems([]);
+ setTotal(0);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ ruleIds,
+ space,
+ enabled,
+ pageIndex,
+ pageSize,
+ sortField,
+ sortDirection,
+ search,
+ severityLevels,
+ reloadTrigger,
+ ]);
+
+ const refresh = useCallback(() => {
+ setReloadTrigger((prev) => prev + 1);
+ }, []);
+
+ return { items, total, loading, refresh };
+}
diff --git a/public/pages/WazuhRules/index.ts b/public/pages/WazuhRules/index.ts
new file mode 100644
index 000000000..a81cdad53
--- /dev/null
+++ b/public/pages/WazuhRules/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export { Rules } from './containers/Rules/Rules';
+export { CreateRule } from './containers/CreateRule/CreateRule';
+export { EditRule } from './containers/EditRule/EditRule';
diff --git a/public/pages/WazuhRules/utils/compliance.ts b/public/pages/WazuhRules/utils/compliance.ts
new file mode 100644
index 000000000..4906841a7
--- /dev/null
+++ b/public/pages/WazuhRules/utils/compliance.ts
@@ -0,0 +1,130 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { dump, load } from 'js-yaml';
+
+export type ComplianceKey =
+ | 'pci_dss'
+ | 'hipaa'
+ | 'gdpr'
+ | 'nist_800_53'
+ | 'nist_800_171'
+ | 'cmmc'
+ | 'iso_27001'
+ | 'nis2'
+ | 'tsc'
+ | 'fedramp';
+
+export type ComplianceState = Record;
+
+export interface ComplianceFramework {
+ key: ComplianceKey;
+ label: string;
+ columnHeader: string;
+ placeholder: string;
+ addButtonName: string;
+}
+
+export const COMPLIANCE_FRAMEWORKS: ComplianceFramework[] = [
+ {
+ key: 'pci_dss',
+ label: 'PCI DSS',
+ columnHeader: 'Requirement',
+ placeholder: 'e.g. 1.1.1',
+ addButtonName: 'Add PCI DSS requirement',
+ },
+ {
+ key: 'hipaa',
+ label: 'HIPAA',
+ columnHeader: 'Control',
+ placeholder: 'e.g. 164.308.a.1.II',
+ addButtonName: 'Add HIPAA control',
+ },
+ {
+ key: 'gdpr',
+ label: 'GDPR',
+ columnHeader: 'Article',
+ placeholder: 'e.g. IV_35.7.d',
+ addButtonName: 'Add GDPR article',
+ },
+ {
+ key: 'nist_800_53',
+ label: 'NIST SP 800-53',
+ columnHeader: 'Control',
+ placeholder: 'e.g. AC.1',
+ addButtonName: 'Add NIST SP 800-53 control',
+ },
+ {
+ key: 'nist_800_171',
+ label: 'NIST SP 800-171',
+ columnHeader: 'Control',
+ placeholder: 'e.g. 3.1.1',
+ addButtonName: 'Add NIST SP 800-171 control',
+ },
+ {
+ key: 'cmmc',
+ label: 'CMMC',
+ columnHeader: 'Practice',
+ placeholder: 'e.g. AC.1.001',
+ addButtonName: 'Add CMMC practice',
+ },
+ {
+ key: 'iso_27001',
+ label: 'ISO 27001',
+ columnHeader: 'Control',
+ placeholder: 'e.g. A.9.1.1',
+ addButtonName: 'Add ISO 27001 control',
+ },
+ {
+ key: 'nis2',
+ label: 'NIS2',
+ columnHeader: 'Requirement',
+ placeholder: 'e.g. Article 21',
+ addButtonName: 'Add NIS2 requirement',
+ },
+ {
+ key: 'tsc',
+ label: 'TSC',
+ columnHeader: 'Criteria',
+ placeholder: 'e.g. CC6.1',
+ addButtonName: 'Add TSC criteria',
+ },
+ {
+ key: 'fedramp',
+ label: 'FedRAMP',
+ columnHeader: 'Control',
+ placeholder: 'e.g. AC-1',
+ addButtonName: 'Add FedRAMP control',
+ },
+];
+
+export const COMPLIANCE_KEYS: ComplianceKey[] = COMPLIANCE_FRAMEWORKS.map((f) => f.key);
+
+export const EMPTY_COMPLIANCE: ComplianceState = Object.fromEntries(
+ COMPLIANCE_KEYS.map((k) => [k, []])
+) as ComplianceState;
+
+export function parseComplianceYml(yml: string): ComplianceState {
+ try {
+ const parsed = yml ? (load(yml) as any) : {};
+ const result: ComplianceState = { ...EMPTY_COMPLIANCE };
+ for (const key of COMPLIANCE_KEYS) {
+ if (Array.isArray(parsed?.[key])) {
+ result[key] = parsed[key].filter(Boolean);
+ }
+ }
+ return result;
+ } catch {
+ return { ...EMPTY_COMPLIANCE };
+ }
+}
+
+export function dumpComplianceYml(state: ComplianceState): string {
+ const obj: Record = {};
+ for (const key of COMPLIANCE_KEYS) {
+ if (state[key].length) obj[key] = state[key];
+ }
+ return Object.keys(obj).length ? dump(obj) : '';
+}
diff --git a/public/pages/WazuhRules/utils/constants.ts b/public/pages/WazuhRules/utils/constants.ts
new file mode 100644
index 000000000..8e15a9440
--- /dev/null
+++ b/public/pages/WazuhRules/utils/constants.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+const RULE_KEYWORD_SEARCH_FIELDS = [
+ 'document.metadata.title',
+ 'document.metadata.author',
+ 'document.level',
+ 'document.logsource.category',
+ 'document.logsource.product',
+ 'document.logsource.service',
+];
+
+const RULE_TEXT_SEARCH_FIELDS = ['document.metadata.description'];
+
+const escapeWildcard = (str: string) => str.replace(/[*?]/g, '\\$&');
+
+export const buildRulesSearchQuery = (searchText: string) => {
+ const trimmed = searchText.trim();
+ if (!trimmed) {
+ return { match_all: {} };
+ }
+
+ return {
+ bool: {
+ should: [
+ ...RULE_KEYWORD_SEARCH_FIELDS.map((field) => ({
+ wildcard: {
+ [field]: {
+ value: `*${escapeWildcard(trimmed)}*`,
+ case_insensitive: true,
+ },
+ },
+ })),
+ ...RULE_TEXT_SEARCH_FIELDS.map((field) => ({
+ match_phrase: {
+ [field]: trimmed,
+ },
+ })),
+ ],
+ minimum_should_match: 1,
+ },
+ };
+};
diff --git a/public/pages/WazuhRules/utils/helpers.tsx b/public/pages/WazuhRules/utils/helpers.tsx
new file mode 100644
index 000000000..79e6e871b
--- /dev/null
+++ b/public/pages/WazuhRules/utils/helpers.tsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { EuiBreadcrumb } from '@elastic/eui';
+import { dump, load } from 'js-yaml';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { IntegrationBase, Rule, RuleItemInfoBase } from '../../../../types';
+import { BREADCRUMBS } from '../../../utils/constants';
+import { errorNotificationToast } from '../../../utils/helpers';
+import {
+ AUTHOR_REGEX,
+ RULE_DESCRIPTION_REGEX,
+ RULE_NAME_REGEX,
+ validateDescription,
+ validateName,
+} from '../../../utils/validation';
+import { ruleTypes, sigmaRuleLogSourceFields } from './constants';
+
+export interface RuleTableItem {
+ title: string;
+ level: string;
+ category: string;
+ source: string;
+ description: string;
+ ruleInfo: RuleItemInfoBase;
+ ruleId: string;
+ integration?: IntegrationBase;
+}
+
+export const getRuleIntegrationTitle = (rule: { integration?: any }): string =>
+ rule.integration?.document?.metadata?.title || '';
+
+export function validateRule(
+ rule: Rule,
+ notifications: NotificationsStart,
+ ruleAction: 'create' | 'save'
+): boolean {
+ const invalidFields = [];
+
+ const title = rule.metadata?.title as string;
+ const description = rule.metadata?.description as string;
+ const author = rule.metadata?.author as string;
+
+ if (!title || !validateName(title, RULE_NAME_REGEX)) invalidFields.push('Rule name');
+ if (!validateDescription(description, RULE_DESCRIPTION_REGEX)) {
+ invalidFields.push('Description');
+ }
+ if (!rule.category) invalidFields.push('Integration');
+ if (!rule.detection) invalidFields.push('Detection');
+ if (!rule.level) invalidFields.push('Rule level');
+ if (!validateName(author, AUTHOR_REGEX)) invalidFields.push('Author');
+ if (!rule.status) invalidFields.push('Rule status');
+
+ if (rule.detection) {
+ try {
+ const json = load(rule.detection);
+ dump(json);
+ } catch (error: any) {
+ invalidFields.push('Detection');
+ }
+ }
+
+ if (invalidFields.length > 0) {
+ errorNotificationToast(
+ notifications!,
+ ruleAction,
+ 'rule',
+ `Enter valid input for ${invalidFields.join(', ')}`
+ );
+
+ return false;
+ }
+
+ return true;
+}
+
+export function setRulesRelatedBreadCrumb(
+ breadCrumb: EuiBreadcrumb,
+ breadCrumbSetter?: (breadCrumbs: EuiBreadcrumb[]) => void
+) {
+ breadCrumbSetter?.([BREADCRUMBS.RULES, breadCrumb]);
+}
+
+export function getLogTypeFromLogSource(logSource: { [k: string]: string }) {
+ const logTypes = new Set(ruleTypes.map(({ value }) => value));
+ let logType;
+
+ for (let field of sigmaRuleLogSourceFields) {
+ logType = logSource[field];
+
+ if (logType && logTypes.has(logType)) {
+ break;
+ }
+ }
+
+ return logType;
+}
diff --git a/public/pages/WazuhRules/utils/mappers.ts b/public/pages/WazuhRules/utils/mappers.ts
new file mode 100644
index 000000000..c343e471d
--- /dev/null
+++ b/public/pages/WazuhRules/utils/mappers.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { dump, load } from 'js-yaml';
+import { Rule } from '../../../../types';
+
+function safeLoad(yamlStr: string | undefined): any {
+ if (!yamlStr || (typeof yamlStr === 'string' && !yamlStr.trim())) return undefined;
+ if (typeof yamlStr !== 'string') return yamlStr;
+ try {
+ return load(yamlStr);
+ } catch {
+ return undefined;
+ }
+}
+
+function safeDump(obj: any): string {
+ if (!obj) return '';
+ if (typeof obj === 'string') return obj;
+ try {
+ return dump(obj);
+ } catch {
+ return '';
+ }
+}
+
+export const mapYamlObjectToYamlString = (yamlObject: any): string => {
+ try {
+ if (!yamlObject.detection) {
+ const { detection, ...rest } = yamlObject;
+ return dump(rest);
+ }
+ return dump(yamlObject);
+ } catch (error: any) {
+ console.warn('Security Analytics - Rule Editor - Yaml dump', error);
+ return '';
+ }
+};
+
+export const mapRuleToYamlObject = (rule: Rule): any => {
+ const detection = safeLoad(rule.detection);
+ const mitre = safeLoad(rule.mitre);
+ const compliance = safeLoad(rule.compliance);
+
+ const metadata: Record = {
+ title: rule.metadata?.title || '',
+ author: rule.metadata?.author || '',
+ description: rule.metadata?.description || '',
+ references: rule.metadata?.references?.length ? rule.metadata.references : [''],
+ documentation: rule.metadata?.documentation ?? '',
+ supports: rule.metadata?.supports?.length ? rule.metadata.supports : [''],
+ };
+
+ if (rule.metadata?.modified) metadata.modified = rule.metadata.modified;
+
+ const yamlObject: any = {
+ id: rule.id || '',
+ logsource: { product: rule.category || '' },
+ tags: rule.tags?.map((tag) => tag.value) ?? [],
+ falsepositives: rule.false_positives?.map((fp) => fp.value) ?? [],
+ level: rule.level || '',
+ status: rule.status || '',
+ enabled: rule.enabled ?? true,
+ detection,
+ metadata,
+ };
+
+ if (mitre) yamlObject.mitre = mitre;
+ if (compliance) yamlObject.compliance = compliance;
+
+ return yamlObject;
+};
+
+export const mapYamlObjectToRule = (obj: any): Rule => {
+ const detection = safeDump(obj.detection);
+ const mitre = safeDump(obj.mitre);
+ const compliance = safeDump(obj.compliance);
+ const meta = obj.metadata ?? {};
+ const references = (meta.references ?? []).filter(Boolean);
+ const supports = (meta.supports ?? []).filter(Boolean);
+ const tags = (obj.tags ?? []).filter(Boolean);
+ const falsepositives = (obj.falsepositives ?? []).filter(Boolean);
+
+ return {
+ id: obj.id,
+ category: obj.logsource ? obj.logsource.product : undefined,
+ log_source: {},
+ tags: tags.map((tag: string) => ({ value: tag })),
+ false_positives: falsepositives.map((fp: string) => ({ value: fp })),
+ level: obj.level,
+ status: obj.status,
+ enabled: obj.enabled ?? true,
+ detection,
+ metadata: {
+ title: meta.title || '',
+ author: meta.author || '',
+ description: meta.description || '',
+ references,
+ date: meta.date,
+ modified: meta.modified,
+ documentation: meta.documentation,
+ supports,
+ },
+ mitre,
+ compliance,
+ };
+};
diff --git a/public/pages/WazuhRules/utils/mitre.ts b/public/pages/WazuhRules/utils/mitre.ts
new file mode 100644
index 000000000..db7c12d2a
--- /dev/null
+++ b/public/pages/WazuhRules/utils/mitre.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { dump, load } from 'js-yaml';
+
+export interface MitreEntry {
+ id: string;
+ name: string;
+}
+
+export interface MitreState {
+ tactic: MitreEntry[];
+ technique: MitreEntry[];
+ subtechnique: MitreEntry[];
+}
+
+export interface MitreSection {
+ field: keyof MitreState;
+ title: string;
+ idPlaceholder: string;
+ namePlaceholder: string;
+ addButtonName: string;
+}
+
+export const MITRE_SECTIONS: MitreSection[] = [
+ {
+ field: 'tactic',
+ title: 'Tactics',
+ idPlaceholder: 'e.g. TA0001',
+ namePlaceholder: 'e.g. Initial Access',
+ addButtonName: 'Add tactic',
+ },
+ {
+ field: 'technique',
+ title: 'Techniques',
+ idPlaceholder: 'e.g. T1078',
+ namePlaceholder: 'e.g. Valid Accounts',
+ addButtonName: 'Add technique',
+ },
+ {
+ field: 'subtechnique',
+ title: 'Sub-techniques',
+ idPlaceholder: 'e.g. T1078.001',
+ namePlaceholder: 'e.g. Default Accounts',
+ addButtonName: 'Add sub-technique',
+ },
+];
+
+export const EMPTY_MITRE: MitreState = { tactic: [], technique: [], subtechnique: [] };
+
+function parseMitreField(field: unknown): MitreEntry[] {
+ if (typeof field !== 'object' || field === null || Array.isArray(field)) return [];
+ const obj = field as Record;
+ const ids = Array.isArray(obj.id) ? obj.id : [];
+ const names = Array.isArray(obj.name) ? obj.name : [];
+ const len = Math.max(ids.length, names.length);
+ return Array.from({ length: len }, (_, i) => ({ id: ids[i] ?? '', name: names[i] ?? '' }));
+}
+
+export function parseMitreYml(yml: string): MitreState {
+ try {
+ const parsed = (yml ? load(yml) : null) as Record | null | undefined;
+ return {
+ tactic: parseMitreField(parsed?.tactic),
+ technique: parseMitreField(parsed?.technique),
+ subtechnique: parseMitreField(parsed?.subtechnique),
+ };
+ } catch {
+ return { ...EMPTY_MITRE };
+ }
+}
+
+export function dumpMitreYml(state: MitreState): string {
+ const obj: Record = {};
+ for (const key of ['tactic', 'technique', 'subtechnique'] as const) {
+ const entries = state[key].filter((e) => e.id || e.name);
+ if (entries.length) {
+ obj[key] = {
+ id: entries.map((e) => e.id),
+ name: entries.map((e) => e.name),
+ };
+ }
+ }
+ return Object.keys(obj).length ? dump(obj) : '';
+}
diff --git a/public/plugin.ts b/public/plugin.ts
index 9ac2559d5..3e2804a57 100644
--- a/public/plugin.ts
+++ b/public/plugin.ts
@@ -8,26 +8,35 @@ import {
AppUpdater,
CoreSetup,
CoreStart,
- DEFAULT_APP_CATEGORIES,
DEFAULT_NAV_GROUPS,
Plugin,
PluginInitializerContext,
+ AppNavLinkStatus,
} from '../../../src/core/public';
import {
- CORRELATIONS_NAV_ID,
- CORRELATIONS_RULE_NAV_ID,
+ // Wazuh: hide Correlations app in Security Analytics nav.
+ // CORRELATIONS_NAV_ID,
+ // Wazuh: hide Correlation rules app in Security Analytics nav.
+ // CORRELATIONS_RULE_NAV_ID,
DETECTORS_NAV_ID,
DETECTION_RULE_NAV_ID,
FINDINGS_NAV_ID,
- GET_STARTED_NAV_ID,
+ // Wazuh: hide Insights category (keep Findings at root).
+ // INSIGHTS_NAV_ID,
+ INTEGRATIONS_NAV_ID,
LOG_TYPES_NAV_ID,
OVERVIEW_NAV_ID,
PLUGIN_NAME,
ROUTES,
- THREAT_ALERTS_NAV_ID,
- THREAT_INTEL_NAV_ID,
+ // Wazuh: hide Alerts app in Security Analytics nav.
+ // THREAT_ALERTS_NAV_ID,
dataSourceObservable,
setDarkMode,
+ DETECTION_NAV_ID,
+ NORMALIZATION_NAV_ID,
+ DECODERS_NAV_ID,
+ KVDBS_NAV_ID,
+ LOG_TEST_NAV_ID,
} from './utils/constants';
import { SecurityAnalyticsPluginSetup, SecurityAnalyticsPluginStart } from './index';
import { DataPublicPluginStart, DataPublicPluginSetup } from '../../../src/plugins/data/public';
@@ -48,7 +57,9 @@ import {
setNotifications,
setSavedObjectsClient,
} from './services/utils/constants';
-import { initializeServices, registerThreatAlertsCard } from './utils/helpers';
+import { initializeServices } from './utils/helpers';
+// Wazuh: hide Threat Alerts overview card registration.
+// import { registerThreatAlertsCard } from './utils/helpers';
import { BehaviorSubject } from 'rxjs';
export interface SecurityAnalyticsPluginSetupDeps {
@@ -104,15 +115,18 @@ export class SecurityAnalyticsPlugin
return renderApp(coreStart, params, redirect, depsStart, dataSourceManagement);
};
+ // <- Main menu Security Analytics created with sub-menus for each section
core.application.register({
id: PLUGIN_NAME,
title: 'Security Analytics',
order: 7000,
category: {
- id: 'opensearch',
- label: 'OpenSearch Plugins',
- order: 2000,
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
},
+ navLinkStatus: AppNavLinkStatus.hidden,
mount: async (params: AppMountParameters) => {
const { renderApp } = await import('./security_analytics_app');
const [coreStart, depsStart] = await core.getStartServices();
@@ -120,139 +134,270 @@ export class SecurityAnalyticsPlugin
},
});
- if (core.chrome.navGroup.getNavGroupEnabled()) {
- core.application.register({
- id: OVERVIEW_NAV_ID,
- title: 'Overview',
- order: 0,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.LANDING_PAGE);
- },
- });
+ // Wazuh: hide Overview app to be substituted by the Integrations app
+ // core.application.register({
+ // id: OVERVIEW_NAV_ID,
+ // title: 'Overview',
+ // order: 7000,
+ // category: {
+ // id: 'security_analytics',
+ // label: 'Security analytics',
+ // order: 550,
+ // euiIconType: 'securityAnalyticsApp',
+ // },
+ // updater$: this.appStateUpdater,
+ // mount: async (params: AppMountParameters) => {
+ // const { renderApp } = await import('./security_analytics_app');
+ // const [coreStart, depsStart] = await core.getStartServices();
+ // return renderApp(coreStart, params, ROUTES.LANDING_PAGE, depsStart, dataSourceManagement);
+ // },
+ // });
- core.application.register({
- id: GET_STARTED_NAV_ID,
- title: 'Get started',
- order: 1,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.GETTING_STARTED);
- },
- });
+ core.application.register({
+ id: INTEGRATIONS_NAV_ID,
+ title: 'Overview',
+ order: 7000,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ updater$: this.appStateUpdater,
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.INTEGRATIONS);
+ },
+ });
- core.application.register({
- id: THREAT_ALERTS_NAV_ID,
- title: 'Threat alerts',
- order: 300,
- category: DEFAULT_APP_CATEGORIES.investigate,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.ALERTS);
- },
- });
+ // Wazuh: hide Findings app from the Security Analytics navigation.
+ // core.application.register({
+ // id: FINDINGS_NAV_ID,
+ // title: 'Findings',
+ // order: 7001,
+ // category: {
+ // id: 'security_analytics',
+ // label: 'Security Analytics',
+ // order: 550,
+ // euiIconType: 'securityAnalyticsApp',
+ // },
+ // updater$: this.appStateUpdater,
+ // mount: async (params: AppMountParameters) => {
+ // return mountWrapper(params, ROUTES.FINDINGS);
+ // },
+ // });
- core.application.register({
- id: FINDINGS_NAV_ID,
- title: 'Findings',
- order: 400,
- category: DEFAULT_APP_CATEGORIES.investigate,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.FINDINGS);
- },
- });
+ // Wazuh: hide Alerts app from the Security Analytics navigation.
+ // core.application.register({
+ // id: THREAT_ALERTS_NAV_ID,
+ // title: 'Alerts',
+ // order: 7002,
+ // category: {
+ // id: 'security_analytics',
+ // label: 'Security Analytics',
+ // order: 550,
+ // euiIconType: 'securityAnalyticsApp',
+ // },
+ // updater$: this.appStateUpdater,
+ // mount: async (params: AppMountParameters) => {
+ // return mountWrapper(params, ROUTES.ALERTS);
+ // },
+ // });
- core.application.register({
- id: CORRELATIONS_NAV_ID,
- title: 'Correlations',
- order: 500,
- category: DEFAULT_APP_CATEGORIES.investigate,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.CORRELATIONS);
- },
- });
+ // Wazuh: hide Correlations app from the Security Analytics navigation.
+ // core.application.register({
+ // id: CORRELATIONS_NAV_ID,
+ // title: 'Correlations',
+ // order: 7003,
+ // category: {
+ // id: 'security_analytics',
+ // label: 'Security Analytics',
+ // order: 550,
+ // euiIconType: 'securityAnalyticsApp',
+ // },
+ // updater$: this.appStateUpdater,
+ // mount: async (params: AppMountParameters) => {
+ // return mountWrapper(params, ROUTES.CORRELATIONS);
+ // },
+ // });
- core.application.register({
- id: DETECTORS_NAV_ID,
- title: 'Threat detectors',
- order: 600,
- category: DEFAULT_APP_CATEGORIES.configure,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.DETECTORS);
- },
- });
+ core.application.register({
+ id: DECODERS_NAV_ID,
+ title: 'Decoders',
+ order: 7006,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.DECODERS);
+ },
+ });
- core.application.register({
- id: DETECTION_RULE_NAV_ID,
- title: 'Detection rules',
- order: 700,
- category: DEFAULT_APP_CATEGORIES.configure,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.RULES);
- },
- });
+ core.application.register({
+ id: KVDBS_NAV_ID,
+ title: 'KVDBs',
+ order: 7007,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ updater$: this.appStateUpdater,
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.KVDBS);
+ },
+ });
- core.application.register({
- id: CORRELATIONS_RULE_NAV_ID,
- title: 'Correlation rules',
- order: 800,
- category: DEFAULT_APP_CATEGORIES.configure,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.CORRELATION_RULES);
- },
+ core.application.register({
+ id: LOG_TEST_NAV_ID,
+ title: 'Log test',
+ order: 7011,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ updater$: this.appStateUpdater,
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.LOG_TEST);
+ },
+ });
+
+ core.application.register({
+ id: DETECTORS_NAV_ID,
+ title: 'Detectors',
+ order: 7009,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ updater$: this.appStateUpdater,
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.DETECTORS);
+ },
+ });
+
+ core.application.register({
+ id: DETECTION_RULE_NAV_ID,
+ title: 'Rules', // Wazuh: rename 'Detection rules' to 'Rules'.
+ order: 7010,
+ category: {
+ id: 'security_analytics',
+ label: 'Security analytics',
+ order: 550,
+ euiIconType: 'securityAnalyticsApp',
+ },
+ updater$: this.appStateUpdater,
+ mount: async (params: AppMountParameters) => {
+ return mountWrapper(params, ROUTES.RULES);
+ },
+ });
+
+ // Wazuh: hide Correlation rules app from the Security Analytics navigation.
+ // core.application.register({
+ // id: CORRELATIONS_RULE_NAV_ID,
+ // title: 'Correlation rules',
+ // order: 7011,
+ // category: {
+ // id: 'security_analytics',
+ // label: 'Security Analytics',
+ // order: 550,
+ // euiIconType: 'securityAnalyticsApp',
+ // },
+ // updater$: this.appStateUpdater,
+ // mount: async (params: AppMountParameters) => {
+ // return mountWrapper(params, ROUTES.CORRELATION_RULES);
+ // },
+ // });
+
+ // Main menu Security Analytics created with sub-menus for each section ->
+ if (core.chrome.navGroup.getNavGroupEnabled()) {
+ dataSourceObservable.subscribe((dataSourceOption) => {
+ if (dataSourceOption) {
+ this.appStateUpdater.next(this.updateDefaultRouteOfManagementApplications);
+ }
});
+ // Wazuh: hide Insights category and keep Findings at root.
+ // core.application.register({
+ // id: INSIGHTS_NAV_ID,
+ // title: "Insights",
+ // mount: async () => {
+ // return () => {};
+ // }
+ // })
+
+ // Wazuh: register an empty app to allow the nested apps in the sidebar menu
core.application.register({
- id: THREAT_INTEL_NAV_ID,
- title: 'Threat intelligence',
- order: 900,
- category: DEFAULT_APP_CATEGORIES.configure,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.THREAT_INTEL_OVERVIEW);
+ id: DETECTION_NAV_ID,
+ title: 'Detection',
+ mount: async () => {
+ return () => {};
},
});
+ // Wazuh: register an empty app to allow the nested apps in the sidebar menu
core.application.register({
- id: LOG_TYPES_NAV_ID,
- title: 'Log types',
- order: 1000,
- category: DEFAULT_APP_CATEGORIES.configure,
- updater$: this.appStateUpdater,
- mount: async (params: AppMountParameters) => {
- return mountWrapper(params, ROUTES.LOG_TYPES);
+ id: NORMALIZATION_NAV_ID,
+ title: 'Normalization',
+ mount: async () => {
+ return () => {};
},
});
- dataSourceObservable.subscribe((dataSourceOption) => {
- if (dataSourceOption) {
- this.appStateUpdater.next(this.updateDefaultRouteOfManagementApplications);
- }
- });
-
const navlinks = [
{ id: OVERVIEW_NAV_ID, showInAllNavGroup: true },
- { id: GET_STARTED_NAV_ID, showInAllNavGroup: true },
- { id: THREAT_ALERTS_NAV_ID, showInAllNavGroup: true },
- { id: FINDINGS_NAV_ID, showInAllNavGroup: true },
- { id: CORRELATIONS_NAV_ID, showInAllNavGroup: true },
+ // Wazuh does not use Get Started page
+ // { id: GET_STARTED_NAV_ID, showInAllNavGroup: true },
+ // Wazuh: hide Insights category and its sub-items (Alerts/Correlations).
+ // {
+ // id: INSIGHTS_NAV_ID,
+ // title: "Insights",
+ // showInAllNavGroup: true,
+ // order: 7001,
+ // },
+ // { id: THREAT_ALERTS_NAV_ID, parentNavLinkId: INSIGHTS_NAV_ID, showInAllNavGroup: true },
+ // { id: FINDINGS_NAV_ID, parentNavLinkId: INSIGHTS_NAV_ID, showInAllNavGroup: true },
+ // { id: CORRELATIONS_NAV_ID, parentNavLinkId: INSIGHTS_NAV_ID, showInAllNavGroup: true },
+ // { id: FINDINGS_NAV_ID, showInAllNavGroup: true, order: 7001 },
+ { id: LOG_TYPES_NAV_ID, showInAllNavGroup: true, order: 7004 },
+ {
+ id: NORMALIZATION_NAV_ID,
+ title: 'Normalization',
+ showInAllNavGroup: true,
+ order: 7003,
+ },
+ {
+ id: DECODERS_NAV_ID,
+ parentNavLinkId: NORMALIZATION_NAV_ID,
+ showInAllNavGroup: true,
+ order: 7006,
+ },
+ {
+ id: KVDBS_NAV_ID,
+ parentNavLinkId: NORMALIZATION_NAV_ID,
+ showInAllNavGroup: true,
+ order: 7007,
+ },
{
- id: PLUGIN_NAME,
- category: DEFAULT_APP_CATEGORIES.configure,
- title: 'Threat detection',
+ id: DETECTION_NAV_ID,
+ title: 'Detection',
showInAllNavGroup: true,
- order: 600,
+ order: 7009,
},
- { id: DETECTORS_NAV_ID, parentNavLinkId: PLUGIN_NAME, showInAllNavGroup: true },
- { id: DETECTION_RULE_NAV_ID, parentNavLinkId: PLUGIN_NAME, showInAllNavGroup: true },
- { id: CORRELATIONS_RULE_NAV_ID, showInAllNavGroup: true },
- { id: THREAT_INTEL_NAV_ID, showInAllNavGroup: true },
- { id: LOG_TYPES_NAV_ID, showInAllNavGroup: true },
+ { id: DETECTORS_NAV_ID, parentNavLinkId: DETECTION_NAV_ID, showInAllNavGroup: true },
+ { id: DETECTION_RULE_NAV_ID, parentNavLinkId: DETECTION_NAV_ID, showInAllNavGroup: true },
+ // Wazuh: hide Correlation rules from Detection category.
+ // { id: CORRELATIONS_RULE_NAV_ID, parentNavLinkId: DETECTION_NAV_ID, showInAllNavGroup: true },
+ // Wazuh does not use Threat Intelligence
+ // { id: THREAT_INTEL_NAV_ID, showInAllNavGroup: true },
+ { id: LOG_TEST_NAV_ID, showInAllNavGroup: true, order: 7011 },
];
core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], navlinks);
@@ -282,7 +427,8 @@ export class SecurityAnalyticsPlugin
setNotifications(core.notifications);
setSavedObjectsClient(core.savedObjects.client);
initializeServices(core, data.indexPatterns, data.search);
- registerThreatAlertsCard();
+ // Wazuh: hide Threat Alerts overview card.
+ // registerThreatAlertsCard();
return {};
}
diff --git a/public/services/AlertsService.ts b/public/services/AlertsService.ts
index 06a43742e..b33177b8b 100644
--- a/public/services/AlertsService.ts
+++ b/public/services/AlertsService.ts
@@ -15,6 +15,7 @@ import {
} from '../../types';
import { dataSourceInfo } from './utils/constants';
import { errorNotificationToast } from '../utils/helpers';
+import { THREAT_INTEL_ENABLED } from '../utils/constants';
export default class AlertsService {
constructor(private httpClient: HttpSetup, private notifications: NotificationsStart) {}
@@ -72,6 +73,16 @@ export default class AlertsService {
getThreatIntelAlerts = async (
getAlertsParams: GetThreatIntelAlertsParams
): Promise> => {
+ if (!THREAT_INTEL_ENABLED) {
+ return {
+ ok: true,
+ response: {
+ total_alerts: 0,
+ alerts: [],
+ },
+ };
+ }
+
const { size, sortOrder, startIndex, startTime, endTime } = getAlertsParams;
const query = {
sortOrder: sortOrder || 'desc',
@@ -95,6 +106,10 @@ export default class AlertsService {
state: 'ACKNOWLEDGED' | 'COMPLETED',
alertIds: string[]
): Promise> => {
+ if (!THREAT_INTEL_ENABLED) {
+ return { ok: true, response: {} };
+ }
+
const url = `${API.THREAT_INTEL_BASE}/alerts/status`;
return await this.httpClient.put(`..${url}`, {
query: {
diff --git a/public/services/DecodersService.ts b/public/services/DecodersService.ts
new file mode 100644
index 000000000..7ed9b4414
--- /dev/null
+++ b/public/services/DecodersService.ts
@@ -0,0 +1,133 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import { API } from '../../server/utils/constants';
+import { ServerResponse } from '../../server/models/types';
+import { CUDDecoderResponse, GetDecoderResponse, SearchDecodersResponse } from '../../types';
+import { parse as LosslessParse, stringify as LosslessStringify } from 'lossless-json';
+
+export default class DecodersService {
+ private readonly httpClient: HttpSetup;
+ private readonly baseUrl: string = `..${API.DECODERS_BASE}`;
+
+ constructor(httpClient: HttpSetup) {
+ this.httpClient = httpClient;
+ }
+
+ public normalizeSpace(space?: unknown): string | undefined {
+ if (!space) {
+ return undefined;
+ }
+ if (typeof space === 'string') {
+ return space;
+ }
+ if (typeof space === 'object') {
+ const record = space as Record;
+ if (typeof record.name === 'string') {
+ return record.name;
+ }
+ if (typeof record.id === 'string') {
+ return record.id;
+ }
+ if (typeof record.value === 'string') {
+ return record.value;
+ }
+ }
+ return undefined;
+ }
+
+ private parseHttpError(error: any): string {
+ return error?.body?.message || error?.body?.error || error?.message || 'Unknown error';
+ }
+
+ searchDecoders = async (
+ body: any,
+ space?: string
+ ): Promise> => {
+ try {
+ const url = `${this.baseUrl}/_search`;
+ const normalizedSpace = this.normalizeSpace(space);
+ const query = normalizedSpace ? { space: normalizedSpace } : {};
+ return await this.httpClient.post(url, {
+ query,
+ body: JSON.stringify(body),
+ });
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+
+ getDecoder = async (
+ decoderId: string,
+ space: string
+ ): Promise> => {
+ try {
+ const url = `${this.baseUrl}/${decoderId}`;
+ const normalizedSpace = this.normalizeSpace(space);
+ const query = normalizedSpace ? `?space=${normalizedSpace}` : '';
+ const response = await fetch(`${url}${query}`, {
+ headers: {
+ 'osd-xsrf': 'true',
+ },
+ });
+ const text = await response.text();
+ return LosslessParse(text) as ServerResponse;
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+
+ createDecoder = async (body: {
+ document: any;
+ integrationId: string;
+ }): Promise> => {
+ try {
+ const url = `${this.baseUrl}`;
+ return await this.httpClient.post(url, {
+ body: JSON.stringify({
+ documentJson: LosslessStringify(body.document),
+ integrationId: body.integrationId,
+ }),
+ });
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+
+ updateDecoder = async (
+ decoderId: string,
+ body: { document: any }
+ ): Promise> => {
+ try {
+ const url = `${this.baseUrl}/${decoderId}`;
+ return await this.httpClient.put(url, {
+ body: JSON.stringify({
+ documentJson: LosslessStringify(body.document),
+ }),
+ });
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+
+ deleteDecoder = async (decoderId: string): Promise> => {
+ try {
+ const url = `${this.baseUrl}/${decoderId}`;
+ return await this.httpClient.delete(url, {});
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+
+ getDraftIntegrations = async (): Promise> => {
+ try {
+ const url = `${this.baseUrl}/integrations/draft`;
+ return await this.httpClient.get(url, {});
+ } catch (error: any) {
+ return { ok: false, error: this.parseHttpError(error) };
+ }
+ };
+}
diff --git a/public/services/DetectorService.ts b/public/services/DetectorService.ts
index 6f35019c5..b3c4cef17 100644
--- a/public/services/DetectorService.ts
+++ b/public/services/DetectorService.ts
@@ -49,14 +49,37 @@ export default class DetectorsService implements IDetectorService {
};
getDetectorWithId = async (id: string): Promise> => {
- const url = `..${API.DETECTORS_BASE}/${id}`;
- const res = (await this.httpClient.get(url, {
+ const url = `..${API.SEARCH_DETECTORS}`;
+ const searchRes = (await this.httpClient.post(url, {
+ body: JSON.stringify({
+ size: 1,
+ query: { ids: { values: [id] } },
+ }),
query: {
dataSourceId: dataSourceInfo.activeDataSource.id,
},
- })) as ServerResponse;
+ })) as ServerResponse;
- return res;
+ if (!searchRes.ok) {
+ return searchRes;
+ }
+
+ const hit = searchRes.response?.hits?.hits?.[0];
+ if (!hit) {
+ return {
+ ok: false,
+ error: `Detector ${id} not found`,
+ } as ServerResponse;
+ }
+
+ return {
+ ok: true,
+ response: {
+ _id: hit._id,
+ _version: (hit as any)._version ?? 1,
+ detector: hit._source,
+ },
+ } as ServerResponse;
};
updateDetector = async (
diff --git a/public/services/FiltersService.ts b/public/services/FiltersService.ts
new file mode 100644
index 000000000..c29ed9320
--- /dev/null
+++ b/public/services/FiltersService.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import {
+ FilterSearchRequest,
+ FilterSearchResponse,
+ ServerResponse,
+ CreateFilterPayload,
+ UpdateFilterPayload,
+ CUDFilterResponse,
+} from '../../types';
+import { API } from '../../server/utils/constants';
+
+export default class FiltersService {
+ private readonly baseUrl: string = `..${API.FILTERS_BASE}`;
+
+ constructor(private httpClient: HttpSetup) {}
+
+ searchFilters = async (
+ params: FilterSearchRequest
+ ): Promise> => {
+ const url = `${this.baseUrl}/_search`;
+ return (await this.httpClient.post(url, {
+ body: JSON.stringify(params ?? {}),
+ })) as ServerResponse;
+ };
+
+ createFilter = async (body: CreateFilterPayload): Promise> => {
+ return (await this.httpClient.post(this.baseUrl, {
+ body: JSON.stringify(body),
+ })) as ServerResponse;
+ };
+
+ updateFilter = async (
+ filterId: string,
+ body: UpdateFilterPayload
+ ): Promise> => {
+ return (await this.httpClient.put(`${this.baseUrl}/${filterId}`, {
+ body: JSON.stringify(body),
+ })) as ServerResponse;
+ };
+
+ deleteFilter = async (filterId: string): Promise> => {
+ return (await this.httpClient.delete(`${this.baseUrl}/${filterId}`, {})) as ServerResponse<
+ CUDFilterResponse
+ >;
+ };
+}
diff --git a/public/services/FindingsService.ts b/public/services/FindingsService.ts
index 538e04396..0a6f58da7 100644
--- a/public/services/FindingsService.ts
+++ b/public/services/FindingsService.ts
@@ -14,6 +14,7 @@ import {
} from '../../types';
import { dataSourceInfo } from './utils/constants';
import { errorNotificationToast } from '../utils/helpers';
+import { THREAT_INTEL_ENABLED } from '../utils/constants';
export default class FindingsService {
constructor(private httpClient: HttpSetup, private notifications: NotificationsStart) {}
@@ -38,6 +39,16 @@ export default class FindingsService {
getThreatIntelFindings = async (
getFindingsParams: GetThreatIntelFindingsParams
): Promise> => {
+ if (!THREAT_INTEL_ENABLED) {
+ return {
+ ok: true,
+ response: {
+ total_findings: 0,
+ ioc_findings: [],
+ },
+ };
+ }
+
const query = {
sortOrder: 'desc',
size: 10000,
diff --git a/public/services/IntegrationService.ts b/public/services/IntegrationService.ts
new file mode 100644
index 000000000..88c6e08d6
--- /dev/null
+++ b/public/services/IntegrationService.ts
@@ -0,0 +1,134 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import {
+ CreateIntegrationRequestBody,
+ CreateIntegrationResponse,
+ DeleteIntegrationResponse,
+ GetPromote,
+ GetPromoteBySpaceResponse,
+ IntegrationBase,
+ PromoteIntegrationRequestBody,
+ PromoteIntegrationResponse,
+ SearchIntegrationsResponse,
+ ServerResponse,
+ UpdateIntegrationResponse,
+} from '../../types';
+import { API } from '../../server/utils/constants';
+import { dataSourceInfo } from './utils/constants';
+
+export default class IntegrationService {
+ constructor(private httpClient: HttpSetup) {}
+
+ createIntegration = async (integration: CreateIntegrationRequestBody) => {
+ const url = `..${API.INTEGRATION_BASE}`;
+ const response = (await this.httpClient.post(url, {
+ body: JSON.stringify(integration),
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+
+ return response;
+ };
+
+ searchIntegrations = async ({
+ spaceFilter,
+ id,
+ }: {
+ spaceFilter?: string | null;
+ id?: string;
+ }): Promise> => {
+ const url = `..${API.INTEGRATION_BASE}/_search`;
+ let query;
+
+ if (id) {
+ // Only id provided - search by id only
+ query = {
+ terms: { _id: [id] },
+ };
+ } else {
+ // Only spaceFilter provided - search by space
+ query = {
+ bool: {
+ must: {
+ query_string: {
+ query: `space.name:${spaceFilter ? spaceFilter : '*'}`,
+ },
+ },
+ },
+ };
+ }
+
+ const queryString = JSON.stringify(query);
+ return (await this.httpClient.post(url, {
+ body: queryString,
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+ };
+
+ updateIntegration = async (
+ integrationId: string,
+ integration:
+ | IntegrationBase
+ | {
+ id?: string;
+ document: IntegrationBase['document'];
+ space: IntegrationBase['space'];
+ }
+ ): Promise> => {
+ const url = `..${API.INTEGRATION_BASE}/${integrationId}`;
+ // Route schema expects only { document, space }; strip id and other extra fields
+ const payload = {
+ document: integration.document,
+ space: integration.space,
+ };
+ const response = (await this.httpClient.put(url, {
+ body: JSON.stringify(payload),
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+
+ return response;
+ };
+
+ deleteIntegration = async (
+ integrationId: string
+ ): Promise> => {
+ const url = `..${API.INTEGRATION_BASE}/${integrationId}`;
+ return (await this.httpClient.delete(url, {
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+ };
+
+ getPromoteIntegration = async ({
+ space,
+ }: GetPromote): Promise> => {
+ const url = `..${API.INTEGRATION_BASE}/promote/${space}`;
+ return (await this.httpClient.get(url, {
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+ };
+
+ promoteIntegration = async (
+ data: PromoteIntegrationRequestBody
+ ): Promise> => {
+ const url = `..${API.INTEGRATION_BASE}/promote`;
+ return (await this.httpClient.post(url, {
+ body: JSON.stringify(data),
+ query: {
+ dataSourceId: dataSourceInfo.activeDataSource.id,
+ },
+ })) as ServerResponse;
+ };
+}
diff --git a/public/services/KVDBsService.ts b/public/services/KVDBsService.ts
new file mode 100644
index 000000000..9f1a790c9
--- /dev/null
+++ b/public/services/KVDBsService.ts
@@ -0,0 +1,60 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import {
+ CreateKVDBPayload,
+ CUDKVDBResponse,
+ KVDBIntegrationsSearchResponse,
+ KVDBSearchRequest,
+ KVDBSearchResponse,
+ ServerResponse,
+ UpdateKVDBPayload,
+} from '../../types';
+import { API } from '../../server/utils/constants';
+
+export default class KVDBsService {
+ private readonly baseUrl: string = `..${API.KVDBS_BASE}`;
+
+ constructor(private httpClient: HttpSetup) {}
+
+ searchKVDBs = async (params: KVDBSearchRequest): Promise> => {
+ const url = `${this.baseUrl}/_search`;
+ return (await this.httpClient.post(url, {
+ body: JSON.stringify(params ?? {}),
+ })) as ServerResponse;
+ };
+
+ searchIntegrations = async (
+ kvdbIds: string[]
+ ): Promise> => {
+ const url = `${this.baseUrl}/_integrations`;
+ return (await this.httpClient.post(url, {
+ body: JSON.stringify({ kvdbIds }),
+ })) as ServerResponse;
+ };
+
+ createKVDB = async (body: CreateKVDBPayload): Promise> => {
+ const url = `${this.baseUrl}`;
+ return await this.httpClient.post(url, {
+ body: JSON.stringify(body),
+ });
+ };
+
+ updateKVDB = async (
+ kvdbId: string,
+ body: UpdateKVDBPayload
+ ): Promise> => {
+ const url = `${this.baseUrl}/${kvdbId}`;
+ return await this.httpClient.put(url, {
+ body: JSON.stringify(body),
+ });
+ };
+
+ deleteKVDB = async (kvdbId: string): Promise> => {
+ const url = `${this.baseUrl}/${kvdbId}`;
+ return await this.httpClient.delete(url, {});
+ };
+}
diff --git a/public/services/LogTestService.ts b/public/services/LogTestService.ts
new file mode 100644
index 000000000..903788e9d
--- /dev/null
+++ b/public/services/LogTestService.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import { ServerResponse, LogTestApiRequest, LogTestResponse } from '../../types';
+import { API } from '../../server/utils/constants';
+
+export default class LogTestService {
+ constructor(private httpClient: HttpSetup) {}
+
+ executeLogTest = async (
+ params: LogTestApiRequest
+ ): Promise> => {
+ const url = `${API.LOG_TEST_BASE}`;
+ return (await this.httpClient.post(url, {
+ body: JSON.stringify(params),
+ })) as ServerResponse;
+ };
+}
diff --git a/public/services/LogTypeService.ts b/public/services/LogTypeService.ts
index 8607fb80b..d7f0a5551 100644
--- a/public/services/LogTypeService.ts
+++ b/public/services/LogTypeService.ts
@@ -41,7 +41,7 @@ export default class LogTypeService {
must: {
query_string: {
query:
- '(source: Sigma and !(name: others*) and !(name: test*)) or (source: Custom)',
+ '(space: standard and !(name: others*) and !(name: test*)) or (space: custom)',
},
},
},
diff --git a/public/services/PoliciesService.ts b/public/services/PoliciesService.ts
new file mode 100644
index 000000000..ed67b1f50
--- /dev/null
+++ b/public/services/PoliciesService.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { HttpSetup } from 'opensearch-dashboards/public';
+import { API } from '../../server/utils/constants';
+import { ServerResponse } from '../../server/models/types';
+import {
+ GetPolicyResponse,
+ SearchPoliciesResponse,
+ SearchPolicyOptions,
+ UpdatePolicyRequestBody,
+ UpdatePolicyResponse,
+} from '../../types';
+
+export default class PoliciesService {
+ httpClient: HttpSetup;
+
+ constructor(httpClient: HttpSetup) {
+ this.httpClient = httpClient;
+ }
+
+ searchPolicies = async (
+ space: string,
+ options: SearchPolicyOptions = {}
+ ): Promise> => {
+ const url = `..${API.POLICIES_BASE}/_search`;
+
+ const query = { space };
+ return (await this.httpClient.post(url, {
+ query,
+ body: JSON.stringify(options),
+ })) as ServerResponse;
+ };
+
+ getPolicy = async (policyId: string): Promise> => {
+ const url = `..${API.POLICIES_BASE}/${policyId}`;
+
+ return (await this.httpClient.get(url, {})) as ServerResponse;
+ };
+
+ updatePolicy = async (
+ space: string,
+ policyDocumentData: UpdatePolicyRequestBody
+ ): Promise> => {
+ const url = `..${API.POLICIES_BASE}/${space}`;
+
+ return (await this.httpClient.put(url, {
+ body: JSON.stringify(policyDocumentData),
+ })) as ServerResponse;
+ };
+
+ deleteSpace = async (space: string): Promise> => {
+ const url = `..${API.SPACES_BASE}/${space}`;
+ return (await this.httpClient.delete(url)) as ServerResponse;
+ };
+}
diff --git a/public/services/RuleService.ts b/public/services/RuleService.ts
index 1c7526424..3177f95f1 100644
--- a/public/services/RuleService.ts
+++ b/public/services/RuleService.ts
@@ -6,7 +6,6 @@
import { HttpSetup } from 'opensearch-dashboards/public';
import { ServerResponse } from '../../server/models/types';
import {
- CreateRuleRequest,
CreateRuleResponse,
DeleteRuleResponse,
GetRulesResponse,
@@ -23,37 +22,45 @@ export default class RuleService {
this.httpClient = httpClient;
}
- getRules = async (prePackaged: boolean, body: any): Promise> => {
+ // Wazuh: added space parameter to getRules API
+ getRules = async (
+ prePackaged: boolean,
+ body: any,
+ space?: string
+ ): Promise> => {
const url = `..${API.RULES_BASE}/_search`;
return (await this.httpClient.post(url, {
query: {
prePackaged,
+ ...(space !== undefined && { space }),
dataSourceId: dataSourceInfo.activeDataSource.id,
},
body: JSON.stringify(body),
})) as ServerResponse;
};
- createRule = async (rule: Rule): Promise> => {
+ // Wazuh: added integrationId parameter to createRule
+ createRule = async (rule: {
+ document: Rule;
+ integrationId: string;
+ }): Promise> => {
const url = `..${API.RULES_BASE}`;
- const ruleBody: CreateRuleRequest = { rule: rule };
return (await this.httpClient.post(url, {
- body: JSON.stringify(ruleBody),
+ body: JSON.stringify(rule),
query: {
dataSourceId: dataSourceInfo.activeDataSource.id,
},
})) as ServerResponse;
};
+ // Wazuh: removed category parameter from updateRule API
updateRule = async (
ruleId: string,
- category: string,
- rule: Rule
+ rule: { document: Rule }
): Promise> => {
const url = `..${API.RULES_BASE}/${ruleId}`;
return (await this.httpClient.put(url, {
query: {
- category,
dataSourceId: dataSourceInfo.activeDataSource.id,
},
body: JSON.stringify(rule),
diff --git a/public/services/index.ts b/public/services/index.ts
index 08ff2ff3e..6331c2e4d 100644
--- a/public/services/index.ts
+++ b/public/services/index.ts
@@ -15,8 +15,13 @@ import NotificationsService from './NotificationsService';
import IndexPatternsService from './IndexPatternsService';
import SavedObjectService from './SavedObjectService';
import CorrelationService from './CorrelationService';
+import PoliciesService from './PoliciesService';
+import IntegrationService from './IntegrationService';
import LogTypeService from './LogTypeService';
import ThreatIntelService from './ThreatIntelService';
+import DecodersService from './DecodersService';
+import KVDBsService from './KVDBsService';
+import FiltersService from './FiltersService';
export {
SaContextConsumer,
@@ -33,5 +38,10 @@ export {
IndexPatternsService,
SavedObjectService,
LogTypeService,
+ IntegrationService,
+ PoliciesService,
+ DecodersService,
ThreatIntelService,
+ KVDBsService,
+ FiltersService,
};
diff --git a/public/store/AlertsStore.ts b/public/store/AlertsStore.ts
index 13e8440af..e8856621d 100644
--- a/public/store/AlertsStore.ts
+++ b/public/store/AlertsStore.ts
@@ -7,6 +7,7 @@ import { NotificationsStart } from 'opensearch-dashboards/public';
import { AlertsService } from '../services';
import { errorNotificationToast } from '../utils/helpers';
import { AlertResponse, Duration, ThreatIntelAlert } from '../../types';
+import { THREAT_INTEL_ENABLED } from '../utils/constants';
export class AlertsStore {
constructor(
@@ -103,6 +104,10 @@ export class AlertsStore {
duration: Duration,
onPartialAlertsFetched?: (alerts: ThreatIntelAlert[]) => void
) {
+ if (!THREAT_INTEL_ENABLED) {
+ return [];
+ }
+
let allAlerts: any[] = [];
const maxAlertsReturned = 10000;
let startIndex = 0;
diff --git a/public/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts
index d533d9b85..0b696fb35 100644
--- a/public/store/CorrelationsStore.ts
+++ b/public/store/CorrelationsStore.ts
@@ -299,7 +299,8 @@ export class CorrelationsStore implements ICorrelationsStore {
timestamp: new Date(f.timestamp).toLocaleString(),
detectionRule: rule
? {
- name: rule._source.title,
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ name: rule._source.metadata?.title ?? '',
severity: rule._source.level,
tags: rule._source.tags,
}
diff --git a/public/store/DataStore.ts b/public/store/DataStore.ts
index dc07ee04e..0a122688d 100644
--- a/public/store/DataStore.ts
+++ b/public/store/DataStore.ts
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { RulesStore } from './RulesStore';
+import { RulesStore } from './WazuhRulesStore'; // Wazuh: use WazuhRulesStore instead of RulesStore
import { BrowserServices } from '../models/interfaces';
import { NotificationsStart } from 'opensearch-dashboards/public';
import { DetectorsStore } from './DetectorsStore';
@@ -13,6 +13,12 @@ import { LogTypeStore } from './LogTypeStore';
import { AlertsStore } from './AlertsStore';
import { ThreatIntelStore } from './ThreatIntelStore';
import { DocumentStore } from './DocumentStore';
+import { IntegrationStore } from './IntegrationStore';
+import { DecodersStore } from './DecodersStore';
+import { KVDBsStore } from './KVDBsStore';
+import { FiltersStore } from './FiltersStore';
+import { PoliciesStore } from './PoliciesStore';
+import { LogTestStore } from './LogTestStore';
export class DataStore {
public static rules: RulesStore;
@@ -20,6 +26,12 @@ export class DataStore {
public static correlations: CorrelationsStore;
public static findings: FindingsStore;
public static logTypes: LogTypeStore;
+ public static integrations: IntegrationStore;
+ public static policies: PoliciesStore;
+ public static decoders: DecodersStore;
+ public static kvdbs: KVDBsStore;
+ public static filters: FiltersStore;
+ public static logTests: LogTestStore;
public static alerts: AlertsStore;
public static threatIntel: ThreatIntelStore;
public static documents: DocumentStore;
@@ -51,7 +63,15 @@ export class DataStore {
rulesStore
);
+ DataStore.integrations = new IntegrationStore(services.integrationService, notifications);
+ DataStore.policies = new PoliciesStore(services.policiesService, notifications);
DataStore.logTypes = new LogTypeStore(services.logTypeService, notifications);
+ DataStore.decoders = new DecodersStore(services.decodersService, notifications);
+
+ DataStore.kvdbs = new KVDBsStore(services.kvdbsService, notifications);
+ DataStore.filters = new FiltersStore(services.filtersService, notifications);
+
+ DataStore.logTests = new LogTestStore(services.logTestService, notifications);
DataStore.alerts = new AlertsStore(services.alertService, notifications);
diff --git a/public/store/DecodersStore.ts b/public/store/DecodersStore.ts
new file mode 100644
index 000000000..11d7ccb48
--- /dev/null
+++ b/public/store/DecodersStore.ts
@@ -0,0 +1,120 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { CUDDecoderResponse, DecoderItem, SearchDecodersResponse } from '../../types';
+import DecodersService from '../services/DecodersService';
+import { errorNotificationToast } from '../utils/helpers';
+
+export interface DecodersSearchParams {
+ from?: number;
+ size?: number;
+ sort?: any;
+ query?: any;
+ _source?: any;
+}
+
+export class DecodersStore {
+ constructor(private service: DecodersService, private notifications: NotificationsStart) {}
+
+ public async searchDecoders(
+ params: DecodersSearchParams,
+ space?: string
+ ): Promise {
+ const response = await this.service.searchDecoders(params, space);
+ if (!response.ok) {
+ if (
+ response.error?.includes('index_not_found_exception') ||
+ response.error?.includes('no such index')
+ ) {
+ return { total: 0, items: [] };
+ }
+ errorNotificationToast(this.notifications, 'retrieve', 'decoders', response.error);
+ return { total: 0, items: [] };
+ }
+
+ const items: DecoderItem[] = response.response.items.map((item) => ({
+ ...item,
+ space:
+ this.service.normalizeSpace(item.space) ??
+ this.service.normalizeSpace(item.document?.space),
+ }));
+
+ return { ...response.response, items };
+ }
+
+ public async getDecoder(decoderId: string, space: string): Promise {
+ const response = await this.service.getDecoder(decoderId, space);
+ if (!response.ok) {
+ if (
+ response.error?.includes('index_not_found_exception') ||
+ response.error?.includes('no such index')
+ ) {
+ return undefined;
+ }
+ errorNotificationToast(this.notifications, 'retrieve', 'decoder', response.error);
+ return undefined;
+ }
+
+ const item = response.response.item;
+ if (!item) {
+ return undefined;
+ }
+
+ return {
+ ...item,
+ space:
+ this.service.normalizeSpace(item.space) ??
+ this.service.normalizeSpace(item.document?.space),
+ };
+ }
+
+ public async createDecoder(body: {
+ document: any;
+ integrationId: string;
+ }): Promise {
+ const responseRequest = await this.service.createDecoder(body);
+ if (!responseRequest.ok) {
+ errorNotificationToast(this.notifications, 'create', 'decoder', responseRequest.error);
+ return undefined;
+ }
+ return responseRequest.response;
+ }
+
+ public async updateDecoder(
+ decoderId: string,
+ body: { document: any }
+ ): Promise {
+ const responseRequest = await this.service.updateDecoder(decoderId, body);
+ if (!responseRequest.ok) {
+ errorNotificationToast(this.notifications, 'update', 'decoder', responseRequest.error);
+ return undefined;
+ }
+ return responseRequest.response;
+ }
+
+ public async deleteDecoder(decoderId: string): Promise {
+ const responseRequest = await this.service.deleteDecoder(decoderId);
+ if (!responseRequest.ok) {
+ errorNotificationToast(this.notifications, 'delete', 'decoder', responseRequest.error);
+ return undefined;
+ }
+ return responseRequest.response;
+ }
+
+ public async getDraftIntegrations(): Promise {
+ const responseRequest = await this.service.getDraftIntegrations();
+ if (!responseRequest.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'retrieve',
+ 'draft integrations',
+ responseRequest.error
+ );
+ return [];
+ }
+ return responseRequest.response?.items || [];
+ }
+}
diff --git a/public/store/FiltersStore.ts b/public/store/FiltersStore.ts
new file mode 100644
index 000000000..53ed2447b
--- /dev/null
+++ b/public/store/FiltersStore.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ FilterItem,
+ FilterSearchRequest,
+ FilterSearchResponse,
+ ServerResponse,
+ CreateFilterPayload,
+ UpdateFilterPayload,
+ CUDFilterResponse,
+} from '../../types';
+import FiltersService from '../services/FiltersService';
+import { errorNotificationToast } from '../utils/helpers';
+
+export class FiltersStore {
+ constructor(private service: FiltersService, private notifications: NotificationsStart) {}
+
+ public async searchFilters(
+ params: FilterSearchRequest
+ ): Promise<{ items: FilterItem[]; total: number }> {
+ try {
+ const response: ServerResponse = await this.service.searchFilters(
+ params
+ );
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'fetch', 'Filters', response.error);
+ return { items: [], total: 0 };
+ }
+
+ const hits = response.response.hits.hits ?? [];
+ const total =
+ typeof response.response.hits.total === 'number'
+ ? response.response.hits.total
+ : response.response.hits.total?.value ?? hits.length;
+ const items: FilterItem[] = hits.map((hit) => ({
+ id: hit._id,
+ ...hit._source,
+ }));
+
+ return { items, total };
+ } catch (error: any) {
+ errorNotificationToast(this.notifications, 'fetch', 'Filters', error.message);
+ return { items: [], total: 0 };
+ }
+ }
+
+ public async getFilter(id: string): Promise {
+ const response = await this.service.searchFilters({
+ size: 1,
+ query: { ids: { values: [id] } },
+ });
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'fetch', 'filter', response.error);
+ return undefined;
+ }
+ const hit = response.response.hits.hits?.[0];
+ if (!hit) return undefined;
+ return { id: hit._id, ...hit._source };
+ }
+
+ public async createFilter(body: CreateFilterPayload): Promise {
+ const response = await this.service.createFilter(body);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'create', 'filter', response.error);
+ return undefined;
+ }
+ return response.response;
+ }
+
+ public async updateFilter(
+ filterId: string,
+ body: UpdateFilterPayload
+ ): Promise {
+ const response = await this.service.updateFilter(filterId, body);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'update', 'filter', response.error);
+ return undefined;
+ }
+ return response.response;
+ }
+
+ public async deleteFilter(filterId: string): Promise {
+ const response = await this.service.deleteFilter(filterId);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'delete', 'filter', response.error);
+ return undefined;
+ }
+ return response.response;
+ }
+}
diff --git a/public/store/IntegrationStore.ts b/public/store/IntegrationStore.ts
new file mode 100644
index 000000000..4ac26f074
--- /dev/null
+++ b/public/store/IntegrationStore.ts
@@ -0,0 +1,292 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ GetPromote,
+ GetPromoteBySpaceResponse,
+ Integration,
+ IntegrationBase,
+ PromoteIntegrationRequestBody,
+} from '../../types';
+import IntegrationService from '../services/IntegrationService';
+import { errorNotificationToast } from '../utils/helpers';
+import { ruleTypes } from '../pages/Rules/utils/constants';
+import {
+ DATA_SOURCE_NOT_SET_ERROR,
+ integrationCategoryFilters,
+ integrationsByCategories,
+} from '../utils/constants';
+
+export class IntegrationStore {
+ constructor(private service: IntegrationService, private notifications: NotificationsStart) {}
+
+ private formatRelatedEntitiesList(entities: string[]): string {
+ if (entities.length === 0) {
+ return 'related entities';
+ }
+
+ if (entities.length === 1) {
+ return entities[0];
+ }
+
+ if (entities.length === 2) {
+ return `${entities[0]} and ${entities[1]}`;
+ }
+
+ return `${entities.slice(0, -1).join(', ')}, and ${entities[entities.length - 1]}`;
+ }
+
+ /** Same role as KVDBsStore.getErrorMessage: string / Http body / thrown errors. */
+ private getErrorMessage(error: unknown, fallback: string): string {
+ if (typeof error === 'string') {
+ return error;
+ }
+ if (error instanceof Error) {
+ return error.message || fallback;
+ }
+ if (error && typeof error === 'object') {
+ const e = error as { message?: string; body?: { message?: string } };
+ const msg = e.body?.message ?? e.message;
+ if (typeof msg === 'string' && msg.length > 0) {
+ return msg;
+ }
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return fallback;
+ }
+ }
+ return fallback;
+ }
+
+ public async getIntegration(
+ id: string,
+ spaceFilter?: string | null
+ ): Promise {
+ const integrationsRes = await this.service.searchIntegrations({
+ id,
+ spaceFilter,
+ });
+ if (integrationsRes.ok) {
+ const integrations: Integration[] = integrationsRes.response.hits.hits.map((hit) => {
+ return {
+ id: hit._id,
+ ...hit._source,
+ };
+ });
+
+ return integrations[0];
+ }
+
+ return undefined;
+ }
+
+ public async getIntegrations(spaceFilter: string): Promise {
+ try {
+ const integrationsRes = await this.service.searchIntegrations({
+ spaceFilter,
+ });
+ if (integrationsRes.ok) {
+ const integrations: Integration[] = integrationsRes.response.hits.hits.map((hit) => {
+ return {
+ id: hit._id,
+ ...hit._source,
+ space: hit._source.space,
+ };
+ });
+
+ ruleTypes.splice(
+ 0,
+ ruleTypes.length,
+ ...integrations
+ .map(({ id, document: { category, metadata }, space }) => ({
+ label: metadata.title ?? '-',
+ value: metadata?.title ?? '',
+ id,
+ category,
+ isStandard: space.name === 'Standard',
+ }))
+ .sort((a, b) => {
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
+ })
+ );
+
+ // Set integration category types
+ for (const key in integrationsByCategories) {
+ delete integrationsByCategories[key];
+ }
+ integrations.forEach((integration) => {
+ integrationsByCategories[integration.document.category] =
+ integrationsByCategories[integration.document.category] || [];
+ integrationsByCategories[integration.document.category].push(integration);
+ });
+ integrationCategoryFilters.splice(
+ 0,
+ integrationCategoryFilters.length,
+ ...Object.keys(integrationsByCategories).sort((a, b) => {
+ if (a === 'Other') {
+ return 1;
+ } else if (b === 'Other') {
+ return -1;
+ } else {
+ return a < b ? -1 : a > b ? 1 : 0;
+ }
+ })
+ );
+
+ return integrations;
+ }
+
+ return [];
+ } catch (error: any) {
+ if (error.message === DATA_SOURCE_NOT_SET_ERROR) {
+ errorNotificationToast(
+ this.notifications,
+ 'Fetch',
+ 'Integrations',
+ 'Select valid data source.'
+ );
+ return [];
+ }
+
+ throw error;
+ }
+ }
+
+ public async createIntegration(integration: IntegrationBase): Promise<[boolean, string]> {
+ try {
+ const { document, space } = integration;
+ const createRes = await this.service.createIntegration({ document, space });
+
+ if (!createRes.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'create',
+ 'integration',
+ this.getErrorMessage(createRes.error, 'Failed to create integration.')
+ );
+ return [false, ''];
+ }
+
+ return [true, createRes.response.message];
+ } catch (error: unknown) {
+ errorNotificationToast(
+ this.notifications,
+ 'create',
+ 'integration',
+ this.getErrorMessage(error, 'An unexpected error occurred.')
+ );
+ return [false, ''];
+ }
+ }
+
+ public async updateIntegration(integrationId: string, document: Integration): Promise {
+ try {
+ const updateRes = await this.service.updateIntegration(integrationId, document);
+
+ if (!updateRes.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'update',
+ 'integration',
+ this.getErrorMessage(updateRes.error, 'Failed to update integration.')
+ );
+ return false;
+ }
+
+ return true;
+ } catch (error: unknown) {
+ errorNotificationToast(
+ this.notifications,
+ 'update',
+ 'integration',
+ this.getErrorMessage(error, 'An unexpected error occurred.')
+ );
+ return false;
+ }
+ }
+
+ public async deleteIntegration(id: string) {
+ try {
+ const deleteRes = await this.service.deleteIntegration(id);
+
+ if (!deleteRes.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'delete',
+ 'integration',
+ this.getErrorMessage(deleteRes.error, 'Error occurred while deleting integration.')
+ );
+ }
+
+ return { ok: deleteRes.ok, error: deleteRes.error || null };
+ } catch (e: unknown) {
+ const msg = this.getErrorMessage(e, 'An unexpected error occurred.');
+ errorNotificationToast(this.notifications, 'delete', 'integration', msg);
+ return {
+ ok: false,
+ error: { message: msg },
+ };
+ }
+ }
+
+ public async getPromote(
+ data: GetPromote
+ ): Promise<[GetPromoteBySpaceResponse['ok'], GetPromoteBySpaceResponse['response']]> {
+ const promoteRes = await this.service.getPromoteIntegration(data);
+ if (!promoteRes.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'promote',
+ 'integration',
+ promoteRes.error.message || promoteRes.error
+ );
+ }
+
+ return [promoteRes.ok, promoteRes.response];
+ }
+
+ public async promoteIntegration(data: PromoteIntegrationRequestBody) {
+ const promoteRes = await this.service.promoteIntegration(data);
+ if (!promoteRes.ok) {
+ errorNotificationToast(this.notifications, 'promote', 'integration', promoteRes.error);
+ }
+
+ return [promoteRes.ok, promoteRes.error];
+ }
+
+ /**
+ * This method checks if an integration can be deleted.
+ * An integration can be deleted if it has no associated rules, decoders or KVDB items.
+ */
+ public canDeleteIntegration(integration: Integration): boolean {
+ const rules = (integration as Integration & { rules?: unknown }).rules;
+ const decoders = (integration as Integration & { decoders?: unknown }).decoders;
+ const kvdbs = (integration as Integration & { kvdbs?: unknown }).kvdbs;
+ const rulesCount = Array.isArray(rules) ? rules.length : 0;
+ const decodersCount = Array.isArray(decoders) ? decoders.length : 0;
+ const kvdbsCount = Array.isArray(kvdbs) ? kvdbs.length : 0;
+ return rulesCount === 0 && decodersCount === 0 && kvdbsCount === 0;
+ }
+
+ public getRelatedEntitiesMessage({
+ hasRules,
+ hasDecoders,
+ hasKVDBs,
+ }: {
+ hasRules: boolean;
+ hasDecoders: boolean;
+ hasKVDBs: boolean;
+ }): string {
+ const relatedEntities = [
+ hasRules ? 'detection rules' : null,
+ hasDecoders ? 'decoders' : null,
+ hasKVDBs ? 'KVDBs' : null,
+ ].filter(Boolean) as string[];
+
+ return this.formatRelatedEntitiesList(relatedEntities);
+ }
+}
diff --git a/public/store/KVDBsStore.ts b/public/store/KVDBsStore.ts
new file mode 100644
index 000000000..fda6918af
--- /dev/null
+++ b/public/store/KVDBsStore.ts
@@ -0,0 +1,226 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ CreateKVDBPayload,
+ CUDKVDBResponse,
+ KVDBIntegrationSummary,
+ KVDBIntegrationsSearchResponse,
+ KVDBItem,
+ KVDBSearchRequest,
+ KVDBSearchResponse,
+ ServerResponse,
+ UpdateKVDBPayload,
+} from '../../types';
+import KVDBsService from '../services/KVDBsService';
+import { errorNotificationToast } from '../utils/helpers';
+
+export class KVDBsStore {
+ constructor(
+ private service: KVDBsService,
+ private notifications: NotificationsStart
+ ) {}
+
+ public async searchKVDBs(
+ params: KVDBSearchRequest,
+ options?: { skipIntegrationMap?: boolean }
+ ): Promise<{ items: KVDBItem[]; total: number }> {
+ try {
+ const response: ServerResponse = await this.service.searchKVDBs(params);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'fetch', 'KVDBs', response.error);
+ return { items: [], total: 0 };
+ }
+
+ const hits = response.response.hits.hits ?? [];
+ const total =
+ typeof response.response.hits.total === 'number'
+ ? response.response.hits.total
+ : (response.response.hits.total?.value ?? hits.length);
+ const items: KVDBItem[] = hits.map((hit) => ({
+ id: hit._id,
+ ...hit._source,
+ }));
+
+ // Skip the integrations lookup when the caller already knows the integration
+ // context (e.g. the integration details tab), avoiding an extra request.
+ if (options?.skipIntegrationMap) {
+ return { items, total };
+ }
+
+ const kvdbIds = Array.from(
+ new Set(items.map((item) => item.document?.id).filter((id): id is string => Boolean(id)))
+ );
+
+ const integrationsMap = await this.getIntegrationsMap(kvdbIds);
+
+ const itemsWithIntegration = items.map((item) => ({
+ ...item,
+ integration: integrationsMap.get(item.document?.id ?? '') ?? undefined,
+ }));
+
+ return { items: itemsWithIntegration, total };
+ } catch (error: any) {
+ errorNotificationToast(this.notifications, 'fetch', 'KVDBs', error.message);
+ return { items: [], total: 0 };
+ }
+ }
+
+ public async getKVDB(id: string): Promise {
+ const response: ServerResponse = await this.service.searchKVDBs({
+ size: 1,
+ query: {
+ ids: {
+ values: [id],
+ },
+ },
+ });
+
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'fetch', 'KVDB', response.error);
+ return undefined;
+ }
+
+ const hit = response.response.hits.hits?.[0];
+ if (!hit) {
+ return undefined;
+ }
+
+ const item: KVDBItem = {
+ id: hit._id,
+ ...hit._source,
+ };
+
+ const integrationsMap = await this.getIntegrationsMap(
+ item.document?.id ? [item.document.id] : []
+ );
+
+ return {
+ ...item,
+ integration: integrationsMap.get(item.document?.id ?? '') ?? undefined,
+ };
+ }
+
+ private getErrorMessage(error: unknown, fallback: string): string {
+ if (typeof error === 'string') return error;
+ const err = error as { body?: { message?: string }; message?: string };
+ return err?.body?.message ?? err?.message ?? fallback;
+ }
+
+ public async createKVDB(body: CreateKVDBPayload): Promise {
+ try {
+ const response = await this.service.createKVDB(body);
+ if (!response.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'create',
+ 'KVDB',
+ this.getErrorMessage(response.error, 'Failed to create KVDB')
+ );
+ return undefined;
+ }
+ return response.response;
+ } catch (error: unknown) {
+ errorNotificationToast(
+ this.notifications,
+ 'create',
+ 'KVDB',
+ this.getErrorMessage(error, 'An unexpected error occurred.')
+ );
+ return undefined;
+ }
+ }
+
+ public async updateKVDB(
+ kvdbId: string,
+ body: UpdateKVDBPayload
+ ): Promise {
+ try {
+ const response = await this.service.updateKVDB(kvdbId, body);
+ if (!response.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'update',
+ 'KVDB',
+ this.getErrorMessage(response.error, 'Failed to update KVDB')
+ );
+ return undefined;
+ }
+ return response.response;
+ } catch (error: unknown) {
+ errorNotificationToast(
+ this.notifications,
+ 'update',
+ 'KVDB',
+ this.getErrorMessage(error, 'An unexpected error occurred.')
+ );
+ return undefined;
+ }
+ }
+
+ public async deleteKVDB(kvdbId: string): Promise {
+ try {
+ const response = await this.service.deleteKVDB(kvdbId);
+ if (!response.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'delete',
+ 'KVDB',
+ this.getErrorMessage(response.error, 'Failed to delete KVDB')
+ );
+ return undefined;
+ }
+ return response.response;
+ } catch (error: unknown) {
+ errorNotificationToast(
+ this.notifications,
+ 'delete',
+ 'KVDB',
+ this.getErrorMessage(error, 'An unexpected error occurred.')
+ );
+ return undefined;
+ }
+ }
+
+ private async getIntegrationsMap(
+ kvdbIds: string[]
+ ): Promise> {
+ if (!kvdbIds.length) {
+ return new Map();
+ }
+
+ const response: ServerResponse =
+ await this.service.searchIntegrations(kvdbIds);
+
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'fetch', 'integrations', response.error);
+ return new Map();
+ }
+
+ const integrationsMap = new Map();
+ const hits = response.response.hits.hits ?? [];
+
+ hits.forEach((hit) => {
+ const integration = hit._source;
+ const integrationId = integration.document?.id ?? hit._id;
+ const integrationTitle = integration.document?.metadata?.title;
+ const related = integration.document?.kvdbs ?? [];
+ const relatedIds = Array.isArray(related) ? related : [related];
+
+ relatedIds.forEach((kvdbId) => {
+ if (!kvdbId || integrationsMap.has(kvdbId)) {
+ return;
+ }
+ integrationsMap.set(kvdbId, {
+ id: integrationId,
+ title: integrationTitle,
+ });
+ });
+ });
+
+ return integrationsMap;
+ }
+}
diff --git a/public/store/LogTestStore.ts b/public/store/LogTestStore.ts
new file mode 100644
index 000000000..a6ee2851a
--- /dev/null
+++ b/public/store/LogTestStore.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { errorNotificationToast } from '../utils/helpers';
+import LogTestService from '../services/LogTestService';
+import { LogTestApiRequest, LogTestResponse } from '../../types';
+
+function formatLogTestClientError(error: unknown): string {
+ const err = error as any;
+ const body = err?.body;
+ if (typeof body === 'string' && body.trim()) {
+ return body.trim();
+ }
+ if (body && typeof body === 'object') {
+ if (typeof body.message === 'string' && body.message.trim()) {
+ return body.message.trim();
+ }
+ const nested = (body as any).error;
+ if (typeof nested === 'string' && nested.trim()) {
+ return nested.trim();
+ }
+ if (nested && typeof nested === 'object') {
+ if (typeof nested.reason === 'string' && nested.reason.trim()) {
+ return nested.reason.trim();
+ }
+ const root = nested.root_cause;
+ if (Array.isArray(root)) {
+ const first = root.find((c: any) => typeof c?.reason === 'string' && c.reason.trim());
+ if (first?.reason) {
+ return String(first.reason).trim();
+ }
+ }
+ }
+ }
+ if (typeof err?.message === 'string' && err.message.trim()) {
+ return err.message.trim();
+ }
+ return 'An unexpected error occurred while running the log test.';
+}
+
+export interface LogTestStoreResult {
+ success: boolean;
+ data?: LogTestResponse;
+ error?: string;
+}
+
+export class LogTestStore {
+ constructor(
+ private service: LogTestService,
+ private notifications: NotificationsStart
+ ) {}
+
+ executeLogTest = async (request: LogTestApiRequest): Promise => {
+ try {
+ const response = await this.service.executeLogTest(request);
+
+ if (!response.ok) {
+ errorNotificationToast(
+ this.notifications,
+ 'execute',
+ 'log test',
+ response.error
+ );
+ return {
+ success: false,
+ error: response.error,
+ };
+ }
+
+ return {
+ success: true,
+ data: response.response,
+ };
+ } catch (error: unknown) {
+ const errorMessage = formatLogTestClientError(error);
+ errorNotificationToast(this.notifications, 'submit', 'Log test', errorMessage);
+ return {
+ success: false,
+ error: errorMessage,
+ };
+ }
+ };
+}
\ No newline at end of file
diff --git a/public/store/LogTypeStore.ts b/public/store/LogTypeStore.ts
index f1fb07cdd..89a217f66 100644
--- a/public/store/LogTypeStore.ts
+++ b/public/store/LogTypeStore.ts
@@ -14,20 +14,32 @@ import {
logTypeCategories,
logTypesByCategories,
} from '../utils/constants';
+import { DetectorSourceLabel, getDetectorSourceLabel } from '../utils/detectorSource';
import { getLogTypeLabel } from '../pages/LogTypes/utils/helpers';
+/** Indexer stores lifecycle as `space`; UI model keeps `source` mapped to its label. */
+function mapLogTypeFromHit(hit: {
+ _id: string;
+ _source: LogTypeBase & { space?: string };
+}): LogType {
+ const src = hit._source;
+ const { space, source: _, ...rest } = src;
+ const raw = typeof space === 'string' ? space : '';
+ const source = getDetectorSourceLabel(raw) ?? raw;
+ return {
+ id: hit._id,
+ ...rest,
+ source,
+ };
+}
+
export class LogTypeStore {
constructor(private service: LogTypeService, private notifications: NotificationsStart) {}
public async getLogType(id: string): Promise {
const logTypesRes = await this.service.searchLogTypes(id);
if (logTypesRes.ok) {
- const logTypes: LogType[] = logTypesRes.response.hits.hits.map((hit) => {
- return {
- id: hit._id,
- ...hit._source,
- };
- });
+ const logTypes: LogType[] = logTypesRes.response.hits.hits.map((hit) => mapLogTypeFromHit(hit));
let detectionRules: RuleItemInfoBase[] = [];
@@ -48,13 +60,7 @@ export class LogTypeStore {
try {
const logTypesRes = await this.service.searchLogTypes();
if (logTypesRes.ok) {
- const logTypes: LogType[] = logTypesRes.response.hits.hits.map((hit) => {
- return {
- id: hit._id,
- ...hit._source,
- source: hit._source.source.toLowerCase() === 'sigma' ? 'Standard' : hit._source.source,
- };
- });
+ const logTypes: LogType[] = logTypesRes.response.hits.hits.map((hit) => mapLogTypeFromHit(hit));
ruleTypes.splice(
0,
@@ -65,7 +71,7 @@ export class LogTypeStore {
value: name,
id,
category,
- isStandard: source === 'Standard',
+ isStandard: source === DetectorSourceLabel.Standard,
}))
.sort((a, b) => {
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
diff --git a/public/store/PoliciesStore.ts b/public/store/PoliciesStore.ts
new file mode 100644
index 000000000..dbef87801
--- /dev/null
+++ b/public/store/PoliciesStore.ts
@@ -0,0 +1,94 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ PolicyItem,
+ SearchPoliciesResponse,
+ SearchPolicyOptions,
+ UpdatePolicyRequestBody,
+ UpdatePolicyResponse,
+} from '../../types';
+import PoliciesService from '../services/PoliciesService';
+import { errorNotificationToast } from '../utils/helpers';
+
+export interface PoliciesSearchParams {
+ from?: number;
+ size?: number;
+ sort?: any;
+ query?: any;
+ _source?: any;
+}
+
+export class PoliciesStore {
+ constructor(private service: PoliciesService, private notifications: NotificationsStart) {}
+
+ public async searchPolicies(
+ space: string,
+ options: SearchPolicyOptions
+ ): Promise {
+ const response = await this.service.searchPolicies(space, options);
+ if (!response.ok) {
+ if (
+ response.error?.includes('index_not_found_exception') ||
+ response.error?.includes('no such index')
+ ) {
+ return { total: 0, items: [] };
+ }
+ errorNotificationToast(this.notifications, 'retrieve', 'policies', response.error);
+ return { total: 0, items: [] };
+ }
+
+ const items: PolicyItem[] = response.response.items.map((item) => ({
+ ...item,
+ }));
+
+ return { ...response.response, items };
+ }
+
+ public async getPolicy(policyId: string): Promise {
+ const response = await this.service.getPolicy(policyId);
+ if (!response.ok) {
+ if (
+ response.error?.includes('index_not_found_exception') ||
+ response.error?.includes('no such index')
+ ) {
+ return undefined;
+ }
+ errorNotificationToast(this.notifications, 'retrieve', 'policy', response.error);
+ return undefined;
+ }
+
+ const item = response.response.item;
+ if (!item) {
+ return undefined;
+ }
+
+ return {
+ ...item,
+ };
+ }
+
+ public async updatePolicy(
+ space: string,
+ data: UpdatePolicyRequestBody
+ ): Promise<[boolean, UpdatePolicyResponse['response']]> {
+ const response = await this.service.updatePolicy(space, data);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'update', 'policy', response.error);
+ return [false, null];
+ }
+ return [response.ok, response.response];
+ }
+
+ public async deleteSpace(space: string): Promise {
+ const response = await this.service.deleteSpace(space);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'clear', 'space', response.error);
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/public/store/RulesStore.test.ts b/public/store/RulesStore.test.ts
index 7bf7deb2f..f99fd08ed 100644
--- a/public/store/RulesStore.test.ts
+++ b/public/store/RulesStore.test.ts
@@ -6,7 +6,7 @@
import { DataStore } from './DataStore';
import notificationsStartMock from '../../test/mocks/services/notifications/NotificationsStart.mock';
import services from '../../test/mocks/services';
-import { RulesStore } from './RulesStore';
+import { RulesStore } from './WazuhRulesStore';
import { expect } from '@jest/globals';
import * as rulesResponseMock from '../../.cypress/fixtures/sample_rule.json';
describe('Rules store specs', () => {
diff --git a/public/store/ThreatIntelStore.ts b/public/store/ThreatIntelStore.ts
index 03603cb2e..764e2fe11 100644
--- a/public/store/ThreatIntelStore.ts
+++ b/public/store/ThreatIntelStore.ts
@@ -5,6 +5,7 @@
import { Duration, ThreatIntelFinding } from '../../types';
import { FindingsService } from '../services';
+import { THREAT_INTEL_ENABLED } from '../utils/constants';
export class ThreatIntelStore {
constructor(private findingsService: FindingsService) {}
@@ -14,6 +15,10 @@ export class ThreatIntelStore {
duration?: Duration,
onPartialFindingsFetched?: (findings: ThreatIntelFinding[]) => void
) {
+ if (!THREAT_INTEL_ENABLED) {
+ return [];
+ }
+
let start = 0;
const size = 10000;
let remaining = 10000;
@@ -57,6 +62,10 @@ export class ThreatIntelStore {
}
public async getThreatIntelFindingsByIds(findingIds: string[]) {
+ if (!THREAT_INTEL_ENABLED) {
+ return [];
+ }
+
const res = await this.findingsService.getThreatIntelFindings({
findingIds: findingIds.join(','),
});
diff --git a/public/store/WazuhRulesStore.ts b/public/store/WazuhRulesStore.ts
new file mode 100644
index 000000000..361878246
--- /dev/null
+++ b/public/store/WazuhRulesStore.ts
@@ -0,0 +1,306 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { RuleService } from '../services';
+import { dump } from 'js-yaml';
+import { RuleItemInfoBase, IRulesCache, Rule, IWazuhRulesStore } from '../../types/Rule';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { errorNotificationToast } from '../utils/helpers';
+
+/**
+ * Class is used to make rule's API calls and cache the rules.
+ * If there is a cache data requests are skipped and result is returned from the cache.
+ * If cache is invalidated then the request is made to get a new set of data.
+ *
+ * @class RulesStore
+ * @implements IRulesStore
+ * @param {BrowserServices} services Uses services to make API requests
+ */
+export class RulesStore implements IWazuhRulesStore {
+ /**
+ * Rule service instance
+ *
+ * @property {RuleService} service
+ * @readonly
+ */
+ readonly service: RuleService;
+
+ /**
+ * Notifications
+ * @property {NotificationsStart}
+ * @readonly
+ */
+ readonly notifications: NotificationsStart;
+
+ constructor(service: RuleService, notifications: NotificationsStart) {
+ this.service = service;
+ this.notifications = notifications;
+ }
+
+ /**
+ * Keeps rule's data cached
+ *
+ * @property {IRulesCache} cache
+ */
+ private cache: IRulesCache = {};
+
+ /**
+ * Invalidates all rules data
+ */
+ private invalidateCache = () => {
+ this.cache = {};
+ return this;
+ };
+
+ /**
+ * Returns all rules, custom and pre-packaged
+ *
+ * @method getAllRules
+ * @param {terms?: { [key: string]: string[] }} [terms]
+ * @returns {Promise}
+ */
+ public async getAllRules(terms?: { [key: string]: string[] }): Promise {
+ this.invalidateCache();
+ const [customRules, prePackagedRules] = await Promise.all([
+ this.getCustomRules(terms),
+ this.getPrePackagedRules(terms),
+ ]);
+ return customRules.concat(prePackagedRules);
+ }
+
+ /**
+ * Returns only pre-packaged rules
+ * @param {{[p: string]: string[]}} terms
+ * @returns {Promise}
+ */
+ public async getPrePackagedRules(terms?: { [key: string]: string[] }) {
+ return this.getRules(true, terms);
+ }
+
+ /**
+ * Returns only custom rules
+ * @param {{[p: string]: string[]}} terms
+ * @returns {Promise}
+ */
+ public async getCustomRules(terms?: { [key: string]: string[] }) {
+ return this.getRules(false, terms);
+ }
+
+ /**
+ * Makes the request to get pre-packaged or custom rules
+ *
+ * @param {boolean} prePackaged
+ * @param {terms?: { [key: string]: string[] }} terms
+ * @returns {Promise}
+ */
+ public async getRules(
+ prePackaged: boolean,
+ terms?: { [key: string]: string[] }
+ ): Promise {
+ const cacheKey: string = `getRules:${JSON.stringify(arguments)}`;
+
+ if (this.cache[cacheKey]) {
+ return this.cache[cacheKey];
+ }
+
+ let query: object;
+ if (terms?.['_id']) {
+ query = { ids: { values: terms['_id'] } };
+ } else if (terms?.['document.id']) {
+ query = {
+ terms: {
+ 'document.id': terms['document.id'],
+ },
+ };
+ } else if (terms?.['rule.category']) {
+ const categories = terms['rule.category'].map((c) => c.toLowerCase());
+ query = {
+ bool: {
+ should: [
+ { terms: { 'document.logsource.category': categories } },
+ { terms: { 'document.logsource.product': categories } },
+ ],
+ minimum_should_match: 1,
+ },
+ };
+ } else {
+ query = { match_all: {} };
+ }
+
+ const body = {
+ from: 0,
+ size: 5000,
+ query,
+ };
+
+ const response = await this.service.getRules(prePackaged, body);
+
+ if (response?.ok) {
+ return (this.cache[cacheKey] = response.response.hits.hits.map((hit: any) =>
+ this.mapToRuleItem(hit, prePackaged)
+ ));
+ } else {
+ if (!response.error?.includes('index doesnt exist')) {
+ errorNotificationToast(this.notifications, 'retrieve', 'rules', response.error);
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Create a new rule
+ *
+ * @param {Rule} rule
+ * @param {string} integrationId
+ * @returns {Promise}
+ */
+ public createRule = async (rule: Rule, integrationId: string): Promise => {
+ const response = await this.invalidateCache().service.createRule({
+ document: rule,
+ integrationId,
+ });
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'create', 'rule', response.error);
+ }
+
+ return response.ok;
+ };
+
+ /**
+ * Update a rule
+ *
+ * @param {string} id
+ * @param {Rule} rule
+ * @returns {Promise}
+ */
+ public updateRule = async (id: string, rule: Rule): Promise => {
+ const response = await this.invalidateCache().service.updateRule(id, { document: rule });
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'update', 'rule', response.error);
+ }
+
+ return response.ok;
+ };
+
+ /**
+ * Update a rule
+ *
+ * @param {string} id
+ * @returns {Promise}
+ */
+ public deleteRule = async (id: string): Promise => {
+ const response = await this.invalidateCache().service.deleteRule(id);
+ if (!response.ok) {
+ errorNotificationToast(this.notifications, 'delete', 'rule', response.error);
+ }
+
+ return response.ok;
+ };
+
+ private normalizeSpace(space: unknown): string | undefined {
+ if (!space) return undefined;
+ if (typeof space === 'string') return space;
+ if (typeof space === 'object') {
+ const record = space as Record;
+ if (typeof record.name === 'string') return record.name;
+ if (typeof record.id === 'string') return record.id;
+ if (typeof record.value === 'string') return record.value;
+ }
+ return undefined;
+ }
+
+ public async searchRules(
+ params: {
+ query?: any;
+ from?: number;
+ size?: number;
+ sort?: Array>;
+ _source?: any;
+ },
+ space: string
+ ): Promise<{ total: number; items: RuleItemInfoBase[] }> {
+ const body: any = {
+ from: params.from ?? 0,
+ size: params.size ?? 25,
+ track_total_hits: true,
+ query: params.query ?? { match_all: {} },
+ };
+ if (params.sort) body.sort = params.sort;
+ if (params._source !== undefined) body._source = params._source;
+
+ const isStandard = space === 'standard';
+ const response = await this.service.getRules(isStandard, body, space);
+
+ if (!response?.ok) {
+ if (!response?.error?.includes('index doesnt exist')) {
+ errorNotificationToast(this.notifications, 'retrieve', 'rules', response?.error);
+ }
+ return { total: 0, items: [] };
+ }
+
+ const items = response.response.hits.hits.map((hit: any) =>
+ this.mapToRuleItem(hit, isStandard)
+ );
+ const total = response.response.hits.total?.value ?? items.length;
+ return { total, items };
+ }
+
+ public async getRule(id: string, space: string): Promise {
+ const isStandard = space === 'standard';
+ const response = await this.service.getRules(
+ isStandard,
+ { query: { term: { 'document.id': id } }, size: 1 },
+ space
+ );
+ if (!response?.ok) return undefined;
+ const hit = response.response.hits.hits?.[0];
+ return hit ? this.mapToRuleItem(hit, isStandard) : undefined;
+ }
+
+ private toYamlString(value: unknown): string {
+ if (!value) return '';
+ if (typeof value === 'string') return value;
+ try {
+ return dump(value);
+ } catch {
+ return '';
+ }
+ }
+
+ private mapToRuleItem(hit: any, prePackaged: boolean): RuleItemInfoBase {
+ const doc = hit._source?.document ?? {};
+ const logsource = doc.logsource ?? {};
+ const meta = doc.metadata ?? {};
+ const integration = hit.integration || null;
+
+ return {
+ ...hit,
+ integration,
+ _id: hit._source?.document?.id ?? hit._id,
+ prePackaged,
+ space: this.normalizeSpace(hit._source?.space),
+ _source: {
+ id: hit._source?.document?.id ?? hit._id,
+ level: doc.level ?? '',
+ category: logsource.category ?? logsource.product ?? logsource.service ?? '',
+ status: doc.status ?? '',
+ detection: doc.detection ? dump(doc.detection) : '',
+ tags: (doc.tags ?? meta.tags ?? []).map((t: string) => ({ value: t })),
+ false_positives: (doc.falsepositives ?? meta.false_positives ?? []).map((fp: string) => ({
+ value: fp,
+ })),
+ log_source: logsource,
+ metadata: meta,
+ enabled: doc.enabled ?? true,
+ mitre: this.toYamlString(doc.mitre),
+ compliance: this.toYamlString(doc.compliance),
+ rule: '',
+ queries: [],
+ query_field_names: [],
+ },
+ };
+ }
+}
diff --git a/public/utils/constants.ts b/public/utils/constants.ts
index dec93b83e..80fc6f511 100644
--- a/public/utils/constants.ts
+++ b/public/utils/constants.ts
@@ -28,10 +28,19 @@ export const THREAT_ALERTS_NAV_ID = `threat_alerts`;
export const FINDINGS_NAV_ID = `findings`;
export const CORRELATIONS_NAV_ID = `correlations`;
export const DETECTORS_NAV_ID = `detectors`;
-export const DETECTION_RULE_NAV_ID = `detection_rules`;
+export const DETECTION_RULE_NAV_ID = `rules`; // Wazuh: rename 'Detection rules' to 'Rules'.
export const CORRELATIONS_RULE_NAV_ID = `correlation_rules`;
export const THREAT_INTEL_NAV_ID = `threat_intelligence`;
+export const INTEGRATIONS_NAV_ID = `sa-integrations`;
export const LOG_TYPES_NAV_ID = `log_types`;
+export const INSIGHTS_NAV_ID = 'insights_nav';
+export const DETECTION_NAV_ID = 'detection_nav';
+export const NORMALIZATION_NAV_ID = 'normalization';
+export const DECODERS_NAV_ID = 'decoders';
+export const KVDBS_NAV_ID = 'kvdbs';
+export const LOG_TEST_NAV_ID = 'log_test';
+// Wazuh: Threat intelligence app/endpoints are not available.
+export const THREAT_INTEL_ENABLED = false;
export let isDarkMode: boolean = false;
@@ -61,6 +70,12 @@ export const ROUTES = Object.freeze({
CORRELATION_RULES: '/correlations/rules',
CORRELATION_RULE_CREATE: '/correlations/create-rule',
CORRELATION_RULE_EDIT: '/correlations/rule',
+ INTEGRATIONS: '/integrations',
+ FILTERS: '/filters',
+ FILTERS_CREATE: '/create-filter',
+ FILTERS_EDIT: '/edit-filter',
+ INTEGRATIONS_CREATE: '/create-integration',
+ PROMOTE: '/promote',
LOG_TYPES: '/log-types',
LOG_TYPES_CREATE: '/create-log-type',
THREAT_INTEL_OVERVIEW: '/threat-intel',
@@ -68,9 +83,18 @@ export const ROUTES = Object.freeze({
THREAT_INTEL_CREATE_SCAN_CONFIG: '/create-scan-config',
THREAT_INTEL_EDIT_SCAN_CONFIG: '/edit-scan-config',
THREAT_INTEL_SOURCE_DETAILS: '/threat-intel-source',
+ // Wazuh
+ NORMALIZATION: '/normalization',
+ DECODERS: '/decoders',
+ DECODERS_CREATE: '/create-decoder',
+ DECODERS_EDIT: '/edit-decoder',
+ KVDBS: '/kvdbs',
+ KVDBS_CREATE: '/create-kvdb',
+ KVDBS_EDIT: '/edit-kvdb',
+ LOG_TEST: '/log-test',
get LANDING_PAGE(): string {
- return this.OVERVIEW;
+ return this.INTEGRATIONS;
},
});
@@ -79,12 +103,18 @@ export const getNotificationDetailsHref = (channelId: string) =>
`notifications-dashboards#/channels-details/${channelId}`;
export const BREADCRUMBS = Object.freeze({
- SECURITY_ANALYTICS: { text: 'Security Analytics', href: `#${ROUTES.OVERVIEW}` },
+ SECURITY_ANALYTICS: {
+ text: 'Security Analytics',
+ href: `#${ROUTES.INTEGRATIONS}`,
+ },
OVERVIEW: { text: 'Overview', href: `#${ROUTES.OVERVIEW}` },
GETTING_STARTED: { text: 'Get started', href: `#${ROUTES.GETTING_STARTED}` },
FINDINGS: { text: 'Findings', href: `#${ROUTES.FINDINGS}` },
- DETECTORS: { text: 'Threat detectors', href: `#${ROUTES.DETECTORS}` },
- DETECTORS_CREATE: { text: 'Create detector', href: `#${ROUTES.DETECTORS_CREATE}` },
+ DETECTORS: { text: 'Detectors', href: `#${ROUTES.DETECTORS}` },
+ DETECTORS_CREATE: {
+ text: 'Create detector',
+ href: `#${ROUTES.DETECTORS_CREATE}`,
+ },
EDIT_DETECTOR_DETAILS: { text: 'Edit detector details' },
DETECTORS_DETAILS: (name: string, detectorId: string) => ({
text: `${name}`,
@@ -94,25 +124,57 @@ export const BREADCRUMBS = Object.freeze({
text: `${name}`,
href: `#${ROUTES.EDIT_DETECTOR_DETAILS}/${detectorId}`,
}),
- RULES: { text: 'Detection rules', href: `#${ROUTES.RULES}` },
- ALERTS: { text: 'Threat alerts', href: `#${ROUTES.ALERTS}` },
- RULES_CREATE: { text: 'Create detection rule', href: `#${ROUTES.RULES_CREATE}` },
+ RULES: { text: 'Rules', href: `#${ROUTES.RULES}` }, // Wazuh: rename 'Detection rules' to 'Rules'.
+ ALERTS: { text: 'Alerts', href: `#${ROUTES.ALERTS}` },
+ RULES_CREATE: {
+ text: 'Create rule', // Wazuh: rename 'detection rule' to 'rule'
+ href: `#${ROUTES.RULES_CREATE}`,
+ },
RULES_EDIT: { text: 'Edit rule', href: `#${ROUTES.RULES_EDIT}` },
RULE_EDIT_DETAILS: (name: string) => ({
text: `${name}`,
href: `#${ROUTES.RULES_EDIT}`,
}),
- RULES_DUPLICATE: { text: 'Duplicate rule', href: `#${ROUTES.RULES_DUPLICATE}` },
+ RULES_DUPLICATE: {
+ text: 'Duplicate rule',
+ href: `#${ROUTES.RULES_DUPLICATE}`,
+ },
RULES_IMPORT: { text: 'Import rule', href: `#${ROUTES.RULES_IMPORT}` },
CORRELATIONS: { text: 'Correlations', href: `#${ROUTES.CORRELATIONS}` },
- CORRELATION_RULES: { text: 'Correlation rules', href: `#${ROUTES.CORRELATION_RULES}` },
+ CORRELATION_RULES: {
+ text: 'Correlation rules',
+ href: `#${ROUTES.CORRELATION_RULES}`,
+ },
CORRELATIONS_RULE_CREATE: (action: string) => ({
text: `${action} correlation rule`,
href: `#${ROUTES.CORRELATION_RULE_CREATE}`,
}),
- LOG_TYPES: { text: 'Log types', href: `#${ROUTES.LOG_TYPES}` },
- LOG_TYPE_CREATE: { text: 'Create log type', href: `#${ROUTES.LOG_TYPES_CREATE}` },
- THREAT_INTEL_OVERVIEW: { text: 'Threat intelligence', href: `#${ROUTES.THREAT_INTEL_OVERVIEW}` },
+ LOG_TYPES: { text: 'Integrations', href: `#${ROUTES.LOG_TYPES}` }, // Replace Log Types with Integrations by Wazuh
+ LOG_TYPE_CREATE: {
+ text: 'Create integration',
+ href: `#${ROUTES.LOG_TYPES_CREATE}`,
+ }, // Replace Log Type with Integration by Wazuh
+ NORMALIZATION: { text: 'Normalization' },
+ INTEGRATIONS: { text: 'Overview', href: `#${ROUTES.INTEGRATIONS}` }, // Replace Log Types with Integrations by Wazuh
+ INTEGRATIONS_CREATE: {
+ text: 'Create integration',
+ href: `#${ROUTES.INTEGRATIONS_CREATE}`,
+ }, // Replace Log Type with Integration by Wazuh
+ PROMOTE: { text: 'Promote', href: `#${ROUTES.PROMOTE}` },
+ DECODERS: { text: 'Decoders', href: `#${ROUTES.DECODERS}` },
+ DECODERS_CREATE: { text: 'Create', href: `#${ROUTES.DECODERS_CREATE}` },
+ DECODERS_EDIT: { text: 'Edit' },
+ KVDBS: { text: 'KVDBs', href: `#${ROUTES.KVDBS}` },
+ KVDBS_CREATE: { text: 'Create', href: `#${ROUTES.KVDBS_CREATE}` },
+ KVDBS_EDIT: { text: 'Edit' },
+ FILTERS: { text: 'Overview', href: `#${ROUTES.FILTERS}` },
+ FILTERS_CREATE: { text: 'Create filter', href: `#${ROUTES.FILTERS_CREATE}` },
+ FILTERS_EDIT: { text: 'Edit filter' },
+ LOG_TEST: { text: 'Log test', href: `#${ROUTES.LOG_TEST}` },
+ THREAT_INTEL_OVERVIEW: {
+ text: 'Threat intelligence',
+ href: `#${ROUTES.THREAT_INTEL_OVERVIEW}`,
+ },
THREAT_INTEL_ADD_CUSTOM_SOURCE: {
text: 'Add threat intel source',
href: `#${ROUTES.THREAT_INTEL_ADD_CUSTOM_SOURCE}`,
@@ -129,6 +191,8 @@ export const BREADCRUMBS = Object.freeze({
text: `${name}`,
href: `#${ROUTES.THREAT_INTEL_SOURCE_DETAILS}/${id}`,
}),
+ INSIGHTS: { text: 'Insights' },
+ DETECTION: { text: 'Detection' },
});
export enum SortDirection {
@@ -221,15 +285,82 @@ export const pendingDashboardCreations: {
[detectorId: string]: undefined | Promise>>;
} = {};
-export const logTypeCategoryDescription: { name: string; description: string }[] = [
- { name: 'Access Management', description: 'User access, authentication, group management' },
- { name: 'Applications', description: 'Application lifecycle, API, and web resources activities' },
- { name: 'Cloud Services', description: 'Services managed by cloud providers' },
- { name: 'Network Activity', description: 'DNS, HTTP, Email, SSH, FTP, DHCP, RDP' },
+export const logTypeCategoryDescription: {
+ name: string;
+ description: string;
+}[] = [
+ {
+ name: 'Access Management',
+ description: 'User access, authentication, group management',
+ },
+ {
+ name: 'Applications',
+ description: 'Application lifecycle, API, and web resources activities',
+ },
+ {
+ name: 'Cloud Services',
+ description: 'Services managed by cloud providers',
+ },
+ {
+ name: 'Network Activity',
+ description: 'DNS, HTTP, Email, SSH, FTP, DHCP, RDP',
+ },
{ name: 'System Activity', description: 'System monitoring logs' },
{ name: 'Other', description: 'Logs not covered in other categories' },
];
+/* Wazuh: Replaced log types with integrations categories. */
+export const integrationCategories: {
+ label: string;
+ value: string;
+ description: string;
+}[] = [
+ {
+ label: 'Access Management',
+ value: 'access-management',
+ description: 'User access, authentication, group management',
+ },
+ {
+ label: 'Applications',
+ value: 'applications',
+ description: 'Application lifecycle, API, and web resources activities',
+ },
+ {
+ label: 'Cloud Services',
+ value: 'cloud-services',
+ description: 'Services managed by cloud providers',
+ },
+ {
+ label: 'Network Activity',
+ value: 'network-activity',
+ description: 'DNS, HTTP, Email, SSH, FTP, DHCP, RDP',
+ },
+ {
+ label: 'System Activity',
+ value: 'system-activity',
+ description: 'System monitoring logs',
+ },
+ {
+ label: 'Security',
+ value: 'security',
+ description: 'Security-related logs and events',
+ },
+ {
+ label: 'Other',
+ value: 'other',
+ description: 'Logs not covered in other categories',
+ },
+ {
+ label: 'Unclassified',
+ value: 'unclassified',
+ description: 'Logs that have not been classified into a category',
+ },
+];
+
+export const integrationsByCategories: { [category: string]: Integration[] } = {};
+
+export const integrationCategoryFilters: string[] = integrationCategories.map(({ value }) => value);
+
export const logTypeCategories: string[] = [];
export const logTypesByCategories: { [category: string]: LogType[] } = {};
@@ -329,3 +460,22 @@ export const DEFAULT_MESSAGE_SOURCE = {
- Detector data sources: {{ctx.detector.datasources}}`,
MESSAGE_SUBJECT: `Triggered alert condition: {{ctx.trigger.name}} - Severity: {{ctx.trigger.severity}} - Threat detector: {{ctx.detector.name}}`,
};
+
+// Wazuh: promotions order
+export const PROMOTE_ENTITIES_ORDER = [
+ 'policy',
+ 'integrations',
+ 'decoders',
+ 'kvdbs',
+ 'filters',
+ 'rules',
+] as const;
+
+export const PROMOTE_ENTITIES_LABELS: Record = {
+ policy: 'Space',
+ integrations: 'Integrations',
+ decoders: 'Decoders',
+ kvdbs: 'KVDBs',
+ filters: 'Filters',
+ rules: 'Rules',
+};
diff --git a/public/utils/dateFormat.test.ts b/public/utils/dateFormat.test.ts
new file mode 100644
index 000000000..2aa7727e8
--- /dev/null
+++ b/public/utils/dateFormat.test.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import moment from 'moment';
+import { uiSettingsServiceMock } from '../../../../src/core/public/ui_settings/ui_settings_service.mock';
+import { setUISettings } from '../services/utils/constants';
+import { DEFAULT_EMPTY_DATA } from './constants';
+import { formatUIDate } from './dateFormat';
+
+const momentInstance = (moment() as unknown) as {
+ format: jest.Mock;
+ tz: jest.Mock;
+ isValid: jest.Mock;
+};
+
+const uiSettings = uiSettingsServiceMock.createStartContract();
+
+const setSettings = (values: Record) => {
+ (uiSettings.get as jest.Mock).mockImplementation((key: string, defaultValue?: unknown) =>
+ key in values ? values[key] : defaultValue
+ );
+};
+
+describe('formatUIDate', () => {
+ beforeAll(() => {
+ setUISettings(uiSettings);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('formats a valid date using the configured dateFormat setting', () => {
+ setSettings({ dateFormat: 'YYYY-MM-DD', 'dateFormat:tz': 'America/New_York' });
+
+ formatUIDate('2024-01-15T10:00:00Z');
+
+ expect(momentInstance.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ });
+
+ it('applies the configured dateFormat:tz timezone', () => {
+ setSettings({ dateFormat: 'YYYY-MM-DD', 'dateFormat:tz': 'America/New_York' });
+
+ formatUIDate('2024-01-15T10:00:00Z');
+
+ expect(momentInstance.tz).toHaveBeenCalledWith('America/New_York');
+ });
+
+ it('falls back to the detected timezone when dateFormat:tz is "Browser"', () => {
+ setSettings({ dateFormat: 'YYYY-MM-DD', 'dateFormat:tz': 'Browser' });
+
+ formatUIDate('2024-01-15T10:00:00Z');
+
+ // test/setup.jest.ts mocks moment.tz.guess() => 'Pacific/Tahiti'.
+ expect(momentInstance.tz).toHaveBeenCalledWith('Pacific/Tahiti');
+ });
+
+ it('returns DEFAULT_EMPTY_DATA for empty input without formatting', () => {
+ setSettings({ dateFormat: 'YYYY-MM-DD' });
+
+ expect(formatUIDate(undefined)).toBe(DEFAULT_EMPTY_DATA);
+ expect(formatUIDate('')).toBe(DEFAULT_EMPTY_DATA);
+ expect(formatUIDate(0)).toBe(DEFAULT_EMPTY_DATA);
+ expect(momentInstance.format).not.toHaveBeenCalled();
+ });
+
+ it('returns DEFAULT_EMPTY_DATA for an invalid date', () => {
+ setSettings({ dateFormat: 'YYYY-MM-DD' });
+ // The shared mock instance is always valid, so force an invalid result once.
+ jest.spyOn(momentInstance, 'isValid').mockReturnValueOnce(false);
+
+ expect(formatUIDate('not-a-date')).toBe(DEFAULT_EMPTY_DATA);
+ expect(momentInstance.format).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the default format when uiSettings is unavailable', () => {
+ jest.isolateModules(() => {
+ // Fresh module registry β constants' getUISettings() is unset and throws;
+ // formatUIDate must swallow it and use the default format (DEFAULT_DATE_FORMAT).
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { formatUIDate: isolatedFormat } = require('./dateFormat');
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const isolatedMoment = require('moment');
+
+ expect(() => isolatedFormat('2024-01-15T10:00:00Z')).not.toThrow();
+ expect(isolatedMoment().format).toHaveBeenCalledWith('MM/DD/YY h:mm a');
+ });
+ });
+});
diff --git a/public/utils/dateFormat.ts b/public/utils/dateFormat.ts
new file mode 100644
index 000000000..4a125f40e
--- /dev/null
+++ b/public/utils/dateFormat.ts
@@ -0,0 +1,39 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import moment from 'moment';
+import { getUISettings } from '../services/utils/constants';
+import { DEFAULT_EMPTY_DATA } from './constants';
+
+/** Fallback format when `dateFormat` is unavailable (matches the old `renderTime`). */
+const DEFAULT_DATE_FORMAT = 'MM/DD/YY h:mm a';
+
+/** Reads a uiSettings value; returns undefined if the client isn't set yet (e.g. unit tests). */
+const getUiSetting = (key: string): string | undefined => {
+ try {
+ return getUISettings().get(key);
+ } catch {
+ return undefined;
+ }
+};
+
+/** Timezone from `dateFormat:tz`; `Browser` or unset falls back to the detected zone. */
+const getTimeZone = (): string => {
+ const dateFormatTZ = getUiSetting('dateFormat:tz');
+ return !dateFormatTZ || dateFormatTZ === 'Browser' ? moment.tz.guess() : dateFormatTZ;
+};
+
+/**
+ * Formats a date honoring the `dateFormat` and `dateFormat:tz` advanced settings.
+ * Returns `DEFAULT_EMPTY_DATA` for empty or invalid input.
+ */
+export const formatUIDate = (date?: number | string | Date): string => {
+ const momentDate = moment(date);
+ if (!date || !momentDate.isValid()) {
+ return DEFAULT_EMPTY_DATA;
+ }
+ const dateFormat = getUiSetting('dateFormat') || DEFAULT_DATE_FORMAT;
+ return momentDate.tz(getTimeZone()).format(dateFormat);
+};
diff --git a/public/utils/detectorSource.ts b/public/utils/detectorSource.ts
new file mode 100644
index 000000000..950a1e3b2
--- /dev/null
+++ b/public/utils/detectorSource.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Wazuh: centralised vocabulary for the detector / logtype `source` (a.k.a. "space") field.
+
+/** Raw values stored in the indexer (normalized to lowercase). */
+export enum DetectorSourceRaw {
+ Standard = 'standard',
+ Custom = 'custom',
+}
+
+/** Labels rendered in the UI */
+export enum DetectorSourceLabel {
+ Standard = 'Standard',
+ Custom = 'Custom',
+}
+
+/** Raw β UI label mapping. Anything not listed falls back to the raw value. */
+export const DETECTOR_SOURCE_LABEL_BY_RAW: Readonly> = {
+ [DetectorSourceRaw.Standard]: DetectorSourceLabel.Standard,
+ [DetectorSourceRaw.Custom]: DetectorSourceLabel.Custom,
+};
+
+/** Returns the UI label for a raw `source` value, or `undefined` if not provided. */
+export const getDetectorSourceLabel = (raw: string | undefined): string | undefined => {
+ if (!raw) return undefined;
+ return DETECTOR_SOURCE_LABEL_BY_RAW[raw.toLowerCase()] ?? raw;
+};
+
+/** True for read-only/standard detectors. */
+export const isStandardSource = (raw: string | undefined): boolean =>
+ raw?.toLowerCase() === DetectorSourceRaw.Standard;
diff --git a/public/utils/helpers.tsx b/public/utils/helpers.tsx
index bc1c6f658..7278bb287 100644
--- a/public/utils/helpers.tsx
+++ b/public/utils/helpers.tsx
@@ -16,7 +16,6 @@ import {
euiPaletteColorBlind,
EuiEmptyPrompt,
} from '@elastic/eui';
-import moment from 'moment';
import { PeriodSchedule } from '../../models/interfaces';
import React from 'react';
import {
@@ -25,6 +24,7 @@ import {
BREADCRUMBS,
DEFAULT_EMPTY_DATA,
defaultColorForVisualizations,
+ integrationCategories,
logTypeCategories,
logTypeCategoryDescription,
logTypesByCategories,
@@ -43,10 +43,15 @@ import {
} from 'opensearch-dashboards/public';
import {
AlertsService,
+ DecodersService,
FieldMappingService,
IndexPatternsService,
IndexService,
LogTypeService,
+ IntegrationService,
+ PoliciesService,
+ KVDBsService,
+ FiltersService,
NotificationsService,
OpenSearchService,
} from '../services';
@@ -58,6 +63,8 @@ import { LogCategoryOptionView } from '../components/Utility/LogCategoryOption';
import { getLogTypeLabel } from '../pages/LogTypes/utils/helpers';
import { euiThemeVars } from '@osd/ui-shared-deps/theme';
import dateMath from '@elastic/datemath';
+// Wazuh: shared date formatter that honors the `dateFormat`/`dateFormat:tz` advanced settings.
+import { formatUIDate } from './dateFormat';
import {
getBreadCrumbsSetter,
getBrowserServices,
@@ -81,16 +88,16 @@ import * as pluginManifest from '../../opensearch_dashboards.json';
import { DataSourceThreatAlertsCard } from '../components/DataSourceThreatAlertsCard/DataSourceThreatAlertsCard';
import { DataSourceAttributes } from '../../../../src/plugins/data_source/common/data_sources';
import { ISearchStart } from '../../../../src/plugins/data/public';
+import LogTestService from '../services/LogTestService';
export const parseStringsToOptions = (strings: string[]) => {
return strings.map((str) => ({ id: str, label: str }));
};
-export const renderTime = (time: number | string) => {
- const momentTime = moment(time);
- if (time && momentTime.isValid()) return momentTime.format('MM/DD/YY h:mm a');
- return DEFAULT_EMPTY_DATA;
-};
+// Wazuh: delegate to formatUIDate so dates honor the `dateFormat`/`dateFormat:tz` advanced
+// settings. Upstream returned `moment(time).format('MM/DD/YY h:mm a')` with a
+// `DEFAULT_EMPTY_DATA` fallback (that fallback now lives inside formatUIDate).
+export const renderTime = (time: number | string) => formatUIDate(time);
export function createTextDetailsGroup(
data: { label: string; content: any; url?: string; target?: string }[]
@@ -182,10 +189,12 @@ export function ruleItemInfosToItems(
return ruleItemsInfo.map((itemInfo) => ({
id: itemInfo._id,
active: itemInfo.enabled,
- description: itemInfo._source.description,
+ // Wazuh: Remove duplicated fields in metadata and root: description.
+ description: itemInfo._source.metadata?.description ?? '',
library: itemInfo.prePackaged ? 'Standard' : 'Custom',
logType: detectorType.toLowerCase(),
- name: itemInfo._source.title,
+ // Wazuh: Remove duplicated fields in metadata and root: title.
+ name: itemInfo._source.metadata?.title ?? '',
severity: itemInfo._source.level,
ruleInfo: itemInfo,
}));
@@ -300,10 +309,10 @@ export const formatRuleType = (matchingRuleType: string) => {
);
if (logType) {
- return `${logType.category}: ${getLogTypeLabel(logType.value)}`;
+ return logType.value; // Wazuh: remove category from label
}
- return DEFAULT_EMPTY_DATA;
+ return matchingRuleType || DEFAULT_EMPTY_DATA; // Wazuh: in case of not in logtype, return original value
};
export const getSeverityBadge = (severity: string) => {
@@ -336,25 +345,50 @@ export async function getLogTypeOptions() {
return formatToLogTypeOptions(logTypesByCategories);
}
+/**
+ * Returns Integration ComboBox options filtered by space and ordered alphabetically.
+ * [Wazuh custom] Uses document.metadata?.title for normalized metadata structure (issue #117)
+ * [Wazuh custom] Adds id with the same value to give support to other components.
+ */
+
+export async function getIntegrationOptionsBySpace(
+ space: string
+): Promise<{ id: string; value: string; label: string }[]> {
+ const integrations = await DataStore.integrations.getIntegrations(space);
+ // [Wazuh custom] Use metadata object instead of document.title
+ return integrations
+ .map(({ document: { metadata } }) => ({
+ id: metadata?.title ?? '',
+ value: metadata?.title ?? '',
+ label: metadata?.title ?? '',
+ }))
+ .filter((opt) => opt.label)
+ .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
+}
+
export function getLogTypeFilterOptions() {
const options: any[] = [];
+ // Wazuh: dedup by value β logTypesByCategories may contain the same log type more than once.
+ const seenValues = new Set();
formatToLogTypeOptions(logTypesByCategories).forEach((categoryData) => {
const categoryName = categoryData.label;
const logTypes = categoryData.options;
for (let i = 0; i < logTypes.length; i++) {
- if (i === 0) {
- options.push({
- value: logTypes.map((logType) => logType.value).join(' or '),
- view: ,
- });
+ const entry = logTypes[i];
+ if (seenValues.has(entry.value)) {
+ continue;
}
-
+ seenValues.add(entry.value);
+ // Wazuh: add the category name in front of log type label for better context in filter options, e.g. "Security: Audit logs"
options.push({
- value: logTypes[i].value,
+ value: entry.value,
view: (
- {getLogTypeLabel(logTypes[i].label)}
+
+ {categoryName}
+
+ {getLogTypeLabel(entry.label)}
),
});
@@ -379,6 +413,32 @@ export function getLogTypeCategoryOptions(): any[] {
}));
}
+export function getIntegrationCategoryOptions(displayDescription: boolean = true): any[] {
+ return integrationCategories.map(({ label, value, description }) => ({
+ value,
+ inputDisplay: label,
+ dropdownDisplay: (
+ <>
+ {label}
+ {displayDescription ? (
+
+ {description}
+
+ ) : null}
+ >
+ ),
+ }));
+}
+
+/** Search bar filter options: same content as SuperSelect dropdown (`view` for EuiInMemoryTable filters). */
+export function getIntegrationCategoryFilterOptions(displayDescription: boolean = true): any[] {
+ return getIntegrationCategoryOptions(displayDescription).map((opt) => ({
+ value: opt.value,
+ name: opt.inputDisplay,
+ view: opt.dropdownDisplay,
+ }));
+}
+
/**
* Removes the given detectionType from the list of types inside the given trigger
* and returns the new list of detectionTypes
@@ -415,7 +475,10 @@ export async function getDataSources(
): Promise<
| {
ok: true;
- dataSources: { label: string; options: { label: string; value: string; index?: string }[] }[];
+ dataSources: {
+ label: string;
+ options: { label: string; value: string; index?: string }[];
+ }[];
}
| { ok: false; error: string }
> {
@@ -445,20 +508,52 @@ export async function getDataSources(
if (indicesResponse.ok) {
const indices = indicesResponse.response.indices;
- const indexOptions = indices
- .map(({ index }) => ({ label: index, value: index }))
- .filter(({ label }) => !label.startsWith('.'));
-
- dataSourceOptions.push({
- label: 'Indices',
- options: indexOptions,
+ // Wazuh -- start
+ const indexOptions: { label: string; value: string }[] = [];
+ const dataStreamOptions: { label: string; value: string }[] = [];
+ const dataStreamsSet = new Set();
+
+ // Matches OpenSearch data stream backing indices (e.g., .ds-wazuh-alerts-000001)
+ const DATA_STREAM_BACKING_INDEX_PATTERN = /^\.ds-(.+)-\d+$/;
+ // Regex capture group containing the logical data stream name
+ const DATA_STREAM_NAME_GROUP = 1;
+
+ indices.forEach(({ index }) => {
+ const dsMatch = index.match(DATA_STREAM_BACKING_INDEX_PATTERN);
+
+ if (dsMatch) {
+ const dsName = dsMatch[DATA_STREAM_NAME_GROUP];
+ if (!dataStreamsSet.has(dsName)) {
+ dataStreamsSet.add(dsName);
+ dataStreamOptions.push({ label: dsName, value: dsName });
+ }
+ } else if (!index.startsWith('.')) {
+ indexOptions.push({ label: index, value: index });
+ }
});
- } else {
- errorNotificationToast(notifications, 'retrieve', 'indices', indicesResponse.error);
- return { ok: false, error: indicesResponse.error };
- }
+ if (dataStreamOptions.length > 0) {
+ const aliasGroup = dataSourceOptions.find((group) => group.label === 'Aliases');
+ if (aliasGroup) {
+ aliasGroup.options.push(...dataStreamOptions);
+ } else {
+ dataSourceOptions.push({
+ label: 'Aliases',
+ options: dataStreamOptions,
+ });
+ }
+ }
+ if (indexOptions.length > 0) {
+ dataSourceOptions.push({
+ label: 'Indexes',
+ options: indexOptions,
+ });
+ }
+ } else {
+ throw indicesResponse.error;
+ }
+ // Wazuh -- end
return {
ok: true,
dataSources: dataSourceOptions,
@@ -601,7 +696,12 @@ export function getBadgeText(severity: string) {
}
export function getAlertSeverityColor(severity: string) {
- return ALERT_SEVERITY_PROPS[severity]?.color || { background: 'white', text: 'black' };
+ return (
+ ALERT_SEVERITY_PROPS[severity]?.color || {
+ background: 'white',
+ text: 'black',
+ }
+ );
}
export function getAlertSeverityBadge(severity: string) {
@@ -676,6 +776,12 @@ export function initializeServices(
const savedObjectsService = new SavedObjectService(savedObjects.client, indexService);
const indexPatternsService = new IndexPatternsService(indexPattern);
const logTypeService = new LogTypeService(http);
+ const integrationService = new IntegrationService(http);
+ const policiesService = new PoliciesService(http);
+ const decodersService = new DecodersService(http);
+ const kvdbsService = new KVDBsService(http);
+ const filtersService = new FiltersService(http);
+ const logTestService = new LogTestService(http);
const metricsService = new MetricsService(http);
const threatIntelService = new ThreatIntelService(http, coreStart.notifications);
@@ -692,6 +798,12 @@ export function initializeServices(
savedObjectsService,
indexPatternsService,
logTypeService,
+ integrationService,
+ policiesService,
+ decodersService,
+ kvdbsService,
+ filtersService,
+ logTestService,
metricsService,
threatIntelService,
};
@@ -707,3 +819,41 @@ export const buildRouteUrl = (appId: string, route: string) => {
return `#${route}`;
}
};
+
+/** Wazuh custom plugin helper function to format various value types into a display friendly string. */
+export const formatCellValue = (value: unknown) => {
+ if (value === null || value === undefined || value === '') {
+ return DEFAULT_EMPTY_DATA;
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ if (Array.isArray(value)) {
+ const formatted = value
+ .map((entry) => {
+ if (entry === null || entry === undefined) {
+ return '';
+ }
+ if (typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean') {
+ return String(entry);
+ }
+ if (typeof entry === 'object' && 'name' in entry && typeof entry.name === 'string') {
+ return entry.name;
+ }
+ return JSON.stringify(entry);
+ })
+ .filter(Boolean)
+ .join(', ');
+ return formatted || DEFAULT_EMPTY_DATA;
+ }
+ if (typeof value === 'object') {
+ if ('name' in value && typeof value.name === 'string') {
+ return value.name;
+ }
+ if ('value' in value && typeof value.value === 'string') {
+ return value.value;
+ }
+ return JSON.stringify(value);
+ }
+ return DEFAULT_EMPTY_DATA;
+};
diff --git a/public/utils/jsonSchemaValidation.ts b/public/utils/jsonSchemaValidation.ts
new file mode 100644
index 000000000..5d037269a
--- /dev/null
+++ b/public/utils/jsonSchemaValidation.ts
@@ -0,0 +1,291 @@
+import Ajv, { ErrorObject } from 'ajv';
+import { FormikErrors } from 'formik';
+
+const ajv = new Ajv({ allErrors: true, strict: false });
+const validatorCache = new WeakMap>();
+
+function getValidator(schema: object) {
+ if (!validatorCache.has(schema)) validatorCache.set(schema, ajv.compile(schema));
+ return validatorCache.get(schema)!;
+}
+
+// Showing these top level errors adds noise without helping the user fix anything.
+const SKIP_KEYWORDS = new Set(['oneOf', 'anyOf', 'allOf', 'if', 'then', 'else']);
+
+function instancePathToKey(instancePath: string): string {
+ if (!instancePath) return 'root';
+ return instancePath
+ .replace(/^\//, '')
+ .split('/')
+ .map(seg => {
+ const decoded = seg.replace(/~1/g, '/').replace(/~0/g, '~');
+ return /^\d+$/.test(decoded) ? `[${decoded}]` : decoded;
+ })
+ .join('.')
+ .replace(/\.\[/g, '[');
+}
+
+function fieldPath(error: ErrorObject): string {
+ const raw = instancePathToKey(error.instancePath);
+
+ // These keywords report at the *parent* object level. The specific field is in params.
+ switch (error.keyword) {
+ case 'required':
+ return join(raw, error.params?.missingProperty as string);
+ case 'additionalProperties':
+ return join(raw, error.params?.additionalProperty as string);
+ case 'propertyNames':
+ return join(raw, error.params?.propertyName as string);
+ case 'dependencies':
+ case 'dependentRequired':
+ return join(raw, error.params?.missingProperty as string);
+ default:
+ return raw || 'root';
+ }
+}
+
+function join(parent: string, field: string): string {
+ // 'root' means instancePath was empty (root object level); don't prepend it.
+ return parent && parent !== 'root' ? `${parent}.${field}` : field;
+}
+
+const MAX_LABEL_LENGTH = 60;
+
+function humanLabel(path: string): string {
+ if (path === 'root') return 'value';
+ const display = path.length > MAX_LABEL_LENGTH ? path.slice(0, MAX_LABEL_LENGTH - 3) + '...' : path;
+ return `'${display}'`;
+}
+
+function buildMessage(error: ErrorObject): string | null {
+ if (SKIP_KEYWORDS.has(error.keyword)) return null;
+
+ const path = fieldPath(error);
+ const label = humanLabel(path);
+
+ switch (error.keyword) {
+ case 'required':
+ return `${label} is required`;
+ case 'additionalProperties':
+ return `${label} is not a recognized field`;
+ case 'propertyNames':
+ return `${label} is not a valid property name`;
+ case 'dependencies':
+ case 'dependentRequired': {
+ const raw = instancePathToKey(error.instancePath);
+ const triggerLabel = humanLabel(join(raw, error.params?.property as string));
+ return `${label} is required when ${triggerLabel} is present`;
+ }
+ }
+
+ const msg = error.message ?? 'is invalid';
+ return `${label} ${msg}`;
+}
+
+// Resolve a $ref within the root schema.
+function resolveRef(rootSchema: any, ref: string): any {
+ if (!ref?.startsWith('#/')) return null;
+ return ref.slice(2).split('/').reduce((node: any, part: string) => node?.[part], rootSchema);
+}
+
+// Follow a chain of $ref until a concrete schema is reached (safe cycle).
+function derefSchema(node: any, rootSchema: any): any {
+ const seen = new Set();
+ let current = node;
+ while (current?.$ref) {
+ if (seen.has(current.$ref)) break;
+ seen.add(current.$ref);
+ current = resolveRef(rootSchema, current.$ref);
+ }
+ return current;
+}
+
+/**
+ * Collect all branches from every combiner (oneOf, anyOf, allOf) present on a node.
+ * A node can have multiple combiners simultaneously (e.g. allOf for constraints + oneOf for variants).
+ */
+function collectBranches(node: any): any[] {
+ return [
+ ...(node.oneOf ?? []),
+ ...(node.anyOf ?? []),
+ ...(node.allOf ?? []),
+ ];
+}
+
+/**
+ * Look up a property or patternProperty key inside a schema node, recursing into
+ * any combiner branches as needed. Returns the sub schema or null if not found.
+ */
+function findPropertySchema(node: any, seg: string, rootSchema: any): any | null {
+ if (!node) return null;
+
+ // Direct property
+ if (node.properties?.[seg] !== undefined) return node.properties[seg];
+
+ // Pattern property
+ const patternMatch = Object.entries(node.patternProperties ?? {}).find(([pattern]) => {
+ try { return new RegExp(pattern).test(seg); } catch { return false; }
+ });
+ if (patternMatch) return patternMatch[1];
+
+ // Recurse into all combiner branches
+ const branches = collectBranches(node);
+ for (const branch of branches) {
+ const resolved = derefSchema(branch, rootSchema);
+ if (!resolved) continue;
+ const found = findPropertySchema(resolved, seg, rootSchema);
+ if (found !== null) return found;
+ }
+
+ return null;
+}
+
+/**
+ * Navigate the root schema following an instance path (e.g. "/normalize/0")
+ * and return the sub schema that validates the value at that path.
+ */
+function schemaAtInstancePath(rootSchema: any, instancePath: string): any {
+ const segments = instancePath.replace(/^\//, '').split('/').filter(Boolean);
+ let node: any = rootSchema;
+
+ for (const seg of segments) {
+ node = derefSchema(node, rootSchema);
+ if (!node) return null;
+
+ if (/^\d+$/.test(seg)) {
+ node = node.items ?? null;
+ } else {
+ const found = findPropertySchema(node, seg, rootSchema);
+ if (found !== null) {
+ node = found;
+ continue;
+ }
+ // additionalProperties fallback
+ node = typeof node.additionalProperties === 'object' ? node.additionalProperties : null;
+ }
+ }
+
+ return derefSchema(node, rootSchema);
+}
+
+/**
+ * Describe one branch of a oneOf/anyOf in human readable form using only
+ * information that is generically present in a JSON Schema branch:
+ * required named properties and patternProperties keys
+ */
+function describeBranch(rawBranch: any, rootSchema: any): string {
+ const branch = derefSchema(rawBranch, rootSchema);
+ if (!branch) return '';
+
+ const required: string[] = branch.required ?? [];
+ const patternKeys = Object.keys(branch.patternProperties ?? {});
+ const minProps: number = branch.minProperties ?? 0;
+
+ // Include pattern keys only when minProperties forces at least one to be present.
+ const mandatoryPatternCount = Math.max(0, minProps - required.length);
+ const shownPatternKeys = patternKeys.length > 0 && mandatoryPatternCount > 0 ? patternKeys : [];
+
+ const parts = [...required, ...shownPatternKeys];
+ if (parts.length > 0) return parts.join(' + ');
+
+ // Non, object branch (e.g. a string/array type constraint), use description or type.
+ if (branch.description && typeof branch.description === 'string') return branch.description;
+ if (branch.type) return Array.isArray(branch.type) ? branch.type.join(' | ') : branch.type;
+ return '';
+}
+
+/** Build a human readable message for a oneOf/anyOf failure at the given path. */
+function buildOneOfMessage(rootSchema: any, instancePath: string, key: string): string {
+ const label = humanLabel(key);
+ const node = schemaAtInstancePath(rootSchema, instancePath);
+
+ // Use the errorMessage keyword if present
+ if (node?.errorMessage && typeof node.errorMessage === 'string') {
+ return `${label}: ${node.errorMessage}`;
+ }
+
+ // Fall back to the schema description when available.
+ if (node?.description && typeof node.description === 'string') {
+ return `${label}: ${node.description}`;
+ }
+
+ // Last resort: auto generate a list of valid branch shapes.
+ const branches: any[] = node?.oneOf ?? node?.anyOf ?? [];
+ if (branches.length === 0) {
+ return `${label} does not match any valid format`;
+ }
+
+ const options = branches
+ .map((b, i) => `(${i + 1}) ${describeBranch(b, rootSchema)}`)
+ .filter(Boolean)
+ .join(', ');
+
+ return `${label} must match one of the valid formats: ${options}`;
+}
+
+interface ValidateOptions {
+ skipRequired?: string[];
+}
+
+export function validateWithJsonSchema(
+ schema: object,
+ data: T,
+ options?: ValidateOptions
+): FormikErrors {
+ const validate = getValidator(schema);
+ if (validate(data)) return {};
+
+ const skipRequired = new Set(options?.skipRequired ?? []);
+ const allErrors = validate.errors ?? [];
+
+ // Instance paths where a oneOf/anyOf combiner failed.
+ const combinerPaths = new Set(
+ allErrors
+ .filter(e => e.keyword === 'oneOf' || e.keyword === 'anyOf')
+ .map(e => e.instancePath)
+ );
+
+ const result: Record = {};
+
+ // When a path P has a oneOf/anyOf failure AND no child errors (no errors at
+ // paths starting with P + '/'), the data at P doesn't resemble any branch at
+ // all. We emit a single "valid formats" message derived from the schema.
+ // When child errors DO exist, at least one branch accepted the top level
+ // structure and validation went deeper.
+ for (const path of Array.from(combinerPaths)) {
+ const childPrefix = path === '' ? '/' : path + '/';
+ const hasChildErrors = allErrors.some(e => e.instancePath.startsWith(childPrefix));
+ if (hasChildErrors) continue;
+
+ const key = instancePathToKey(path);
+ if (!result[key]) {
+ result[key] = buildOneOfMessage(schema as any, path, key);
+ }
+ }
+
+ // Process individual errors
+ // Errors whose instancePath sits directly on a combinerPath are noise
+ // (they are replaced by the synthesized message or by deeper child errors).
+ // Everything else is a genuine constraint and is shown directly.
+ for (const error of allErrors) {
+ if (error.keyword === 'oneOf' || error.keyword === 'anyOf') continue;
+ if (SKIP_KEYWORDS.has(error.keyword)) continue;
+ if (combinerPaths.has(error.instancePath)) continue; // branch-level noise
+ if (error.keyword === 'required' && skipRequired.has(error.params?.missingProperty as string)) continue;
+
+ const msg = buildMessage(error);
+ if (!msg) continue;
+
+ const key = fieldPath(error);
+ if (!result[key]) result[key] = msg;
+ }
+
+ // Drop parent level keys when more specific child keys exist to avoid redundant messages
+ const keys = Object.keys(result);
+ return Object.fromEntries(
+ Object.entries(result).filter(
+ ([key]) => !keys.some(other => other !== key && (other.startsWith(key + '.') || other.startsWith(key + '[')))
+ )
+ ) as FormikErrors;
+}
+
diff --git a/public/utils/validation.ts b/public/utils/validation.ts
index aca8c316c..dd00b9cca 100644
--- a/public/utils/validation.ts
+++ b/public/utils/validation.ts
@@ -13,12 +13,22 @@ export const NAME_REGEX = new RegExp(/^[a-zA-Z0-9 _-]{5,50}$/);
// This regex pattern support MIN to MAX character limit for detection rule name
export const RULE_NAME_REGEX = new RegExp(/^.{1,256}$/);
+// Restriction defined by OpenSearch security analitycs.
+// Applies to LogTypes and Integrations name/title.
export const LOG_TYPE_NAME_REGEX = new RegExp(/^[a-z0-9_-]{2,50}$/);
+// This regex pattern support MIN to MAX character limit.
+// NOTE: This length constraint is strictly client-side.
+// The backend indexer does not seem to enforce a character limit for this field.
+export const INTEGRATION_AUTHOR_REGEX = new RegExp(/^.{2,50}$/);
+
// This regex pattern support MIN to MAX character limit, capital and lowercase letters,
// numbers 0-9, hyphens, dot, and underscores.
export const DETECTION_NAME_REGEX = new RegExp(/^[a-zA-Z0-9_.-]{1,50}$/);
+// Filter name pattern: filter// where name is lowercase alphanumeric with hyphens/underscores.
+export const FILTER_NAME_REGEX = new RegExp(/^filter\/[a-z0-9_-]+\/[a-z0-9_-]+$/);
+
// This regex pattern support MIN to MAX character limit, capital and lowercase letters,
// numbers 0-9, hyphens, dot, and underscores.
export const THREAT_INTEL_SOURCE_NAME_REGEX = new RegExp(/^[a-zA-Z0-9 _-]{1,128}$/);
@@ -56,7 +66,7 @@ export function validateCondition(
name: string,
regex: RegExp = DETECTION_CONDITION_REGEX
): boolean {
- return name.trim().match(regex) !== null;
+ return String(name).trim().match(regex) !== null; // Wazuh: ensure name is string
}
const nameOrTitleErrorBase = `should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores.
diff --git a/scripts/build-tools/README.md b/scripts/build-tools/README.md
new file mode 100644
index 000000000..3be31a3bd
--- /dev/null
+++ b/scripts/build-tools/README.md
@@ -0,0 +1,45 @@
+# update-engine-schemas
+
+Fetches JSON Schema files from the [wazuh/wazuh](https://github.com/wazuh/wazuh) repository and writes them to `common/schemas/`. These schemas are used to validate resource editor forms (decoders, and any future resource types) against the same constraints the engine enforces.
+
+## Usage
+
+Runs automatically on `yarn install` via the `postinstall` hook. Can also be triggered manually:
+
+```bash
+yarn generate:schemas
+```
+
+If no branch is provided through the environment, the script will prompt for one:
+
+```
+Enter the git reference (branch/tag) of the wazuh/wazuh repository to fetch schemas from (e.g., main, v5.0.0):
+```
+
+To skip the prompt, pass the reference explicitly:
+
+```bash
+GIT_REF=main yarn generate:schemas
+# or
+bash scripts/build-tools/update-engine-schemas main
+```
+
+To skip the download entirely (e.g. in CI where schemas are already present):
+
+```bash
+SKIP_DOWNLOAD_ENGINE_SCHEMAS=true yarn generate:schemas
+```
+
+## How branch resolution works
+
+The script builds a list of candidate references in order: the input ref, the `wazuh.version` from `package.json`, `v${version}`, and `main` as a final fallback. It checks each against the wazuh/wazuh repository via `git ls-remote` and uses the first one that exists.
+
+## Adding a new schema
+
+Add an entry to `SCHEMA_SOURCES` in `scripts/generate-engine-schemas.js`:
+
+```js
+{ name: 'wazuh-filters', urls: [wazuhUrl('src/engine/ruleset/schemas/wazuh-filters.json'), '...main fallback...'] }
+```
+
+The schema will be written to `common/schemas/wazuh-filters.schema.json` and can be imported directly in the relevant form page.
diff --git a/scripts/build-tools/update-engine-schemas b/scripts/build-tools/update-engine-schemas
new file mode 100755
index 000000000..3dcbb9887
--- /dev/null
+++ b/scripts/build-tools/update-engine-schemas
@@ -0,0 +1,98 @@
+#!/bin/bash
+# This script fetches engine JSON Schema files from the wazuh/wazuh repository.
+# Usage: bash update-engine-schemas [wazuh-branch-ref]
+# Example: bash update-engine-schemas main
+# Example: GIT_REF=main bash update-engine-schemas
+
+set -e
+
+SCRIPT_PATH=$(dirname $0)
+RESOURCE_REPO_URL=https://github.com/wazuh/wazuh.git
+
+CANDIDATE_GIT_REFS=()
+RESOLVED_CANDIDATE_REF=""
+
+update_engine_schemas() {
+ local ref="$RESOLVED_CANDIDATE_REF"
+ echo "Fetching engine schemas with wazuh reference [$ref]..."
+ node $(realpath "$SCRIPT_PATH/../generate-engine-schemas.js") --branch $ref
+}
+
+try_package_json() {
+ local file=$(realpath "$SCRIPT_PATH/../../package.json")
+ if [ ! -f "$file" ]; then
+ return 1
+ fi
+
+ local version
+ version=$(node -p "require('$file').wazuh?.version")
+
+ if [ -n "$version" ] && [ "$version" != "undefined" ]; then
+ echo "$version"
+ return 0
+ fi
+
+ return 1
+}
+
+get_candidate_ref() {
+ echo 'Getting candidate git references...'
+ CANDIDATE_GIT_REFS=("$INPUT_CANDIDATE_REF")
+
+ local version=$(try_package_json || true)
+
+ if [ -n "$version" ]; then
+ CANDIDATE_GIT_REFS+=("$version" "v$version")
+ fi
+
+ echo "Candidate git references: ${CANDIDATE_GIT_REFS[*]}"
+ for ref in "${CANDIDATE_GIT_REFS[@]}"; do
+ if git ls-remote --exit-code --heads "$RESOURCE_REPO_URL" "$ref" >/dev/null 2>&1 || \
+ git ls-remote --exit-code --tags "$RESOURCE_REPO_URL" "$ref" >/dev/null 2>&1; then
+ RESOLVED_CANDIDATE_REF=$ref
+ return 0
+ fi
+ done
+
+ echo 'Error: Could not resolve any candidate reference in wazuh/wazuh'
+ exit 1
+}
+
+get_candidate_reference() {
+ echo 'Resolving branch references...'
+ get_candidate_ref
+
+ [ -z "$RESOLVED_CANDIDATE_REF" ] && {
+ echo "Error: Could not resolve the candidate reference from input [$INPUT_CANDIDATE_REF]"
+ exit 1
+ }
+
+ echo "Resolved candidate: $RESOLVED_CANDIDATE_REF"
+}
+
+check_dependencies() {
+ if ! command -v git >/dev/null 2>&1; then
+ echo "Error: git is not installed or not found in PATH."
+ exit 1
+ fi
+
+ if ! command -v node >/dev/null 2>&1; then
+ echo "Error: Node.js is not installed or not found in PATH."
+ exit 1
+ fi
+}
+
+if [ "$SKIP_DOWNLOAD_ENGINE_SCHEMAS" = "true" ]; then
+ echo 'Skipping the download of engine schemas'
+ exit 0
+fi
+
+INPUT_CANDIDATE_REF="${1:-$GIT_REF}"
+
+while [ -z "$INPUT_CANDIDATE_REF" ]; do
+ read -p "Enter the git reference (branch/tag) of the wazuh/wazuh repository to fetch schemas from (e.g., main, v5.0.0): " INPUT_CANDIDATE_REF
+done
+
+check_dependencies
+get_candidate_reference
+update_engine_schemas
diff --git a/scripts/generate-engine-schemas.js b/scripts/generate-engine-schemas.js
new file mode 100644
index 000000000..9c29c3ab4
--- /dev/null
+++ b/scripts/generate-engine-schemas.js
@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const packageJsonPath = path.resolve(__dirname, '..', 'package.json');
+const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+
+function parseInput(input) {
+ const options = {};
+ for (let i = 0; i < input.length; i++) {
+ if (input[i].startsWith('--')) {
+ const key = input[i].slice(2);
+ const value =
+ input[i + 1] && !input[i + 1].startsWith('--') ? input[i + 1] : true;
+ options[key] = value;
+ if (value !== true) i++;
+ }
+ }
+ return options;
+}
+
+function wazuhUrl(filePath) {
+ return `https://raw.githubusercontent.com/wazuh/wazuh/{branch}/${filePath}`;
+}
+
+function interpolate(template, variables) {
+ return template.replace(/{(\w+)}/g, (_, key) => {
+ return key in variables ? variables[key] : `{${key}}`;
+ });
+}
+
+// Add entries here for each engine resource schema to fetch.
+// Each entry can list multiple urls β they are tried in order until one succeeds.
+// Use wazuhUrl() for version-tracked paths; add a hardcoded main URL as fallback
+// for schemas that may not exist on the version branch yet.
+const SCHEMA_SOURCES = [
+ {
+ name: 'wazuh-decoders',
+ urls: [
+ wazuhUrl('src/engine/ruleset/schemas/wazuh-decoders.json'),
+ ],
+ },
+];
+
+function fetchSchema(url) {
+ return new Promise((resolve, reject) => {
+ https
+ .get(url, (res) => {
+ if (res.statusCode === 301 || res.statusCode === 302) {
+ fetchSchema(res.headers.location).then(resolve).catch(reject);
+ return;
+ }
+ if (res.statusCode !== 200) {
+ reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
+ return;
+ }
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ try {
+ resolve(JSON.parse(data));
+ } catch (e) {
+ reject(new Error(`Failed to parse JSON from ${url}: ${e.message}`));
+ }
+ });
+ })
+ .on('error', (error) => {
+ reject(new Error(`Failed to fetch ${url}: ${error.message}`));
+ });
+ });
+}
+
+async function fetchSchemaFromUrls(urls) {
+ let lastError;
+ for (const url of urls) {
+ try {
+ const schema = await fetchSchema(url);
+ return { schema, url };
+ } catch (error) {
+ lastError = error;
+ console.log(` β οΈ Failed to fetch from ${url}: ${error.message}`);
+ }
+ }
+ throw lastError;
+}
+
+function saveOutput(location, data) {
+ const dir = path.dirname(location);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(location, JSON.stringify(data, null, 2));
+}
+
+async function processSchema(sourceConfig, config) {
+ console.log(`βοΈ Processing ${sourceConfig.name} schema...`);
+ try {
+ const urls = sourceConfig.urls.map((url) =>
+ interpolate(url, { branch: config.branch }),
+ );
+ const { schema, url } = await fetchSchemaFromUrls(urls);
+ console.log(` β
Successfully fetched from: ${url}`);
+
+ const outputPath = path.resolve(
+ config.destination,
+ `${sourceConfig.name}.schema.json`,
+ );
+ saveOutput(outputPath, schema);
+ console.log(` β
Written to ${outputPath}`);
+
+ return schema;
+ } catch (error) {
+ console.error(` β Error processing ${sourceConfig.name}: ${error.message}`);
+ throw error;
+ }
+}
+
+async function main(config) {
+ console.log(`π¦ Using Wazuh version: ${config.branch}`);
+
+ for (const source of SCHEMA_SOURCES) {
+ await processSchema(source, config);
+ }
+
+ console.log('\n⨠Schema generation completed!');
+}
+
+if (require.main === module) {
+ const config = parseInput(process.argv.slice(2));
+ config.branch = config.branch || packageJson.wazuh?.version;
+ config.destination = path.resolve(
+ config.destination || path.resolve(__dirname, '..', 'common', 'schemas'),
+ );
+
+ main(config).catch((error) => {
+ console.error('π₯ Script failed:', error);
+ process.exit(1);
+ });
+}
diff --git a/server/clusters/addDecoderMethods.ts b/server/clusters/addDecoderMethods.ts
new file mode 100644
index 000000000..48236d13c
--- /dev/null
+++ b/server/clusters/addDecoderMethods.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METHOD_NAMES } from '../utils/constants';
+
+const CONTENT_MANAGER_BASE_PATH = '/_plugins/_content_manager';
+
+export function addDecoderMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.CREATE_DECODER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/decoders`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+
+ securityAnalytics[METHOD_NAMES.UPDATE_DECODER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/decoders/<%=decoderId%>`,
+ req: {
+ decoderId: {
+ type: 'string',
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.DELETE_DECODER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/decoders/<%=decoderId%>`,
+ req: {
+ decoderId: {
+ type: 'string',
+ },
+ },
+ },
+ method: 'DELETE',
+ });
+}
diff --git a/server/clusters/addFiltersMethods.ts b/server/clusters/addFiltersMethods.ts
new file mode 100644
index 000000000..95869ec43
--- /dev/null
+++ b/server/clusters/addFiltersMethods.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METHOD_NAMES, CONTENT_MANAGER_BASE_PATH } from '../utils/constants';
+
+export function addFiltersMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.CREATE_FILTER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/filters`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+
+ securityAnalytics[METHOD_NAMES.UPDATE_FILTER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/filters/<%=filterId%>`,
+ req: {
+ filterId: { type: 'string' },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.DELETE_FILTER] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/filters/<%=filterId%>`,
+ req: {
+ filterId: { type: 'string' },
+ },
+ },
+ method: 'DELETE',
+ });
+}
diff --git a/server/clusters/addIntegrationsMethods.ts b/server/clusters/addIntegrationsMethods.ts
new file mode 100644
index 000000000..d14c78b8f
--- /dev/null
+++ b/server/clusters/addIntegrationsMethods.ts
@@ -0,0 +1,62 @@
+import { METHOD_NAMES, API, BASE_API_PATH } from '../utils/constants';
+
+const BASE_PATH = '/_plugins/_content_manager';
+export function addIntegrationsMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.DELETE_INTEGRATION] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/integrations/<%=integrationId%>`,
+ req: {
+ integrationId: {
+ type: 'string',
+ required: true,
+ },
+ },
+ },
+ needBody: false,
+ method: 'DELETE',
+ });
+
+ securityAnalytics[METHOD_NAMES.CREATE_INTEGRATION] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/integrations`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+
+ securityAnalytics[METHOD_NAMES.UPDATE_INTEGRATION] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/integrations/<%=integrationId%>`,
+ req: {
+ integrationId: {
+ type: 'string',
+ required: true,
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.GET_PROMOTE_BY_SPACE] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/promote`,
+ params: {
+ space: {
+ type: 'string',
+ required: true,
+ },
+ },
+ },
+ needBody: true,
+ method: 'GET',
+ });
+
+ securityAnalytics[METHOD_NAMES.PROMOTE_INTEGRATION] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/promote`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+}
diff --git a/server/clusters/addKVDBsMethods.ts b/server/clusters/addKVDBsMethods.ts
new file mode 100644
index 000000000..070a40dd9
--- /dev/null
+++ b/server/clusters/addKVDBsMethods.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METHOD_NAMES, CONTENT_MANAGER_BASE_PATH } from '../utils/constants';
+
+export function addKVDBsMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.CREATE_KVDB] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/kvdbs`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+
+ securityAnalytics[METHOD_NAMES.UPDATE_KVDB] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/kvdbs/<%=kvdbId%>`,
+ req: {
+ kvdbId: {
+ type: 'string',
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.DELETE_KVDB] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/kvdbs/<%=kvdbId%>`,
+ req: {
+ kvdbId: {
+ type: 'string',
+ },
+ },
+ },
+ method: 'DELETE',
+ });
+}
diff --git a/server/clusters/addLogTestMethods.ts b/server/clusters/addLogTestMethods.ts
new file mode 100644
index 000000000..d361bc212
--- /dev/null
+++ b/server/clusters/addLogTestMethods.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METHOD_NAMES, CONTENT_MANAGER_BASE_PATH } from '../utils/constants';
+
+export function addLogTestMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.TEST_LOG] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/logtest`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+}
diff --git a/server/clusters/addPoliciesMethods.ts b/server/clusters/addPoliciesMethods.ts
new file mode 100644
index 000000000..5ff7a18b1
--- /dev/null
+++ b/server/clusters/addPoliciesMethods.ts
@@ -0,0 +1,31 @@
+import { METHOD_NAMES } from '../utils/constants';
+
+const BASE_PATH = '/_plugins/_content_manager';
+export function addPoliciesMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.UPDATE_POLICY] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/policy/<%=space%>`,
+ req: {
+ space: {
+ type: 'string',
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.DELETE_SPACE] = createAction({
+ url: {
+ fmt: `${BASE_PATH}/space/<%=space%>`,
+ req: {
+ space: {
+ type: 'string',
+ required: true,
+ },
+ },
+ },
+ needBody: false,
+ method: 'DELETE',
+ });
+}
diff --git a/server/clusters/addWazuhRuleMethods.ts b/server/clusters/addWazuhRuleMethods.ts
new file mode 100644
index 000000000..6d992889c
--- /dev/null
+++ b/server/clusters/addWazuhRuleMethods.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { METHOD_NAMES, CONTENT_MANAGER_BASE_PATH } from '../utils/constants';
+
+export function addWazuhRulesMethods(securityAnalytics: any, createAction: any): void {
+ securityAnalytics[METHOD_NAMES.CREATE_WAZUH_RULE] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/rules`,
+ },
+ needBody: true,
+ method: 'POST',
+ });
+
+ securityAnalytics[METHOD_NAMES.UPDATE_WAZUH_RULE] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/rules/<%=ruleId%>`,
+ req: {
+ ruleId: {
+ type: 'string',
+ },
+ },
+ },
+ needBody: true,
+ method: 'PUT',
+ });
+
+ securityAnalytics[METHOD_NAMES.DELETE_WAZUH_RULE] = createAction({
+ url: {
+ fmt: `${CONTENT_MANAGER_BASE_PATH}/rules/<%=ruleId%>`,
+ req: {
+ ruleId: {
+ type: 'string',
+ },
+ },
+ },
+ method: 'DELETE',
+ });
+}
diff --git a/server/clusters/securityAnalyticsPlugin.ts b/server/clusters/securityAnalyticsPlugin.ts
index 796908e02..6909a10e0 100644
--- a/server/clusters/securityAnalyticsPlugin.ts
+++ b/server/clusters/securityAnalyticsPlugin.ts
@@ -13,6 +13,14 @@ import { addNotificationsMethods } from './addNotificationsMethods';
import { addCorrelationMethods } from './addCorrelationMethods';
import { addLogTypeMethods } from './addLogTypeMethods';
import { addThreatIntelMethods } from './addThreatIntelMethods';
+// Wazuh
+import { addIntegrationsMethods } from './addIntegrationsMethods';
+import { addPoliciesMethods } from './addPoliciesMethods';
+import { addDecoderMethods } from './addDecoderMethods';
+import { addKVDBsMethods } from './addKVDBsMethods';
+import { addFiltersMethods } from './addFiltersMethods';
+import { addLogTestMethods } from './addLogTestMethods';
+import { addWazuhRulesMethods } from './addWazuhRuleMethods';
export function securityAnalyticsPlugin(Client: any, config: any, components: any) {
const createAction = components.clientAction.factory;
@@ -29,4 +37,12 @@ export function securityAnalyticsPlugin(Client: any, config: any, components: an
addNotificationsMethods(securityAnalytics, createAction);
addLogTypeMethods(securityAnalytics, createAction);
addThreatIntelMethods(securityAnalytics, createAction);
+ // Wazuh
+ addIntegrationsMethods(securityAnalytics, createAction);
+ addPoliciesMethods(securityAnalytics, createAction);
+ addDecoderMethods(securityAnalytics, createAction);
+ addKVDBsMethods(securityAnalytics, createAction);
+ addFiltersMethods(securityAnalytics, createAction);
+ addLogTestMethods(securityAnalytics, createAction);
+ addWazuhRulesMethods(securityAnalytics, createAction);
}
diff --git a/server/models/interfaces/Detectors.ts b/server/models/interfaces/Detectors.ts
index f854c76fe..56d0642f9 100644
--- a/server/models/interfaces/Detectors.ts
+++ b/server/models/interfaces/Detectors.ts
@@ -64,6 +64,11 @@ export interface DetectorHit {
_id: string;
}
+// Wazuh
+export type DetectorHitWithSpace = DetectorHit & {
+ space: string;
+};
+
export type DetectorResponse = Detector & {
last_update_time: number;
enabled_time: number;
diff --git a/server/models/interfaces/Rules.ts b/server/models/interfaces/Rules.ts
index b8698af76..521414a14 100644
--- a/server/models/interfaces/Rules.ts
+++ b/server/models/interfaces/Rules.ts
@@ -18,7 +18,8 @@ export interface CreateRuleResponse {
_id: string;
_version: number;
rule: Omit & {
- last_update_time: number;
+ // Wazuh: Remove duplicated fields in metadata and root: last_update_time.
+ // last_update_time: number;
monitor_id: string;
};
}
@@ -70,7 +71,8 @@ export interface RuleInfo {
export type RuleSource = Rule & {
rule: string;
- last_update_time: string;
+ // Wazuh: Remove duplicated fields in metadata and root: last_update_time.
+ // last_update_time: number;
queries: { value: string }[];
query_field_names: { value: string }[];
};
diff --git a/server/models/interfaces/index.ts b/server/models/interfaces/index.ts
index 3cfc6c3b9..6d61500d1 100644
--- a/server/models/interfaces/index.ts
+++ b/server/models/interfaces/index.ts
@@ -13,10 +13,17 @@ import {
CorrelationService,
} from '../../services';
import AlertService from '../../services/AlertService';
+import { DecodersService } from '../../services/DecodersService';
+import { PoliciesService } from '../../services/PoliciesService';
+import { IntegrationService } from '../../services/IntegrationService';
+import WazuhRuleService from '../../services/WazuhRuleService';
import { LogTypeService } from '../../services/LogTypeService';
import MetricsService from '../../services/MetricsService';
import RulesService from '../../services/RuleService';
import ThreatIntelService from '../../services/ThreatIntelService';
+import { KVDBsService } from '../../services/KVDBsService';
+import { LogTestService } from '../../services/LogTestService';
+import { FiltersService } from '../../services/FiltersService';
export interface SecurityAnalyticsApi {
readonly DETECTORS_BASE: string;
@@ -39,10 +46,17 @@ export interface SecurityAnalyticsApi {
readonly UPDATE_ALIASES: string;
readonly CORRELATIONS: string;
readonly LOGTYPE_BASE: string;
+ readonly INTEGRATION_BASE: string;
+ readonly POLICIES_BASE: string;
+ readonly KVDBS_BASE: string;
+ readonly FILTERS_BASE: string;
+ readonly LOG_TEST_BASE: string;
readonly METRICS: string;
readonly GET_CORRELATION_ALERTS: string;
readonly ACK_CORRELATION_ALERTS: string;
readonly THREAT_INTEL_BASE: string;
+ readonly DECODERS_BASE: string;
+ readonly SPACES_BASE: string;
}
export interface NodeServices {
@@ -55,9 +69,16 @@ export interface NodeServices {
alertService: AlertService;
rulesService: RulesService;
notificationsService: NotificationsService;
+ policiesService: PoliciesService;
+ integrationService: IntegrationService;
+ wazuhRulesService: WazuhRuleService;
logTypeService: LogTypeService;
+ kvdbsService: KVDBsService;
+ filtersService: FiltersService;
metricsService: MetricsService;
threatIntelService: ThreatIntelService;
+ decodersService: DecodersService;
+ logTestService: LogTestService;
}
export interface GetIndicesResponse {
diff --git a/server/plugin.ts b/server/plugin.ts
index d8a157781..b77b45429 100644
--- a/server/plugin.ts
+++ b/server/plugin.ts
@@ -23,7 +23,12 @@ import {
setupAlertsRoutes,
setupNotificationsRoutes,
setupLogTypeRoutes,
+ setupIntegrationRoutes,
+ setupPoliciesRoutes,
setupRulesRoutes,
+ setupDecodersRoutes,
+ setupKVDBsRoutes,
+ setupFiltersRoutes,
} from './routes';
import { setupMetricsRoutes } from './routes/MetricsRoutes';
import {
@@ -36,14 +41,22 @@ import {
RulesService,
NotificationsService,
CorrelationService,
+ WazuhRulesService,
} from './services';
+import { IntegrationService } from './services/IntegrationService';
+import { PoliciesService } from './services/PoliciesService';
import { LogTypeService } from './services/LogTypeService';
+import { KVDBsService } from './services/KVDBsService';
+import { FiltersService } from './services/FiltersService';
import MetricsService from './services/MetricsService';
import { SecurityAnalyticsPluginConfigType } from '../config';
import { DataSourcePluginSetup } from 'src/plugins/data_source/server';
import { securityAnalyticsPlugin } from './clusters/securityAnalyticsPlugin';
import ThreatIntelService from './services/ThreatIntelService';
import { setupThreatIntelRoutes } from './routes/ThreatIntel';
+import { DecodersService } from './services/DecodersService';
+import { setupLogTestRoutes } from './routes/LogTestRoutes';
+import { LogTestService } from './services/LogTestService';
export interface SecurityAnalyticsPluginDependencies {
dataSource?: DataSourcePluginSetup;
@@ -78,8 +91,15 @@ export class SecurityAnalyticsPlugin
rulesService: new RulesService(securityAnalyticsClient, dataSourceEnabled),
notificationsService: new NotificationsService(securityAnalyticsClient, dataSourceEnabled),
logTypeService: new LogTypeService(securityAnalyticsClient, dataSourceEnabled),
+ integrationService: new IntegrationService(securityAnalyticsClient, dataSourceEnabled),
+ wazuhRulesService: new WazuhRulesService(securityAnalyticsClient),
+ policiesService: new PoliciesService(securityAnalyticsClient, dataSourceEnabled),
+ kvdbsService: new KVDBsService(securityAnalyticsClient, false),
+ filtersService: new FiltersService(securityAnalyticsClient, false),
+ logTestService: new LogTestService(securityAnalyticsClient, false),
metricsService: new MetricsService(),
threatIntelService: new ThreatIntelService(securityAnalyticsClient, dataSourceEnabled),
+ decodersService: new DecodersService(securityAnalyticsClient),
};
// Create router
@@ -96,8 +116,14 @@ export class SecurityAnalyticsPlugin
setupRulesRoutes(services, router);
setupNotificationsRoutes(services, router);
setupLogTypeRoutes(services, router);
+ setupIntegrationRoutes(services, router);
+ setupPoliciesRoutes(services, router);
+ setupKVDBsRoutes(services, router);
+ setupFiltersRoutes(services, router);
+ setupLogTestRoutes(services, router);
setupMetricsRoutes(services, router);
setupThreatIntelRoutes(services, router);
+ setupDecodersRoutes(services, router);
// @ts-ignore
const config$ = this.initializerContext.config.create();
diff --git a/server/routes/DecodersRoutes.ts b/server/routes/DecodersRoutes.ts
new file mode 100644
index 000000000..217939077
--- /dev/null
+++ b/server/routes/DecodersRoutes.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+
+/*
+FIXME: These schemas are no longer used to validate the create and update endpoints,
+ we should create new validation here or validate the decoder in the server endpoint.
+
+const decoderMetadataSchema = schema.object({
+ title: schema.string(),
+ author: schema.string(),
+ date: schema.maybe(schema.string()),
+ modified: schema.maybe(schema.string()),
+ description: schema.maybe(schema.string()),
+ references: schema.maybe(schema.arrayOf(schema.string())),
+ documentation: schema.maybe(schema.string()),
+ supports: schema.maybe(schema.arrayOf(schema.string())),
+ module: schema.maybe(schema.string()),
+ compatibility: schema.maybe(schema.string()),
+ versions: schema.maybe(schema.string()),
+});
+
+const decoderDocumentSchema = schema.object(
+ {
+ id: schema.maybe(schema.string()),
+ name: schema.string(),
+ enabled: schema.maybe(schema.boolean()),
+ metadata: decoderMetadataSchema,
+ definitions: schema.maybe(schema.any()),
+ check: schema.maybe(schema.any()),
+ parents: schema.maybe(schema.arrayOf(schema.string())),
+ normalize: schema.maybe(schema.any()),
+ decoder: schema.maybe(schema.any()),
+ // Legacy/decoder-specific fields from form
+ description: schema.maybe(schema.string()),
+ source: schema.maybe(schema.string()),
+ program_name: schema.maybe(schema.string()),
+ order: schema.maybe(schema.number()),
+ fields: schema.maybe(schema.any()),
+ parent: schema.maybe(schema.string()),
+ regex: schema.maybe(schema.string()),
+ prematch: schema.maybe(schema.string()),
+ },
+ {
+ // WORKAROUND: This allows us to accept additional fields in the decoder document such as the parse|anything or other wrong fields. The engine should thrown an error for unknown fields.
+ // This could be enhanced with a custom validation function that checks the known and the parse|anything dynamic field if we want the validation in the dashboard endpoint instead of the engine.
+ unknowns: 'allow',
+ }
+);
+*/
+
+export function setupDecodersRoutes(services: NodeServices, router: IRouter) {
+ const { decodersService } = services;
+
+ router.post(
+ {
+ path: `${API.DECODERS_BASE}/_search`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ decodersService.searchDecoders
+ );
+
+ router.get(
+ {
+ path: `${API.DECODERS_BASE}/{decoderId}`,
+ validate: {
+ params: schema.object({
+ decoderId: schema.string(),
+ }),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ decodersService.getDecoder
+ );
+
+ router.post(
+ {
+ path: `${API.DECODERS_BASE}`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ decodersService.createDecoder
+ );
+
+ router.put(
+ {
+ path: `${API.DECODERS_BASE}/{decoderId}`,
+ validate: {
+ params: schema.object({
+ decoderId: schema.string(),
+ }),
+ body: schema.any(),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ decodersService.updateDecoder
+ );
+
+ router.delete(
+ {
+ path: `${API.DECODERS_BASE}/{decoderId}`,
+ validate: {
+ params: schema.object({
+ decoderId: schema.string(),
+ }),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ decodersService.deleteDecoder
+ );
+
+ router.get(
+ {
+ path: `${API.DECODERS_BASE}/integrations/draft`,
+ validate: {
+ query: createQueryValidationSchema(),
+ },
+ },
+ decodersService.getDraftIntegrations
+ );
+}
diff --git a/server/routes/FiltersRoutes.ts b/server/routes/FiltersRoutes.ts
new file mode 100644
index 000000000..e637a08a3
--- /dev/null
+++ b/server/routes/FiltersRoutes.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+
+const filterBodySchema = schema.object({ space: schema.string(), resourceYaml: schema.string() });
+
+export function setupFiltersRoutes(services: NodeServices, router: IRouter) {
+ const { filtersService } = services;
+
+ router.post(
+ {
+ path: `${API.FILTERS_BASE}/_search`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ filtersService.searchFilters
+ );
+
+ router.post(
+ {
+ path: `${API.FILTERS_BASE}`,
+ validate: {
+ body: filterBodySchema,
+ query: createQueryValidationSchema(),
+ },
+ },
+ filtersService.createFilter
+ );
+
+ router.put(
+ {
+ path: `${API.FILTERS_BASE}/{filterId}`,
+ validate: {
+ params: schema.object({ filterId: schema.string() }),
+ body: filterBodySchema,
+ query: createQueryValidationSchema(),
+ },
+ },
+ filtersService.updateFilter
+ );
+
+ router.delete(
+ {
+ path: `${API.FILTERS_BASE}/{filterId}`,
+ validate: {
+ params: schema.object({ filterId: schema.string() }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ filtersService.deleteFilter
+ );
+}
diff --git a/server/routes/IntegrationRoutes.ts b/server/routes/IntegrationRoutes.ts
new file mode 100644
index 000000000..21b541ab9
--- /dev/null
+++ b/server/routes/IntegrationRoutes.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+import { AllowedActionsBySpace, SPACE_ACTIONS } from '../../common/constants';
+
+export function setupIntegrationRoutes(services: NodeServices, router: IRouter) {
+ const { integrationService } = services;
+
+ const integrationMetadataSchema = schema.object({
+ title: schema.string(),
+ author: schema.string(),
+ date: schema.string({ defaultValue: '' }),
+ modified: schema.string({ defaultValue: '' }),
+ description: schema.string({ defaultValue: '' }),
+ references: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ documentation: schema.string({ defaultValue: '' }),
+ supports: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ });
+
+ const spaceSchema = schema.object({
+ name: schema.string({ defaultValue: '' }),
+ });
+
+ router.post(
+ {
+ path: API.INTEGRATION_BASE,
+ validate: {
+ body: schema.object({
+ document: schema.object({
+ id: schema.string({ defaultValue: '' }),
+ category: schema.string(),
+ metadata: integrationMetadataSchema,
+ enabled: schema.boolean({ defaultValue: false }),
+ tags: schema.nullable(
+ schema.object({
+ correlation_id: schema.number(),
+ })
+ ),
+ }),
+ space: spaceSchema,
+ id: schema.maybe(schema.string()),
+ detectionRulesCount: schema.maybe(schema.number()),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.createIntegration
+ );
+
+ router.post(
+ {
+ path: `${API.INTEGRATION_BASE}/_search`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.searchIntegrations
+ );
+
+ router.put(
+ {
+ path: `${API.INTEGRATION_BASE}/{integrationId}`,
+ validate: {
+ params: schema.object({
+ integrationId: schema.string(),
+ }),
+ body: schema.object({
+ id: schema.maybe(schema.string()),
+ detectionRulesCount: schema.maybe(schema.number()),
+ decodersCount: schema.maybe(schema.number()),
+ kvdbsCount: schema.maybe(schema.number()),
+ document: schema.object({
+ id: schema.string({ defaultValue: '' }),
+ category: schema.string(),
+ metadata: integrationMetadataSchema,
+ decoders: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ enabled: schema.boolean({ defaultValue: false }), // TODO: adapt if this can be configured by user in UI
+ kvdbs: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ rules: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ tags: schema.nullable(
+ schema.object({
+ correlation_id: schema.number(),
+ })
+ ),
+ }),
+ space: schema.maybe(spaceSchema),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.updateIntegration
+ );
+
+ router.get(
+ {
+ path: `${API.INTEGRATION_BASE}/promote/{space}`,
+ validate: {
+ params: schema.object({
+ space: schema.oneOf(
+ Object.entries(AllowedActionsBySpace)
+ .filter(([_, actions]) => actions.includes(SPACE_ACTIONS.PROMOTE))
+ .map(([space]) => schema.literal(space))
+ ),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.getPromoteBySpace
+ );
+
+ router.post(
+ {
+ path: `${API.INTEGRATION_BASE}/promote`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.promoteIntegration
+ );
+
+ router.delete(
+ {
+ path: `${API.INTEGRATION_BASE}/{integrationId}`,
+ validate: {
+ params: schema.object({
+ integrationId: schema.string(),
+ }),
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ integrationService.deleteIntegration
+ );
+}
diff --git a/server/routes/KVDBsRoutes.ts b/server/routes/KVDBsRoutes.ts
new file mode 100644
index 000000000..b64aa2cc8
--- /dev/null
+++ b/server/routes/KVDBsRoutes.ts
@@ -0,0 +1,100 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+
+const kvdbMetadataSchema = schema.object({
+ title: schema.string(),
+ author: schema.string(),
+ description: schema.maybe(schema.string()),
+ documentation: schema.maybe(schema.string()),
+ references: schema.maybe(schema.arrayOf(schema.string())),
+ supports: schema.maybe(schema.arrayOf(schema.string())),
+});
+
+const kvdbCreateResourceSchema = schema.object({
+ metadata: kvdbMetadataSchema,
+ enabled: schema.maybe(schema.boolean()),
+ content: schema.any(),
+});
+
+const kvdbUpdateResourceSchema = schema.object({
+ metadata: kvdbMetadataSchema,
+ enabled: schema.maybe(schema.boolean()),
+ content: schema.any(),
+});
+
+export function setupKVDBsRoutes(services: NodeServices, router: IRouter) {
+ const { kvdbsService } = services;
+
+ router.post(
+ {
+ path: `${API.KVDBS_BASE}/_search`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ kvdbsService.searchKVDBs
+ );
+
+ router.post(
+ {
+ path: `${API.KVDBS_BASE}/_integrations`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema(),
+ },
+ },
+ kvdbsService.searchIntegrations
+ );
+
+ router.post(
+ {
+ path: `${API.KVDBS_BASE}`,
+ validate: {
+ body: schema.object({
+ resourceYaml: schema.string(),
+ integrationId: schema.string(),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ kvdbsService.createKVDB
+ );
+
+ router.put(
+ {
+ path: `${API.KVDBS_BASE}/{kvdbId}`,
+ validate: {
+ params: schema.object({
+ kvdbId: schema.string(),
+ }),
+ body: schema.object({
+ resourceYaml: schema.string(),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ kvdbsService.updateKVDB
+ );
+
+ router.delete(
+ {
+ path: `${API.KVDBS_BASE}/{kvdbId}`,
+ validate: {
+ params: schema.object({
+ kvdbId: schema.string(),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ kvdbsService.deleteKVDB
+ );
+}
diff --git a/server/routes/LogTestRoutes.ts b/server/routes/LogTestRoutes.ts
new file mode 100644
index 000000000..ffbf2aa3c
--- /dev/null
+++ b/server/routes/LogTestRoutes.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+
+export function setupLogTestRoutes(services: NodeServices, router: IRouter) {
+ const { logTestService } = services;
+
+ router.post(
+ {
+ path: API.LOG_TEST_BASE,
+ validate: {
+ body: schema.object({
+ document: schema.object({
+ queue: schema.number(),
+ location: schema.string(),
+ metadata: schema.maybe(
+ schema.recordOf(schema.string(), schema.any()),
+ ),
+ event: schema.string(),
+ trace_level: schema.maybe(
+ schema.oneOf([
+ schema.literal('NONE'),
+ schema.literal('ASSET_ONLY'),
+ schema.literal('ALL'),
+ ]),
+ ),
+ space: schema.string(),
+ integration: schema.maybe(schema.string()),
+ }),
+ }),
+ query: createQueryValidationSchema(),
+ },
+ },
+ logTestService.logTest,
+ );
+}
diff --git a/server/routes/PoliciesRoutes.ts b/server/routes/PoliciesRoutes.ts
new file mode 100644
index 000000000..38aadab69
--- /dev/null
+++ b/server/routes/PoliciesRoutes.ts
@@ -0,0 +1,85 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { IRouter } from 'opensearch-dashboards/server';
+import { schema } from '@osd/config-schema';
+import { NodeServices } from '../models/interfaces';
+import { API } from '../utils/constants';
+import { createQueryValidationSchema } from '../utils/helpers';
+
+export function setupPoliciesRoutes(services: NodeServices, router: IRouter) {
+ const { policiesService } = services;
+
+ router.post(
+ {
+ path: `${API.POLICIES_BASE}/_search`,
+ validate: {
+ body: schema.any(),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ policiesService.searchPolicies
+ );
+
+ router.get(
+ {
+ path: `${API.POLICIES_BASE}/{policyId}`,
+ validate: {
+ params: schema.object({
+ policyId: schema.string(),
+ }),
+ query: createQueryValidationSchema({
+ space: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ policiesService.getPolicy
+ );
+
+ const policyMetadataSchema = schema.object({
+ title: schema.string(),
+ author: schema.string(),
+ description: schema.string({ defaultValue: '' }),
+ documentation: schema.string({ defaultValue: '' }),
+ references: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ compatibility: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ });
+
+ router.put(
+ {
+ path: `${API.POLICIES_BASE}/{space}`,
+ validate: {
+ params: schema.object({
+ space: schema.oneOf([schema.literal('draft'), schema.literal('standard')]),
+ }),
+ body: schema.object({
+ metadata: policyMetadataSchema,
+ enabled: schema.boolean({ defaultValue: false }),
+ index_discarded_events: schema.boolean({ defaultValue: false }),
+ index_unclassified_events: schema.boolean({ defaultValue: false }),
+ enrichments: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ filters: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ integrations: schema.arrayOf(schema.string(), { defaultValue: [] }),
+ root_decoder: schema.string({ defaultValue: '' }),
+ }),
+ },
+ },
+ policiesService.updatePolicy
+ );
+
+ router.delete(
+ {
+ path: `${API.SPACES_BASE}/{space}`,
+ validate: {
+ params: schema.object({
+ space: schema.string(),
+ }),
+ },
+ },
+ policiesService.deleteSpace
+ );
+}
diff --git a/server/routes/RuleRoutes.ts b/server/routes/RuleRoutes.ts
index 3592101cb..3e368cce5 100644
--- a/server/routes/RuleRoutes.ts
+++ b/server/routes/RuleRoutes.ts
@@ -10,34 +10,39 @@ import { API } from '../utils/constants';
import { createQueryValidationSchema } from '../utils/helpers';
export function setupRulesRoutes(services: NodeServices, router: IRouter) {
- const { rulesService } = services;
+ const { wazuhRulesService } = services; // Wazuh: use wazuh rules service
+ // Wazuh: use wazuh rules service for rules routes
router.post(
{
path: `${API.RULES_BASE}/_search`,
validate: {
query: createQueryValidationSchema({
prePackaged: schema.boolean(),
+ space: schema.maybe(schema.string()),
}),
body: schema.any(),
},
},
- rulesService.getRules
+ wazuhRulesService.getRules
);
+ // Wazuh: use wazuh rules service for rules routes
router.post(
{
path: `${API.RULES_BASE}`,
validate: {
- body: schema.any(),
- query: createQueryValidationSchema({
- category: schema.maybe(schema.string()),
+ body: schema.object({
+ document: schema.any(),
+ integrationId: schema.string(),
}),
+ query: createQueryValidationSchema(),
},
},
- rulesService.createRule
+ wazuhRulesService.createRule
);
+ // Wazuh: use wazuh rules service for rules routes
router.delete(
{
path: `${API.RULES_BASE}/{ruleId}`,
@@ -48,22 +53,23 @@ export function setupRulesRoutes(services: NodeServices, router: IRouter) {
query: createQueryValidationSchema(),
},
},
- rulesService.deleteRule
+ wazuhRulesService.deleteRule
);
+ // Wazuh: use wazuh rules service for rules routes
router.put(
{
path: `${API.RULES_BASE}/{ruleId}`,
validate: {
- query: createQueryValidationSchema({
- category: schema.string(),
+ query: createQueryValidationSchema(),
+ body: schema.object({
+ document: schema.any(),
}),
- body: schema.any(),
params: schema.object({
ruleId: schema.string(),
}),
},
},
- rulesService.updateRule
+ wazuhRulesService.updateRule
);
}
diff --git a/server/routes/index.ts b/server/routes/index.ts
index cff7aad92..170cfaea8 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -12,4 +12,9 @@ export * from './IndexRoutes';
export * from './AlertRoutes';
export * from './NotificationsRoutes';
export * from './LogTypeRoutes';
+export * from './IntegrationRoutes';
+export * from './PoliciesRoutes';
export * from './RuleRoutes';
+export * from './DecodersRoutes';
+export * from './KVDBsRoutes';
+export * from './FiltersRoutes';
diff --git a/server/services/DecodersService.ts b/server/services/DecodersService.ts
new file mode 100644
index 000000000..d98bc7bb8
--- /dev/null
+++ b/server/services/DecodersService.ts
@@ -0,0 +1,488 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+ ILegacyCustomClusterClient,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import { DecoderItem, GetDecoderResponse, SearchDecodersResponse } from '../../types';
+import { CLIENT_DECODER_METHODS, CONTENT_INDICES } from '../utils/constants';
+
+const SPACE_FIELD_CANDIDATES = [
+ 'space.keyword',
+ 'space',
+ 'space.name.keyword',
+ 'space.name',
+ 'document.space.keyword',
+ 'document.space',
+ 'document.space.name.keyword',
+ 'document.space.name',
+];
+
+interface SpaceFieldCaps {
+ searchFields: string[];
+ aggFields: string[];
+}
+
+export class DecodersService {
+ constructor(private osDriver: ILegacyCustomClusterClient) {}
+
+ private spaceFieldCaps?: SpaceFieldCaps;
+ private spaceFieldCapsPromise?: Promise;
+
+ private getClient(request: OpenSearchDashboardsRequest) {
+ return this.osDriver.asScoped(request).callAsCurrentUser;
+ }
+
+ private async getSpaceFieldCaps(client: any): Promise {
+ if (this.spaceFieldCaps) {
+ return this.spaceFieldCaps;
+ }
+
+ if (!this.spaceFieldCapsPromise) {
+ this.spaceFieldCapsPromise = (async () => {
+ try {
+ const fieldCapsResponse = await client('fieldCaps', {
+ index: CONTENT_INDICES.DECODERS,
+ fields: SPACE_FIELD_CANDIDATES,
+ });
+ const fields = fieldCapsResponse?.fields ?? {};
+ const searchFields: string[] = [];
+ const aggFields: string[] = [];
+ SPACE_FIELD_CANDIDATES.forEach((field) => {
+ const types = fields[field];
+ if (!types) {
+ return;
+ }
+ const entries = Object.entries(types) as Array<
+ [string, { searchable?: boolean; aggregatable?: boolean }]
+ >;
+ const isSearchable = entries.some(
+ ([type, meta]) => meta?.searchable && type !== 'object' && type !== 'nested'
+ );
+ const isAggregatable = entries.some(
+ ([type, meta]) => meta?.aggregatable && type !== 'object' && type !== 'nested'
+ );
+ if (isSearchable) {
+ searchFields.push(field);
+ }
+ if (isAggregatable) {
+ aggFields.push(field);
+ }
+ });
+
+ const result = {
+ searchFields: searchFields.length ? searchFields : SPACE_FIELD_CANDIDATES,
+ aggFields: aggFields.length ? aggFields : SPACE_FIELD_CANDIDATES,
+ };
+ this.spaceFieldCaps = result;
+ return result;
+ } catch (error: any) {
+ console.warn('Security Analytics - DecodersService - fieldCaps:', error?.message);
+ }
+
+ const fallback = {
+ searchFields: SPACE_FIELD_CANDIDATES,
+ aggFields: SPACE_FIELD_CANDIDATES,
+ };
+ this.spaceFieldCaps = fallback;
+ return fallback;
+ })();
+ }
+
+ return this.spaceFieldCapsPromise;
+ }
+
+ private buildSpaceFilter(space: string, fields: string[]) {
+ return {
+ bool: {
+ should: fields.map((field) => ({ term: { [field]: space } })),
+ minimum_should_match: 1,
+ },
+ };
+ }
+
+ private applySpaceFilter(query: any, space: string | undefined, fields: string[]) {
+ if (!space) {
+ return query ?? { match_all: {} };
+ }
+
+ const spaceFilter = this.buildSpaceFilter(space, fields);
+
+ if (!query || Object.keys(query).length === 0) {
+ return { bool: { filter: [spaceFilter] } };
+ }
+
+ if (query.bool) {
+ const { filter, ...restBool } = query.bool;
+ const existingFilter = Array.isArray(filter) ? filter : filter ? [filter] : [];
+ return {
+ bool: {
+ ...restBool,
+ filter: [...existingFilter, spaceFilter],
+ },
+ };
+ }
+
+ return {
+ bool: {
+ must: [query],
+ filter: [spaceFilter],
+ },
+ };
+ }
+
+ private async fetchIntegrationMap(client: any, decoderIds: string[]) {
+ const integrations = new Map();
+
+ if (!decoderIds.length) {
+ return integrations;
+ }
+
+ try {
+ const integrationResponse = await client('search', {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: {
+ size: 10000,
+ query: {
+ terms: {
+ 'document.decoders': decoderIds,
+ },
+ },
+ _source: ['document.metadata.title', 'document.decoders'],
+ },
+ });
+
+ const hits = integrationResponse?.hits?.hits ?? [];
+ hits.forEach((hit: any) => {
+ const title = hit?._source?.document?.metadata?.title;
+ const decoderRefs = hit?._source?.document?.decoders;
+ const decoderList = Array.isArray(decoderRefs)
+ ? decoderRefs
+ : decoderRefs
+ ? [decoderRefs]
+ : [];
+ decoderList.forEach((decoderId: string) => {
+ if (!integrations.has(decoderId)) {
+ integrations.set(decoderId, []);
+ }
+ if (title && !integrations.get(decoderId)!.includes(title)) {
+ integrations.get(decoderId)!.push(title);
+ }
+ });
+ });
+ } catch (error: any) {
+ console.warn('Security Analytics - DecodersService - fetchIntegrationMap:', error?.message);
+ }
+
+ return integrations;
+ }
+
+ searchDecoders = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const body = (request.body as any) ?? {};
+ const space = (request.query as { space?: string })?.space;
+ const { from = 0, size = 25, sort, query, _source } = body;
+
+ const client = this.getClient(request);
+ const { searchFields } = await this.getSpaceFieldCaps(client);
+ const searchResponse = await client('search', {
+ index: CONTENT_INDICES.DECODERS,
+ body: {
+ from,
+ size,
+ sort,
+ track_total_hits: true,
+ _source:
+ _source === undefined
+ ? {
+ includes: [
+ 'document.id',
+ 'document.name',
+ 'document.metadata.title',
+ 'document.metadata.author',
+ 'space',
+ ],
+ }
+ : _source,
+ query: this.applySpaceFilter(query, space, searchFields),
+ },
+ });
+
+ const hits = searchResponse?.hits?.hits ?? [];
+ const decoderIds = hits.map((hit: any) => hit._source?.document?.id ?? hit._id);
+ const integrationMap = await this.fetchIntegrationMap(client, decoderIds);
+ const items: DecoderItem[] = hits.map((hit: any) => ({
+ id: hit._id,
+ ...hit._source,
+ integrations: integrationMap.get(hit._source?.document?.id ?? hit._id) ?? [],
+ }));
+ const total =
+ typeof searchResponse?.hits?.total === 'number'
+ ? searchResponse.hits.total
+ : searchResponse?.hits?.total?.value ?? items.length;
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ total,
+ items,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - searchDecoders:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ getDecoder = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ decoderId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { decoderId } = request.params;
+ const space = (request.query as { space?: string })?.space;
+ const client = this.getClient(request);
+ const { searchFields } = await this.getSpaceFieldCaps(client);
+ const searchResponse = await client('search', {
+ index: CONTENT_INDICES.DECODERS,
+ body: {
+ size: 1,
+ query: this.applySpaceFilter({ term: { 'document.id': decoderId } }, space, searchFields),
+ },
+ });
+
+ const hit = searchResponse?.hits?.hits?.[0];
+ if (!hit) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Decoder not found',
+ },
+ });
+ }
+
+ const docId = hit._source?.document?.id ?? hit._id;
+ const integrationMap = await this.fetchIntegrationMap(client, [docId]);
+ const item: DecoderItem = {
+ id: hit._id,
+ ...hit._source,
+ integrations: integrationMap.get(docId) ?? [],
+ };
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ item,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - getDecoder:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ createDecoder = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const body = request.body as { documentJson: string; integrationId: string };
+ const client = this.getClient(request);
+
+ const { documentJson, integrationId } = body;
+ if (!documentJson) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Decoder document is required',
+ },
+ });
+ }
+
+ const createBody = {
+ body: `{"resource":${documentJson},"integration":"${integrationId}"}`,
+ };
+
+ const createResponse = await client(CLIENT_DECODER_METHODS.CREATE_DECODER, createBody);
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: createResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - createDecoder:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ updateDecoder = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ decoderId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { decoderId } = request.params;
+ const body = request.body as { documentJson: string };
+ const client = this.getClient(request);
+
+ const { documentJson } = body;
+ if (!documentJson) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Decoder document is required',
+ },
+ });
+ }
+
+ const updateBody = {
+ body: `{"resource":${documentJson}}`,
+ decoderId,
+ };
+
+ const responseRequest = await client(CLIENT_DECODER_METHODS.UPDATE_DECODER, updateBody);
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: responseRequest,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - updateDecoder:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ deleteDecoder = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ decoderId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { decoderId } = request.params;
+ const client = this.getClient(request);
+
+ const deleteBody = { decoderId };
+
+ await client(CLIENT_DECODER_METHODS.DELETE_DECODER, deleteBody);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: null,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - deleteDecoder:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ getDraftIntegrations = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const client = this.getClient(request);
+ const searchResponse = await client('search', {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: {
+ size: 10000,
+ query: {
+ term: {
+ 'space.name': {
+ value: 'draft',
+ },
+ },
+ },
+ _source: true,
+ },
+ });
+
+ const hits = searchResponse?.hits?.hits ?? [];
+
+ const total = hits.length;
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ total,
+ items: hits,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - DecodersService - getDraftIntegrations:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+}
diff --git a/server/services/FiltersService.ts b/server/services/FiltersService.ts
new file mode 100644
index 000000000..435acfe5c
--- /dev/null
+++ b/server/services/FiltersService.ts
@@ -0,0 +1,124 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import {
+ FilterSearchRequest,
+ FilterSearchResponse,
+ CreateFilterPayload,
+ UpdateFilterPayload,
+ CUDFilterResponse,
+} from '../../types';
+import { MDSEnabledClientService } from './MDSEnabledClientService';
+import { CLIENT_FILTER_METHODS, CONTENT_INDICES } from '../utils/constants';
+import { buildYamlBody } from '../utils/helpers';
+
+export class FiltersService extends MDSEnabledClientService {
+ searchFilters = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const body = request.body ?? { query: { match_all: {} } };
+ const client = this.getClient(request, context);
+ const searchResponse: FilterSearchResponse = await client('search', {
+ index: CONTENT_INDICES.FILTERS,
+ body: JSON.stringify(body),
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: searchResponse,
+ },
+ });
+ } catch (error) {
+ console.error('Security Analytics - FiltersService - searchFilters:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ createFilter = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { resourceYaml, space } = request.body as CreateFilterPayload;
+ const client = this.getClient(request, context);
+ const createResponse = await client(CLIENT_FILTER_METHODS.CREATE_FILTER, {
+ body: buildYamlBody(resourceYaml, { space: space }),
+ headers: { 'Content-Type': 'application/yaml' },
+ });
+ return response.custom({ statusCode: 200, body: { ok: true, response: createResponse } });
+ } catch (error) {
+ console.error('Security Analytics - FiltersService - createFilter:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.body?.message || error.message },
+ });
+ }
+ };
+
+ updateFilter = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ filterId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { filterId } = request.params;
+ const { resourceYaml, space } = request.body as UpdateFilterPayload;
+ const client = this.getClient(request, context);
+ const updateResponse = await client(CLIENT_FILTER_METHODS.UPDATE_FILTER, {
+ filterId,
+ body: buildYamlBody(resourceYaml, { space: space }),
+ headers: { 'Content-Type': 'application/yaml' },
+ });
+ return response.custom({ statusCode: 200, body: { ok: true, response: updateResponse } });
+ } catch (error) {
+ console.error('Security Analytics - FiltersService - updateFilter:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.body?.message || error.message },
+ });
+ }
+ };
+
+ deleteFilter = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ filterId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { filterId } = request.params;
+ const client = this.getClient(request, context);
+ await client(CLIENT_FILTER_METHODS.DELETE_FILTER, { filterId });
+ return response.custom({ statusCode: 200, body: { ok: true, response: null } });
+ } catch (error) {
+ console.error('Security Analytics - FiltersService - deleteFilter:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.message },
+ });
+ }
+ };
+}
diff --git a/server/services/FindingsService.ts b/server/services/FindingsService.ts
index 4349f6af4..74903de7f 100644
--- a/server/services/FindingsService.ts
+++ b/server/services/FindingsService.ts
@@ -45,7 +45,7 @@ export default class FindingsService extends MDSEnabledClientService {
getFindingsResponse.findings.forEach((finding: any) => {
const types: string[] = [];
if (!finding.queries.every((query: any) => query.id.startsWith('threat_intel_'))) {
- types.push('Detection rules');
+ types.push('Rules'); // Wazuh: rename 'Detection rules' to 'Rules'
}
if (finding.queries.some((query: any) => query.id.startsWith('threat_intel_'))) {
types.push('Threat intelligence');
diff --git a/server/services/IntegrationService.ts b/server/services/IntegrationService.ts
new file mode 100644
index 000000000..c9dd3b40a
--- /dev/null
+++ b/server/services/IntegrationService.ts
@@ -0,0 +1,429 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import {
+ CreateIntegrationRequestBody,
+ CreateIntegrationResponse,
+ DeleteIntegrationParams,
+ DeleteIntegrationResponse,
+ GetPromoteBySpaceResponse,
+ IntegrationBase,
+ PromoteIntegrationRequestBody,
+ PromoteIntegrationResponse,
+ PromoteSpaces,
+ SearchIntegrationsResponse,
+ UpdateIntegrationParams,
+ UpdateIntegrationResponse,
+} from '../../types';
+import { CLIENT_INTEGRATION_METHODS, CONTENT_INDICES } from '../utils/constants';
+import { MDSEnabledClientService } from './MDSEnabledClientService';
+import { get, sortBy } from 'lodash';
+import { getNextSpace } from '../../common/helpers';
+
+export class IntegrationService extends MDSEnabledClientService {
+ createIntegration = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const { document } = request.body;
+ const client = this.getClient(request, context);
+ const createIntegrationResponse: CreateIntegrationResponse = await client(
+ CLIENT_INTEGRATION_METHODS.CREATE_INTEGRATION,
+ { body: { resource: document } }
+ );
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: createIntegrationResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - createIntegration:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body || error.message,
+ },
+ });
+ }
+ };
+
+ searchIntegrations = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ let query: any = request.body;
+
+ const client = this.getClient(request, context);
+ const searchIntegrationsResponse: SearchIntegrationsResponse = await client(
+ // CLIENT_INTEGRATION_METHODS.SEARCH_INTEGRATIONS,
+ 'search',
+ {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: {
+ size: 10000,
+ query: query ?? {
+ match_all: {},
+ },
+ },
+ }
+ );
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: searchIntegrationsResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - searchIntegrations:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ updateIntegration = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ integrationId: string }, unknown, IntegrationBase>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const {
+ document: { id, date, modified, ...document },
+ } = request.body;
+ const { integrationId } = request.params;
+ const params: UpdateIntegrationParams = {
+ body: { resource: document },
+ integrationId,
+ };
+ const client = this.getClient(request, context);
+ const updateIntegrationResponse: UpdateIntegrationResponse = await client(
+ CLIENT_INTEGRATION_METHODS.UPDATE_INTEGRATION,
+ params
+ );
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: updateIntegrationResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - updateIntegration:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body.message || error.message,
+ },
+ });
+ }
+ };
+
+ resolvePromoteEntity = async (
+ client: any,
+ promoteEntityData: { id: string }[],
+ {
+ index,
+ space,
+ nameProp,
+ idProp,
+ }: {
+ index: string;
+ space: PromoteSpaces;
+ nameProp: string;
+ idProp: string;
+ },
+ targetSpace: PromoteSpaces | null = null
+ ) => {
+ const ids = promoteEntityData.map(({ id }) => id.replace(/^\w_/, '')); // TODO: this removes the `d_` prefix of the ids
+
+ const searchEntityByIdsAndSpace = async (idsToSearch: string[], searchSpace: PromoteSpaces) => {
+ if (idsToSearch.length === 0) {
+ return {};
+ }
+
+ // TODO: This should paginate the results
+ const searchResponse: SearchIntegrationsResponse = await client('search', {
+ index,
+ body: {
+ size: 10000,
+ _source: [nameProp, idProp],
+ query: {
+ bool: {
+ must: [
+ {
+ terms: {
+ [idProp]: idsToSearch,
+ },
+ },
+ {
+ term: {
+ 'space.name': searchSpace,
+ },
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ return Object.fromEntries(
+ searchResponse.hits.hits.map(({ _source }) => [
+ get(_source, idProp),
+ get(_source, nameProp),
+ ])
+ );
+ };
+
+ const entitiesInCurrentSpace = await searchEntityByIdsAndSpace(ids, space);
+
+ if (!targetSpace) {
+ return entitiesInCurrentSpace;
+ }
+
+ const unresolvedIds = ids.filter((id) => !entitiesInCurrentSpace[id]);
+ if (unresolvedIds.length === 0) {
+ return entitiesInCurrentSpace;
+ }
+
+ const entitiesInTargetSpace = await searchEntityByIdsAndSpace(unresolvedIds, targetSpace);
+ return {
+ ...entitiesInTargetSpace,
+ ...entitiesInCurrentSpace,
+ };
+ };
+
+ getPromoteBySpace = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ integrationId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const { space } = request.params;
+ const params: { space: string } = { space };
+ const client = this.getClient(request, context);
+ const promoteSpace: PromoteIntegrationRequestBody = await client(
+ CLIENT_INTEGRATION_METHODS.GET_PROMOTE_BY_SPACE,
+ params
+ );
+ const targetSpace = getNextSpace(space);
+
+ const availablePromotions: Record> = {
+ integrations: {},
+ decoders: {},
+ kvdbs: {},
+ filters: {},
+ policy: {},
+ rules: {},
+ };
+
+ //
+ if (promoteSpace.changes.integrations.length > 0) {
+ availablePromotions['integrations'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.integrations,
+ {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ space,
+ nameProp: 'document.metadata.title',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ if (promoteSpace.changes.decoders.length > 0) {
+ availablePromotions['decoders'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.decoders,
+ {
+ index: CONTENT_INDICES.DECODERS,
+ space,
+ nameProp: 'document.name',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ if (promoteSpace.changes.kvdbs.length > 0) {
+ availablePromotions['kvdbs'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.kvdbs,
+ {
+ index: CONTENT_INDICES.KVDBS,
+ space,
+ nameProp: 'document.metadata.title',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ if (promoteSpace.changes.filters.length > 0) {
+ availablePromotions['filters'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.filters,
+ {
+ index: CONTENT_INDICES.FILTERS,
+ space,
+ nameProp: 'document.metadata.title',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ if (promoteSpace.changes.policy.length > 0) {
+ availablePromotions['policy'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.policy,
+ {
+ index: CONTENT_INDICES.POLICIES,
+ space,
+ nameProp: 'document.metadata.title',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ if (promoteSpace.changes.rules?.length > 0) {
+ availablePromotions['rules'] = await this.resolvePromoteEntity(
+ client,
+ promoteSpace.changes.rules,
+ {
+ index: CONTENT_INDICES.RULES,
+ space,
+ nameProp: 'document.metadata.title',
+ idProp: 'document.id',
+ },
+ targetSpace
+ );
+ }
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ promote: promoteSpace,
+ available_promotions: availablePromotions,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - promoteIntegration:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body || error.message,
+ },
+ });
+ }
+ };
+
+ promoteIntegration = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ integrationId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const body = request.body;
+ const params: { body: any } = { body };
+ const client = this.getClient(request, context);
+ const promoteIntegrationResponse: PromoteIntegrationResponse = await client(
+ CLIENT_INTEGRATION_METHODS.PROMOTE_INTEGRATION,
+ params
+ );
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: promoteIntegrationResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - promoteIntegration:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ deleteIntegration = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ integrationId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const { integrationId } = request.params;
+ const params: DeleteIntegrationParams = { integrationId };
+ const client = this.getClient(request, context);
+ const deleteIntegrationResponse: DeleteIntegrationResponse = await client(
+ CLIENT_INTEGRATION_METHODS.DELETE_INTEGRATION,
+ params
+ );
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: deleteIntegrationResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - IntegrationService - deleteIntegration:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body || error.message,
+ },
+ });
+ }
+ };
+}
diff --git a/server/services/KVDBsService.ts b/server/services/KVDBsService.ts
new file mode 100644
index 000000000..d1167c1e0
--- /dev/null
+++ b/server/services/KVDBsService.ts
@@ -0,0 +1,202 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import {
+ CreateKVDBPayload,
+ KVDBIntegrationsSearchResponse,
+ KVDBSearchRequest,
+ KVDBSearchResponse,
+ UpdateKVDBPayload,
+} from '../../types';
+import { CLIENT_KVDB_METHODS, CONTENT_INDICES } from '../utils/constants';
+import { buildYamlBody } from '../utils/helpers';
+import { MDSEnabledClientService } from './MDSEnabledClientService';
+
+export class KVDBsService extends MDSEnabledClientService {
+ searchKVDBs = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const body = request.body ?? { query: { match_all: {} } };
+ const client = this.getClient(request, context);
+ const searchResponse: KVDBSearchResponse = await client('search', {
+ index: CONTENT_INDICES.KVDBS,
+ body: JSON.stringify(body),
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: searchResponse,
+ },
+ });
+ } catch (error) {
+ console.error('Security Analytics - KVDBsService - searchKVDBs:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ searchIntegrations = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const { kvdbIds } = request.body ?? { kvdbIds: [] };
+ if (!kvdbIds.length) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: { hits: { hits: [] } },
+ },
+ });
+ }
+
+ const client = this.getClient(request, context);
+ const searchResponse: KVDBIntegrationsSearchResponse = await client('search', {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: JSON.stringify({
+ size: kvdbIds.length,
+ query: {
+ terms: {
+ 'document.kvdbs': kvdbIds,
+ },
+ },
+ _source: ['document.id', 'document.metadata.title', 'document.kvdbs'],
+ }),
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: searchResponse,
+ },
+ });
+ } catch (error) {
+ console.error('Security Analytics - KVDBsService - searchIntegrations:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ createKVDB = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { resourceYaml, integrationId } = request.body as CreateKVDBPayload;
+ if (!resourceYaml) {
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: 'KVDB resource is required' },
+ });
+ }
+
+ const client = this.getClient(request, context);
+ const createResponse = await client(CLIENT_KVDB_METHODS.CREATE_KVDB, {
+ body: buildYamlBody(resourceYaml, integrationId ? { integration: integrationId } : undefined),
+ headers: { 'Content-Type': 'application/yaml' },
+ });
+
+ return response.custom({ statusCode: 200, body: { ok: true, response: createResponse } });
+ } catch (error) {
+ console.error('Security Analytics - KVDBsService - createKVDB:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.body?.message || error.message },
+ });
+ }
+ };
+
+ updateKVDB = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ kvdbId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { kvdbId } = request.params;
+ const { resourceYaml } = request.body as UpdateKVDBPayload;
+ if (!resourceYaml) {
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: 'KVDB resource is required' },
+ });
+ }
+
+ const client = this.getClient(request, context);
+ const updateResponse = await client(CLIENT_KVDB_METHODS.UPDATE_KVDB, {
+ body: buildYamlBody(resourceYaml),
+ kvdbId,
+ headers: { 'Content-Type': 'application/yaml' },
+ });
+
+ return response.custom({ statusCode: 200, body: { ok: true, response: updateResponse } });
+ } catch (error) {
+ console.error('Security Analytics - KVDBsService - updateKVDB:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.body?.message || error.message },
+ });
+ }
+ };
+
+ deleteKVDB = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ kvdbId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { kvdbId } = request.params;
+ const client = this.getClient(request, context);
+
+ const deleteBody = { kvdbId };
+
+ await client(CLIENT_KVDB_METHODS.DELETE_KVDB, deleteBody);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: null,
+ },
+ });
+ } catch (error) {
+ console.error('Security Analytics - KVDBsService - deleteKVDB:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+}
diff --git a/server/services/LogTestService.ts b/server/services/LogTestService.ts
new file mode 100644
index 000000000..7825ed59f
--- /dev/null
+++ b/server/services/LogTestService.ts
@@ -0,0 +1,124 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import { LogTestApiRequest, LogTestResponse } from '../../types';
+import { CLIENT_LOG_TEST_METHODS } from '../utils/constants';
+import { MDSEnabledClientService } from './MDSEnabledClientService';
+
+
+function formatLogTestServiceError(error: any): string {
+ const body = error?.body;
+ if (typeof body === 'string' && body.trim()) {
+ return body.trim();
+ }
+ if (body && typeof body === 'object') {
+ if (typeof body.message === 'string' && body.message.trim()) {
+ return body.message.trim();
+ }
+ const nested = body.error;
+ if (typeof nested === 'string' && nested.trim()) {
+ return nested.trim();
+ }
+ if (nested && typeof nested === 'object') {
+ if (typeof nested.reason === 'string' && nested.reason.trim()) {
+ return nested.reason.trim();
+ }
+ const root = nested.root_cause;
+ if (Array.isArray(root)) {
+ const first = root.find((c: any) => typeof c?.reason === 'string' && c.reason.trim());
+ if (first?.reason) {
+ return String(first.reason).trim();
+ }
+ }
+ }
+ }
+ if (typeof error?.message === 'string' && error.message.trim()) {
+ return error.message.trim();
+ }
+ return 'Log test failed due to an unexpected error.';
+}
+
+export class LogTestService extends MDSEnabledClientService {
+ logTest = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { document: rawDocument } = request.body as LogTestApiRequest;
+ const rawMeta = rawDocument.metadata;
+ const hasMetadata =
+ rawMeta && typeof rawMeta === 'object' && !Array.isArray(rawMeta) && Object.keys(rawMeta).length > 0;
+
+ const logTest = {
+ ...rawDocument,
+ location: rawDocument.location?.trim() || '-',
+ metadata: hasMetadata ? rawMeta : {},
+ };
+
+ const client = this.getClient(request, context);
+
+ if (logTest.queue === undefined || logTest.queue === null) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Queue is required.',
+ },
+ });
+ }
+
+ if (!logTest.event) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Event is required.',
+ },
+ });
+ }
+
+ if (!logTest.space) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Space is required.',
+ },
+ });
+ }
+
+ const logTestResponse: LogTestResponse = await client(CLIENT_LOG_TEST_METHODS.TEST_LOG, {
+ body: logTest,
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: logTestResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - LogTestService - logTest:', error);
+ const extracted = formatLogTestServiceError(error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: extracted,
+ },
+ });
+ }
+ };
+}
diff --git a/server/services/PoliciesService.ts b/server/services/PoliciesService.ts
new file mode 100644
index 000000000..1dcc373f7
--- /dev/null
+++ b/server/services/PoliciesService.ts
@@ -0,0 +1,390 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ IOpenSearchDashboardsResponse,
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ RequestHandlerContext,
+ ResponseError,
+} from 'opensearch-dashboards/server';
+import { ServerResponse } from '../models/types';
+import {
+ PolicyItem,
+ GetPolicyResponse,
+ SearchPoliciesResponse,
+ UpdatePolicyRequestBody,
+} from '../../types';
+import { MDSEnabledClientService } from './MDSEnabledClientService';
+import { CLIENT_POLICY_METHODS, CONTENT_INDICES } from '../utils/constants';
+
+const SPACE_FIELD_CANDIDATES = [
+ 'space.keyword',
+ 'space',
+ 'space.name.keyword',
+ 'space.name',
+ 'document.space.keyword',
+ 'document.space',
+ 'document.space.name.keyword',
+ 'document.space.name',
+];
+
+interface SpaceFieldCaps {
+ searchFields: string[];
+ aggFields: string[];
+}
+
+export class PoliciesService extends MDSEnabledClientService {
+ private spaceFieldCaps?: SpaceFieldCaps;
+ private spaceFieldCapsPromise?: Promise;
+
+ private async getSpaceFieldCaps(client: any): Promise {
+ if (this.spaceFieldCaps) {
+ return this.spaceFieldCaps;
+ }
+
+ if (!this.spaceFieldCapsPromise) {
+ this.spaceFieldCapsPromise = (async () => {
+ try {
+ const fieldCapsResponse = await client('fieldCaps', {
+ index: CONTENT_INDICES.POLICIES,
+ fields: SPACE_FIELD_CANDIDATES,
+ });
+ const fields = fieldCapsResponse?.fields ?? {};
+ const searchFields: string[] = [];
+ const aggFields: string[] = [];
+ SPACE_FIELD_CANDIDATES.forEach((field) => {
+ const types = fields[field];
+ if (!types) {
+ return;
+ }
+ const entries = Object.entries(types) as Array<
+ [string, { searchable?: boolean; aggregatable?: boolean }]
+ >;
+ const isSearchable = entries.some(
+ ([type, meta]) => meta?.searchable && type !== 'object' && type !== 'nested'
+ );
+ const isAggregatable = entries.some(
+ ([type, meta]) => meta?.aggregatable && type !== 'object' && type !== 'nested'
+ );
+ if (isSearchable) {
+ searchFields.push(field);
+ }
+ if (isAggregatable) {
+ aggFields.push(field);
+ }
+ });
+
+ const result = {
+ searchFields: searchFields.length ? searchFields : SPACE_FIELD_CANDIDATES,
+ aggFields: aggFields.length ? aggFields : SPACE_FIELD_CANDIDATES,
+ };
+ this.spaceFieldCaps = result;
+ return result;
+ } catch (error: any) {
+ console.warn('Security Analytics - PoliciesService - fieldCaps:', error?.message);
+ }
+
+ const fallback = {
+ searchFields: SPACE_FIELD_CANDIDATES,
+ aggFields: SPACE_FIELD_CANDIDATES,
+ };
+ this.spaceFieldCaps = fallback;
+ return fallback;
+ })();
+ }
+
+ return this.spaceFieldCapsPromise;
+ }
+
+ private buildSpaceFilter(space: string, fields: string[]) {
+ return {
+ bool: {
+ should: fields.map((field) => ({ term: { [field]: space } })),
+ minimum_should_match: 1,
+ },
+ };
+ }
+
+ private applySpaceFilter(query: any, space: string | undefined, fields: string[]) {
+ if (!space) {
+ return query ?? { match_all: {} };
+ }
+
+ const spaceFilter = this.buildSpaceFilter(space, fields);
+
+ if (!query || Object.keys(query).length === 0) {
+ return { bool: { filter: [spaceFilter] } };
+ }
+
+ if (query.bool) {
+ const { filter, ...restBool } = query.bool;
+ const existingFilter = Array.isArray(filter) ? filter : filter ? [filter] : [];
+ return {
+ bool: {
+ ...restBool,
+ filter: [...existingFilter, spaceFilter],
+ },
+ };
+ }
+
+ return {
+ bool: {
+ must: [query],
+ filter: [spaceFilter],
+ },
+ };
+ }
+
+ private async fetchIntegrationMap(
+ client: any,
+ space: string,
+ integrationsIds: string[],
+ _source?: string[]
+ ): Promise> {
+ let integrations = new Map();
+
+ if (!integrationsIds.length) {
+ return integrations;
+ }
+
+ const query = this.applySpaceFilter(
+ {
+ terms: {
+ 'document.id': integrationsIds,
+ },
+ },
+ space,
+ (await this.getSpaceFieldCaps(client)).searchFields
+ );
+
+ try {
+ const integrationResponse = await client('search', {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: {
+ size: 10000,
+ query: query,
+ _source: _source,
+ },
+ });
+
+ const hits = integrationResponse?.hits?.hits ?? [];
+
+ integrations = new Map(
+ hits.map((hit: any) => [hit?._source?.document?.id, { _id: hit._id, ...hit._source }])
+ );
+ } catch (error: any) {
+ console.warn('Security Analytics - PoliciesService - fetchIntegrationMap:', error?.message);
+ }
+
+ return integrations;
+ }
+
+ searchPolicies = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise<
+ IOpenSearchDashboardsResponse | ResponseError>
+ > => {
+ try {
+ const body = (request.body as any) ?? {};
+ const space = (request.query as { space?: string })?.space;
+ const {
+ from = 0,
+ size = 25,
+ sort,
+ query,
+ _source,
+ includeIntegrationFields,
+ includeIntegrationsMap = true,
+ } = body;
+ const client = this.getClient(request, context);
+ const { searchFields } = await this.getSpaceFieldCaps(client);
+ const searchResponse = await client('search', {
+ index: CONTENT_INDICES.POLICIES,
+ body: {
+ from,
+ size,
+ sort,
+ track_total_hits: true,
+ _source: _source === undefined ? { includes: ['document', 'space'] } : _source,
+ query: this.applySpaceFilter(query, space, searchFields),
+ },
+ });
+
+ const hits = searchResponse?.hits?.hits ?? [];
+ // Skip the integrations search/enrichment when the caller doesn't need integrationsMap.
+ const integrationsIds = includeIntegrationsMap
+ ? [...hits.map((hit: any) => hit?._source?.document?.integrations)].flat()
+ : [];
+ const integrationMap = includeIntegrationsMap
+ ? await this.fetchIntegrationMap(client, space, integrationsIds, includeIntegrationFields)
+ : new Map();
+
+ const items: PolicyItem[] = hits.map((hit: any) => ({
+ id: hit._id,
+ ...hit._source,
+ integrationsMap: includeIntegrationsMap
+ ? Object.fromEntries(
+ hit._source?.document?.integrations?.map?.((id) => [
+ id,
+ integrationMap.get(id) ?? {},
+ ]) ?? []
+ )
+ : {},
+ }));
+ const total =
+ typeof searchResponse?.hits?.total === 'number'
+ ? searchResponse.hits.total
+ : searchResponse?.hits?.total?.value ?? items.length;
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ total,
+ items,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - PoliciesService - searchPolicies:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ getPolicy = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ policyId: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { policyId } = request.params;
+ const space = (request.query as { space?: string })?.space;
+ const client = this.getClient(request, context);
+ const { searchFields } = await this.getSpaceFieldCaps(client);
+ const query = {
+ index: CONTENT_INDICES.POLICIES,
+ body: {
+ size: 1,
+ query: this.applySpaceFilter({ ids: { values: [policyId] } }, space, searchFields),
+ },
+ };
+
+ const searchResponse = await client('search', query);
+
+ const hit = searchResponse?.hits?.hits?.[0];
+ if (!hit) {
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: 'Policy not found',
+ },
+ });
+ }
+
+ const integrationMap = await this.fetchIntegrationMap(client, [hit._id]);
+ const item: PolicyItem = {
+ id: hit._id,
+ ...hit._source,
+ integrations: integrationMap.get(hit._id) ?? [],
+ };
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: {
+ item,
+ },
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - PoliciesService - getPolicy:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.message,
+ },
+ });
+ }
+ };
+
+ updatePolicy = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ space: string }, {}, UpdatePolicyRequestBody>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { space } = request.params;
+ const { body } = request;
+
+ const client = this.getClient(request, context);
+ const updatePolicyResponse = await client(CLIENT_POLICY_METHODS.UPDATE_POLICY, {
+ space,
+ body: {
+ resource: body,
+ },
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: updatePolicyResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - PoliciesService - updatePolicy:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error?.body?.message || error.message,
+ },
+ });
+ }
+ };
+
+ deleteSpace = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{ space: string }>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { space } = request.params;
+ const client = this.getClient(request, context);
+
+ await client(CLIENT_POLICY_METHODS.DELETE_SPACE, { space });
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: null,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - PoliciesService - deleteSpace:', error);
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: false,
+ error: error.body?.message || error.message,
+ },
+ });
+ }
+ };
+}
diff --git a/server/services/WazuhRuleService.ts b/server/services/WazuhRuleService.ts
new file mode 100644
index 000000000..c3db37463
--- /dev/null
+++ b/server/services/WazuhRuleService.ts
@@ -0,0 +1,312 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {
+ OpenSearchDashboardsRequest,
+ OpenSearchDashboardsResponseFactory,
+ IOpenSearchDashboardsResponse,
+ ResponseError,
+ RequestHandlerContext,
+ ILegacyCustomClusterClient,
+} from 'opensearch-dashboards/server';
+import {
+ DeleteRuleParams,
+ DeleteRuleResponse,
+ GetRulesParams,
+ GetRulesResponse,
+} from '../models/interfaces';
+import { CLIENT_RULE_METHODS, CONTENT_INDICES } from '../utils/constants';
+import { ServerResponse } from '../models/types';
+import { load } from 'js-yaml';
+import { Rule } from '../../types';
+import { SpaceTypes } from '../../common/constants';
+
+export default class WazuhRulesService {
+ constructor(private osDriver: ILegacyCustomClusterClient) {}
+
+ private getClient(request: OpenSearchDashboardsRequest) {
+ return this.osDriver.asScoped(request).callAsCurrentUser;
+ }
+
+ private getSpaceFromPrePackaged(prePackaged: boolean): string {
+ return prePackaged === false ? SpaceTypes.CUSTOM.value : SpaceTypes.STANDARD.value;
+ }
+
+ private buildQuery(prePackaged: boolean, incomingQuery?: any, space?: string) {
+ // When an explicit space is provided it takes precedence over the prePackaged binary model
+ const bool: any = {
+ filter: [{ term: { 'space.name': space ?? this.getSpaceFromPrePackaged(prePackaged) } }],
+ };
+
+ if (incomingQuery && !incomingQuery.match_all) {
+ bool.must = [incomingQuery];
+ }
+
+ return { bool };
+ }
+
+ private parseYamlField(yamlStr: string | undefined): any {
+ if (!yamlStr || (typeof yamlStr === 'string' && !yamlStr.trim())) return undefined;
+ if (typeof yamlStr !== 'string') return yamlStr;
+ try {
+ return load(yamlStr);
+ } catch {
+ return undefined;
+ }
+ }
+
+ private buildRuleResource(rule: Rule) {
+ const resource: Record = {
+ level: rule.level,
+ status: rule.status,
+ logsource:
+ rule.log_source && Object.keys(rule.log_source).length > 0
+ ? rule.log_source
+ : { product: rule.category },
+ detection: load(rule.detection),
+ enabled: rule.enabled ?? true,
+ };
+ if (rule.tags?.length) resource.tags = rule.tags.map((t) => t.value);
+ if (rule.false_positives?.length)
+ resource.falsepositives = rule.false_positives.map((fp) => fp.value);
+
+ const metadata: Record = {
+ title: rule.metadata?.title,
+ author: rule.metadata?.author,
+ description: rule.metadata?.description,
+ references: rule.metadata?.references?.length ? rule.metadata.references : [],
+ };
+ if (rule.metadata?.date) metadata.date = rule.metadata.date;
+ if (rule.metadata?.modified) metadata.modified = rule.metadata.modified;
+ if (rule.metadata?.documentation) metadata.documentation = rule.metadata.documentation;
+ if (rule.metadata?.supports?.length) metadata.supports = rule.metadata.supports;
+ resource.metadata = metadata;
+
+ const mitre = this.parseYamlField(rule.mitre);
+ if (mitre) resource.mitre = mitre;
+
+ const compliance = this.parseYamlField(rule.compliance);
+ if (compliance) resource.compliance = compliance;
+
+ return resource;
+ }
+
+ private async fetchIntegrationMap(client: any, ruleIds: string[], space: string) {
+ const integrationMap = new Map();
+ if (!ruleIds.length) return integrationMap;
+
+ try {
+ const integrationResponse = await client('search', {
+ index: CONTENT_INDICES.INTEGRATIONS,
+ body: {
+ size: 10000,
+ query: {
+ bool: {
+ must: [
+ {
+ terms: {
+ 'document.rules': ruleIds,
+ },
+ },
+ {
+ term: {
+ 'space.name': space,
+ },
+ },
+ ],
+ },
+ },
+ _source: ['document.id', 'document.metadata.title', 'document.rules'],
+ },
+ });
+ const integrationHits = integrationResponse?.hits?.hits || [];
+ integrationHits.forEach((integrationHit: any) => {
+ const rules = integrationHit?._source?.document?.rules || [];
+ rules.forEach((ruleId: string) => {
+ if (!integrationMap.has(ruleId)) {
+ integrationMap.set(ruleId, {
+ document: {
+ metadata: integrationHit._source.document.metadata,
+ id: integrationHit._source.document.id,
+ },
+ });
+ }
+ });
+ });
+ } catch (error: any) {
+ console.warn('Security Analytics - WazuhRulesService - fetchIntegrationMap:', error?.message);
+ }
+
+ return integrationMap;
+ }
+
+ getRules = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest<{}, GetRulesParams>,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { prePackaged, space } = request.query as {
+ prePackaged: boolean;
+ space?: string;
+ };
+
+ const { from = 0, size = 5000, query, sort, _source } = (request.body as any) ?? {};
+ const client = this.getClient(request);
+ const searchBody: any = {
+ from,
+ size,
+ track_total_hits: true,
+ query: this.buildQuery(prePackaged, query, space),
+ };
+ if (sort) searchBody.sort = sort;
+ if (_source !== undefined) searchBody._source = _source;
+ const searchResponse = await client('search', {
+ index: CONTENT_INDICES.RULES,
+ body: searchBody,
+ });
+
+ const ruleHits = searchResponse?.hits?.hits || [];
+ const ruleIds = ruleHits.map((hit: any) => hit._source?.document?.id || hit.document?.id);
+ const integrationMap = await this.fetchIntegrationMap(
+ client,
+ ruleIds,
+ space ?? this.getSpaceFromPrePackaged(prePackaged)
+ );
+ const enrichedHits = ruleHits.map((hit: any) => ({
+ ...hit,
+ integration: integrationMap.get(hit._source?.document?.id || hit.document?.id) || null,
+ }));
+
+ const enrichedResponse = {
+ ...searchResponse,
+ hits: {
+ ...searchResponse.hits,
+ hits: enrichedHits,
+ },
+ };
+
+ return response.custom({
+ statusCode: 200,
+ body: {
+ ok: true,
+ response: enrichedResponse,
+ },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - RulesService - getRules:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.message },
+ });
+ }
+ };
+
+ createRule = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { document: rule, integrationId } = request.body as {
+ document: Rule;
+ integrationId: string;
+ };
+ if (!rule)
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: 'Rule document is required' },
+ });
+
+ const client = this.getClient(request);
+
+ const createResponse = await client(CLIENT_RULE_METHODS.CREATE_RULE, {
+ body: {
+ resource: this.buildRuleResource(rule),
+ integration: integrationId,
+ },
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: { ok: true, response: createResponse },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - RulesService - createRule:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error?.body?.message || error.message },
+ });
+ }
+ };
+
+ updateRule = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { ruleId } = request.params as { ruleId: string };
+ const { document: rule } = request.body as { document: Rule };
+ if (!rule)
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: 'Rule document is required' },
+ });
+
+ const client = this.getClient(request);
+ const updateResponse = await client(CLIENT_RULE_METHODS.UPDATE_RULE, {
+ ruleId,
+ body: { resource: this.buildRuleResource(rule) },
+ });
+
+ return response.custom({
+ statusCode: 200,
+ body: { ok: true, response: updateResponse },
+ });
+ return response.custom({
+ statusCode: 200,
+ body: { ok: true, response: updateResponse },
+ });
+ } catch (error: any) {
+ console.error('Security Analytics - RulesService - updateRule:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error?.body?.message || error.message },
+ });
+ }
+ };
+
+ deleteRule = async (
+ context: RequestHandlerContext,
+ request: OpenSearchDashboardsRequest,
+ response: OpenSearchDashboardsResponseFactory
+ ): Promise | ResponseError>> => {
+ try {
+ const { ruleId } = request.params;
+ const client = this.getClient(request);
+ await client(CLIENT_RULE_METHODS.DELETE_RULE, { ruleId });
+
+ return response.custom({
+ statusCode: 200,
+ body: { ok: true, response: {} },
+ });
+ return response.custom({
+ statusCode: 200,
+ body: { ok: true, response: {} },
+ });
+ } catch (error) {
+ console.error('Security Analytics - RulesService - deleteRule:', error);
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.message },
+ });
+ return response.custom({
+ statusCode: 200,
+ body: { ok: false, error: error.message },
+ });
+ }
+ };
+}
diff --git a/server/services/index.ts b/server/services/index.ts
index e43ffff82..24dac36bc 100644
--- a/server/services/index.ts
+++ b/server/services/index.ts
@@ -12,6 +12,8 @@ import FieldMappingService from './FieldMappingService';
import AlertService from './AlertService';
import RulesService from './RuleService';
import NotificationsService from './NotificationsService';
+import { KVDBsService } from './KVDBsService';
+import WazuhRulesService from './WazuhRuleService';
export {
DetectorService,
@@ -22,5 +24,7 @@ export {
OpenSearchService,
AlertService,
RulesService,
+ WazuhRulesService,
NotificationsService,
+ KVDBsService,
};
diff --git a/server/utils/constants.ts b/server/utils/constants.ts
index 3255de942..0b580d39a 100644
--- a/server/utils/constants.ts
+++ b/server/utils/constants.ts
@@ -34,11 +34,18 @@ export const API: SecurityAnalyticsApi = {
ACKNOWLEDGE_ALERTS: `${BASE_API_PATH}/detectors/{detector_id}/_acknowledge/alerts`,
UPDATE_ALIASES: `${BASE_API_PATH}/update_aliases`,
CORRELATIONS: `${BASE_API_PATH}/correlations`,
+ INTEGRATION_BASE: `${BASE_API_PATH}/integrations`,
LOGTYPE_BASE: `${BASE_API_PATH}/logtype`,
+ KVDBS_BASE: `${BASE_API_PATH}/kvdbs`,
+ FILTERS_BASE: `${BASE_API_PATH}/filters`,
+ LOG_TEST_BASE: `${BASE_API_PATH}/logtest`,
METRICS: `/api/security_analytics/stats`,
GET_CORRELATION_ALERTS: `${BASE_API_PATH}/correlationAlerts`,
ACK_CORRELATION_ALERTS: `${BASE_API_PATH}/_acknowledge/correlationAlerts`,
THREAT_INTEL_BASE: `${BASE_API_PATH}/threat_intel`,
+ DECODERS_BASE: `${BASE_API_PATH}/decoders`,
+ POLICIES_BASE: `${BASE_API_PATH}/policies`,
+ SPACES_BASE: `${BASE_API_PATH}/spaces`,
};
/**
@@ -90,12 +97,33 @@ export const METHOD_NAMES = {
GET_CHANNElS: 'getChannels',
GET_FEATURES: 'getFeatures',
+ // Wazuh: Integration methods
+ SEARCH_INTEGRATIONS: 'search',
+ CREATE_INTEGRATION: 'createIntegration',
+ UPDATE_INTEGRATION: 'updateIntegration',
+ DELETE_INTEGRATION: 'deleteIntegration',
+ PROMOTE_INTEGRATION: 'promoteIntegration',
+ GET_PROMOTE_BY_SPACE: 'getPromoteBySpace',
+
+ //Wazuh: Rule methods
+ CREATE_WAZUH_RULE: 'createWazuhRule',
+ DELETE_WAZUH_RULE: 'deleteWazuhRule',
+ GET_WAZUH_RULE: 'getWazuhRule',
+ GET_WAZUH_RULES: 'getWazuhRules',
+ UPDATE_WAZUH_RULE: 'updateWazuhRule',
+
+ // Wazuh: Policy methods
+ UPDATE_POLICY: 'updatePolicy',
+
// LogType methods
SEARCH_LOGTYPES: 'searchLogTypes',
CREATE_LOGTYPE: 'createLogType',
UPDATE_LOGTYPE: 'updateLogType',
DELETE_LOGTYPE: 'deleteLogType',
+ // LogTest methods
+ TEST_LOG: 'executeLogtest',
+
// Threat intel methods
ADD_THREAT_INTEL_SOURCE: 'addThreatIntelSource',
UPDATE_THREAT_INTEL_SOURCE: 'updateThreatIntelSource',
@@ -111,6 +139,24 @@ export const METHOD_NAMES = {
GET_THREAT_INTEL_FINDINGS: 'getThreatIntelFindings',
GET_THREAT_INTEL_ALERTS: 'getThreatIntelAlerts',
UPDATE_THREAT_INTEL_ALERTS_STATE: 'updateThreatIntelAlertsStatus',
+
+ // Space methods
+ DELETE_SPACE: 'deleteSpace',
+
+ // Decoder methods
+ CREATE_DECODER: 'createDecoder',
+ UPDATE_DECODER: 'updateDecoder',
+ DELETE_DECODER: 'deleteDecoder',
+
+ // KVDB methods
+ CREATE_KVDB: 'createKvdb',
+ UPDATE_KVDB: 'updateKvdb',
+ DELETE_KVDB: 'deleteKvdb',
+
+ // Filter methods
+ CREATE_FILTER: 'createFilter',
+ UPDATE_FILTER: 'updateFilter',
+ DELETE_FILTER: 'deleteFilter',
};
/**
@@ -119,11 +165,12 @@ export const METHOD_NAMES = {
* @see ../clusters/securityAnalytics/securityAnalyticsPlugin
*/
export const CLIENT_RULE_METHODS = {
- CREATE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_RULE}`,
- DELETE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_RULE}`,
- GET_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_RULE}`,
- GET_RULES: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_RULES}`,
- UPDATE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_RULE}`,
+ // Wazuh: use wazuh rule methods
+ CREATE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_WAZUH_RULE}`,
+ DELETE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_WAZUH_RULE}`,
+ GET_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_WAZUH_RULE}`,
+ GET_RULES: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_WAZUH_RULES}`,
+ UPDATE_RULE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_WAZUH_RULE}`,
};
export const CLIENT_DETECTOR_METHODS = {
@@ -171,6 +218,26 @@ export const CLIENT_LOGTYPE_METHODS = {
DELETE_LOGTYPE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_LOGTYPE}`,
};
+// Wazuh
+export const CLIENT_INTEGRATION_METHODS = {
+ SEARCH_INTEGRATIONS: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.SEARCH_INTEGRATIONS}`,
+ CREATE_INTEGRATION: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_INTEGRATION}`,
+ UPDATE_INTEGRATION: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_INTEGRATION}`,
+ DELETE_INTEGRATION: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_INTEGRATION}`,
+ PROMOTE_INTEGRATION: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.PROMOTE_INTEGRATION}`,
+ GET_PROMOTE_BY_SPACE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.GET_PROMOTE_BY_SPACE}`,
+};
+
+// Wazuh
+export const CLIENT_POLICY_METHODS = {
+ UPDATE_POLICY: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_POLICY}`,
+ DELETE_SPACE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_SPACE}`,
+};
+
+export const CLIENT_LOG_TEST_METHODS = {
+ TEST_LOG: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.TEST_LOG}`,
+};
+
export const CLIENT_THREAT_INTEL_METHODS = {
ADD_THREAT_INTEL_SOURCE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.ADD_THREAT_INTEL_SOURCE}`,
UPDATE_THREAT_INTEL_SOURCE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_THREAT_INTEL_SOURCE}`,
@@ -188,6 +255,24 @@ export const CLIENT_THREAT_INTEL_METHODS = {
UPDATE_THREAT_INTEL_ALERTS_STATE: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_THREAT_INTEL_ALERTS_STATE}`,
};
+export const CLIENT_DECODER_METHODS = {
+ CREATE_DECODER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_DECODER}`,
+ UPDATE_DECODER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_DECODER}`,
+ DELETE_DECODER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_DECODER}`,
+};
+
+export const CLIENT_KVDB_METHODS = {
+ CREATE_KVDB: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_KVDB}`,
+ UPDATE_KVDB: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_KVDB}`,
+ DELETE_KVDB: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_KVDB}`,
+};
+
+export const CLIENT_FILTER_METHODS = {
+ CREATE_FILTER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.CREATE_FILTER}`,
+ UPDATE_FILTER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.UPDATE_FILTER}`,
+ DELETE_FILTER: `${PLUGIN_PROPERTY_NAME}.${METHOD_NAMES.DELETE_FILTER}`,
+};
+
export const DEFAULT_METRICS_COUNTER: MetricsCounter = {
CreateDetector: {
[CreateDetectorSteps.started]: 0,
@@ -200,3 +285,14 @@ export const DEFAULT_METRICS_COUNTER: MetricsCounter = {
[CreateDetectorSteps.createClicked]: 0,
},
};
+
+export const CONTENT_MANAGER_BASE_PATH = '/_plugins/_content_manager';
+
+export const CONTENT_INDICES = {
+ POLICIES: 'wazuh-threatintel-policies',
+ INTEGRATIONS: 'wazuh-threatintel-integrations',
+ DECODERS: 'wazuh-threatintel-decoders',
+ KVDBS: 'wazuh-threatintel-kvdbs',
+ FILTERS: 'wazuh-threatintel-filters',
+ RULES: 'wazuh-threatintel-rules',
+};
diff --git a/server/utils/helpers.ts b/server/utils/helpers.ts
index 23694f453..8fe60d68e 100644
--- a/server/utils/helpers.ts
+++ b/server/utils/helpers.ts
@@ -4,6 +4,7 @@
*/
import { Props, schema } from '@osd/config-schema';
+import YAML from 'yaml';
export function createQueryValidationSchema(fieldSchemaObj?: Props) {
return schema.object({
@@ -11,3 +12,16 @@ export function createQueryValidationSchema(fieldSchemaObj?: Props) {
dataSourceId: schema.maybe(schema.string()),
});
}
+
+// This function recieves the crude yaml resource and optional extra params (e.g: integration for kvdbs or space for filters).
+// The function formats the yaml to have the structure that the backend expects.
+export const buildYamlBody = (resourceYaml: string, params?: Record): string => {
+ const doc = new YAML.Document();
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ doc.set(key, value);
+ }
+ }
+ doc.set('resource', YAML.parseDocument(resourceYaml).contents);
+ return doc.toString({ lineWidth: 0 });
+};
diff --git a/test/mocks/Detectors/containers/Detectors/Detector.mock.ts b/test/mocks/Detectors/containers/Detectors/Detector.mock.ts
index 1273c1b1e..e41f2d7c7 100644
--- a/test/mocks/Detectors/containers/Detectors/Detector.mock.ts
+++ b/test/mocks/Detectors/containers/Detectors/Detector.mock.ts
@@ -16,6 +16,7 @@ export default {
name: 'detector_name',
enabled: true,
createdBy: 'someone',
+ source: 'custom',
schedule: periodScheduleMock,
inputs: [detectorInputMock],
triggers: times(2, (index) => {
diff --git a/test/setup.jest.ts b/test/setup.jest.ts
index 84f2277eb..f9643a72e 100644
--- a/test/setup.jest.ts
+++ b/test/setup.jest.ts
@@ -65,9 +65,13 @@ jest.mock('moment', () => {
// Set moment tz mock
if (!moment.tz) moment.tz = {};
moment.tz.names = () => ['Pacific/Tahiti'];
+ // Wazuh: mock moment.tz.guess; formatUIDate calls it when `dateFormat:tz` is 'Browser'/unset.
+ moment.tz.guess = () => 'Pacific/Tahiti';
const momentInstance = moment();
momentInstance.format = jest.fn().mockReturnValue('2023-01-25T10:05');
+ // Wazuh: make moment().tz() chainable so formatUIDate's `.tz(...).format(...)` works under the mock.
+ momentInstance.tz = jest.fn().mockReturnValue(momentInstance);
function fakeMoment() {
return momentInstance;
@@ -93,16 +97,14 @@ jest.mock('react', () => {
/**
* Mocks the vega view renderer
*/
-jest.mock('vega/build/vega.js', () => {
- const vega = jest.requireActual('vega/build/vega.js');
- return {
- ...vega,
+jest.mock('vega/build/vega.js', () => ({
View: jest.fn().mockReturnValue({
tooltip: jest.fn(),
runAsync: jest.fn().mockReturnValue(new Promise(jest.fn())),
}),
- };
-});
+ }),
+ { virtual: true }
+);
DataStore.init(services, notificationsStartMock);
diff --git a/tools/.gitignore b/tools/.gitignore
new file mode 100644
index 000000000..98895da33
--- /dev/null
+++ b/tools/.gitignore
@@ -0,0 +1,2 @@
+# Ignore repository bumper log files
+repository_bumper_*.log
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 000000000..48fb1671f
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,77 @@
+# Versioning tools
+
+## repository_bumper.sh
+
+This script automates the process of updating the version and stage in the Wazuh Security Analytics Dashboard plugin repository, using only shell commands.
+
+### Usage
+
+```bash
+./repository_bumper.sh --version VERSION --stage STAGE [--tag] [--set-as-main] [--help]
+```
+
+#### Parameters
+
+- `--version VERSION`
+ Specifies the new version (e.g., `4.6.0`).
+ Required if `--tag` is not used.
+
+- `--stage STAGE`
+ Specifies the stage (e.g., `alpha0`, `beta1`, `rc2`, etc.).
+ Required if `--tag` is not used.
+
+- `--tag`
+ Generate a tag version format.
+
+- `--set-as-main`
+ Enable main branch mode: bump version values but keep branch references pointing to `main`.
+
+- `--help`
+ Shows help and exits.
+
+#### Examples
+
+```bash
+./repository_bumper.sh --version 4.6.0 --stage alpha0
+./repository_bumper.sh --version 4.6.0 --stage beta1
+./repository_bumper.sh --tag --stage alpha1
+./repository_bumper.sh --version 5.1.0 --stage alpha0 --set-as-main
+./repository_bumper.sh --tag
+```
+
+### What does the script do?
+
+1. **Validates input parameters** and shows error messages if they are incorrect.
+2. **Reads the current version and stage** from `VERSION.json` and the revision from `package.json`.
+3. **Compares the new version with the current one**:
+ - If the version changes (major, minor, or patch), it resets the revision to `00`.
+ - If the version is the same, it increments the revision.
+4. **Updates the files**:
+ - `CHANGELOG.md`: Changes the `version` and `revision` in the version header.
+ - `VERSION.json`: Changes the `version` and `stage` fields.
+ - `package.json`: Changes the `version` and `revision` fields inside the `wazuh` object.
+ - `.github/workflows/5_builderpackage_security_analytics_plugin.yml`: Updates the default value of the `reference` input.
+ - `.github/workflows/5_builderprecompiled_base-dev-environment.yml`: Updates the default value of the `reference` input.
+5. **Handles branch reference replacements**:
+ - If `--set-as-main` is used, branch references to `main` are preserved.
+ - Otherwise, `main` references in supported workflow fields are replaced with the target version.
+6. **Logs all actions** to a log file in the `tools` directory.
+
+### Notes
+
+- The script must be run from anywhere inside the git repository.
+- You need write permissions for the files to be modified.
+- The script uses `sed` to modify files, so the file format must be consistent.
+
+### Affected files
+
+- `CHANGELOG.md`
+- `VERSION.json`
+- `package.json`
+- `.github/workflows/5_builderpackage_security_analytics_plugin.yml`
+- `.github/workflows/5_builderprecompiled_base-dev-environment.yml`
+
+### Log
+
+Each execution generates a log file in the `tools` directory with details of the operation.
+
diff --git a/tools/repository_bumper.sh b/tools/repository_bumper.sh
new file mode 100755
index 000000000..f83b26a74
--- /dev/null
+++ b/tools/repository_bumper.sh
@@ -0,0 +1,605 @@
+#!/bin/bash
+#
+# Wazuh Security Analytics Dashboard Plugin repository bumper (Pure Shell Version)
+# This script automates version and stage bumping across the repository using only shell commands.
+
+set -e
+
+# --- Global Variables ---
+SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_PATH=$(git rev-parse --show-toplevel 2>/dev/null)
+DATE_TIME=$(date "+%Y-%m-%d_%H-%M-%S-%3N")
+LOG_FILE="${SCRIPT_PATH}/repository_bumper_${DATE_TIME}.log"
+PACKAGE_JSON="${REPO_PATH}/package.json"
+VERSION_FILE="${REPO_PATH}/VERSION.json"
+VERSION=""
+REVISION="00"
+TAG=false
+CURRENT_VERSION=""
+# When --set-as-main: skip replacing branch refs (e.g. main) in workflows; set in parse_arguments()
+skip_urls="no"
+
+# --- Helper Functions ---
+
+# Function to log messages
+log() {
+ local message="$1"
+ local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
+ echo "[${timestamp}] ${message}" | tee -a "$LOG_FILE"
+}
+
+# Function to show usage
+usage() {
+ echo "Usage: $0 --version VERSION --stage STAGE [--help]"
+ echo ""
+ echo "Parameters:"
+ echo " --version VERSION Specify the version (e.g., 4.6.0)"
+ echo " Required if --tag is not used"
+ echo " --stage STAGE Specify the stage (e.g., alpha0, beta1, rc2, etc.)"
+ echo " Required if --tag is not used"
+ echo " --tag Generate a tag"
+ echo " --set-as-main Bump version values only; keep branch defaults (e.g. main) unchanged"
+ echo " --help Display this help message"
+ echo ""
+ echo "Example:"
+ echo " $0 --version 4.6.0 --stage alpha0"
+ echo " $0 --tag --stage alpha1"
+ echo " $0 --tag"
+}
+
+# Function to perform portable sed in-place editing
+sed_inplace() {
+ local options=""
+ local pattern=""
+ local file=""
+
+ # Parse arguments to handle options like -E
+ while [[ $# -gt 0 ]]; do
+ case $1 in
+ -E|-r)
+ options="$options $1"
+ shift
+ ;;
+ *)
+ if [ -z "$pattern" ]; then
+ pattern="$1"
+ elif [ -z "$file" ]; then
+ file="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ # Detect OS and use appropriate sed syntax
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ # macOS (BSD sed) requires empty string after -i
+ sed -i '' $options "$pattern" "$file"
+ else
+ # Linux (GNU sed) doesn't require anything after -i
+ sed -i $options "$pattern" "$file"
+ fi
+}
+
+# Function to run sed with extended regex in a cross-platform way
+sed_extended() {
+ # macOS uses -E, some Linux systems use -r
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ sed -E "$@"
+ else
+ # Try -E first, fall back to -r if it fails
+ sed -E "$@" 2>/dev/null || sed -r "$@"
+ fi
+}
+
+# Function to update JSON file using sed
+update_json() {
+ local file="$1"
+ local key="$2"
+ local value="$3"
+
+ # Get the current value of the key at the top level (line 3 for version in package.json)
+ local current_value
+ if [ "$key" = "version" ] && [[ "$file" == *"package.json" ]]; then
+ # For package.json, specifically get the version from line 3 to avoid nested version in pluginPlatform
+ current_value=$(sed -n '3p' "$file" | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
+ else
+ current_value=$(grep -o "^[[:space:]]*\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" | sed "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/")
+ fi
+
+ if [ "$current_value" = "$value" ]; then
+ # If the value is already correct, do nothing and don't report
+ return
+ fi
+
+ log "Updating $key to $value in $file using sed"
+
+ # Read the file, apply the filter, and write to a temporary file, then replace the original.
+ # WARNING: Using sed for JSON manipulation is fragile and not recommended.
+ log "Attempting to update $key to $value in $file using sed (Note: This is fragile)"
+
+ # Escape key and value for use in sed regex and replacement string
+ # Basic escaping for common sed special characters: &, /, \
+ local escaped_key=$(printf '%s\n' "$key" | sed -e 's/[&/\]/\\&/g')
+ local escaped_value=$(printf '%s\n' "$value" | sed -e 's/[&/\]/\\&/g')
+
+ # For package.json version updates, be more specific to avoid updating nested versions
+ if [ "$key" = "version" ] && [[ "$file" == *"package.json" ]]; then
+ # Update only the first occurrence of version (which should be the top-level one)
+ sed "1,/^[[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/{s/^\\([[:space:]]*\\)\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\\1\"version\": \"$escaped_value\"/;}" "$file" >"${file}.tmp"
+ else
+ # Use the general approach for other keys
+ sed "s/^\\([[:space:]]*\\)\"$escaped_key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\\1\"$escaped_key\": \"$escaped_value\"/g" "$file" >"${file}.tmp"
+ fi
+
+ # Check if sed actually made a change (simple check: compare files)
+ if cmp -s "$file" "${file}.tmp"; then
+ rm -f "${file}.tmp"
+ return
+ else
+ # If files differ, move the temp file to the original file name
+ mv "${file}.tmp" "$file" && log "Successfully updated $key in $file using sed" || {
+ log "ERROR: Failed to move temporary file after updating $key in $file using sed."
+ rm -f "${file}.tmp" # Clean up temp file on error
+ exit 1
+ }
+ fi
+}
+
+# Function to update CHANGELOG.md
+update_changelog() {
+ log "Updating CHANGELOG.md..."
+ local changelog_file="${REPO_PATH}/CHANGELOG.md"
+
+ # Extract OpenSearch Dashboards version from package.json
+ # Attempt to extract OpenSearch Dashboards version using sed (WARNING: Fragile!)
+ # This assumes "pluginPlatform": { ... "version": "x.y.z" ... } structure
+ # It looks for the block starting with "pluginPlatform": { and ending with }
+ # Within that block, it finds the line starting with "version": "..." and extracts the value.
+ # This is significantly less reliable than using jq.
+ log "Attempting to extract .version from $PACKAGE_JSON using sed (Note: This is fragile)"
+ # Extract OpenSearch Dashboards version from package.json (first occurrence of "version")
+ OPENSEARCH_VERSION=$(sed -n '/"opensearchDashboards": {/,/}/ s/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$PACKAGE_JSON" | head -n 1)
+ if [ -z "$OPENSEARCH_VERSION" ] || [ "$OPENSEARCH_VERSION" == "null" ]; then
+ log "ERROR: Could not extract pluginPlatform.version from $PACKAGE_JSON for changelog"
+ exit 1
+ fi
+ log "Detected OpenSearch Dashboards version for changelog: $OPENSEARCH_VERSION"
+
+ # Construct the new changelog entry
+ # Note: Using printf for better handling of newlines and potential special characters
+ # Use the calculated REVISION variable here
+ # Prepare the header to search for
+ local changelog_header="## Wazuh dashboard v${VERSION} - OpenSearch Dashboards ${OPENSEARCH_VERSION} - Revision "
+ local changelog_header_regex="^${changelog_header}[0-9]+"
+
+ # Check if an entry for this version and OpenSearch version already exists
+ if grep -qE "$changelog_header_regex" "$changelog_file"; then
+ if [ -n "$STAGE" ]; then
+ log "Changelog entry for this version and OpenSearch Dashboards version exists. Updating revision only."
+ # Use sed to update only the revision number in the header
+ sed_inplace -E "s|(${changelog_header_regex})|${changelog_header}${REVISION}|" "$changelog_file" &&
+ log "CHANGELOG.md revision updated successfully." || {
+ log "ERROR: Failed to update revision in $changelog_file"
+ exit 1
+ }
+ fi
+ else
+ log "No existing changelog entry for this version and OpenSearch Dashboards version. Inserting new entry."
+
+ # Create the new entry directly in the changelog using sed
+ local temp_file=$(mktemp)
+ head -n 4 "$changelog_file" >"$temp_file"
+ printf "## Wazuh dashboard v%s - OpenSearch Dashboards %s - Revision %s\n\n### Added\n\n- Support for Wazuh %s\n\n" "$VERSION" "$OPENSEARCH_VERSION" "$REVISION" "$VERSION" >>"$temp_file"
+ tail -n +5 "$changelog_file" >>"$temp_file"
+
+ mv "$temp_file" "$changelog_file" || {
+ log "ERROR: Failed to update $changelog_file"
+ rm -f "$temp_file" # Clean up temp file on error
+ exit 1
+ }
+ log "CHANGELOG.md updated successfully."
+ fi
+}
+
+# --- Core Logic Functions ---
+
+parse_arguments() {
+ while [[ $# -gt 0 ]]; do
+ key="$1"
+ case $key in
+ --version)
+ VERSION="$2"
+ shift 2
+ ;;
+ --stage)
+ STAGE="$2"
+ shift 2
+ ;;
+ --tag)
+ TAG=true
+ shift
+ ;;
+ --set-as-main)
+ set_as_main="yes"
+ shift 1
+ ;;
+ --help)
+ usage
+ exit 0
+ ;;
+ *)
+ log "ERROR: Unknown option: $1" # Log error instead of just echo
+ usage
+ exit 1
+ ;;
+ esac
+ done
+
+ if [[ -n "$set_as_main" ]]; then
+ skip_urls="yes"
+ else
+ skip_urls="no"
+ fi
+}
+
+# Function to validate input parameters
+validate_input() {
+ if [ -z "$VERSION" ] && [ "$TAG" != true ]; then
+ log "ERROR: --version is required unless --tag is set"
+ usage
+ exit 1
+ fi
+
+ if [ -z "$STAGE" ] && [ "$TAG" != true ]; then
+ log "ERROR: --stage is required unless --tag is set"
+ usage
+ exit 1
+ fi
+ if [ -n "$VERSION" ] && ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ log "ERROR: Version must be in the format x.y.z (e.g., 4.6.0)"
+ exit 1
+ fi
+
+ if [ -n "$STAGE" ] && ! [[ $STAGE =~ ^[a-zA-Z]+[0-9]+$ ]]; then
+ log "ERROR: Stage must be alphanumeric (e.g., alpha0, beta1, rc2)"
+ exit 1
+ fi
+}
+
+# Function to perform pre-update checks and gather initial data
+pre_update_checks() {
+ if [ ! -f "$VERSION_FILE" ]; then
+ log "ERROR: Root VERSION.json not found at $VERSION_FILE"
+ exit 1
+ fi
+
+ # Attempt to extract version from VERSION.json using sed
+ log "Attempting to extract current version from $VERSION_FILE using sed..."
+ CURRENT_VERSION=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$VERSION_FILE" | head -n 1) # head -n 1 ensures only the first match is taken
+
+ # Check if sed successfully extracted a version
+ if [ -z "$CURRENT_VERSION" ]; then
+ log "ERROR: Failed to extract 'version' from $VERSION_FILE using sed. Check file format or key presence."
+ exit 1 # Exit if sed fails
+ fi
+ log "Successfully extracted version using sed: $CURRENT_VERSION"
+
+ if [ "$CURRENT_VERSION" == "null" ]; then # Check specifically for "null" string if sed might output that
+ log "ERROR: Could not read current version from $VERSION_FILE (value was 'null')"
+ exit 1
+ fi
+ log "Current version detected in VERSION.json: $CURRENT_VERSION"
+
+ # Attempt to extract stage from VERSION.json using sed
+ log "Attempting to extract current stage from $VERSION_FILE using sed..."
+ CURRENT_STAGE=$(sed -n 's/^[[:space:]]*"stage"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$VERSION_FILE" | head -n 1) # head -n 1 ensures only the first match is taken
+
+ # Check if sed successfully extracted a stage
+ if [ -z "$CURRENT_STAGE" ]; then
+ log "ERROR: Failed to extract 'stage' from $VERSION_FILE using sed. Check file format or key presence."
+ exit 1 # Exit if sed fails
+ fi
+ log "Successfully extracted stage using sed: $CURRENT_STAGE"
+
+ if [ "$CURRENT_STAGE" == "null" ]; then # Check specifically for "null" string if sed might output that
+ log "ERROR: Could not read current stage from $VERSION_FILE (value was 'null')"
+ exit 1
+ fi
+ log "Current stage detected in VERSION.json: $CURRENT_STAGE"
+
+ # Attempt to extract current revision from package.json using sed
+ local PACKAGE_JSON="${REPO_PATH}/package.json"
+ log "Attempting to extract current revision from $PACKAGE_JSON using sed..."
+ CURRENT_REVISION=$(sed -n '/"wazuh": {/,/}/ s/^[[:space:]]*"revision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$PACKAGE_JSON" | head -n 1)
+
+ if [ -z "$CURRENT_REVISION" ]; then
+ log "ERROR: Failed to extract 'revision' from $PACKAGE_JSON using sed. Check file format or key presence."
+ exit 1
+ fi
+ log "Successfully extracted revision using sed: $CURRENT_REVISION"
+
+ if [ "$CURRENT_REVISION" == "null" ]; then
+ log "ERROR: Could not read current revision from $PACKAGE_JSON (value was 'null')"
+ exit 1
+ fi
+ log "Current revision detected in package.json: $CURRENT_REVISION"
+
+ log "Default revision set to: $REVISION" # Log default revision here
+}
+
+# Function to compare versions and determine revision
+compare_versions_and_set_revision() {
+ log "Comparing new version ($VERSION) with current version ($CURRENT_VERSION)..."
+
+ # Split versions into parts using '.' as delimiter
+ IFS='.' read -r -a NEW_VERSION_PARTS <<<"$VERSION"
+ IFS='.' read -r -a CURRENT_VERSION_PARTS <<<"$CURRENT_VERSION"
+
+ # Ensure both versions have 3 parts (Major.Minor.Patch)
+ if [ ${#NEW_VERSION_PARTS[@]} -ne 3 ] || [ ${#CURRENT_VERSION_PARTS[@]} -ne 3 ]; then
+ log "ERROR: Invalid version format detected during comparison. Both versions must be x.y.z."
+ exit 1
+ fi
+
+ # Compare Major version
+ if ((${NEW_VERSION_PARTS[0]} < ${CURRENT_VERSION_PARTS[0]})); then
+ log "ERROR: New major version (${NEW_VERSION_PARTS[0]}) cannot be lower than current major version (${CURRENT_VERSION_PARTS[0]})."
+ exit 1
+ elif ((${NEW_VERSION_PARTS[0]} > ${CURRENT_VERSION_PARTS[0]})); then
+ log "Version check passed: New version ($VERSION) is greater than current version ($CURRENT_VERSION) (Major increased)."
+ REVISION="00" # Reset revision on major increase
+ else
+ # Major versions are equal, compare Minor version
+ if ((${NEW_VERSION_PARTS[1]} < ${CURRENT_VERSION_PARTS[1]})); then
+ log "ERROR: New minor version (${NEW_VERSION_PARTS[1]}) cannot be lower than current minor version (${CURRENT_VERSION_PARTS[1]}) when major versions are the same."
+ exit 1
+ elif ((${NEW_VERSION_PARTS[1]} > ${CURRENT_VERSION_PARTS[1]})); then
+ log "Version check passed: New version ($VERSION) is greater than current version ($CURRENT_VERSION) (Minor increased)."
+ REVISION="00" # Reset revision on minor increase
+ else
+ # Major and Minor versions are equal, compare Patch version
+ if ((${NEW_VERSION_PARTS[2]} < ${CURRENT_VERSION_PARTS[2]})); then
+ log "ERROR: New patch version (${NEW_VERSION_PARTS[2]}) cannot be lower than current patch version (${CURRENT_VERSION_PARTS[2]}) when major and minor versions are the same."
+ exit 1
+ elif ((${NEW_VERSION_PARTS[2]} > ${CURRENT_VERSION_PARTS[2]})); then
+ log "Version check passed: New version ($VERSION) is greater than current version ($CURRENT_VERSION) (Patch increased)."
+ REVISION="00" # Reset revision on patch increase
+ else
+ # Versions are identical (Major, Minor, Patch are equal)
+ log "New version ($VERSION) is identical to current version ($CURRENT_VERSION). Incrementing revision."
+ local main_package_json="${REPO_PATH}/package.json" # Need path again
+ log "Attempting to extract current revision from $main_package_json using sed (Note: This is fragile)"
+ local current_revision_val=$(sed -n 's/^[[:space:]]*"revision"[[:space:]]*:[[:space:]]*"\([^"]*\)".*$/\1/p' "$main_package_json" | head -n 1)
+ # Check if sed successfully extracted a revision
+ if [ -z "$current_revision_val" ]; then
+ log "ERROR: Failed to extract 'revision' from $main_package_json using sed. Check file format or key presence."
+ exit 1 # Exit if sed fails
+ fi
+ log "Successfully extracted revision using sed: $current_revision_val"
+ if [ -z "$current_revision_val" ] || [ "$current_revision_val" == "null" ]; then
+ log "ERROR: Could not read current revision from $main_package_json"
+ exit 1
+ fi
+ # Ensure CURRENT_REVISION is treated as a number (remove leading zeros for arithmetic if necessary, handle base 10)
+ local current_revision_int=$((10#$current_revision_val))
+ local new_revision_int=$((current_revision_int + 1))
+ if [ -n "$STAGE" ] && [ "$STAGE" != "$CURRENT_STAGE" ]; then
+ # Format back to two digits with leading zero
+ log "Incrementing revision."
+ REVISION=$(printf "%02d" "$new_revision_int")
+ log "Current revision: $current_revision_val. New revision set to: $REVISION"
+ else
+ REVISION=$(printf "%02d" "$current_revision_int")
+ log "Current revision: $current_revision_val."
+ fi
+ fi
+ fi
+ fi
+ log "Final revision determined: $REVISION"
+}
+
+# Function to update VERSION.json
+update_root_version_json() {
+ if [ -f "$VERSION_FILE" ]; then
+ log "Processing $VERSION_FILE"
+ # Update version and revision in VERSION.json
+ local modified=false
+
+ # Update version in VERSION.json
+ if [[ "$CURRENT_VERSION" != "$VERSION" ]]; then
+ sed_inplace "s/^[[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/ \"version\": \"$VERSION\"/" "$VERSION_FILE"
+ modified=true
+ fi
+
+ # Update stage in VERSION.json
+ if [ -n "$STAGE" ] && [[ "$CURRENT_STAGE" != "$STAGE" ]]; then
+ sed_inplace "s/^[[:space:]]*\"stage\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/ \"stage\": \"$STAGE\"/" "$VERSION_FILE"
+ modified=true
+ fi
+
+ if [[ $modified == true ]]; then
+ log "Successfully updated $VERSION_FILE with new version: $VERSION and stage: $STAGE"
+ fi
+ else
+ log "WARNING: $VERSION_FILE not found. Skipping update."
+ fi
+}
+
+update_package_json() {
+ local PACKAGE_JSON="${REPO_PATH}/package.json" # Define path, assuming it's the main one
+ if [ -f "$PACKAGE_JSON" ]; then
+ log "Processing $PACKAGE_JSON"
+ local modified=false
+ # Update version and revision in package.json
+ # "wazuh": {
+ # "version": "4.13.0",
+ # "revision": "00"
+ # },
+ # Update version and revision in package.json using the structure above
+ # Use sed with address range to target lines within the "wazuh": { ... } block
+ # Update version in package.json
+ if [[ "$CURRENT_VERSION" != "$VERSION" ]]; then
+ log "Attempting to update version to $VERSION within 'wazuh' object in $PACKAGE_JSON"
+ # Note: This sed command assumes a specific formatting and might be fragile.
+ # It looks for the block starting with a line containing "wazuh": { and ending with the next line containing only }
+ # Within that block, it replaces the value on the line starting with "version":
+ sed_inplace "/\"wazuh\": {/,/}/ s/^\\([[:space:]]*\"version\"[[:space:]]*:[[:space:]]*\\)\"[^\"]*\"/\\1\"$VERSION\"/" "$PACKAGE_JSON"
+ modified=true
+ fi
+
+ # Update revision in package.json
+ if [[ "$CURRENT_REVISION" != "$REVISION" ]]; then
+ log "Attempting to update revision to $REVISION within 'wazuh' object in $PACKAGE_JSON"
+ # Similar sed command for the revision line within the same block
+ sed_inplace "/\"wazuh\": {/,/}/ s/^\\([[:space:]]*\"revision\"[[:space:]]*:[[:space:]]*\\)\"[^\"]*\"/\\1\"$REVISION\"/" "$PACKAGE_JSON"
+ modified=true
+ fi
+
+ if [[ $modified == true ]]; then
+ log "Successfully updated $PACKAGE_JSON with new version: $VERSION and revision: $REVISION"
+ fi
+ else
+ log "WARNING: $PACKAGE_JSON not found. Skipping update."
+ fi
+}
+
+# Replace "main" in default: reference inputs (5_* workflows only) when not in --set-as-main mode.
+update_branch_reference_defaults() {
+ if [[ "$skip_urls" == "yes" ]]; then
+ log "skip_urls is yes (--set-as-main): leaving workflow branch defaults unchanged"
+ return 0
+ fi
+
+ local bump_string="$GIT_REF_REPLACEMENT"
+ local files=(
+ "${REPO_PATH}/.github/workflows/5_builderpackage_security_analytics_plugin.yml"
+ "${REPO_PATH}/.github/workflows/5_builderprecompiled_base-dev-environment.yml"
+ )
+ local f
+ for f in "${files[@]}"; do
+ if [ ! -f "$f" ]; then
+ log "WARNING: $f not found. Skipping mainβ${bump_string} default update."
+ continue
+ fi
+ log "Replacing default: main with default: ${bump_string} in $f (where applicable)"
+ sed_inplace "s/^\\([[:space:]]*default:[[:space:]]*\\)main\\([[:space:]]*\\)$/\\1${bump_string}\\2/" "$f"
+ done
+}
+
+# Function to update specFile URL in docker/imposter/wazuh-config.yml
+update_imposter_config() {
+ local new_version="$1"
+ local imposter_config_file="${REPO_PATH}/docker/imposter/wazuh-config.yml"
+ local api_info_file="${REPO_PATH}/docker/imposter/api-info/api_info.json"
+
+ if [ ! -f "$imposter_config_file" ]; then
+ log "WARNING: $imposter_config_file not found. Skipping specFile URL update."
+ return
+ fi
+
+ if [ ! -f "$api_info_file" ]; then
+ log "WARNING: $api_info_file not found. Skipping specFile URL update."
+ return
+ fi
+
+ local replacement
+ if [ "$TAG" = true ]; then
+ replacement="v${VERSION}"
+ if [ -n "$STAGE" ]; then
+ replacement+="-${STAGE}"
+ fi
+ else
+ replacement="${VERSION}"
+ fi
+
+ # Extract current reference from URL
+ local current_spec_ref
+ current_spec_ref=$(grep -oE 'specFile: https://raw.githubusercontent.com/wazuh/wazuh/[^/]+/' "$imposter_config_file" | sed_extended 's|.*/wazuh/([^/]+)/.*|\1|' | head -n1)
+
+ if [ "$current_spec_ref" = "$replacement" ]; then
+ return
+ fi
+
+ log "Updating specFile URL in $imposter_config_file from $current_spec_ref to $VERSION"
+
+ update_json "$api_info_file" "api_version" "$VERSION" || {
+ log "ERROR: Failed to update apiVersion in $api_info_file"
+ exit 1
+ }
+
+ log "Updating specFile URL in $imposter_config_file to version $new_version"
+
+ # Use sed to replace the version string within the specFile URL
+ # Create a more compatible sed command for macOS
+ sed_inplace "s|specFile: https://raw.githubusercontent.com/wazuh/wazuh/[^/]*/|specFile: https://raw.githubusercontent.com/wazuh/wazuh/${replacement}/|" "$imposter_config_file" &&
+ log "Successfully updated specFile URL in $imposter_config_file" || {
+ log "ERROR: Failed to update specFile URL in $imposter_config_file using sed."
+ exit 1
+ }
+}
+
+get_git_ref_replacement(){
+ local replacement
+ if [ "$TAG" = true ]; then
+ replacement="v${VERSION}"
+ if [ -n "$STAGE" ]; then
+ replacement+="-${STAGE}"
+ fi
+ else
+ replacement="${VERSION}"
+ fi
+
+ GIT_REF_REPLACEMENT="$replacement"
+}
+
+# --- Main Execution ---
+main() {
+ # Initialize log file
+ touch "$LOG_FILE"
+ log "Starting repository bump process"
+
+ # Check if inside a git repository early
+ if [ -z "$REPO_PATH" ]; then
+ # Use log function for consistency, redirect initial error to stderr
+ log "ERROR: Failed to determine repository root. Ensure you are inside the git repository." >&2
+ exit 1
+ fi
+ log "Repository path: $REPO_PATH"
+
+ # Parse and validate arguments
+ parse_arguments "$@"
+ validate_input
+ log "Version: $VERSION"
+ log "Stage: $STAGE"
+
+ # Perform pre-update checks
+ pre_update_checks
+ if [ -z "$VERSION" ]; then
+ VERSION=$CURRENT_VERSION # If no version provided, use current version
+ fi
+
+ # Compare versions and determine revision
+ compare_versions_and_set_revision
+
+ get_git_ref_replacement
+
+ # Start file modifications
+ log "Starting file modifications..."
+
+ update_root_version_json
+ update_package_json
+ update_changelog
+ update_branch_reference_defaults
+
+ # Update docker/imposter/wazuh-config.yml
+# log "Updating docker/imposter/wazuh-config.yml..."
+# update_imposter_config "$VERSION" <------------ Commented because the imposter files don't exist yet
+
+ log "File modifications completed."
+ log "Repository bump completed successfully. Log file: $LOG_FILE"
+ exit 0
+}
+
+# Execute main function with all script arguments
+main "$@"
diff --git a/tsconfig.json b/tsconfig.json
index 5cdb1d22c..63dad89da 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,6 +17,7 @@
"compilerOptions": {
"skipLibCheck": true,
"esModuleInterop": true,
+ "resolveJsonModule": true,
"outDir": "./target",
"types": ["cypress", "node", "cypress-real-events", "jest"]
}
diff --git a/types/Decoders.ts b/types/Decoders.ts
new file mode 100644
index 000000000..da76b6a5e
--- /dev/null
+++ b/types/Decoders.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { CatalogResourceMetadata } from './ResourceMetadata';
+
+export type DecoderMetadata = CatalogResourceMetadata;
+
+export interface DecoderDocument {
+ id: string;
+ name: string;
+ enabled?: boolean;
+ metadata: DecoderMetadata;
+ definitions?: Record;
+ check?: Record;
+ parents?: string[];
+ normalize?: Record;
+}
+
+export interface DecoderSource {
+ document: DecoderDocument;
+ yaml?: string;
+ space?: string;
+}
+
+export interface DecoderItem extends DecoderSource {
+ id: string;
+ integrations?: string[];
+}
+
+export interface SearchDecodersResponse {
+ total: number;
+ items: DecoderItem[];
+}
+
+export interface GetDecoderResponse {
+ item?: DecoderItem;
+}
+
+export interface CUDDecoderResponse {
+ message: string;
+ status: number;
+ error: string | null;
+}
diff --git a/types/Detector.ts b/types/Detector.ts
index b51862792..dcfcf73df 100644
--- a/types/Detector.ts
+++ b/types/Detector.ts
@@ -33,6 +33,7 @@ export interface Detector {
inputs: DetectorInput[];
triggers: AlertCondition[];
threat_intel_enabled: boolean;
+ source?: string;
}
export interface AlertConditionRuleOption {
diff --git a/types/Filters.ts b/types/Filters.ts
new file mode 100644
index 000000000..520bdd95c
--- /dev/null
+++ b/types/Filters.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright Wazuh Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type FilterCheckListItem = Record;
+export type FilterCheck = string | FilterCheckListItem[];
+
+export interface FilterDocument {
+ id: string;
+ name: string;
+ type: string;
+ check: FilterCheck;
+ enabled: boolean;
+ metadata?: {
+ title?: string;
+ description?: string;
+ author?: string | { name?: string };
+ date?: string;
+ modified?: string;
+ references?: string[];
+ documentation?: string;
+ supports?: string[];
+ };
+}
+
+export interface FilterSource {
+ document: FilterDocument;
+ yaml: string;
+ space: { name: string };
+ hash?: { sha256: string };
+}
+
+export interface FilterItem extends FilterSource {
+ id: string;
+}
+
+export interface FilterSearchRequest {
+ from?: number;
+ size?: number;
+ sort?: Array>;
+ query?: Record;
+ track_total_hits?: boolean;
+ _source?: any;
+}
+
+export interface FilterSearchResponse {
+ hits: {
+ total?: { value: number } | number;
+ hits: { _id: string; _source: FilterSource }[];
+ };
+}
+
+export interface FilterResource {
+ name: string;
+ enabled: boolean;
+ check: FilterCheck;
+ type: string;
+ metadata?: {
+ title?: string;
+ author?: string;
+ date?: string;
+ modified?: string;
+ description?: string;
+ references?: string[];
+ documentation?: string;
+ supports?: string[];
+ };
+}
+
+export interface CreateFilterPayload {
+ space: string;
+ resourceYaml: string;
+}
+export interface UpdateFilterPayload {
+ space: string;
+ resourceYaml: string;
+}
+export interface CUDFilterResponse {
+ message: string;
+ status: number;
+}
diff --git a/types/Integrations.ts b/types/Integrations.ts
new file mode 100644
index 000000000..ead206f84
--- /dev/null
+++ b/types/Integrations.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { RuleItemInfoBase } from './Rule';
+import { CatalogResourceMetadata } from './ResourceMetadata';
+
+export interface IntegrationWithRules extends Integration {
+ detectionRules: RuleItemInfoBase[];
+}
+
+export interface IntegrationItem extends Integration {
+ detectionRulesCount: number;
+ decodersCount: number;
+ kvdbsCount: number;
+}
+
+export interface Integration {
+ id: string;
+ document: IntegrationBase['document'];
+ space: IntegrationBase['space'];
+}
+
+export interface IntegrationMetadata extends CatalogResourceMetadata {}
+
+export interface IntegrationBase {
+ document: {
+ id: string;
+ parent_decoder?: string;
+ category: string;
+ metadata: IntegrationMetadata;
+ enabled?: boolean;
+ decoders?: string[];
+ kvdbs?: string[];
+ rules?: string[];
+ tags?: {
+ correlation_id: number;
+ } | null;
+ };
+ space: {
+ name: string;
+ };
+}
+
+export interface SearchIntegrationsResponse {
+ hits: {
+ hits: {
+ _id: string;
+ _source: IntegrationBase;
+ }[];
+ };
+}
+
+export interface CreateIntegrationRequestBody extends IntegrationBase {}
+
+export interface CreateIntegrationResponse {
+ message: string;
+ status: number;
+}
+
+export interface UpdateIntegrationParams {
+ integrationId: string;
+ body: { resource: IntegrationBase['document'] };
+}
+
+export interface UpdateIntegrationResponse {
+ _id: string;
+ integration: IntegrationBase;
+}
+
+export interface DeleteIntegrationParams {
+ integrationId: string;
+}
+
+export interface DeleteIntegrationResponse {}
+
+export interface PromoteIntegrationResponse {}
+
+export type UserSpace = 'draft' | 'test' | 'custom';
+export type PromoteSpaces = Omit