@cryptotaxi247 / CoPilot / commits / f639cb2f

574 velociraptor params (#575)

* feat: include parameters in artifact definitions query * feat: add parameter validation for artifact collection * feat: add artifact retrieval by name and enhance parameter handling in collection * feat: enhance artifact parameters display and interaction in collection * feat: add backslash escaping for Windows paths in parameter defaults * precommit fixes * precommit-fixes * feat: update current version to 0.1.15

taylor_socfortress committed Dec 29, 2025 at 11:23 UTC f639cb2fdafa436bff9669221f03b1272427206b
6 files changed +415 -125
backend/app/connectors/velociraptor/routes/artifacts.py
+7
@@ -33,6 +33,7 @@ from app.connectors.velociraptor.services.artifacts import quarantine_host
33 from app.connectors.velociraptor.services.artifacts import run_artifact_collection
34 from app.connectors.velociraptor.services.artifacts import run_file_collection
35 from app.connectors.velociraptor.services.artifacts import run_remote_command
36 +from app.connectors.velociraptor.services.artifacts import validate_artifact_parameters
37 from app.db.db_session import get_db
38 from app.db.universal_models import Agents
39
@@ -452,6 +453,12 @@ async def collect_artifact(
453 detail=f"Artifact name {collect_artifact_body.artifact_name} does not apply for hostname {collect_artifact_body.hostname} or does not exist",
454 )
455
456 + # Validate parameters if provided
457 + await validate_artifact_parameters(
458 + collect_artifact_body.artifact_name,
459 + collect_artifact_body.parameters,
460 + )
461 +
462 collect_artifact_body.velociraptor_id = await get_velociraptor_id(
463 session,
464 collect_artifact_body.hostname,
backend/app/connectors/velociraptor/services/artifacts.py
+77 -1
@@ -1,5 +1,8 @@
1 from datetime import datetime
2 +from typing import Dict
3 +from typing import List
4 from typing import Optional
5 +from typing import Union
6
7 import httpx
8 from fastapi import HTTPException
@@ -14,6 +17,7 @@ from app.connectors.velociraptor.schema.artifacts import ArtifactsResponse
17 from app.connectors.velociraptor.schema.artifacts import CollectArtifactBody
18 from app.connectors.velociraptor.schema.artifacts import CollectArtifactResponse
19 from app.connectors.velociraptor.schema.artifacts import CollectFileBody
20 +from app.connectors.velociraptor.schema.artifacts import ParameterKeyValue
21 from app.connectors.velociraptor.schema.artifacts import QuarantineBody
22 from app.connectors.velociraptor.schema.artifacts import QuarantineResponse
23 from app.connectors.velociraptor.schema.artifacts import RunCommandBody
@@ -88,7 +92,7 @@ async def get_artifacts() -> ArtifactsResponse:
92 """
93 logger.info("Fetching artifacts from Velociraptor")
94 velociraptor_service = await UniversalService.create("Velociraptor")
91 - query = create_query("SELECT name,description FROM artifact_definitions()")
95 + query = create_query("SELECT name,description,parameters FROM artifact_definitions()")
96 all_artifacts = velociraptor_service.execute_query(query)
97 try:
98 if all_artifacts["success"]:
@@ -156,6 +160,78 @@ async def get_artifact_by_name(artifact_name: str) -> ArtifactsResponse:
160 )
161
162
163 +async def validate_artifact_parameters(
164 + artifact_name: str,
165 + provided_parameters: Optional[Dict[str, Union[str, List[ParameterKeyValue]]]],
166 +) -> None:
167 + """
168 + Validates that the provided parameters match the artifact's expected parameters.
169 +
170 + Args:
171 + artifact_name (str): The name of the artifact to validate against.
172 + provided_parameters (Optional[Dict]): The parameters provided in the request.
173 +
174 + Raises:
175 + HTTPException: If any provided parameter is not valid for the artifact.
176 + """
177 + if not provided_parameters:
178 + return # No parameters to validate
179 +
180 + # Fetch the artifact details
181 + artifact_response = await get_artifact_by_name(artifact_name)
182 +
183 + if not artifact_response.artifacts or len(artifact_response.artifacts) == 0:
184 + raise HTTPException(
185 + status_code=404,
186 + detail=f"Artifact {artifact_name} not found",
187 + )
188 +
189 + artifact = artifact_response.artifacts[0]
190 +
191 + # If the artifact has no parameters defined, reject any provided parameters
192 + if not artifact.parameters:
193 + raise HTTPException(
194 + status_code=400,
195 + detail=f"Artifact {artifact_name} does not accept any parameters",
196 + )
197 +
198 + # Get valid parameter names from the artifact
199 + valid_param_names = {param.name for param in artifact.parameters}
200 +
201 + logger.info(f"Valid parameters for artifact {artifact_name}: {valid_param_names}")
202 + logger.info(f"Provided parameters for artifact {artifact_name}: {provided_parameters}")
203 +
204 + # Check if provided parameters are valid
205 + # Handle both direct key-value pairs and the 'env' list format
206 + if isinstance(provided_parameters, dict):
207 + if "env" in provided_parameters and isinstance(provided_parameters["env"], list):
208 + # Handle env list format
209 + for param_pair in provided_parameters["env"]:
210 + # Check if it's a ParameterKeyValue model or a dict
211 + if isinstance(param_pair, ParameterKeyValue):
212 + param_name = param_pair.key
213 + elif isinstance(param_pair, dict) and "key" in param_pair:
214 + param_name = param_pair["key"]
215 + else:
216 + continue
217 +
218 + if param_name not in valid_param_names:
219 + raise HTTPException(
220 + status_code=400,
221 + detail=f"Parameter '{param_name}' is not valid for artifact {artifact_name}. Valid parameters: {', '.join(sorted(valid_param_names))}",
222 + )
223 + else:
224 + # Handle direct key-value format
225 + for param_name in provided_parameters.keys():
226 + if param_name not in valid_param_names:
227 + raise HTTPException(
228 + status_code=400,
229 + detail=f"Parameter '{param_name}' is not valid for artifact {artifact_name}. Valid parameters: {', '.join(sorted(valid_param_names))}",
230 + )
231 +
232 + logger.info(f"All provided parameters are valid for artifact {artifact_name}")
233 +
234 +
235 async def get_artifact_parameters_by_prefix_service(
236 artifact_name: str,
237 parameter_prefix: str,
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.14"
10 +CURRENT_VERSION = "0.1.15"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/artifacts.ts
+3
@@ -65,6 +65,9 @@ export default {
65
66 return HttpClient.get<FlaskBaseResponse & { artifacts: Artifact[] }>(url)
67 },
68 + getByName(artifactName: string) {
69 + return HttpClient.get<FlaskBaseResponse & { artifacts: Artifact[] }>(`/artifacts/artifact/${artifactName}`)
70 + },
71 collect(payload: CollectRequest) {
72 return HttpClient.post<FlaskBaseResponse & { results: CollectResult[] }>(`/artifacts/collect`, payload)
73 },
frontend/src/components/artifacts/ArtifactsCollect.vue
+317 -123
@@ -43,6 +43,7 @@
43 filterable
44 size="small"
45 :loading="loadingArtifacts"
46 + @update:value="onArtifactSelect"
47 />
48 </div>
49 <div v-if="!hideVelociraptorIdField" class="grow basis-56">
@@ -68,6 +69,71 @@
69 </div>
70 </div>
71 </div>
72 +
73 + <!-- Parameters Section -->
74 + <div v-if="selectedArtifactParameters.length" class="parameters-section my-4">
75 + <n-card title="Artifact Parameters" size="small">
76 + <template #header-extra>
77 + <n-tag size="small" type="info">
78 + {{ selectedArtifactParameters.length }} parameter{{ selectedArtifactParameters.length !== 1 ? 's' : '' }}
79 + </n-tag>
80 + </template>
81 + <n-spin :show="loadingParameters">
82 + <n-scrollbar style="max-height: 400px">
83 + <div class="parameters-grid">
84 + <div v-for="param in selectedArtifactParameters" :key="param.name" class="parameter-field">
85 + <div class="parameter-header">
86 + <div class="flex items-center gap-2">
87 + <span class="parameter-name">{{ param.name }}</span>
88 + <n-tag v-if="param.type" size="tiny" :bordered="false">
89 + {{ param.type }}
90 + </n-tag>
91 + </div>
92 + <n-popover v-if="param.description" trigger="hover" placement="top">
93 + <template #trigger>
94 + <Icon :name="InfoIcon" :size="16" class="cursor-help text-gray-400 hover:text-gray-600" />
95 + </template>
96 + <div class="parameter-tooltip">
97 + <div class="font-medium mb-2">{{ param.name }}</div>
98 + <div class="text-sm">{{ param.description }}</div>
99 + <div v-if="param.default" class="text-xs mt-2 opacity-70">
100 + Default: <code class="bg-gray-100 px-1 rounded">{{ param.default }}</code>
101 + </div>
102 + </div>
103 + </n-popover>
104 + </div>
105 + <n-input
106 + v-model:value="parameterValues[param.name]"
107 + :placeholder="param.default?.toString() || 'Enter value...'"
108 + size="small"
109 + clearable
110 + class="mt-2"
111 + >
112 + <template v-if="param.default" #suffix>
113 + <n-tooltip trigger="hover" placement="top">
114 + <template #trigger>
115 + <n-button
116 + text
117 + size="tiny"
118 + @click="parameterValues[param.name] = escapeBackslashes(param.default?.toString() || '')"
119 + >
120 + <Icon name="carbon:reset" :size="14" />
121 + </n-button>
122 + </template>
123 + Reset to default
124 + </n-tooltip>
125 + </template>
126 + </n-input>
127 + <div v-if="param.description" class="parameter-description">
128 + {{ param.description }}
129 + </div>
130 + </div>
131 + </div>
132 + </n-scrollbar>
133 + </n-spin>
134 + </n-card>
135 + </div>
136 +
137 <n-spin :show="loading">
138 <div class="my-7 flex min-h-52 flex-col gap-3">
139 <template v-if="collectList.length">
@@ -90,8 +156,8 @@
156 <script setup lang="ts">
157 import type { ArtifactsQuery, CollectRequest } from "@/api/endpoints/artifacts"
158 import type { Agent } from "@/types/agents.d"
93 -import type { Artifact, CollectResult } from "@/types/artifacts.d"
94 -import { NButton, NEmpty, NInput, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
159 +import type { Artifact, ArtifactParameter, CollectResult } from "@/types/artifacts.d"
160 +import { NButton, NCard, NEmpty, NInput, NPopover, NScrollbar, NSelect, NSpin, NTag, NTooltip, useMessage } from "naive-ui"
161 import { nanoid } from "nanoid"
162 import { computed, nextTick, onBeforeMount, ref, toRefs } from "vue"
163 import Api from "@/api"
@@ -99,168 +165,296 @@ import Icon from "@/components/common/Icon.vue"
165 import CollectItem from "./CollectItem.vue"
166
167 const props = defineProps<{
102 - hostname?: string
103 - velociraptorId?: string
104 - agents?: Agent[]
105 - artifacts?: Artifact[]
106 - artifactsFilter?: ArtifactsQuery
107 - hideHostnameField?: boolean
108 - hideVelociraptorIdField?: boolean
168 + hostname?: string
169 + velociraptorId?: string
170 + agents?: Agent[]
171 + artifacts?: Artifact[]
172 + artifactsFilter?: ArtifactsQuery
173 + hideHostnameField?: boolean
174 + hideVelociraptorIdField?: boolean
175 }>()
176
177 const emit = defineEmits<{
112 - (e: "loaded-agents", value: Agent[]): void
113 - (e: "loaded-artifacts", value: Artifact[]): void
178 + (e: "loaded-agents", value: Agent[]): void
179 + (e: "loaded-artifacts", value: Artifact[]): void
180 }>()
181
182 const { hostname, velociraptorId, agents, artifacts, artifactsFilter, hideHostnameField, hideVelociraptorIdField } =
117 - toRefs(props)
183 + toRefs(props)
184
185 const message = useMessage()
186 const loadingAgents = ref(false)
187 const loadingArtifacts = ref(false)
188 +const loadingParameters = ref(false)
189 const loading = ref(false)
190 const agentsList = ref<Agent[]>([])
191 const artifactsList = ref<Artifact[]>([])
192 const collectList = ref<CollectResult[]>([])
193 const isDirty = ref(false)
194 +const selectedArtifactParameters = ref<ArtifactParameter[]>([])
195 +const parameterValues = ref<Record<string, string>>({})
196
197 const InfoIcon = "carbon:information"
198
199 const total = computed<number>(() => {
131 - return collectList.value.length || 0
200 + return collectList.value.length || 0
201 })
202
203 const filters = ref<Partial<CollectRequest>>({})
204
205 const areFiltersValid = computed(() => {
137 - return !!filters.value.artifact_name && !!filters.value.hostname
206 + return !!filters.value.artifact_name && !!filters.value.hostname
207 })
208
209 const agentHostnameOptions = computed(() => {
141 - if (hostname?.value) {
142 - return [{ value: hostname.value, label: hostname.value }]
143 - }
144 - return (agentsList.value || []).map(o => ({ value: o.hostname, label: o.hostname }))
210 + if (hostname?.value) {
211 + return [{ value: hostname.value, label: hostname.value }]
212 + }
213 + return (agentsList.value || []).map(o => ({ value: o.hostname, label: o.hostname }))
214 })
215
216 const artifactsOptions = computed(() => {
148 - return (artifactsList.value || []).map(o => ({ value: o.name, label: o.name }))
217 + return (artifactsList.value || []).map(o => ({ value: o.name, label: o.name }))
218 })
219
220 +// Function to escape backslashes in Windows paths
221 +function escapeBackslashes(value: string): string {
222 + // Only escape if it looks like a Windows path (contains backslashes)
223 + if (value && typeof value === 'string' && value.includes('\\')) {
224 + // Replace single backslashes with double backslashes
225 + return value.replace(/\\/g, '\\\\')
226 + }
227 + return value
228 +}
229 +
230 +async function onArtifactSelect(artifactName: string | null) {
231 + selectedArtifactParameters.value = []
232 + parameterValues.value = {}
233 +
234 + if (!artifactName) {
235 + return
236 + }
237 +
238 + loadingParameters.value = true
239 +
240 + try {
241 + const res = await Api.artifacts.getByName(artifactName)
242 +
243 + if (res.data.success && res.data.artifacts?.length) {
244 + const artifact = res.data.artifacts[0]
245 +
246 + if (artifact.parameters?.length) {
247 + selectedArtifactParameters.value = artifact.parameters
248 +
249 + // Initialize parameter values with defaults (with escaped backslashes)
250 + artifact.parameters.forEach(param => {
251 + if (param.default !== undefined && param.default !== null && param.default !== "") {
252 + const defaultValue = param.default.toString()
253 + parameterValues.value[param.name] = escapeBackslashes(defaultValue)
254 + }
255 + })
256 + }
257 + }
258 + } catch (err: any) {
259 + message.error(err.response?.data?.message || "Failed to load artifact parameters")
260 + } finally {
261 + loadingParameters.value = false
262 + }
263 +}
264 +
265 function getData() {
152 - if (areFiltersValid.value) {
153 - loading.value = true
154 -
155 - Api.artifacts
156 - .collect(filters.value as CollectRequest)
157 - .then(res => {
158 - if (res.data.success) {
159 - isDirty.value = true
160 -
161 - collectList.value = (res.data?.results || []).map(o => {
162 - o.___id = nanoid()
163 - return o
164 - })
165 - } else {
166 - message.warning(res.data?.message || "An error occurred. Please try again later.")
167 - }
168 - })
169 - .catch(err => {
170 - collectList.value = []
171 -
172 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
173 - })
174 - .finally(() => {
175 - loading.value = false
176 - })
177 - }
266 + if (areFiltersValid.value) {
267 + loading.value = true
268 +
269 + // Build parameters object if any values are set
270 + const parameters: CollectRequest["parameters"] = {
271 + env: []
272 + }
273 +
274 + Object.entries(parameterValues.value).forEach(([key, value]) => {
275 + if (value !== undefined && value !== null && value !== "") {
276 + parameters.env!.push({ key, value })
277 + }
278 + })
279 +
280 + const payload: CollectRequest = {
281 + ...filters.value,
282 + hostname: filters.value.hostname!,
283 + artifact_name: filters.value.artifact_name!
284 + }
285 +
286 + // Only add parameters if there are any
287 + if (parameters.env!.length > 0) {
288 + payload.parameters = parameters
289 + }
290 +
291 + Api.artifacts
292 + .collect(payload)
293 + .then(res => {
294 + if (res.data.success) {
295 + isDirty.value = true
296 +
297 + collectList.value = (res.data?.results || []).map(o => {
298 + o.___id = nanoid()
299 + return o
300 + })
301 + } else {
302 + message.warning(res.data?.message || "An error occurred. Please try again later.")
303 + }
304 + })
305 + .catch(err => {
306 + collectList.value = []
307 +
308 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
309 + })
310 + .finally(() => {
311 + loading.value = false
312 + })
313 + }
314 }
315
316 function getAgents(cb?: (agents: Agent[]) => void) {
181 - loadingAgents.value = true
182 -
183 - Api.agents
184 - .getAgents()
185 - .then(res => {
186 - if (res.data.success) {
187 - agentsList.value = res.data.agents || []
188 -
189 - if (cb && typeof cb === "function") {
190 - cb(agentsList.value)
191 - }
192 - } else {
193 - message.error(res.data?.message || "An error occurred. Please try again later.")
194 - }
195 - })
196 - .catch(err => {
197 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
198 - })
199 - .finally(() => {
200 - loadingAgents.value = false
201 - })
317 + loadingAgents.value = true
318 +
319 + Api.agents
320 + .getAgents()
321 + .then(res => {
322 + if (res.data.success) {
323 + agentsList.value = res.data.agents || []
324 +
325 + if (cb && typeof cb === "function") {
326 + cb(agentsList.value)
327 + }
328 + } else {
329 + message.error(res.data?.message || "An error occurred. Please try again later.")
330 + }
331 + })
332 + .catch(err => {
333 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
334 + })
335 + .finally(() => {
336 + loadingAgents.value = false
337 + })
338 }
339
340 function getArtifacts(cb?: (artifacts: Artifact[]) => void) {
205 - loadingArtifacts.value = true
206 -
207 - Api.artifacts
208 - .getAll(artifactsFilter.value)
209 - .then(res => {
210 - if (res.data.success) {
211 - artifactsList.value = res.data.artifacts || []
212 -
213 - if (cb && typeof cb === "function") {
214 - cb(artifactsList.value)
215 - }
216 - } else {
217 - message.error(res.data?.message || "An error occurred. Please try again later.")
218 - }
219 - })
220 - .catch(err => {
221 - message.error(err.response?.data?.message || "An error occurred. Please try again later.")
222 - })
223 - .finally(() => {
224 - loadingArtifacts.value = false
225 - })
341 + loadingArtifacts.value = true
342 +
343 + Api.artifacts
344 + .getAll(artifactsFilter.value)
345 + .then(res => {
346 + if (res.data.success) {
347 + artifactsList.value = res.data.artifacts || []
348 +
349 + if (cb && typeof cb === "function") {
350 + cb(artifactsList.value)
351 + }
352 + } else {
353 + message.error(res.data?.message || "An error occurred. Please try again later.")
354 + }
355 + })
356 + .catch(err => {
357 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
358 + })
359 + .finally(() => {
360 + loadingArtifacts.value = false
361 + })
362 }
363
364 onBeforeMount(() => {
229 - if (hostname?.value) {
230 - filters.value.hostname = hostname.value
231 - }
232 -
233 - if (velociraptorId?.value) {
234 - filters.value.velociraptor_id = velociraptorId.value
235 - }
236 -
237 - if (agents?.value?.length && !agentsList.value.length) {
238 - agentsList.value = agents.value
239 - }
240 -
241 - if (artifacts?.value?.length && !artifactsList.value.length) {
242 - artifactsList.value = artifacts.value
243 - }
244 -
245 - nextTick(() => {
246 - if (!agentsList.value.length && !hostname?.value) {
247 - getAgents((agents: Agent[]) => {
248 - emit("loaded-agents", agents)
249 - })
250 - }
251 - if (!artifactsList.value.length) {
252 - getArtifacts((artifacts: Artifact[]) => {
253 - emit("loaded-artifacts", artifacts)
254 - })
255 - }
256 - })
257 -
258 - // MOCK
259 - /*
260 - collectList.value = collectResult.map(o => {
261 - o.___id = nanoid()
262 - return o
263 - })
264 - */
365 + if (hostname?.value) {
366 + filters.value.hostname = hostname.value
367 + }
368 +
369 + if (velociraptorId?.value) {
370 + filters.value.velociraptor_id = velociraptorId.value
371 + }
372 +
373 + if (agents?.value?.length && !agentsList.value.length) {
374 + agentsList.value = agents.value
375 + }
376 +
377 + if (artifacts?.value?.length && !artifactsList.value.length) {
378 + artifactsList.value = artifacts.value
379 + }
380 +
381 + nextTick(() => {
382 + if (!agentsList.value.length && !hostname?.value) {
383 + getAgents((agents: Agent[]) => {
384 + emit("loaded-agents", agents)
385 + })
386 + }
387 + if (!artifactsList.value.length) {
388 + getArtifacts((artifacts: Artifact[]) => {
389 + emit("loaded-artifacts", artifacts)
390 + })
391 + }
392 + })
393 })
394 </script>
395 +
396 +<style scoped>
397 +.parameters-grid {
398 + display: grid;
399 + gap: 1rem;
400 + padding: 0.5rem;
401 +}
402 +
403 +.parameter-field {
404 + background-color: #f9fafb;
405 + border: 1px solid #e5e7eb;
406 + border-radius: 8px;
407 + padding: 1rem;
408 + transition: all 0.2s ease;
409 +}
410 +
411 +.parameter-field:hover {
412 + border-color: #d1d5db;
413 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
414 +}
415 +
416 +.parameter-header {
417 + display: flex;
418 + align-items: center;
419 + justify-content: space-between;
420 + margin-bottom: 0.5rem;
421 +}
422 +
423 +.parameter-name {
424 + font-weight: 600;
425 + font-size: 0.875rem;
426 + color: #374151;
427 +}
428 +
429 +.parameter-description {
430 + font-size: 0.75rem;
431 + color: #6b7280;
432 + margin-top: 0.5rem;
433 + line-height: 1.4;
434 +}
435 +
436 +.parameter-tooltip {
437 + max-width: 400px;
438 +}
439 +
440 +/* Dark mode support */
441 +@media (prefers-color-scheme: dark) {
442 + .parameter-field {
443 + background-color: #1f2937;
444 + border-color: #374151;
445 + }
446 +
447 + .parameter-field:hover {
448 + border-color: #4b5563;
449 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
450 + }
451 +
452 + .parameter-name {
453 + color: #f3f4f6;
454 + }
455 +
456 + .parameter-description {
457 + color: #9ca3af;
458 + }
459 +}
460 +</style>
frontend/src/types/artifacts.d.ts
+10
@@ -1,6 +1,16 @@
1 export interface Artifact {
2 description: string
3 name: string
4 + author?: string | null
5 + precondition?: string | null
6 + parameters?: ArtifactParameter[]
7 +}
8 +
9 +export interface ArtifactParameter {
10 + name: string
11 + description?: string
12 + type?: string
13 + default?: string | boolean | null
14 }
15
16 export interface CollectResult {