@cryptotaxi247 / CoPilot / commits / dd77abc9

Wazuh agent frontend (#512)

* Add Wazuh groups functionality with API integration and UI components * Refactor update Wazuh group configuration endpoint to accept raw XML content and improve parameter handling

taylor_socfortress committed Sep 12, 2025 at 15:50 UTC dd77abc9dd7084f6af75d164a128a4b5dd836170
7 files changed +488 -9
backend/app/connectors/wazuh_manager/routes/groups.py
+10 -9
@@ -2,15 +2,12 @@ from typing import List
2 from typing import Optional
3
4 from fastapi import APIRouter
5 -from fastapi import Body
5 from fastapi import Path
6 from fastapi import Query
7 +from fastapi import Request
8 from fastapi import Security
9
10 from app.auth.routes.auth import AuthHandler
11 -from app.connectors.wazuh_manager.schema.groups import (
12 - WazuhGroupConfigurationUpdateRequest,
13 -)
11 from app.connectors.wazuh_manager.schema.groups import (
12 WazuhGroupConfigurationUpdateResponse,
13 )
@@ -181,8 +178,8 @@ async def get_wazuh_group_file_endpoint(
178 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
179 )
180 async def update_wazuh_group_configuration_endpoint(
181 + request: Request,
182 group_id: str = Path(..., description="Group ID (name of the group)"),
185 - request: WazuhGroupConfigurationUpdateRequest = Body(..., description="Configuration update request"),
183 pretty: Optional[bool] = Query(False, description="Show results in human-readable format"),
184 wait_for_complete: Optional[bool] = Query(False, description="Disable timeout response"),
185 ) -> WazuhGroupConfigurationUpdateResponse:
@@ -194,12 +191,12 @@ async def update_wazuh_group_configuration_endpoint(
191
192 Parameters:
193 - group_id: The ID (name) of the group to update (required)
197 - - request: The configuration update request containing XML content (required)
194 + - request: Raw XML configuration content in the request body
195 - pretty: Format results for human readability
196 - wait_for_complete: Disable request timeout
197
198 Request Body:
202 - - configuration: Full valid XML configuration content
199 + - Raw XML configuration content (Content-Type: application/xml)
200
201 Returns:
202 - WazuhGroupConfigurationUpdateResponse: Confirmation of successful update
@@ -209,6 +206,10 @@ async def update_wazuh_group_configuration_endpoint(
206 - 404: If the specified group is not found
207 - 500: If there's an error updating the configuration
208 """
209 + # Read the raw XML content from the request body
210 + configuration_content = await request.body()
211 + configuration_xml = configuration_content.decode("utf-8")
212 +
213 # Use locals() to capture all parameters, excluding path and body parameters
213 - params = {k: v for k, v in locals().items() if k not in ["group_id", "request"]}
214 - return await update_wazuh_group_configuration(group_id, request.configuration, **params)
214 + params = {k: v for k, v in locals().items() if k not in ["group_id", "request", "configuration_content", "configuration_xml"]}
215 + return await update_wazuh_group_configuration(group_id, configuration_xml, **params)
frontend/src/api/endpoints/wazuh/groups.ts new
+110
@@ -0,0 +1,110 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import type { WazuhGroup, WazuhGroupConfigurationUpdate, WazuhGroupFile, WazuhGroupFileDetails } from "@/types/wazuh/groups.d"
3 +import { HttpClient } from "../../httpClient"
4 +
5 +// Interface for groups query parameters
6 +export interface GroupsQueryParams {
7 + /** Show results in human-readable format */
8 + pretty?: boolean
9 + /** Disable timeout response */
10 + wait_for_complete?: boolean
11 + /** List of group IDs (separated by comma) */
12 + groups_list?: string[]
13 + /** First element to return in the collection */
14 + offset?: number
15 + /** Maximum number of elements to return */
16 + limit?: number
17 + /** Sort the collection by a field or fields */
18 + sort?: string | null
19 + /** Look for elements containing the specified string */
20 + search?: string | null
21 + /** Select algorithm to generate the returned checksums */
22 + hash?: string | null
23 + /** Query to filter results by */
24 + q?: string | null
25 + /** Select which fields to return */
26 + select?: string[] | null
27 + /** Look for distinct values */
28 + distinct?: boolean
29 +}
30 +
31 +// Interface for group files query parameters
32 +export interface GroupFilesQueryParams {
33 + /** Show results in human-readable format */
34 + pretty?: boolean
35 + /** Disable timeout response */
36 + wait_for_complete?: boolean
37 + /** First element to return in the collection */
38 + offset?: number
39 + /** Maximum number of elements to return */
40 + limit?: number
41 + /** Sort the collection by a field or fields */
42 + sort?: string | null
43 + /** Look for elements containing the specified string */
44 + search?: string | null
45 + /** Select algorithm to generate the returned checksums */
46 + hash?: string | null
47 + /** Query to filter results by */
48 + q?: string | null
49 + /** Select which fields to return */
50 + select?: string[] | null
51 + /** Look for distinct values */
52 + distinct?: boolean
53 +}
54 +
55 +// Interface for group file query parameters
56 +export interface GroupFileQueryParams {
57 + /** Show results in human-readable format */
58 + pretty?: boolean
59 + /** Disable timeout response */
60 + wait_for_complete?: boolean
61 + /** Type of file */
62 + type?: string[] | null
63 + /** Format response in plain text */
64 + raw?: boolean
65 +}
66 +
67 +export default {
68 + getGroups(query: GroupsQueryParams, signal?: AbortSignal) {
69 + return HttpClient.get<FlaskBaseResponse & { results: WazuhGroup[]; total_items: number }>(
70 + `/wazuh_manager/groups`,
71 + {
72 + params: query,
73 + signal
74 + }
75 + )
76 + },
77 + getGroupFiles(groupId: string, query: GroupFilesQueryParams, signal?: AbortSignal) {
78 + return HttpClient.get<FlaskBaseResponse & { results: WazuhGroupFile[]; total_items: number }>(
79 + `/wazuh_manager/groups/${groupId}/files`,
80 + {
81 + params: query,
82 + signal
83 + }
84 + )
85 + },
86 + getGroupFile(groupId: string, filename: string, query?: GroupFileQueryParams) {
87 + return HttpClient.get<FlaskBaseResponse & WazuhGroupFileDetails>(
88 + `/wazuh_manager/groups/${groupId}/files/${filename}`,
89 + {
90 + params: {
91 + raw: true,
92 + pretty: false,
93 + wait_for_complete: false,
94 + ...query
95 + }
96 + }
97 + )
98 + },
99 + updateGroupConfiguration(groupId: string, configContent: string) {
100 + return HttpClient.put<FlaskBaseResponse & WazuhGroupConfigurationUpdate>(
101 + `/wazuh_manager/groups/${groupId}/configuration`,
102 + configContent,
103 + {
104 + headers: {
105 + 'Content-Type': 'application/xml'
106 + }
107 + }
108 + )
109 + }
110 +}
frontend/src/api/endpoints/wazuh/index.ts
+2
@@ -1,8 +1,10 @@
1 +import groups from "./groups"
2 import indices from "./indices"
3 import mitre from "./mitre"
4 import rules from "./rules"
5
6 export default {
7 + groups,
8 mitre,
9 rules,
10 indices
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -67,6 +67,19 @@ export default function getItems(): MenuMixedOption[] {
67 ),
68 key: "Agents"
69 },
70 + {
71 + label: () =>
72 + h(
73 + RouterLink,
74 + {
75 + to: {
76 + name: "Groups"
77 + }
78 + },
79 + { default: () => "Groups" }
80 + ),
81 + key: "Groups"
82 + },
83 {
84 label: () =>
85 h(
frontend/src/router/index.ts
+6
@@ -62,6 +62,12 @@ const router = createRouter({
62 component: () => import("@/views/agents/DetectionRules.vue"),
63 meta: { title: "Detection Rules" }
64 },
65 + {
66 + path: "groups",
67 + name: "Groups",
68 + component: () => import("@/views/agents/Groups.vue"),
69 + meta: { title: "Groups" }
70 + },
71 {
72 path: "copilot-actions",
73 name: "CopilotActions",
frontend/src/types/wazuh/groups.d.ts new
+25
@@ -0,0 +1,25 @@
1 +export interface WazuhGroup {
2 + name: string
3 + count: number
4 + mergedSum: string
5 + configSum: string
6 +}
7 +
8 +export interface WazuhGroupFile {
9 + filename: string
10 + hash: string
11 +}
12 +
13 +export interface WazuhGroupFileDetails {
14 + group_id: string
15 + filename: string
16 + content: string
17 + is_raw: boolean
18 +}
19 +
20 +export interface WazuhGroupConfigurationUpdate {
21 + success: boolean
22 + message: string
23 + group_id: string
24 + filename: string
25 +}
frontend/src/views/agents/Groups.vue new
+322
@@ -0,0 +1,322 @@
1 +<template>
2 + <div class="page page-wrapped page-mobile-full page-without-footer flex flex-col">
3 + <SegmentedPage
4 + main-content-class="!p-0 overflow-hidden grow flex flex-col h-full"
5 + :use-main-scroll="false"
6 + padding="18px"
7 + enable-resize
8 + toolbar-height="54px"
9 + sidebar-content-class="p-0!"
10 + >
11 + <template #sidebar-header>
12 + <div class="flex w-full items-center justify-between gap-3">
13 + <n-input
14 + v-model:value="filters.search"
15 + size="small"
16 + class="max-w-full grow"
17 + clearable
18 + placeholder="Search groups..."
19 + >
20 + <template #prefix>
21 + <Icon :name="SearchIcon" :size="16" />
22 + </template>
23 + </n-input>
24 +
25 + <n-tooltip>
26 + <template #trigger>
27 + <n-button secondary :loading="loadingRefresh" size="small" @click="refreshGroups()">
28 + <template #icon>
29 + <Icon :name="RefreshIcon"></Icon>
30 + </template>
31 + </n-button>
32 + </template>
33 + <div>Refresh Groups</div>
34 + </n-tooltip>
35 + </div>
36 + </template>
37 + <template #sidebar-content>
38 + <n-spin :show="loadingGroups">
39 + <template v-if="groupsList.length">
40 + <div class="divide-border divide-y-1 flex flex-col">
41 + <div
42 + v-for="group of groupsList"
43 + :key="group.name"
44 + class="hover:text-warning cursor-pointer break-all px-4.5 py-2.5 text-sm"
45 + :class="{ 'bg-warning/10': group.name === currentGroup?.name }"
46 + @click.stop="loadGroup(group)"
47 + >
48 + <div class="font-mono">{{ group.name }}</div>
49 + <div class="text-secondary text-xs">{{ group.count }} agents</div>
50 + </div>
51 + </div>
52 + </template>
53 + <template v-else>
54 + <n-empty v-if="!loadingGroups" description="No groups found" class="h-48 justify-center" />
55 + </template>
56 + </n-spin>
57 + </template>
58 + <template v-if="pagination.total" #sidebar-footer>
59 + <div class="flex w-full items-center justify-center">
60 + <n-pagination
61 + v-model:page="pagination.current"
62 + :page-size="pagination.size"
63 + :page-slot="5"
64 + :item-count="pagination.total"
65 + simple
66 + />
67 + </div>
68 + </template>
69 + <template v-if="currentGroup && currentFile" #main-toolbar>
70 + <div class="@container flex items-center justify-between">
71 + <div class="flex items-center gap-2 md:gap-3">
72 + <n-button
73 + v-if="xmlEditorCTX"
74 + size="small"
75 + :disabled="!xmlEditorCTX.canUndo()"
76 + @click="xmlEditorCTX.undo"
77 + >
78 + <div class="flex items-center gap-2">
79 + <Icon :name="UndoIcon" />
80 + <span class="@sm:flex hidden">Undo</span>
81 + </div>
82 + </n-button>
83 + <n-button
84 + v-if="xmlEditorCTX"
85 + size="small"
86 + :disabled="!xmlEditorCTX.canRedo()"
87 + @click="xmlEditorCTX.redo"
88 + >
89 + <div class="flex items-center gap-2">
90 + <span class="@sm:flex hidden">Redo</span>
91 + <Icon :name="RedoIcon" />
92 + </div>
93 + </n-button>
94 + </div>
95 + <div class="flex items-center gap-2 md:gap-3">
96 + <n-popover v-if="xmlErrors.length && xmlEditorCTX" class="p-1!">
97 + <template #trigger>
98 + <div class="flex items-center justify-end gap-2">
99 + <Icon
100 + name="carbon:warning-alt"
101 + :size="20"
102 + class="text-warning animate-fade cursor-help"
103 + />
104 + <span class="text-warning @lg:flex hidden font-mono text-xs">Errors detected</span>
105 + </div>
106 + </template>
107 +
108 + <n-scrollbar class="max-h-100">
109 + <div class="flex max-w-80 flex-col gap-1">
110 + <div
111 + v-for="item of xmlErrors"
112 + :key="JSON.stringify(item)"
113 + class="bg-secondary hover:bg-body flex cursor-pointer flex-col gap-0.5 rounded-sm p-1 font-mono"
114 + @click="xmlEditorCTX.scrollToLine(item.line)"
115 + >
116 + <div class="text-secondary text-[8px]">line: {{ item.line }}</div>
117 + <div class="text-xs">{{ item.message }}</div>
118 + </div>
119 + </div>
120 + </n-scrollbar>
121 + </n-popover>
122 +
123 + <n-button
124 + :loading="uploadingConfig"
125 + size="small"
126 + type="primary"
127 + :disabled="!isDirty"
128 + @click="updateGroupConfiguration()"
129 + >
130 + <div class="flex items-center gap-2">
131 + <Icon :name="UploadIcon" />
132 + <span class="@xs:flex hidden">Update</span>
133 + </div>
134 + </n-button>
135 + </div>
136 + </div>
137 + </template>
138 + <template #main-content>
139 + <div v-if="currentGroup && currentFile" class="px-4.5 break-all py-2.5 text-sm">
140 + <div class="font-mono">
141 + Group: {{ currentGroup?.name }} | File: {{ currentFile?.filename }}
142 + </div>
143 + </div>
144 + <n-spin
145 + :show="loadingFile || uploadingConfig"
146 + class="flex h-full w-full overflow-hidden"
147 + content-class="flex h-full grow flex-col justify-center overflow-hidden"
148 + >
149 + <template v-if="currentGroup && currentFile">
150 + <XMLEditor
151 + v-model="currentFile.content"
152 + class="scrollbar-styled text-sm"
153 + @errors="xmlErrors = $event"
154 + @mounted="xmlEditorCTX = $event"
155 + />
156 + </template>
157 + <template v-else>
158 + <n-empty v-if="!loadingFile" description="Select a group" class="h-48 justify-center" />
159 + </template>
160 + </n-spin>
161 + </template>
162 + </SegmentedPage>
163 + </div>
164 +</template>
165 +
166 +<script setup lang="ts">
167 +import type { XMLEditorCtx, XMLError } from "@/components/common/XMLEditor.vue"
168 +import type { WazuhGroup, WazuhGroupFileDetails } from "@/types/wazuh/groups.d"
169 +import { watchDebounced } from "@vueuse/core"
170 +import axios from "axios"
171 +import _clone from "lodash/cloneDeep"
172 +import { NButton, NEmpty, NInput, NPagination, NPopover, NScrollbar, NSpin, NTooltip, useMessage } from "naive-ui"
173 +import { computed, ref, watch } from "vue"
174 +import Api from "@/api"
175 +import Icon from "@/components/common/Icon.vue"
176 +import SegmentedPage from "@/components/common/SegmentedPage.vue"
177 +import XMLEditor from "@/components/common/XMLEditor.vue"
178 +
179 +const message = useMessage()
180 +const loadingRefresh = ref(false)
181 +const loadingGroups = ref(false)
182 +const loadingFile = ref(false)
183 +const uploadingConfig = ref(false)
184 +const groupsList = ref<WazuhGroup[]>([])
185 +const currentGroup = ref<WazuhGroup | null>(null)
186 +const currentFile = ref<WazuhGroupFileDetails | null>(null)
187 +const backupFile = ref<WazuhGroupFileDetails | null>(null)
188 +const xmlEditorCTX = ref<XMLEditorCtx | null>(null)
189 +const UndoIcon = "carbon:undo"
190 +const RedoIcon = "carbon:redo"
191 +const SearchIcon = "ion:search-outline"
192 +const RefreshIcon = "carbon:renew"
193 +const UploadIcon = "carbon:cloud-upload"
194 +
195 +const filters = ref({
196 + search: null
197 +})
198 +const pagination = ref({
199 + current: 1,
200 + size: 30,
201 + total: 0
202 +})
203 +
204 +const isDirty = computed(() => currentFile.value?.content !== backupFile.value?.content)
205 +const xmlErrors = ref<XMLError[]>([])
206 +
207 +let abortController: AbortController | null = null
208 +
209 +function loadGroup(group: WazuhGroup) {
210 + if (group.name !== currentGroup.value?.name) {
211 + currentGroup.value = group
212 + // Auto-load agent.conf file when a group is selected
213 + loadGroupFile(group.name, "agent.conf")
214 + }
215 +}
216 +
217 +function refreshGroups() {
218 + abortController?.abort()
219 + loadingRefresh.value = true
220 + getGroups().finally(() => {
221 + loadingRefresh.value = false
222 + })
223 +}
224 +
225 +function getGroups() {
226 + abortController?.abort()
227 + abortController = new AbortController()
228 +
229 + loadingGroups.value = true
230 +
231 + return Api.wazuh.groups
232 + .getGroups(
233 + {
234 + search: filters.value.search || undefined,
235 + pretty: false,
236 + wait_for_complete: false,
237 + distinct: false,
238 + offset: (pagination.value.current - 1) * pagination.value.size,
239 + limit: pagination.value.size
240 + },
241 + abortController.signal
242 + )
243 + .then(res => {
244 + if (res.data.success) {
245 + groupsList.value = res.data.results || []
246 + pagination.value.total = res.data.total_items
247 + } else {
248 + pagination.value.total = 0
249 + message.error(res.data?.message || "An error occurred. Please try again later.")
250 + }
251 + loadingGroups.value = false
252 + })
253 + .catch(err => {
254 + if (!axios.isCancel(err)) {
255 + groupsList.value = []
256 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
257 + loadingGroups.value = false
258 + }
259 + })
260 +}
261 +
262 +function loadGroupFile(groupId: string, filename: string) {
263 + loadingFile.value = true
264 +
265 + Api.wazuh.groups
266 + .getGroupFile(groupId, filename)
267 + .then(res => {
268 + if (res.data.success) {
269 + currentFile.value = _clone(res.data)
270 + backupFile.value = _clone(res.data)
271 + } else {
272 + message.error(res.data?.message || "An error occurred. Please try again later.")
273 + }
274 + })
275 + .catch(err => {
276 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
277 + })
278 + .finally(() => {
279 + loadingFile.value = false
280 + })
281 +}
282 +
283 +function updateGroupConfiguration() {
284 + if (currentGroup.value && currentFile.value) {
285 + uploadingConfig.value = true
286 +
287 + Api.wazuh.groups
288 + .updateGroupConfiguration(currentGroup.value.name, currentFile.value.content)
289 + .then(res => {
290 + if (res.data.success) {
291 + currentFile.value = _clone(currentFile.value)
292 + backupFile.value = _clone(currentFile.value)
293 + message.success("Group configuration updated successfully")
294 + } else {
295 + message.error("An error occurred. Please try again later.")
296 + }
297 + })
298 + .catch(err => {
299 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
300 + })
301 + .finally(() => {
302 + uploadingConfig.value = false
303 + })
304 + }
305 +}
306 +
307 +watch(
308 + [filters],
309 + () => {
310 + pagination.value.current = 1
311 + },
312 + { deep: true }
313 +)
314 +
315 +watchDebounced(
316 + [filters, () => pagination.value.current],
317 + () => {
318 + getGroups()
319 + },
320 + { debounce: 250, immediate: true, deep: true }
321 +)
322 +</script>