main
py 515 lines 20.3 KB
Raw
1 import os
2 from typing import List
3 from typing import Optional
4
5 from fastapi import APIRouter
6 from fastapi import Depends
7 from fastapi import HTTPException
8 from fastapi import Query
9 from fastapi import Security
10 from loguru import logger
11 from sqlalchemy.ext.asyncio import AsyncSession
12 from sqlalchemy.future import select
13
14 from app.auth.routes.auth import AuthHandler
15 from app.connectors.velociraptor.schema.artifacts import CollectArtifactBody
16 from app.connectors.velociraptor.schema.artifacts import CollectArtifactResponse
17 from app.connectors.velociraptor.schema.artifacts import InvokeCopilotActionBody
18 from app.connectors.velociraptor.services.artifacts import run_artifact_collection
19 from app.db.db_session import get_db
20 from app.db.universal_models import Agents
21 from app.integrations.copilot_action.schema.copilot_action import ActionDetailResponse
22 from app.integrations.copilot_action.schema.copilot_action import (
23 InventoryMetricsResponse,
24 )
25 from app.integrations.copilot_action.schema.copilot_action import InventoryResponse
26 from app.integrations.copilot_action.schema.copilot_action import (
27 InvokeCopilotActionResponse,
28 )
29 from app.integrations.copilot_action.schema.copilot_action import Technology
30 from app.integrations.copilot_action.services.copilot_action import CopilotActionService
31
32 copilot_action_router = APIRouter()
33 auth_handler = AuthHandler()
34
35 # Helper functions for better modularity
36
37
38 def get_license_key() -> str:
39 """Get and validate the COPILOT_API_KEY environment variable."""
40 license_key = os.getenv("COPILOT_API_KEY")
41 if not license_key:
42 logger.error("COPILOT_API_KEY environment variable not set")
43 raise HTTPException(status_code=500, detail="COPILOT_API_KEY environment variable not configured")
44 return license_key
45
46
47 def calculate_pagination_info(total: int, limit: int, offset: int) -> dict:
48 """Calculate pagination metadata for responses."""
49 current_page = (offset // limit) + 1
50 total_pages = (total + limit - 1) // limit # Ceiling division
51 has_next = offset + limit < total
52 has_prev = offset > 0
53
54 return {
55 "current_page": current_page,
56 "total_pages": total_pages,
57 "has_next": has_next,
58 "has_prev": has_prev,
59 "items_per_page": limit,
60 "total_items": total,
61 }
62
63
64 async def get_agents_by_hostnames(session: AsyncSession, hostnames: List[str]) -> List[Agents]:
65 """Retrieve multiple agents from database by hostnames."""
66 agent_details = await session.execute(select(Agents).filter(Agents.hostname.in_(hostnames)))
67 agents = agent_details.scalars().all()
68
69 found_hostnames = {agent.hostname for agent in agents}
70 missing_hostnames = set(hostnames) - found_hostnames
71
72 if missing_hostnames:
73 raise HTTPException(
74 status_code=404,
75 detail=f"Agents with hostnames {list(missing_hostnames)} not found",
76 )
77
78 logger.info(f"Found {len(agents)} agents")
79 return agents
80
81
82 def determine_artifact_name(agent_os: str) -> str:
83 """Determine the appropriate Velociraptor artifact based on OS."""
84 if "Windows" in agent_os:
85 return "Windows.Execute.RemotePowerShellScript"
86 elif "Linux" in agent_os:
87 return "Linux.Execute.RemoteBashScript"
88 elif "Ubuntu" in agent_os or "Debian" in agent_os or "CentOS" in agent_os or "Red Hat" in agent_os:
89 return "Linux.Execute.RemoteBashScript"
90 else:
91 raise HTTPException(
92 status_code=400,
93 detail=f"Unsupported OS: {agent_os}",
94 )
95
96
97 def build_velociraptor_parameters(copilot_params: dict, script_params: list) -> dict:
98 """
99 Convert Copilot Action parameters to Velociraptor format.
100
101 Args:
102 copilot_params: Dictionary of parameters provided by the user
103 script_params: List of ScriptParameter objects from the action details
104
105 Returns:
106 Dictionary with env array for Velociraptor
107 """
108 if not copilot_params:
109 return {}
110
111 env_array = []
112
113 # Always add ScriptURL first
114 if "ScriptURL" in copilot_params:
115 env_array.append({"key": "ScriptURL", "value": str(copilot_params["ScriptURL"])})
116
117 # Create a mapping of parameter names to their arg_position
118 param_position_map = {}
119 for param in script_params:
120 if param.arg_position is not None:
121 param_position_map[param.name] = param.arg_position
122
123 # Add parameters with arg_position as Arg{position}
124 for param_name, param_value in copilot_params.items():
125 if param_name in param_position_map:
126 arg_key = f"Arg{param_position_map[param_name]}"
127 env_array.append({"key": arg_key, "value": str(param_value)})
128
129 # Add other parameters (those without arg_position and not ScriptURL)
130 for param_name, param_value in copilot_params.items():
131 if param_name not in param_position_map and param_name not in ["ScriptURL"]:
132 env_array.append({"key": param_name, "value": str(param_value)})
133
134 return {"env": env_array}
135
136
137 async def validate_parameters(provided_params: dict, script_params: list) -> None:
138 """
139 Validate provided parameters against the script's expected parameters.
140
141 Args:
142 provided_params: Dictionary of parameters provided by the user
143 script_params: List of ScriptParameter objects defining expected parameters
144
145 Raises:
146 HTTPException: If invalid or missing parameters are provided
147 """
148 if not provided_params:
149 provided_params = {}
150
151 logger.info(f"Validating {len(provided_params)} provided parameters against {len(script_params)} script parameters")
152
153 # Extract parameter info from script
154 valid_param_names = {param.name for param in script_params}
155 required_params = {param.name for param in script_params if param.required}
156 provided_param_keys = set(provided_params.keys())
157
158 # Add ScriptURL as required parameters for Copilot Actions
159 required_params.add("ScriptURL")
160 valid_param_names.add("ScriptURL")
161
162 # Validate: no invalid parameters
163 invalid_params = provided_param_keys - valid_param_names
164 if invalid_params:
165 logger.error(f"Invalid parameters provided: {invalid_params}")
166 raise HTTPException(
167 status_code=400,
168 detail=f"Invalid parameters provided: {list(invalid_params)}. Valid parameters are: {list(valid_param_names)}",
169 )
170
171 # Validate: all required parameters present
172 missing_params = required_params - provided_param_keys
173 if missing_params:
174 logger.error(f"Missing required parameters: {missing_params}")
175 raise HTTPException(status_code=400, detail=f"Missing required parameters: {list(missing_params)}")
176
177 logger.info("Parameter validation successful")
178
179
180 async def build_artifact_collection_body(agent: Agents, artifact_name: str, velociraptor_params: dict) -> CollectArtifactBody:
181 """Build the artifact collection request body for Velociraptor."""
182 return CollectArtifactBody(
183 hostname=agent.hostname,
184 velociraptor_id=agent.velociraptor_id,
185 velociraptor_org=agent.velociraptor_org,
186 artifact_name=artifact_name,
187 parameters=velociraptor_params,
188 )
189
190
191 # Route handlers (keeping existing routes but updating invoke_action)
192
193
194 @copilot_action_router.get(
195 "/inventory",
196 response_model=InventoryResponse,
197 description="Get paginated inventory of available active response scripts",
198 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
199 )
200 async def get_inventory(
201 technology: Optional[Technology] = Query(None, description="Filter by technology type"),
202 category: Optional[str] = Query(None, description="Filter by category"),
203 tag: Optional[str] = Query(None, description="Filter by tag"),
204 q: Optional[str] = Query(None, description="Free-text search query"),
205 limit: int = Query(100, ge=1, le=1000, description="Maximum number of results per page"),
206 offset: int = Query(0, ge=0, description="Number of items to skip (for pagination)"),
207 refresh: bool = Query(False, description="Force refresh cache"),
208 include: Optional[str] = Query(None, description="Comma-separated extra fields to include"),
209 ) -> InventoryResponse:
210 """
211 Retrieve paginated inventory of available active response scripts.
212
213 This endpoint supports pagination through the `limit` and `offset` parameters:
214 - `limit`: Controls how many items are returned per page (1-1000, default 100)
215 - `offset`: Controls how many items to skip (for pagination, default 0)
216
217 The response includes pagination metadata:
218 - `total`: Total number of items available
219 - `count`: Number of items in current response
220 - `has_more`: Whether there are more items available
221 - `next_offset`: Offset to use for the next page
222 - `prev_offset`: Offset to use for the previous page
223
224 Example for paginated requests:
225 - Page 1: GET /inventory?limit=50&offset=0
226 - Page 2: GET /inventory?limit=50&offset=50
227 - Page 3: GET /inventory?limit=50&offset=100
228 """
229 logger.info(
230 f"Fetching active response inventory with filters: tech={technology}, category={category}, tag={tag}, q={q}, limit={limit}, offset={offset}",
231 )
232
233 license_key = get_license_key()
234
235 try:
236 response = await CopilotActionService.get_inventory(
237 license_key=license_key,
238 technology=technology,
239 category=category,
240 tag=tag,
241 q=q,
242 limit=limit,
243 offset=offset,
244 refresh=refresh,
245 include=include,
246 )
247
248 logger.info(f"Successfully fetched inventory: {response.count} of {response.total} actions (offset: {response.offset})")
249 return response
250
251 except Exception as e:
252 logger.error(f"Error fetching inventory: {str(e)}")
253 raise HTTPException(status_code=500, detail=f"Error fetching inventory: {str(e)}")
254
255
256 @copilot_action_router.get(
257 "/inventory/count",
258 description="Get total count of available scripts for pagination calculations",
259 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
260 )
261 async def get_inventory_count(
262 technology: Optional[Technology] = Query(None, description="Filter by technology type"),
263 category: Optional[str] = Query(None, description="Filter by category"),
264 tag: Optional[str] = Query(None, description="Filter by tag"),
265 q: Optional[str] = Query(None, description="Free-text search query"),
266 ) -> dict:
267 """
268 Get the total count of items matching the filters without fetching the full data.
269 Useful for pagination calculations on the frontend.
270 """
271 logger.info(f"Fetching inventory count with filters: tech={technology}, category={category}, tag={tag}, q={q}")
272
273 license_key = get_license_key()
274
275 try:
276 # Fetch with minimal data (limit=1) just to get the total
277 response = await CopilotActionService.get_inventory(
278 license_key=license_key,
279 technology=technology,
280 category=category,
281 tag=tag,
282 q=q,
283 limit=1, # Minimal fetch
284 offset=0,
285 refresh=False,
286 include=None,
287 )
288
289 return {
290 "total": response.total,
291 "message": "Successfully retrieved inventory count",
292 "success": True,
293 **calculate_pagination_info(response.total or 0, 100, 0), # Default pagination info
294 }
295
296 except Exception as e:
297 logger.error(f"Error fetching inventory count: {str(e)}")
298 raise HTTPException(status_code=500, detail=f"Error fetching inventory count: {str(e)}")
299
300
301 @copilot_action_router.get(
302 "/inventory/{copilot_action_name}",
303 response_model=ActionDetailResponse,
304 description="Get details for a specific active response script",
305 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
306 )
307 async def get_action_by_name(copilot_action_name: str) -> ActionDetailResponse:
308 """Get detailed information for a specific active response script."""
309 logger.info(f"Fetching action details for: {copilot_action_name}")
310
311 license_key = get_license_key()
312
313 try:
314 response = await CopilotActionService.get_action_by_name(license_key=license_key, copilot_action_name=copilot_action_name)
315
316 if not response.success:
317 if "not found" in response.message.lower():
318 raise HTTPException(status_code=404, detail=response.message)
319 else:
320 raise HTTPException(status_code=500, detail=response.message)
321
322 logger.info(f"Successfully fetched action details for: {copilot_action_name}")
323 logger.info(f"Raw action feteched: {response}")
324 return response
325
326 except HTTPException:
327 raise
328 except Exception as e:
329 logger.error(f"Error fetching action details: {str(e)}")
330 raise HTTPException(status_code=500, detail=f"Error fetching action details: {str(e)}")
331
332
333 @copilot_action_router.get(
334 "/metrics",
335 response_model=InventoryMetricsResponse,
336 description="Get inventory metrics and status",
337 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
338 )
339 async def get_metrics() -> InventoryMetricsResponse:
340 """Get metrics and status information for the inventory service."""
341 logger.info("Fetching inventory metrics")
342
343 license_key = get_license_key()
344
345 try:
346 response = await CopilotActionService.get_metrics(license_key=license_key)
347 logger.info("Successfully fetched inventory metrics")
348 return response
349
350 except Exception as e:
351 logger.error(f"Error fetching metrics: {str(e)}")
352 raise HTTPException(status_code=500, detail=f"Error fetching metrics: {str(e)}")
353
354
355 @copilot_action_router.get(
356 "/technologies",
357 description="Get available technology types",
358 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
359 )
360 async def get_technologies() -> dict:
361 """Get list of available technology types for filtering."""
362 technologies = [tech.value for tech in Technology]
363
364 return {
365 "technologies": technologies,
366 "total": len(technologies),
367 "message": "Successfully retrieved available technologies",
368 "success": True,
369 }
370
371
372 async def invoke_action_on_agent(
373 agent: Agents,
374 copilot_action_name: str,
375 copilot_action_details: ActionDetailResponse,
376 parameters: dict,
377 session: AsyncSession,
378 ) -> CollectArtifactResponse:
379 """
380 Invoke a Copilot Action on a single agent.
381
382 Args:
383 agent: The agent to invoke the action on
384 copilot_action_name: Name of the action
385 copilot_action_details: Action details from the service
386 parameters: Parameters for the action
387 session: Database session
388 Returns:
389 CollectArtifactResponse: Response from the artifact collection
390 """
391 logger.info(f"Invoking action '{copilot_action_name}' on agent {agent.hostname} with OS {agent.os}")
392 try:
393 # Determine the appropriate artifact based on OS
394 artifact_name = determine_artifact_name(agent.os)
395 logger.info(f"Using artifact: {artifact_name} for OS: {agent.os} on agent {agent.hostname}")
396
397 # Build Velociraptor parameters
398 velociraptor_params = build_velociraptor_parameters(parameters, copilot_action_details.copilot_action.script_parameters)
399
400 # Build artifact collection request
401 artifact_body = await build_artifact_collection_body(agent, artifact_name, velociraptor_params)
402 logger.info(f"Built artifact collection request for {agent.hostname}")
403
404 # Execute the collection
405 response = await run_artifact_collection(artifact_body, session)
406 logger.info(f"Successfully invoked Copilot action on {agent.hostname}")
407
408 return response
409
410 except Exception as e:
411 logger.error(f"Error invoking action on agent {agent.hostname}: {str(e)}")
412 # You might want to return a failed response instead of raising
413 raise HTTPException(status_code=500, detail=f"Error invoking action on agent {agent.hostname}: {str(e)}")
414
415
416 @copilot_action_router.post(
417 "/invoke",
418 response_model=InvokeCopilotActionResponse, # Updated to use structured response
419 description="Invoke a Copilot Action on multiple target agents",
420 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
421 )
422 async def invoke_action(body: InvokeCopilotActionBody, session: AsyncSession = Depends(get_db)) -> InvokeCopilotActionResponse:
423 """
424 Invoke a Copilot Action on multiple target agents.
425
426 This endpoint orchestrates the process of:
427 1. Finding all target agents
428 2. Fetching action details and validating parameters once
429 3. Building and executing the artifact collection request for each agent
430
431 Args:
432 body: Request body containing action name, agent names, and parameters
433 session: Database session
434
435 Returns:
436 InvokeCopilotActionResponse: Structured response with list of results, message, and success status
437 """
438 logger.info(f"Invoking Copilot action '{body.copilot_action_name}' on {len(body.agent_names)} agents")
439
440 try:
441 # Step 1: Get all target agents
442 agents = await get_agents_by_hostnames(session, body.agent_names)
443 logger.info(f"Found agents: {[agent.hostname for agent in agents]}")
444
445 # Step 2: Fetch action details (do this once for all agents)
446 copilot_action_details = await get_action_by_name(body.copilot_action_name)
447 logger.info(f"Found action details for: {body.copilot_action_name}")
448
449 # Step 3: Prepare parameters (do this once for all agents)
450 final_parameters = body.parameters or {}
451
452 # Add the `repo_url` and the to the parameters
453 if copilot_action_details.copilot_action.repo_url:
454 final_parameters["ScriptURL"] = copilot_action_details.copilot_action.repo_url
455
456 logger.info(f"Parameters after adding repo and script: {final_parameters}")
457
458 # Step 4: Validate parameters (do this once for all agents)
459 await validate_parameters(final_parameters, copilot_action_details.copilot_action.script_parameters)
460
461 # Step 5: Execute action on each agent
462 responses = []
463 successful_agents = []
464 failed_agents = []
465
466 for agent in agents:
467 try:
468 response = await invoke_action_on_agent(agent, body.copilot_action_name, copilot_action_details, final_parameters, session)
469 responses.append(response)
470 successful_agents.append(agent.hostname)
471
472 except Exception as e:
473 logger.error(f"Failed to invoke action on agent {agent.hostname}: {str(e)}")
474 failed_agents.append(agent.hostname)
475 # Add a failed response to maintain order
476 failed_response = CollectArtifactResponse(
477 message=f"Failed to invoke action on {agent.hostname}: {str(e)}",
478 success=False,
479 results=[],
480 )
481 responses.append(failed_response)
482
483 # Log summary
484 logger.info(f"Action invocation complete. Successful: {len(successful_agents)}, Failed: {len(failed_agents)}")
485 if successful_agents:
486 logger.info(f"Successful agents: {successful_agents}")
487 if failed_agents:
488 logger.warning(f"Failed agents: {failed_agents}")
489
490 # Return structured response
491 if len(failed_agents) == 0:
492 return InvokeCopilotActionResponse(
493 responses=[response.model_dump() for response in responses],
494 message=f"Successfully invoked action on all {len(successful_agents)} agent(s). Check the appropriate Grafana dashboard for results.",
495 success=True,
496 )
497 elif len(successful_agents) == 0:
498 return InvokeCopilotActionResponse(
499 responses=[response.model_dump() for response in responses],
500 message=f"Failed to invoke action on all {len(failed_agents)} agent(s)",
501 success=False,
502 )
503 else:
504 return InvokeCopilotActionResponse(
505 responses=[response.model_dump() for response in responses],
506 message=f"Partially successful: {len(successful_agents)} succeeded, {len(failed_agents)} failed",
507 success=True, # Consider partial success as success
508 )
509
510 except HTTPException:
511 # Re-raise HTTP exceptions (validation errors, not found, etc.)
512 raise
513 except Exception as e:
514 logger.error(f"Unexpected error invoking Copilot action: {str(e)}")
515 raise HTTPException(status_code=500, detail=f"Error invoking Copilot action: {str(e)}")