Copilot action (#496)
* Add Copilot Action routes and service for inventory management * precommit-fixes
taylor_socfortress committed
Aug 27, 2025 at 12:23 UTC
2055302a3f60304fef495ede79705e95d58699ca
5 files changed
+494
backend/app/integrations/copilot_action/routes/copilot_action.py
new
+184
@@ -0,0 +1,184 @@
1
+import os
2
+from typing import Optional
3
+
4
+from fastapi import APIRouter
5
+from fastapi import HTTPException
6
+from fastapi import Query
7
+from fastapi import Security
8
+from loguru import logger
9
+
10
+from app.auth.routes.auth import AuthHandler
11
+from app.integrations.copilot_action.schema.copilot_action import ActionDetailResponse
12
+from app.integrations.copilot_action.schema.copilot_action import (
13
+ InventoryMetricsResponse,
14
+)
15
+from app.integrations.copilot_action.schema.copilot_action import InventoryResponse
16
+from app.integrations.copilot_action.schema.copilot_action import Technology
17
+from app.integrations.copilot_action.services.copilot_action import CopilotActionService
18
+
19
+copilot_action_router = APIRouter()
20
+auth_handler = AuthHandler()
21
+
22
+
23
+@copilot_action_router.get(
24
+ "/inventory",
25
+ response_model=InventoryResponse,
26
+ description="Get inventory of available active response scripts",
27
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
28
+)
29
+async def get_inventory(
30
+ technology: Optional[Technology] = Query(None, description="Filter by technology type"),
31
+ category: Optional[str] = Query(None, description="Filter by category"),
32
+ tag: Optional[str] = Query(None, description="Filter by tag"),
33
+ q: Optional[str] = Query(None, description="Free-text search query"),
34
+ limit: int = Query(100, ge=1, le=1000, description="Maximum number of results"),
35
+ offset: int = Query(0, ge=0, description="Offset for pagination"),
36
+ refresh: bool = Query(False, description="Force refresh cache"),
37
+ include: Optional[str] = Query(None, description="Comma-separated extra fields to include"),
38
+) -> InventoryResponse:
39
+ """
40
+ Retrieve inventory of available active response scripts.
41
+
42
+ This endpoint fetches the catalog of active response scripts from the
43
+ Copilot Action service, with optional filtering and pagination.
44
+
45
+ Args:
46
+ technology: Filter by technology type (e.g., Windows, Linux, Wazuh)
47
+ category: Filter by category if present
48
+ tag: Filter by tag contained in the tags list
49
+ q: Free-text search in name/description
50
+ limit: Maximum number of results (1-1000)
51
+ offset: Offset for pagination
52
+ refresh: Force refresh the remote cache
53
+ include: Extra fields to include (e.g., 'category,tags')
54
+
55
+ Returns:
56
+ InventoryResponse: List of active response scripts with metadata
57
+ """
58
+ logger.info(f"Fetching active response inventory with filters: tech={technology}, category={category}, tag={tag}, q={q}")
59
+
60
+ # Get license key from environment variable
61
+ license_key = os.getenv("COPILOT_API_KEY")
62
+ if not license_key:
63
+ logger.error("COPILOT_API_KEY environment variable not set")
64
+ raise HTTPException(status_code=500, detail="COPILOT_API_KEY environment variable not configured")
65
+
66
+ # Fetch inventory from service
67
+ try:
68
+ response = await CopilotActionService.get_inventory(
69
+ license_key=license_key,
70
+ technology=technology,
71
+ category=category,
72
+ tag=tag,
73
+ q=q,
74
+ limit=limit,
75
+ offset=offset,
76
+ refresh=refresh,
77
+ include=include,
78
+ )
79
+
80
+ logger.info(f"Successfully fetched inventory: {len(response.copilot_actions)} actions")
81
+ return response
82
+
83
+ except Exception as e:
84
+ logger.error(f"Error fetching inventory: {str(e)}")
85
+ raise HTTPException(status_code=500, detail=f"Error fetching inventory: {str(e)}")
86
+
87
+
88
+@copilot_action_router.get(
89
+ "/inventory/{copilot_action_name}",
90
+ response_model=ActionDetailResponse,
91
+ description="Get details for a specific active response script",
92
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
93
+)
94
+async def get_action_by_name(copilot_action_name: str) -> ActionDetailResponse:
95
+ """
96
+ Get detailed information for a specific active response script.
97
+
98
+ Args:
99
+ copilot_action_name: Name of the action to retrieve
100
+
101
+ Returns:
102
+ ActionDetailResponse: Detailed information about the action
103
+ """
104
+ logger.info(f"Fetching action details for: {copilot_action_name}")
105
+
106
+ # Get license key from environment variable
107
+ license_key = os.getenv("COPILOT_API_KEY")
108
+ if not license_key:
109
+ logger.error("COPILOT_API_KEY environment variable not set")
110
+ raise HTTPException(status_code=500, detail="COPILOT_API_KEY environment variable not configured")
111
+
112
+ # Fetch action details from service
113
+ try:
114
+ response = await CopilotActionService.get_action_by_name(license_key=license_key, copilot_action_name=copilot_action_name)
115
+
116
+ if not response.success:
117
+ if "not found" in response.message.lower():
118
+ raise HTTPException(status_code=404, detail=response.message)
119
+ else:
120
+ raise HTTPException(status_code=500, detail=response.message)
121
+
122
+ logger.info(f"Successfully fetched action details for: {copilot_action_name}")
123
+ return response
124
+
125
+ except HTTPException:
126
+ raise
127
+ except Exception as e:
128
+ logger.error(f"Error fetching action details: {str(e)}")
129
+ raise HTTPException(status_code=500, detail=f"Error fetching action details: {str(e)}")
130
+
131
+
132
+@copilot_action_router.get(
133
+ "/metrics",
134
+ response_model=InventoryMetricsResponse,
135
+ description="Get inventory metrics and status",
136
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
137
+)
138
+async def get_metrics() -> InventoryMetricsResponse:
139
+ """
140
+ Get metrics and status information for the inventory service.
141
+
142
+ Returns:
143
+ InventoryMetricsResponse: Service metrics and status
144
+ """
145
+ logger.info("Fetching inventory metrics")
146
+
147
+ # Get license key from environment variable
148
+ license_key = os.getenv("COPILOT_API_KEY")
149
+ if not license_key:
150
+ logger.error("COPILOT_API_KEY environment variable not set")
151
+ raise HTTPException(status_code=500, detail="COPILOT_API_KEY environment variable not configured")
152
+
153
+ # Fetch metrics from service
154
+ try:
155
+ response = await CopilotActionService.get_metrics(license_key=license_key)
156
+
157
+ logger.info("Successfully fetched inventory metrics")
158
+ return response
159
+
160
+ except Exception as e:
161
+ logger.error(f"Error fetching metrics: {str(e)}")
162
+ raise HTTPException(status_code=500, detail=f"Error fetching metrics: {str(e)}")
163
+
164
+
165
+@copilot_action_router.get(
166
+ "/technologies",
167
+ description="Get available technology types",
168
+ dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
169
+)
170
+async def get_technologies() -> dict:
171
+ """
172
+ Get list of available technology types for filtering.
173
+
174
+ Returns:
175
+ Dictionary containing available technology types
176
+ """
177
+ technologies = [tech.value for tech in Technology]
178
+
179
+ return {
180
+ "technologies": technologies,
181
+ "total": len(technologies),
182
+ "message": "Successfully retrieved available technologies",
183
+ "success": True,
184
+ }
backend/app/integrations/copilot_action/schema/copilot_action.py
new
+109
@@ -0,0 +1,109 @@
1
+from datetime import datetime
2
+from enum import Enum
3
+from typing import Any
4
+from typing import Dict
5
+from typing import List
6
+from typing import Optional
7
+from typing import Union
8
+
9
+from pydantic import BaseModel
10
+from pydantic import Field
11
+from pydantic import HttpUrl
12
+from pydantic import validator
13
+
14
+
15
+class Technology(str, Enum):
16
+ """Technology types for active response scripts"""
17
+
18
+ WAZUH = "Wazuh"
19
+ LINUX = "Linux"
20
+ WINDOWS = "Windows"
21
+ MACOS = "macOS"
22
+ NETWORK = "Network"
23
+ CLOUD = "Cloud"
24
+ VELOCIRAPTOR = "Velociraptor"
25
+
26
+
27
+class ScriptParameter(BaseModel):
28
+ """Parameters required for script execution"""
29
+
30
+ name: str
31
+ type: str
32
+ required: bool
33
+ description: Optional[str] = None
34
+ default: Optional[Union[str, int, float, bool, list, dict]] = None
35
+ enum: Optional[List[str]] = None
36
+
37
+ @validator("type")
38
+ def validate_type(cls, v):
39
+ allowed = {"string", "int", "float", "bool", "path", "enum", "list", "json", "integer", "boolean"}
40
+ if v not in allowed:
41
+ raise ValueError(f"type must be one of {sorted(allowed)}")
42
+ return v
43
+
44
+
45
+class ActiveResponseItem(BaseModel):
46
+ """Individual active response script item"""
47
+
48
+ copilot_action_name: str
49
+ description: str
50
+ technology: Technology
51
+ icon: Optional[str] = None
52
+ script_parameters: List[ScriptParameter] = Field(default_factory=list)
53
+ repo_url: HttpUrl
54
+ script_name: Optional[str] = None
55
+ version: Optional[str] = None
56
+ last_updated: Optional[datetime] = None
57
+ category: Optional[str] = None
58
+ tags: Optional[List[str]] = None
59
+
60
+ @validator("icon", always=True)
61
+ def set_icon_default(cls, v, values):
62
+ if v is None and "technology" in values:
63
+ return values["technology"].value.lower()
64
+ return v
65
+
66
+ @validator("repo_url")
67
+ def ensure_repo_url_ends_with_main(cls, v):
68
+ repo_str = str(v)
69
+ if not repo_str.endswith("/main"):
70
+ return HttpUrl(f"{repo_str}/main")
71
+ return v
72
+
73
+
74
+class InventoryQueryRequest(BaseModel):
75
+ """Request model for inventory queries"""
76
+
77
+ technology: Optional[Technology] = None
78
+ category: Optional[str] = None
79
+ tag: Optional[str] = None
80
+ q: Optional[str] = None # Free-text search
81
+ limit: int = Field(default=100, ge=1, le=1000)
82
+ offset: int = Field(default=0, ge=0)
83
+ refresh: bool = False
84
+ include: Optional[str] = None # Comma-separated extra fields
85
+
86
+
87
+class InventoryResponse(BaseModel):
88
+ """Response model for inventory queries"""
89
+
90
+ copilot_actions: List[ActiveResponseItem]
91
+ message: str
92
+ success: bool
93
+
94
+
95
+class ActionDetailResponse(BaseModel):
96
+ """Response model for single action details"""
97
+
98
+ active_response: ActiveResponseItem
99
+ message: str
100
+ success: bool
101
+
102
+
103
+class InventoryMetricsResponse(BaseModel):
104
+ """Response model for inventory metrics"""
105
+
106
+ status: str
107
+ metrics: Dict[str, Any]
108
+ message: str = "Successfully retrieved inventory metrics"
109
+ success: bool = True
backend/app/integrations/copilot_action/services/copilot_action.py
new
+186
@@ -0,0 +1,186 @@
1
+from typing import Optional
2
+
3
+import httpx
4
+from loguru import logger
5
+
6
+from app.integrations.copilot_action.schema.copilot_action import ActionDetailResponse
7
+from app.integrations.copilot_action.schema.copilot_action import (
8
+ InventoryMetricsResponse,
9
+)
10
+from app.integrations.copilot_action.schema.copilot_action import InventoryResponse
11
+from app.integrations.copilot_action.schema.copilot_action import Technology
12
+
13
+
14
+class CopilotActionService:
15
+ """Service for interacting with the Copilot Action inventory API"""
16
+
17
+ BASE_URL = "https://copilot-action.socfortress.co"
18
+ MODULE_VERSION = "1.0.0"
19
+
20
+ @classmethod
21
+ async def get_inventory(
22
+ cls,
23
+ license_key: str,
24
+ technology: Optional[Technology] = None,
25
+ category: Optional[str] = None,
26
+ tag: Optional[str] = None,
27
+ q: Optional[str] = None,
28
+ limit: int = 100,
29
+ offset: int = 0,
30
+ refresh: bool = False,
31
+ include: Optional[str] = None,
32
+ ) -> InventoryResponse:
33
+ """
34
+ Fetch inventory from the Copilot Action service.
35
+
36
+ Args:
37
+ license_key: API key for authentication
38
+ technology: Filter by technology type
39
+ category: Filter by category
40
+ tag: Filter by tag
41
+ q: Free-text search query
42
+ limit: Maximum number of results
43
+ offset: Offset for pagination
44
+ refresh: Force refresh cache
45
+ include: Comma-separated extra fields to include
46
+
47
+ Returns:
48
+ InventoryResponse: The inventory data
49
+ """
50
+ try:
51
+ url = f"{cls.BASE_URL}/inventory"
52
+
53
+ headers = {"x-api-key": license_key, "module-version": cls.MODULE_VERSION, "Accept": "application/json"}
54
+
55
+ params = {}
56
+ if technology:
57
+ params["technology"] = technology.value
58
+ if category:
59
+ params["category"] = category
60
+ if tag:
61
+ params["tag"] = tag
62
+ if q:
63
+ params["q"] = q
64
+ if limit != 100:
65
+ params["limit"] = limit
66
+ if offset != 0:
67
+ params["offset"] = offset
68
+ if refresh:
69
+ params["refresh"] = "true"
70
+ if include:
71
+ params["include"] = include
72
+
73
+ logger.info(f"Fetching inventory from {url} with params: {params}")
74
+
75
+ async with httpx.AsyncClient() as client:
76
+ response = await client.get(url, headers=headers, params=params, timeout=30.0)
77
+
78
+ response.raise_for_status()
79
+
80
+ try:
81
+ data = response.json()
82
+ except ValueError:
83
+ logger.error(f"Non-JSON response from inventory API: {response.text[:200]}")
84
+ return InventoryResponse(copilot_actions=[], message="Invalid response format from inventory API", success=False)
85
+
86
+ logger.info(f"Successfully fetched inventory: {len(data.get('copilot_actions', []))} actions")
87
+ return InventoryResponse(**data)
88
+
89
+ except httpx.HTTPError as e:
90
+ logger.error(f"HTTP error fetching inventory: {str(e)}")
91
+ return InventoryResponse(copilot_actions=[], message=f"HTTP error fetching inventory: {str(e)}", success=False)
92
+ except Exception as e:
93
+ logger.error(f"Unexpected error fetching inventory: {str(e)}")
94
+ return InventoryResponse(copilot_actions=[], message=f"Unexpected error: {str(e)}", success=False)
95
+
96
+ @classmethod
97
+ async def get_action_by_name(cls, license_key: str, copilot_action_name: str) -> ActionDetailResponse:
98
+ """
99
+ Fetch details for a specific action by name.
100
+
101
+ Args:
102
+ license_key: API key for authentication
103
+ copilot_action_name: Name of the action to fetch
104
+
105
+ Returns:
106
+ ActionDetailResponse: The action details
107
+ """
108
+ try:
109
+ url = f"{cls.BASE_URL}/inventory/{copilot_action_name}"
110
+
111
+ headers = {"x-api-key": license_key, "module-version": cls.MODULE_VERSION, "Accept": "application/json"}
112
+
113
+ logger.info(f"Fetching action details for: {copilot_action_name}")
114
+
115
+ async with httpx.AsyncClient() as client:
116
+ response = await client.get(url, headers=headers, timeout=30.0)
117
+
118
+ response.raise_for_status()
119
+
120
+ try:
121
+ data = response.json()
122
+ except ValueError:
123
+ logger.error(f"Non-JSON response from action API: {response.text[:200]}")
124
+ return ActionDetailResponse(active_response=None, message="Invalid response format from action API", success=False)
125
+
126
+ logger.info(f"Successfully fetched action details for: {copilot_action_name}")
127
+ return ActionDetailResponse(**data)
128
+
129
+ except httpx.HTTPStatusError as e:
130
+ if e.response.status_code == 404:
131
+ logger.warning(f"Action not found: {copilot_action_name}")
132
+ return ActionDetailResponse(active_response=None, message=f"Action '{copilot_action_name}' not found", success=False)
133
+ logger.error(f"HTTP error fetching action details: {str(e)}")
134
+ return ActionDetailResponse(active_response=None, message=f"HTTP error fetching action details: {str(e)}", success=False)
135
+ except Exception as e:
136
+ logger.error(f"Unexpected error fetching action details: {str(e)}")
137
+ return ActionDetailResponse(active_response=None, message=f"Unexpected error: {str(e)}", success=False)
138
+
139
+ @classmethod
140
+ async def get_metrics(cls, license_key: str) -> InventoryMetricsResponse:
141
+ """
142
+ Fetch inventory metrics.
143
+
144
+ Args:
145
+ license_key: API key for authentication
146
+
147
+ Returns:
148
+ InventoryMetricsResponse: The metrics data
149
+ """
150
+ try:
151
+ url = f"{cls.BASE_URL}/metrics"
152
+
153
+ headers = {"x-api-key": license_key, "module-version": cls.MODULE_VERSION, "Accept": "application/json"}
154
+
155
+ logger.info("Fetching inventory metrics")
156
+
157
+ async with httpx.AsyncClient() as client:
158
+ response = await client.get(url, headers=headers, timeout=30.0)
159
+
160
+ response.raise_for_status()
161
+
162
+ try:
163
+ data = response.json()
164
+ except ValueError:
165
+ logger.error(f"Non-JSON response from metrics API: {response.text[:200]}")
166
+ return InventoryMetricsResponse(
167
+ status="error",
168
+ metrics={},
169
+ message="Invalid response format from metrics API",
170
+ success=False,
171
+ )
172
+
173
+ logger.info("Successfully fetched inventory metrics")
174
+ return InventoryMetricsResponse(
175
+ status=data.get("status", "unknown"),
176
+ metrics=data.get("metrics", {}),
177
+ message="Successfully retrieved inventory metrics",
178
+ success=True,
179
+ )
180
+
181
+ except httpx.HTTPError as e:
182
+ logger.error(f"HTTP error fetching metrics: {str(e)}")
183
+ return InventoryMetricsResponse(status="error", metrics={}, message=f"HTTP error fetching metrics: {str(e)}", success=False)
184
+ except Exception as e:
185
+ logger.error(f"Unexpected error fetching metrics: {str(e)}")
186
+ return InventoryMetricsResponse(status="error", metrics={}, message=f"Unexpected error: {str(e)}", success=False)
backend/app/routers/copilot_action.py
new
+13
@@ -0,0 +1,13 @@
1
+from fastapi import APIRouter
2
+
3
+from app.integrations.copilot_action.routes.copilot_action import copilot_action_router
4
+
5
+# Instantiate the APIRouter
6
+router = APIRouter()
7
+
8
+# Include the Copilot Action related routes
9
+router.include_router(
10
+ copilot_action_router,
11
+ prefix="/copilot_action",
12
+ tags=["Copilot Action"],
13
+)
backend/copilot.py
+2
@@ -38,6 +38,7 @@ from app.routers import bitdefender
38
from app.routers import carbonblack
39
from app.routers import cato
40
from app.routers import connectors
41
+from app.routers import copilot_action
42
from app.routers import copilot_mcp
43
from app.routers import cortex
44
from app.routers import crowdstrike
@@ -145,6 +146,7 @@ api_router.include_router(threat_intel.router)
146
api_router.include_router(alert_creation_settings.router)
147
api_router.include_router(integrations.router)
148
api_router.include_router(office365.router)
149
+api_router.include_router(copilot_action.router)
150
api_router.include_router(copilot_mcp.router)
151
api_router.include_router(mimecast.router)
152
api_router.include_router(scheduler.router)