| 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> |