Wazuh agent groups (#510)
* Add Wazuh groups API routes and response models * Add endpoints to retrieve files from Wazuh groups and update response models * precommit-fixes
taylor_socfortress committed
Sep 12, 2025 at 15:11 UTC
530e7772d3719af2b97a3f9ea4945dd250fa5960
5 files changed
+447
-2
backend/app/connectors/wazuh_manager/routes/groups.py
new
+164
@@ -0,0 +1,164 @@
1
+from typing import List
2
+from typing import Optional
3
+
4
+from fastapi import APIRouter
5
+from fastapi import Path
6
+from fastapi import Query
7
+from fastapi import Security
8
+
9
+from app.auth.routes.auth import AuthHandler
10
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupFileResponse
11
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupFilesResponse
12
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupsResponse
13
+from app.connectors.wazuh_manager.services.groups import get_wazuh_group_file
14
+from app.connectors.wazuh_manager.services.groups import get_wazuh_group_files
15
+from app.connectors.wazuh_manager.services.groups import get_wazuh_groups
16
+
17
+wazuh_manager_groups_router = APIRouter()
18
+auth_handler = AuthHandler()
19
+
20
+
21
+@wazuh_manager_groups_router.get(
22
+ "/groups",
23
+ response_model=WazuhGroupsResponse,
24
+ description="Get Wazuh manager groups",
25
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
26
+)
27
+async def list_wazuh_groups(
28
+ pretty: Optional[bool] = Query(False, description="Show results in human-readable format"),
29
+ wait_for_complete: Optional[bool] = Query(False, description="Disable timeout response"),
30
+ groups_list: Optional[List[str]] = Query(None, description="List of group IDs (separated by comma)"),
31
+ offset: Optional[int] = Query(0, ge=0, description="First element to return in the collection"),
32
+ limit: Optional[int] = Query(500, ge=1, le=100000, description="Maximum number of elements to return"),
33
+ sort: Optional[str] = Query(None, description="Sort the collection by a field or fields"),
34
+ search: Optional[str] = Query(None, description="Look for elements containing the specified string"),
35
+ hash: Optional[str] = Query(
36
+ None,
37
+ description="Select algorithm to generate the returned checksums",
38
+ regex="^(md5|sha1|sha224|sha256|sha384|sha512|blake2b|blake2s|sha3_224|sha3_256|sha3_384|sha3_512)$",
39
+ ),
40
+ q: Optional[str] = Query(None, description="Query to filter results by"),
41
+ select: Optional[List[str]] = Query(None, description="Select which fields to return"),
42
+ distinct: Optional[bool] = Query(False, description="Look for distinct values"),
43
+) -> WazuhGroupsResponse:
44
+ """
45
+ Get information about all groups or a list of them.
46
+
47
+ Returns a list containing basic information about each group such as number of agents
48
+ belonging to the group and the checksums of the configuration and shared files.
49
+
50
+ Parameters:
51
+ - pretty: Format results for human readability
52
+ - wait_for_complete: Disable request timeout
53
+ - groups_list: List of group IDs to filter by
54
+ - offset: Pagination offset (default: 0)
55
+ - limit: Maximum results per page (default: 500, max: 100000)
56
+ - sort: Fields to sort by (use +/- prefix for ascending/descending)
57
+ - search: Text search across group properties
58
+ - hash: Algorithm to generate checksums (md5, sha1, etc.)
59
+ - q: Advanced query filter
60
+ - select: Comma-separated list of fields to return
61
+ - distinct: Return only distinct values
62
+
63
+ Returns:
64
+ - WazuhGroupsResponse: List of groups with their information and checksums.
65
+ """
66
+ # Use **locals() to pass all parameters efficiently
67
+ params = {k: v for k, v in locals().items() if k not in ["auth_handler"]}
68
+ return await get_wazuh_groups(**params)
69
+
70
+
71
+@wazuh_manager_groups_router.get(
72
+ "/groups/{group_id}/files",
73
+ response_model=WazuhGroupFilesResponse,
74
+ description="Get files in a Wazuh group",
75
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
76
+)
77
+async def get_wazuh_group_files_endpoint(
78
+ group_id: str = Path(..., description="Group ID (name of the group)"),
79
+ pretty: Optional[bool] = Query(False, description="Show results in human-readable format"),
80
+ wait_for_complete: Optional[bool] = Query(False, description="Disable timeout response"),
81
+ offset: Optional[int] = Query(0, ge=0, description="First element to return in the collection"),
82
+ limit: Optional[int] = Query(500, ge=1, le=100000, description="Maximum number of elements to return"),
83
+ sort: Optional[str] = Query(None, description="Sort the collection by a field or fields"),
84
+ search: Optional[str] = Query(None, description="Look for elements containing the specified string"),
85
+ hash: Optional[str] = Query(
86
+ None,
87
+ description="Select algorithm to generate the returned checksums",
88
+ regex="^(md5|sha1|sha224|sha256|sha384|sha512|blake2b|blake2s|sha3_224|sha3_256|sha3_384|sha3_512)$",
89
+ ),
90
+ q: Optional[str] = Query(None, description="Query to filter results by"),
91
+ select: Optional[List[str]] = Query(None, description="Select which fields to return"),
92
+ distinct: Optional[bool] = Query(False, description="Look for distinct values"),
93
+) -> WazuhGroupFilesResponse:
94
+ """
95
+ Return the files placed under the group directory.
96
+
97
+ This endpoint retrieves a list of all files in a specific Wazuh group directory,
98
+ including their filenames and hash checksums.
99
+
100
+ Parameters:
101
+ - group_id: The ID (name) of the group (required)
102
+ - pretty: Format results for human readability
103
+ - wait_for_complete: Disable request timeout
104
+ - offset: Pagination offset (default: 0)
105
+ - limit: Maximum results per page (default: 500, max: 100000)
106
+ - sort: Fields to sort by (use +/- prefix for ascending/descending)
107
+ - search: Text search across file properties
108
+ - hash: Algorithm to generate checksums (md5, sha1, etc.)
109
+ - q: Advanced query filter
110
+ - select: Comma-separated list of fields to return
111
+ - distinct: Return only distinct values
112
+
113
+ Returns:
114
+ - WazuhGroupFilesResponse: List of files in the group with their checksums.
115
+
116
+ Raises:
117
+ - 404: If the specified group is not found
118
+ - 500: If there's an error retrieving the group files
119
+ """
120
+ # Use locals() to capture all parameters, excluding path parameters
121
+ params = {k: v for k, v in locals().items() if k not in ["group_id"]}
122
+ return await get_wazuh_group_files(group_id, **params)
123
+
124
+
125
+@wazuh_manager_groups_router.get(
126
+ "/groups/{group_id}/files/{filename}",
127
+ response_model=WazuhGroupFileResponse,
128
+ description="Get a file in a Wazuh group",
129
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
130
+)
131
+async def get_wazuh_group_file_endpoint(
132
+ group_id: str = Path(..., description="Group ID (name of the group)"),
133
+ filename: str = Path(..., description="Filename (e.g., agent.conf)"),
134
+ pretty: Optional[bool] = Query(False, description="Show results in human-readable format"),
135
+ wait_for_complete: Optional[bool] = Query(False, description="Disable timeout response"),
136
+ type: Optional[List[str]] = Query(None, description="Type of file", regex="^(conf|rootkit_files|rootkit_trojans|rcl)$"),
137
+ raw: Optional[bool] = Query(True, description="Format response in plain text"),
138
+) -> WazuhGroupFileResponse:
139
+ """
140
+ Return the content of the specified group file.
141
+
142
+ This endpoint retrieves the content of a specific file from a Wazuh group.
143
+ The filename will typically be 'agent.conf' for group configuration.
144
+ By default, content is returned as raw text (raw=True).
145
+
146
+ Parameters:
147
+ - group_id: The ID (name) of the group (required)
148
+ - filename: The name of the file to retrieve (required, e.g., "agent.conf")
149
+ - pretty: Format results for human readability
150
+ - wait_for_complete: Disable request timeout
151
+ - type: Type of file (conf, rootkit_files, rootkit_trojans, rcl)
152
+ - raw: Return content as plain text instead of structured data (default: True)
153
+
154
+ Returns:
155
+ - WazuhGroupFileResponse: The content of the group file, either as raw text
156
+ (default) or as structured data (when raw=false).
157
+
158
+ Raises:
159
+ - 404: If the specified group or file is not found
160
+ - 500: If there's an error retrieving the file content
161
+ """
162
+ # Use locals() to capture all parameters, excluding path parameters
163
+ params = {k: v for k, v in locals().items() if k not in ["group_id", "filename"]}
164
+ return await get_wazuh_group_file(group_id, filename, **params)
backend/app/connectors/wazuh_manager/schema/groups.py
new
+59
@@ -0,0 +1,59 @@
1
+from typing import List
2
+from typing import Optional
3
+from typing import Union
4
+
5
+from pydantic import BaseModel
6
+from pydantic import Field
7
+
8
+
9
+class WazuhGroup(BaseModel):
10
+ """Represents a single Wazuh group from the API."""
11
+
12
+ name: str = Field(..., description="Group name")
13
+ count: int = Field(..., description="Number of agents belonging to the group")
14
+ mergedSum: str = Field(..., description="Checksum of merged configuration files")
15
+ configSum: str = Field(..., description="Checksum of configuration files")
16
+
17
+ class Config:
18
+ extra = "ignore" # Ignore extra fields from API
19
+
20
+
21
+class WazuhGroupsResponse(BaseModel):
22
+ """Response model for Wazuh groups listing."""
23
+
24
+ success: bool = Field(..., description="Whether the request was successful")
25
+ message: str = Field(..., description="Response message")
26
+ results: List[WazuhGroup] = Field(default=[], description="List of groups")
27
+ total_items: Optional[int] = Field(None, description="Total number of groups")
28
+
29
+
30
+class WazuhGroupFileResponse(BaseModel):
31
+ """Response model for Wazuh group file content."""
32
+
33
+ success: bool = Field(..., description="Whether the request was successful")
34
+ message: str = Field(..., description="Response message")
35
+ group_id: str = Field(..., description="The group ID")
36
+ filename: str = Field(..., description="The requested filename")
37
+ content: Union[dict, str] = Field(..., description="File content (structured or raw)")
38
+ is_raw: bool = Field(False, description="Whether the content is raw text")
39
+ total_items: Optional[int] = Field(None, description="Total affected items from API")
40
+
41
+
42
+class WazuhGroupFile(BaseModel):
43
+ """Represents a single file in a Wazuh group."""
44
+
45
+ filename: str = Field(..., description="Name of the file")
46
+ hash: str = Field(..., description="Hash/checksum of the file")
47
+
48
+ class Config:
49
+ extra = "ignore" # Ignore extra fields from API
50
+
51
+
52
+class WazuhGroupFilesResponse(BaseModel):
53
+ """Response model for Wazuh group files listing."""
54
+
55
+ success: bool = Field(..., description="Whether the request was successful")
56
+ message: str = Field(..., description="Response message")
57
+ group_id: str = Field(..., description="The group ID")
58
+ results: List[WazuhGroupFile] = Field(default=[], description="List of files in the group")
59
+ total_items: Optional[int] = Field(None, description="Total number of files")
backend/app/connectors/wazuh_manager/services/groups.py
new
+211
@@ -0,0 +1,211 @@
1
+from fastapi import HTTPException
2
+from loguru import logger
3
+
4
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupFileResponse
5
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupFilesResponse
6
+from app.connectors.wazuh_manager.schema.groups import WazuhGroupsResponse
7
+from app.connectors.wazuh_manager.utils.universal import send_get_request
8
+
9
+
10
+async def get_wazuh_groups(**params) -> WazuhGroupsResponse:
11
+ """
12
+ Fetch Wazuh groups from the Wazuh Manager API.
13
+
14
+ Args:
15
+ **params: All query parameters passed directly to the API
16
+
17
+ Returns:
18
+ WazuhGroupsResponse: Structured response with groups data
19
+
20
+ Raises:
21
+ HTTPException: If there's an error fetching the groups
22
+ """
23
+ # Filter out None values
24
+ clean_params = {k: v for k, v in params.items() if v is not None}
25
+
26
+ # Handle list parameters that need to be joined as comma-separated strings
27
+ if "groups_list" in clean_params and isinstance(clean_params["groups_list"], list):
28
+ clean_params["groups_list"] = ",".join(clean_params["groups_list"])
29
+ if "select" in clean_params and isinstance(clean_params["select"], list):
30
+ clean_params["select"] = ",".join(clean_params["select"])
31
+
32
+ logger.debug(f"Requesting Wazuh groups with params: {clean_params}")
33
+
34
+ try:
35
+ response = await send_get_request(endpoint="/groups", params=clean_params)
36
+
37
+ # Check if the API request was successful
38
+ if not response.get("success"):
39
+ error_detail = response.get("message", "Failed to fetch groups from Wazuh API")
40
+ logger.error(f"Wazuh API error: {error_detail}")
41
+ raise HTTPException(status_code=500, detail=error_detail)
42
+
43
+ # Extract data from nested response structure
44
+ wazuh_data = response.get("data", {}).get("data", {})
45
+ groups = wazuh_data.get("affected_items", [])
46
+ total_items = wazuh_data.get("total_affected_items", len(groups))
47
+
48
+ logger.info(f"Retrieved {len(groups)} of {total_items} Wazuh groups")
49
+
50
+ return WazuhGroupsResponse(
51
+ success=True,
52
+ message=f"Successfully retrieved {len(groups)} groups",
53
+ results=groups,
54
+ total_items=total_items,
55
+ )
56
+
57
+ except HTTPException:
58
+ # Re-raise HTTP exceptions as-is
59
+ raise
60
+ except Exception as e:
61
+ logger.error(f"Error fetching Wazuh groups: {e}")
62
+ raise HTTPException(status_code=500, detail=f"Error fetching groups: {str(e)}")
63
+
64
+
65
+async def get_wazuh_group_file(group_id: str, filename: str, **params) -> WazuhGroupFileResponse:
66
+ """
67
+ Fetch the content of a specific file from a Wazuh group.
68
+
69
+ Args:
70
+ group_id: The ID (name) of the group
71
+ filename: The name of the file to fetch (e.g., "agent.conf")
72
+ **params: All query parameters passed directly to the API
73
+
74
+ Returns:
75
+ WazuhGroupFileResponse: Structured response with group file content
76
+
77
+ Raises:
78
+ HTTPException: If there's an error fetching the group file
79
+ """
80
+ # Filter out None values
81
+ clean_params = {k: v for k, v in params.items() if v is not None}
82
+
83
+ # Handle list parameters that need to be joined as comma-separated strings
84
+ if "type" in clean_params and isinstance(clean_params["type"], list):
85
+ clean_params["type"] = ",".join(clean_params["type"])
86
+
87
+ # Check if raw content is requested
88
+ is_raw = clean_params.get("raw", False)
89
+
90
+ logger.debug(f"Requesting Wazuh group file '{filename}' for group '{group_id}' with params: {clean_params}")
91
+
92
+ try:
93
+ response = await send_get_request(endpoint=f"/groups/{group_id}/files/{filename}", params=clean_params)
94
+ logger.info(f"Response: {response}")
95
+
96
+ # Check if the API request was successful
97
+ if not response.get("success"):
98
+ error_detail = response.get("message", f"Failed to fetch group file '{filename}' for group '{group_id}'")
99
+ logger.error(f"Wazuh API error: {error_detail}")
100
+
101
+ # Handle specific errors
102
+ if "not found" in error_detail.lower():
103
+ raise HTTPException(status_code=404, detail=f"Group file '{filename}' not found in group '{group_id}'")
104
+ else:
105
+ raise HTTPException(status_code=500, detail=error_detail)
106
+
107
+ # Handle raw response differently
108
+ if is_raw:
109
+ # For raw responses, the content is in response["data"]
110
+ content = response.get("data", "")
111
+ logger.info(f"Retrieved raw content for group file '{filename}' in group '{group_id}' ({len(content)} characters)")
112
+
113
+ return WazuhGroupFileResponse(
114
+ success=True,
115
+ message=f"Successfully retrieved raw content for group file '{filename}' in group '{group_id}'",
116
+ group_id=group_id,
117
+ filename=filename,
118
+ content=content,
119
+ is_raw=True,
120
+ total_items=None,
121
+ )
122
+
123
+ # Handle structured response (non-raw)
124
+ wazuh_data = response.get("data", {}).get("data", {})
125
+ affected_items = wazuh_data.get("affected_items", [])
126
+ total_items = wazuh_data.get("total_affected_items", len(affected_items))
127
+
128
+ if not affected_items:
129
+ raise HTTPException(status_code=404, detail=f"No content found for group file '{filename}' in group '{group_id}'")
130
+
131
+ # Extract the content from the first affected item
132
+ content = affected_items[0] if affected_items else {}
133
+
134
+ logger.info(f"Retrieved structured content for group file '{filename}' in group '{group_id}' with {total_items} affected items")
135
+
136
+ return WazuhGroupFileResponse(
137
+ success=True,
138
+ message=f"Successfully retrieved content for group file '{filename}' in group '{group_id}'",
139
+ group_id=group_id,
140
+ filename=filename,
141
+ content=content,
142
+ is_raw=False,
143
+ total_items=total_items,
144
+ )
145
+
146
+ except HTTPException:
147
+ # Re-raise HTTP exceptions as-is
148
+ raise
149
+ except Exception as e:
150
+ logger.error(f"Error fetching Wazuh group file '{filename}' for group '{group_id}': {e}")
151
+ raise HTTPException(status_code=500, detail=f"Error fetching group file: {str(e)}")
152
+
153
+
154
+async def get_wazuh_group_files(group_id: str, **params) -> WazuhGroupFilesResponse:
155
+ """
156
+ Fetch the list of files in a Wazuh group.
157
+
158
+ Args:
159
+ group_id: The ID (name) of the group
160
+ **params: All query parameters passed directly to the API
161
+
162
+ Returns:
163
+ WazuhGroupFilesResponse: Structured response with group files data
164
+
165
+ Raises:
166
+ HTTPException: If there's an error fetching the group files
167
+ """
168
+ # Filter out None values
169
+ clean_params = {k: v for k, v in params.items() if v is not None}
170
+
171
+ # Handle list parameters that need to be joined as comma-separated strings
172
+ if "select" in clean_params and isinstance(clean_params["select"], list):
173
+ clean_params["select"] = ",".join(clean_params["select"])
174
+
175
+ logger.debug(f"Requesting Wazuh group files for group '{group_id}' with params: {clean_params}")
176
+
177
+ try:
178
+ response = await send_get_request(endpoint=f"/groups/{group_id}/files", params=clean_params)
179
+
180
+ # Check if the API request was successful
181
+ if not response.get("success"):
182
+ error_detail = response.get("message", f"Failed to fetch group files for group '{group_id}'")
183
+ logger.error(f"Wazuh API error: {error_detail}")
184
+
185
+ # Handle specific errors
186
+ if "not found" in error_detail.lower():
187
+ raise HTTPException(status_code=404, detail=f"Group '{group_id}' not found")
188
+ else:
189
+ raise HTTPException(status_code=500, detail=error_detail)
190
+
191
+ # Extract data from nested response structure
192
+ wazuh_data = response.get("data", {}).get("data", {})
193
+ files = wazuh_data.get("affected_items", [])
194
+ total_items = wazuh_data.get("total_affected_items", len(files))
195
+
196
+ logger.info(f"Retrieved {len(files)} of {total_items} Wazuh group files for group '{group_id}'")
197
+
198
+ return WazuhGroupFilesResponse(
199
+ success=True,
200
+ message=f"Successfully retrieved {len(files)} files for group '{group_id}'",
201
+ group_id=group_id,
202
+ results=files,
203
+ total_items=total_items,
204
+ )
205
+
206
+ except HTTPException:
207
+ # Re-raise HTTP exceptions as-is
208
+ raise
209
+ except Exception as e:
210
+ logger.error(f"Error fetching Wazuh group files for group '{group_id}': {e}")
211
+ raise HTTPException(status_code=500, detail=f"Error fetching group files: {str(e)}")
backend/app/connectors/wazuh_manager/utils/universal.py
+6
-2
@@ -149,8 +149,12 @@ async def send_get_request(
149
logger.error("No Wazuh Manager connector found in the database")
150
return None
151
try:
152
- # if params is {"raw": True} then we want to return the raw response
153
- if params == {"raw": True}:
152
+ # Check if raw response is requested - support both old and new ways
153
+ # Old way: params == {"raw": True} (exact match for backward compatibility)
154
+ # New way: params contains "raw": True (for requests with multiple parameters)
155
+ is_raw_request = (params == {"raw": True}) or (params and params.get("raw", False))
156
+
157
+ if is_raw_request:
158
response = requests.get(
159
f"{attributes['connector_url']}{endpoint}",
160
headers=wazuh_manager_client,
backend/app/routers/wazuh_manager.py
+7
@@ -1,5 +1,6 @@
1
from fastapi import APIRouter
2
3
+from app.connectors.wazuh_manager.routes.groups import wazuh_manager_groups_router
4
from app.connectors.wazuh_manager.routes.management import (
5
wazuh_manager_management_router,
6
)
@@ -16,6 +17,12 @@ router.include_router(
17
tags=["wazuh-manager"],
18
)
19
20
+router.include_router(
21
+ wazuh_manager_groups_router,
22
+ prefix="/wazuh_manager",
23
+ tags=["wazuh-manager"],
24
+)
25
+
26
router.include_router(
27
wazuh_manager_mitre_router,
28
prefix="/wazuh_manager/mitre",