@cryptotaxi247 / CoPilot / commits / 530e7772

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",