main
vue 467 lines 12.3 KB
Raw
1 <template>
2 <div class="flex flex-col gap-4">
3 <div class="flex flex-wrap items-end justify-between gap-3">
4 <div class="flex min-w-110 flex-1 flex-col gap-1">
5 <h3 class="text-lg font-semibold">Wazuh Rules</h3>
6 <p class="text-secondary text-sm">
7 Every rule shipped by the Wazuh Manager. Sort by hits to spot noisy rules, switch to "Dead" to find
8 rules that never fire, or filter by customer to see the picture for a specific tenant.
9 </p>
10 </div>
11 <n-button secondary type="primary" size="small" @click="showTestLogLineDrawer = true">
12 <template #icon><Icon name="carbon:test-tool" /></template>
13 Test a log line
14 </n-button>
15 </div>
16
17 <div class="flex flex-wrap items-center gap-2">
18 <n-input
19 v-model:value="filter"
20 size="small"
21 placeholder="Filter by ID, description, group, MITRE ID, or filename…"
22 clearable
23 class="min-w-80 flex-1"
24 >
25 <template #prefix><Icon name="carbon:search" /></template>
26 </n-input>
27
28 <n-select
29 v-if="firingStatsAvailable"
30 v-model:value="customerScope"
31 clearable
32 :options="customerOptions"
33 :loading="loadingCustomers || refetchingForCustomer"
34 size="small"
35 class="min-w-80 flex-1"
36 :consistent-menu-width="false"
37 @update:value="onCustomerChange"
38 />
39
40 <Badge type="splitted" color="primary" class="shrink-0">
41 <template #label>Showing</template>
42 <template #value>{{ filteredRules.length }} / {{ rules.length }}</template>
43 </Badge>
44 </div>
45
46 <!-- QUICK FILTER CHIPS - segmented style with hit-count summaries -->
47 <div v-if="firingStatsAvailable" class="flex flex-wrap gap-2">
48 <n-tag
49 :type="activeChip === 'all' ? 'primary' : 'default'"
50 round
51 :bordered="activeChip !== 'all'"
52 class="cursor-pointer!"
53 @click="activeChip = 'all'"
54 >
55 <div class="flex items-center gap-2 text-xs">
56 <Icon name="carbon:list" :size="13" />
57 <span>All</span>
58 <span
59 class="text-secondary font-mono text-[11px] font-semibold"
60 :class="{ 'text-primary!': activeChip === 'all' }"
61 >
62 {{ rules.length }}
63 </span>
64 </div>
65 </n-tag>
66 <n-tag
67 :type="activeChip === 'noisy' ? 'warning' : 'default'"
68 round
69 :bordered="activeChip !== 'noisy'"
70 class="cursor-pointer!"
71 @click="activeChip = 'noisy'"
72 >
73 <div class="flex items-center gap-2 text-xs">
74 <Icon name="carbon:flash" :size="13" />
75 <span>Top noisy</span>
76 <span
77 class="text-secondary font-mono text-[11px] font-semibold"
78 :class="{ 'text-warning!': activeChip === 'noisy' }"
79 >
80 50
81 </span>
82 </div>
83 </n-tag>
84 <n-tag
85 :type="activeChip === 'dead' ? 'error' : 'default'"
86 round
87 :bordered="activeChip !== 'dead'"
88 class="cursor-pointer!"
89 @click="activeChip = 'dead'"
90 >
91 <div class="flex items-center gap-2 text-xs">
92 <Icon name="carbon:warning" :size="13" />
93 <span>Dead (level ≥7)</span>
94 <span
95 class="text-secondary font-mono text-[11px] font-semibold"
96 :class="{ 'text-error!': activeChip === 'dead' }"
97 >
98 {{ deadCount }}
99 </span>
100 </div>
101 </n-tag>
102 </div>
103
104 <!-- Unavailable state: Wazuh Manager not reachable / not configured. -->
105 <n-alert v-if="!loading && !available" type="warning" show-icon>
106 <template #header>Wazuh Manager not available</template>
107 {{ unavailableReason || "Could not reach the Wazuh Manager to load rules." }}
108 </n-alert>
109
110 <n-data-table
111 v-else
112 :columns
113 :data="filteredRules"
114 :loading
115 size="small"
116 :row-props
117 :scroll-x="1400"
118 :pagination
119 class="catalog-table wazuh-rules-table"
120 />
121
122 <!-- Detail modal -->
123 <n-modal
124 v-model:show="showDetailModal"
125 preset="card"
126 :style="{ maxWidth: 'min(880px, 94vw)', minHeight: 'min(600px, 90vh)' }"
127 :title="modalTitle"
128 :bordered="false"
129 segmented
130 >
131 <WazuhRuleDetail v-if="modalRuleId !== null" :rule-id="modalRuleId" />
132 </n-modal>
133
134 <n-drawer
135 v-model:show="showTestLogLineDrawer"
136 :width="700"
137 class="max-w-[95vw]"
138 placement="right"
139 display-directive="show"
140 >
141 <n-drawer-content closable :native-scrollbar="false">
142 <template #header>Test a log line</template>
143 <WazuhLogTest @open-rule="openRuleById" />
144 </n-drawer-content>
145 </n-drawer>
146 </div>
147 </template>
148
149 <script setup lang="tsx">
150 import type { DataTableColumns, SelectOption, TagProps } from "naive-ui"
151 import type { CatalogWazuhRuleRow } from "@/types/detectionCatalog.d"
152 import {
153 NAlert,
154 NButton,
155 NDataTable,
156 NDrawer,
157 NDrawerContent,
158 NInput,
159 NModal,
160 NSelect,
161 NTag,
162 useMessage
163 } from "naive-ui"
164 import { computed, onBeforeMount, ref } from "vue"
165 import Api from "@/api"
166 import Badge from "@/components/common/Badge.vue"
167 import Icon from "@/components/common/Icon.vue"
168 import WazuhLogTest from "./WazuhLogTest.vue"
169 import WazuhRuleDetail from "./WazuhRuleDetail.vue"
170
171 const message = useMessage()
172 const rules = ref<CatalogWazuhRuleRow[]>([])
173 const loading = ref(false)
174 const filter = ref("")
175
176 const available = ref(true)
177 const unavailableReason = ref<string | null>(null)
178 const firingStatsAvailable = ref(true)
179
180 type ChipKey = "all" | "noisy" | "dead"
181 const activeChip = ref<ChipKey>("all")
182
183 const customerScope = ref<string>("")
184 const customerOptions = ref<SelectOption[]>([{ label: "All customers", value: "" }])
185 const loadingCustomers = ref(false)
186 const refetchingForCustomer = ref(false)
187
188 const showTestLogLineDrawer = ref(false)
189 const showDetailModal = ref(false)
190 const modalRuleId = ref<number | null>(null)
191 const modalTitle = ref("Wazuh Rule")
192
193 const pagination = {
194 pageSize: 50,
195 pageSizes: [25, 50, 100, 200],
196 showSizePicker: true
197 }
198
199 // Count of "dead" rules for the chip badge — keeps the analyst informed of
200 // how many candidates the filter would surface before clicking.
201 const deadCount = computed(() => rules.value.filter(r => r.hits_30d === 0 && (r.level ?? 0) >= 7).length)
202
203 const filteredRules = computed<CatalogWazuhRuleRow[]>(() => {
204 const q = filter.value.trim().toLowerCase()
205 const textFiltered = !q
206 ? rules.value
207 : rules.value.filter(r =>
208 [
209 String(r.id ?? ""),
210 r.description,
211 r.filename,
212 r.relative_dirname,
213 ...(r.groups || []),
214 ...(r.mitre || [])
215 ]
216 .join(" ")
217 .toLowerCase()
218 .includes(q)
219 )
220
221 if (activeChip.value === "noisy") {
222 return [...textFiltered].sort((a, b) => b.hits_30d - a.hits_30d).slice(0, 50)
223 }
224 if (activeChip.value === "dead") {
225 return textFiltered.filter(r => r.hits_30d === 0 && (r.level ?? 0) >= 7)
226 }
227 return textFiltered
228 })
229
230 function openRuleDetail(row: CatalogWazuhRuleRow) {
231 if (typeof row.id !== "number") return
232 modalRuleId.value = row.id
233 modalTitle.value = `Rule ${row.id}`
234 showDetailModal.value = true
235 }
236
237 function openRuleById(ruleId: number) {
238 const row = rules.value.find(r => r.id === ruleId)
239 modalRuleId.value = ruleId
240 modalTitle.value = row ? `Rule ${ruleId}${row.description ? ` — ${row.description}` : ""}` : `Rule ${ruleId}`
241 showDetailModal.value = true
242 }
243
244 function rowProps(row: CatalogWazuhRuleRow) {
245 return {
246 style: "cursor: pointer;",
247 onClick: () => openRuleDetail(row)
248 }
249 }
250
251 function levelTagType(level: number | null): TagProps["type"] {
252 if (level === null || level === undefined) return "default"
253 if (level >= 12) return "error"
254 if (level >= 7) return "warning"
255 if (level >= 3) return "info"
256 return "default"
257 }
258
259 function renderRuleDescription(row: CatalogWazuhRuleRow) {
260 if (!row.description) {
261 return <span class="text-tertiary text-xs">(no description)</span>
262 }
263 return <span class="leading-snug">{row.description}</span>
264 }
265
266 function renderRuleGroups(row: CatalogWazuhRuleRow) {
267 if (!row.groups.length) {
268 return <span class="text-tertiary text-xs"></span>
269 }
270 return (
271 <div class="flex flex-wrap gap-1">
272 {row.groups.slice(0, 3).map(g => (
273 <NTag key={g} type="primary" size="small">
274 {g}
275 </NTag>
276 ))}
277 {row.groups.length > 3 && <NTag size="small">{`+${row.groups.length - 3}`}</NTag>}
278 </div>
279 )
280 }
281
282 function renderRuleMitre(row: CatalogWazuhRuleRow) {
283 if (!row.mitre.length) {
284 return <span class="text-tertiary text-xs"></span>
285 }
286 return (
287 <div class="flex flex-wrap gap-1">
288 {row.mitre.map(t => (
289 <NTag key={t} size="small">
290 {t}
291 </NTag>
292 ))}
293 </div>
294 )
295 }
296
297 function loadCustomers() {
298 loadingCustomers.value = true
299 Api.customers
300 .getCustomers()
301 .then(res => {
302 const list = res.data?.customers || []
303 customerOptions.value = [
304 { label: "All customers", value: "" },
305 ...list.map(c => ({
306 label: c.customer_name ? `${c.customer_name} (${c.customer_code})` : c.customer_code,
307 value: c.customer_code
308 }))
309 ]
310 })
311 .catch(() => {
312 /* Non-fatal — keep just "All customers" option. */
313 })
314 .finally(() => {
315 loadingCustomers.value = false
316 })
317 }
318
319 function onCustomerChange(value: string) {
320 customerScope.value = value
321 refetchingForCustomer.value = true
322 load(true)
323 }
324
325 // Hits column — only included when the indexer is reachable. Rendering "0"
326 // everywhere when stats are unavailable would mislead, so we hide the column
327 // entirely.
328 const hitsColumn = computed(() => ({
329 title: "Activity",
330 key: "hits_30d",
331 width: 140,
332 sorter: (a: CatalogWazuhRuleRow, b: CatalogWazuhRuleRow) => a.hits_30d - b.hits_30d,
333 render: (row: CatalogWazuhRuleRow) => {
334 if (row.hits_30d === 0) {
335 return (
336 <div class="flex items-center gap-1.5">
337 <span class="dot dot-muted"></span>
338 <span class="text-secondary text-xs">No hits 30d</span>
339 </div>
340 )
341 }
342 // Color the indicator dot by intensity bucket so analysts can scan
343 // the column without reading numbers.
344 const dotClass =
345 row.hits_30d >= 10000
346 ? "dot-danger"
347 : row.hits_30d >= 1000
348 ? "dot-warning"
349 : row.hits_30d >= 100
350 ? "dot-info"
351 : "dot-success"
352 return (
353 <div class="flex items-center gap-2">
354 <span class={`dot ${dotClass}`}></span>
355 <div class="flex flex-col leading-tight">
356 <span class="font-mono text-xs font-medium">{row.hits_30d.toLocaleString()}</span>
357 <span class="text-secondary text-xs">{`${row.hits_7d.toLocaleString()} in 7d`}</span>
358 </div>
359 </div>
360 )
361 }
362 }))
363
364 const columns = computed<DataTableColumns<CatalogWazuhRuleRow>>(() => {
365 const cols: DataTableColumns<CatalogWazuhRuleRow> = [
366 {
367 title: "ID",
368 key: "id",
369 fixed: "left",
370 width: 100,
371 sorter: (a, b) => (a.id ?? 0) - (b.id ?? 0),
372 render: row => <span class="text-secondary font-mono text-xs">{row.id ?? ""}</span>
373 },
374 {
375 title: "Level",
376 key: "level",
377 width: 90,
378 sorter: (a, b) => (a.level ?? 0) - (b.level ?? 0),
379 render: row => (
380 <NTag size="small" type={levelTagType(row.level)} bordered={false} class="font-mono font-bold">
381 {row.level ?? ""}
382 </NTag>
383 )
384 },
385 {
386 title: "Description",
387 key: "description",
388 width: 400,
389 render: renderRuleDescription
390 },
391 {
392 title: "Groups",
393 key: "groups",
394 minWidth: 100,
395 render: renderRuleGroups
396 },
397 {
398 title: "MITRE",
399 key: "mitre",
400 width: 140,
401 render: renderRuleMitre
402 },
403 {
404 title: "File",
405 key: "filename",
406 width: 200,
407 ellipsis: { tooltip: true },
408 render: row => <span class="text-secondary font-mono text-xs">{row.filename || ""}</span>
409 }
410 ]
411 if (firingStatsAvailable.value) cols.push(hitsColumn.value)
412 return cols
413 })
414
415 function load(isCustomerChange = false) {
416 if (!isCustomerChange) loading.value = true
417 Api.detectionCatalog
418 .listWazuhRules(customerScope.value || undefined)
419 .then(res => {
420 if (res.data?.success) {
421 rules.value = res.data.rules || []
422 available.value = res.data.available
423 unavailableReason.value = res.data.unavailable_reason
424 firingStatsAvailable.value = res.data.firing_stats_available
425 } else {
426 message.warning(res.data?.message || "Failed to load Wazuh rules")
427 }
428 })
429 .catch(err => {
430 message.error(err.response?.data?.detail || err.response?.data?.message || "Failed to load Wazuh rules")
431 })
432 .finally(() => {
433 loading.value = false
434 refetchingForCustomer.value = false
435 })
436 }
437
438 onBeforeMount(() => {
439 loadCustomers()
440 load()
441 })
442 </script>
443
444 <style scoped lang="scss">
445 :deep(.dot) {
446 display: inline-block;
447 width: 8px;
448 height: 8px;
449 border-radius: 50%;
450 flex-shrink: 0;
451 }
452 :deep(.dot-muted) {
453 background-color: var(--border-color);
454 }
455 :deep(.dot-success) {
456 background-color: var(--success-color);
457 }
458 :deep(.dot-info) {
459 background-color: var(--primary-color);
460 }
461 :deep(.dot-warning) {
462 background-color: var(--warning-color);
463 }
464 :deep(.dot-danger) {
465 background-color: var(--error-color);
466 }
467 </style>