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 {