main
vue 607 lines 16.7 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-4">
3 <n-alert type="info">
4 CoPilot Searches provides pre-built detection queries for threat hunting in your Wazuh indexer. See
5 <a href="https://github.com/socfortress/CoPilot-Search-Queries" target="_blank">
6 https://github.com/socfortress/CoPilot-Search-Queries
7 </a>
8 for details.
9 </n-alert>
10
11 <div class="flex flex-col">
12 <div class="flex flex-wrap items-center justify-end gap-2">
13 <n-button-group size="small">
14 <n-button :type="viewMode === 'grid' ? 'primary' : 'default'" @click="viewMode = 'grid'">
15 <template #icon>
16 <Icon :name="GridIcon" />
17 </template>
18 Rules
19 </n-button>
20 <n-button :type="viewMode === 'matrix' ? 'primary' : 'default'" @click="viewMode = 'matrix'">
21 <template #icon>
22 <Icon :name="MatrixIcon" />
23 </template>
24 MITRE Navigator
25 </n-button>
26 </n-button-group>
27
28 <div v-if="viewMode === 'grid'" class="flex min-w-80 grow gap-2">
29 <n-popover overlap placement="bottom-start">
30 <template #trigger>
31 <div class="bg-default rounded-lg">
32 <n-button size="small" class="cursor-help!">
33 <template #icon>
34 <Icon :name="InfoIcon" />
35 </template>
36 </n-button>
37 </div>
38 </template>
39 <div class="flex flex-col gap-2">
40 <div class="box">
41 Total Rules:
42 <code>{{ pagination.total }}</code>
43 </div>
44 <div class="box">
45 Filtered:
46 <code>{{ pagination.filtered }}</code>
47 </div>
48 </div>
49 </n-popover>
50
51 <n-input
52 v-model:value="searchQuery"
53 size="small"
54 placeholder="Search rules..."
55 class="max-w-120"
56 clearable
57 >
58 <template #prefix>
59 <Icon :name="SearchIcon" />
60 </template>
61 </n-input>
62
63 <n-popover :show="showFilters" trigger="manual" overlap placement="right" class="px-0!">
64 <template #trigger>
65 <div class="bg-default rounded-lg">
66 <n-badge :show="filtered" dot type="success" :offset="[-4, 0]">
67 <n-button size="small" @click="showFilters = true">
68 <template #icon>
69 <Icon :name="FilterIcon" />
70 </template>
71 </n-button>
72 </n-badge>
73 </div>
74 </template>
75 <div class="divide-border flex w-50 flex-col gap-0 divide-y">
76 <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3">
77 <n-select
78 v-model:value="selectedPlatform"
79 :options="platformOptions"
80 size="small"
81 placeholder="Platform"
82 class="w-full"
83 clearable
84 :consistent-menu-width="false"
85 />
86
87 <n-select
88 v-model:value="selectedSeverity"
89 :options="severityOptions"
90 clearable
91 size="small"
92 placeholder="Severity"
93 class="w-full"
94 :consistent-menu-width="false"
95 />
96
97 <n-select
98 v-model:value="selectedStatus"
99 :options="statusOptions"
100 clearable
101 size="small"
102 placeholder="Status"
103 class="w-full"
104 :consistent-menu-width="false"
105 />
106
107 <n-checkbox v-model:checked="hasGraylogFilter" size="small">
108 <span class="text-xs">Graylog Only</span>
109 </n-checkbox>
110 </div>
111 <div class="flex justify-between gap-2 px-3 pt-2">
112 <div class="flex justify-start gap-2">
113 <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
114 </div>
115 <div class="flex justify-end gap-2">
116 <n-button size="small" secondary @click="resetFilters()">Reset</n-button>
117 </div>
118 </div>
119 </div>
120 </n-popover>
121 </div>
122
123 <n-button
124 v-if="viewMode === 'grid'"
125 size="small"
126 :type="selectMode ? 'primary' : 'default'"
127 @click="toggleSelectMode"
128 >
129 <template #icon>
130 <Icon :name="SelectIcon" />
131 </template>
132 {{ selectMode ? "Exit select" : "Select" }}
133 </n-button>
134
135 <n-button v-if="viewMode === 'grid'" size="small" :loading="refreshing" @click="handleRefresh">
136 <template #icon>
137 <Icon :name="RefreshIcon" />
138 </template>
139 Refresh Cache
140 </n-button>
141
142 <n-pagination
143 v-if="viewMode === 'grid'"
144 v-model:page="pagination.current"
145 :page-size="pagination.size"
146 :item-count="pagination.filtered"
147 :page-slot="5"
148 />
149 </div>
150
151 <n-spin v-if="viewMode === 'grid'" :show="loading">
152 <div class="my-3">
153 <div
154 v-if="list.length"
155 class="grid grid-cols-1 gap-4 @2xl:grid-cols-2 @5xl:grid-cols-3 @6xl:grid-cols-4"
156 >
157 <RuleCard
158 v-for="rule of list"
159 :key="rule.id"
160 :rule
161 :provisioned="provisionedMap[rule.id] === true"
162 :selectable="selectMode"
163 :selected="selection.has(rule.id)"
164 @update:selected="v => toggleRuleSelected(rule.id, v)"
165 />
166 </div>
167
168 <template v-else>
169 <n-empty v-if="!loading" description="No rules found" class="h-48 justify-center" />
170 </template>
171 </div>
172 </n-spin>
173
174 <MatrixView v-else class="my-3" />
175
176 <div v-if="viewMode === 'grid'" class="flex justify-end">
177 <n-pagination
178 v-if="list.length > 3"
179 v-model:page="pagination.current"
180 :page-size="pagination.size"
181 :item-count="pagination.filtered"
182 :page-slot="6"
183 />
184 </div>
185 </div>
186
187 <!-- Floating selection footer — only shown in grid view, when in select mode AND at least 1 rule selected. -->
188 <Transition name="fade-up">
189 <div v-if="viewMode === 'grid' && selectMode && selection.size > 0" class="selection-footer">
190 <div class="text-default text-sm">
191 <strong>{{ selection.size }}</strong>
192 selected
193 <span class="text-tertiary ml-2 text-xs">
194 ({{ provisionableSelectedCount }} with Graylog query)
195 </span>
196 </div>
197
198 <div class="ml-auto flex items-center gap-2">
199 <n-tooltip placement="top">
200 <template #trigger>
201 <n-button
202 size="small"
203 type="primary"
204 :disabled="provisionableSelectedCount === 0"
205 @click="openBulkProvisionModal"
206 >
207 <template #icon>
208 <Icon :name="ProvisionIcon" />
209 </template>
210 Provision selected
211 </n-button>
212 </template>
213 <template v-if="provisionableSelectedCount === 0">
214 None of the selected rules has a Graylog query.
215 </template>
216 <template v-else>
217 Provision {{ provisionableSelectedCount }} rule{{
218 provisionableSelectedCount === 1 ? "" : "s"
219 }}
220 as Graylog event definitions.
221 </template>
222 </n-tooltip>
223
224 <n-button size="small" @click="exportSelectedCsv">
225 <template #icon>
226 <Icon :name="ExportIcon" />
227 </template>
228 CSV
229 </n-button>
230 <n-button size="small" @click="exportSelectedJson">
231 <template #icon>
232 <Icon :name="ExportIcon" />
233 </template>
234 JSON
235 </n-button>
236
237 <n-button size="small" quaternary @click="clearSelection">Clear</n-button>
238 </div>
239 </div>
240 </Transition>
241
242 <BulkProvisionModal
243 v-model:show="showBulkProvisionModal"
244 :rule-ids="selectedRuleIdsWithGraylog"
245 :provisionable-count="provisionableSelectedCount"
246 @success="onBulkProvisionSuccess"
247 />
248 </div>
249 </template>
250
251 <script setup lang="ts">
252 import type {
253 BulkProvisionGraylogAlertResponse,
254 PlatformFilter,
255 RuleListQuery,
256 RuleSeverity,
257 RuleStatus,
258 RuleSummary
259 } from "@/types/copilotSearches.d"
260 import { watchDebounced } from "@vueuse/core"
261 import axios from "axios"
262 import {
263 NAlert,
264 NBadge,
265 NButton,
266 NButtonGroup,
267 NCheckbox,
268 NEmpty,
269 NInput,
270 NPagination,
271 NPopover,
272 NSelect,
273 NSpin,
274 NTooltip,
275 useMessage
276 } from "naive-ui"
277 import { computed, onMounted, ref, watch } from "vue"
278 import { useRoute, useRouter } from "vue-router"
279 import Api from "@/api"
280 import Icon from "@/components/common/Icon.vue"
281 import BulkProvisionModal from "./BulkProvisionModal.vue"
282 import MatrixView from "./MatrixView.vue"
283 import RuleCard from "./RuleCard.vue"
284
285 const loading = ref(false)
286 const refreshing = ref(false)
287 const message = useMessage()
288 const list = ref<RuleSummary[]>([])
289 const provisionedMap = ref<Record<string, boolean>>({})
290 const pagination = ref({
291 current: 1,
292 size: 24,
293 total: 0,
294 filtered: 0
295 })
296
297 const selectedPlatform = ref<PlatformFilter | null>(null)
298 const selectedSeverity = ref<RuleSeverity | null>(null)
299 const selectedStatus = ref<RuleStatus | null>(null)
300 const searchQuery = ref<string | null>(null)
301 const hasGraylogFilter = ref(false)
302 const showFilters = ref(false)
303
304 const filtered = computed<boolean>(() => {
305 return !!selectedPlatform.value || !!selectedSeverity.value || !!selectedStatus.value || !!hasGraylogFilter.value
306 })
307
308 const InfoIcon = "carbon:information"
309 const FilterIcon = "carbon:filter-edit"
310 const SearchIcon = "carbon:search"
311 const RefreshIcon = "carbon:renew"
312 const GridIcon = "carbon:grid"
313 const MatrixIcon = "carbon:chart-network"
314 const SelectIcon = "carbon:checkbox-checked"
315 const ProvisionIcon = "carbon:add-alt"
316 const ExportIcon = "carbon:download"
317
318 // Always defaults to the rule grid. Matrix view is only entered when the
319 // user clicks the toggle in this session, or follows a `?view=matrix` link.
320 // Intentionally NOT persisted — entering CoPilot Searches always lands on
321 // the familiar rule cards.
322 const viewMode = ref<"grid" | "matrix">("grid")
323
324 // Deep-linking: ?view=matrix overrides the default; toggling the view writes
325 // back to the URL so links are shareable. Lives in List.vue (not the parent
326 // view) so we don't have to touch routing config.
327 const route = useRoute()
328 const router = useRouter()
329
330 onMounted(() => {
331 const v = route.query.view
332 if (v === "matrix" || v === "grid") viewMode.value = v
333 })
334
335 watch(viewMode, v => {
336 if (route.query.view === v) return
337 const next = { ...route.query }
338 if (v === "grid") {
339 // Drop matrix-only deep-link params when leaving the matrix.
340 delete next.view
341 delete next.technique
342 delete next.sub
343 } else {
344 next.view = v
345 }
346 router.replace({ query: next })
347 })
348
349 const platformOptions = [
350 { label: "Linux", value: "linux" },
351 { label: "Windows", value: "windows" },
352 { label: "PowerShell", value: "powershell" },
353 { label: "CVE", value: "cve" }
354 ]
355
356 const severityOptions = [
357 { label: "Low", value: "low" },
358 { label: "Medium", value: "medium" },
359 { label: "High", value: "high" },
360 { label: "Critical", value: "critical" }
361 ]
362
363 const statusOptions = [
364 { label: "Production", value: "production" },
365 { label: "Experimental", value: "experimental" },
366 { label: "Deprecated", value: "deprecated" }
367 ]
368
369 let abortController: AbortController | null = null
370
371 function resetFilters() {
372 selectedPlatform.value = null
373 selectedSeverity.value = null
374 selectedStatus.value = null
375 hasGraylogFilter.value = false
376 showFilters.value = false
377 }
378
379 async function refreshProvisionedMap() {
380 const ids = list.value.map(r => r.id)
381 provisionedMap.value = {}
382 if (!ids.length) return
383 try {
384 const res = await Api.copilotSearches.checkGraylogProvisioningStatus(ids)
385 if (res.data?.success && !res.data.warning) {
386 provisionedMap.value = res.data.provisioned || {}
387 }
388 } catch {
389 // Silent — if Graylog is unreachable, just don't show the chip.
390 }
391 }
392
393 function getList() {
394 abortController?.abort()
395 abortController = new AbortController()
396
397 loading.value = true
398
399 const query: RuleListQuery = {
400 skip: (pagination.value.current - 1) * pagination.value.size,
401 limit: pagination.value.size,
402 platform: selectedPlatform.value || undefined,
403 severity: selectedSeverity.value || undefined,
404 status: selectedStatus.value || undefined,
405 search: searchQuery.value || undefined,
406 has_graylog: hasGraylogFilter.value ? true : undefined
407 }
408
409 Api.copilotSearches
410 .getRules(query, abortController.signal)
411 .then(res => {
412 loading.value = false
413
414 if (res.data.success) {
415 list.value = res.data?.rules || []
416 pagination.value.total = res.data?.total || 0
417 pagination.value.filtered = res.data?.filtered || 0
418 // Best-effort: also fetch which of these rules are already in
419 // Graylog so RuleCard can show the "in Graylog" chip. If it
420 // fails (e.g. Graylog unreachable), no chips, no error.
421 refreshProvisionedMap()
422 } else {
423 message.warning(res.data?.message || "An error occurred. Please try again later.")
424 }
425 })
426 .catch(err => {
427 if (!axios.isCancel(err)) {
428 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
429 loading.value = false
430 }
431 })
432 }
433
434 async function handleRefresh() {
435 refreshing.value = true
436 try {
437 const res = await Api.copilotSearches.refreshCache()
438 if (res.data.success) {
439 message.success(`Cache refreshed! Loaded ${res.data.rules_loaded} rules.`)
440 getList()
441 } else {
442 message.warning(res.data?.message || "Failed to refresh cache")
443 }
444 } catch (err: any) {
445 message.error(err.response?.data?.message || "Failed to refresh cache")
446 } finally {
447 refreshing.value = false
448 }
449 }
450
451 watchDebounced(
452 [selectedPlatform, selectedSeverity, selectedStatus, searchQuery, hasGraylogFilter, () => pagination.value.current],
453 getList,
454 {
455 deep: true,
456 debounce: 300,
457 immediate: true
458 }
459 )
460
461 // ---------------------------------------------------------------------------
462 // Multi-select bulk actions
463 // ---------------------------------------------------------------------------
464
465 const selectMode = ref(false)
466 const selection = ref<Set<string>>(new Set())
467 // Cache of full RuleSummary objects for selected IDs, so we can keep them
468 // available across pagination changes (the visible `list` only holds the
469 // current page).
470 const selectionCache = ref<Map<string, RuleSummary>>(new Map())
471
472 const showBulkProvisionModal = ref(false)
473
474 const provisionableSelectedCount = computed(
475 () => Array.from(selectionCache.value.values()).filter(r => r.has_graylog_query).length
476 )
477
478 const selectedRuleIdsWithGraylog = computed(() =>
479 Array.from(selectionCache.value.values())
480 .filter(r => r.has_graylog_query)
481 .map(r => r.id)
482 )
483
484 function toggleSelectMode() {
485 selectMode.value = !selectMode.value
486 if (!selectMode.value) clearSelection()
487 }
488
489 function toggleRuleSelected(ruleId: string, value: boolean) {
490 if (value) {
491 selection.value.add(ruleId)
492 const summary = list.value.find(r => r.id === ruleId)
493 if (summary) selectionCache.value.set(ruleId, summary)
494 } else {
495 selection.value.delete(ruleId)
496 selectionCache.value.delete(ruleId)
497 }
498 // Trigger reactivity (Set/Map mutations don't notify by themselves).
499 selection.value = new Set(selection.value)
500 selectionCache.value = new Map(selectionCache.value)
501 }
502
503 function clearSelection() {
504 selection.value = new Set()
505 selectionCache.value = new Map()
506 }
507
508 function openBulkProvisionModal() {
509 if (provisionableSelectedCount.value === 0) return
510 showBulkProvisionModal.value = true
511 }
512
513 function onBulkProvisionSuccess(res: BulkProvisionGraylogAlertResponse) {
514 // Reflect new "in Graylog" state on the visible list immediately.
515 const next = { ...provisionedMap.value }
516 for (const r of res.results) {
517 if (r.status === "provisioned" || r.status === "skipped") {
518 next[r.rule_id] = true
519 }
520 }
521 provisionedMap.value = next
522 }
523
524 function downloadBlob(blob: Blob, filename: string) {
525 const url = URL.createObjectURL(blob)
526 const link = document.createElement("a")
527 link.href = url
528 link.download = filename
529 document.body.appendChild(link)
530 link.click()
531 document.body.removeChild(link)
532 URL.revokeObjectURL(url)
533 }
534
535 function exportSelectedCsv() {
536 const rules = Array.from(selectionCache.value.values())
537 if (!rules.length) return
538 const header = [
539 "id",
540 "name",
541 "severity",
542 "platform",
543 "status",
544 "has_graylog_query",
545 "mitre_attack_id",
546 "description"
547 ]
548 const rows = rules.map(r => [
549 r.id,
550 r.name,
551 r.severity,
552 r.platform,
553 r.status,
554 String(r.has_graylog_query),
555 (r.mitre_attack_id || []).join("|"),
556 (r.description || "").replace(/\s+/g, " ")
557 ])
558 const csv = [header, ...rows]
559 .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(","))
560 .join("\n")
561 const stamp = new Date().toISOString().slice(0, 10)
562 downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8;" }), `copilot-searches-selected-${stamp}.csv`)
563 }
564
565 function exportSelectedJson() {
566 const rules = Array.from(selectionCache.value.values())
567 if (!rules.length) return
568 const json = JSON.stringify(rules, null, 2)
569 const stamp = new Date().toISOString().slice(0, 10)
570 downloadBlob(
571 new Blob([json], { type: "application/json;charset=utf-8;" }),
572 `copilot-searches-selected-${stamp}.json`
573 )
574 }
575 </script>
576
577 <style scoped lang="scss">
578 .selection-footer {
579 position: fixed;
580 bottom: 16px;
581 left: 50%;
582 transform: translateX(-50%);
583 z-index: 50;
584 display: flex;
585 align-items: center;
586 gap: 16px;
587 min-width: min(680px, 92vw);
588 max-width: 92vw;
589 padding: 10px 16px;
590 background: var(--bg-secondary-color);
591 border: 1px solid var(--border-color);
592 border-radius: var(--border-radius);
593 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
594 }
595
596 .fade-up-enter-active,
597 .fade-up-leave-active {
598 transition:
599 opacity 0.2s ease,
600 transform 0.2s ease;
601 }
602 .fade-up-enter-from,
603 .fade-up-leave-to {
604 opacity: 0;
605 transform: translate(-50%, 12px);
606 }
607 </style>