| 1 | <template> |
| 2 | <n-spin :show="loading" class="min-h-50"> |
| 3 | <div v-if="rule" class="flex flex-col gap-4"> |
| 4 | <CardEntity :status="hitsCardStatus"> |
| 5 | <template #headerMain> |
| 6 | <div class="flex items-center gap-3"> |
| 7 | <n-tag |
| 8 | size="large" |
| 9 | :type="levelTagType(rule.level)" |
| 10 | :bordered="false" |
| 11 | class="flex min-h-10 min-w-10 items-center justify-center font-mono font-bold" |
| 12 | > |
| 13 | {{ rule.level ?? "—" }} |
| 14 | </n-tag> |
| 15 | <div class="flex flex-col gap-0.5"> |
| 16 | <div class="text-secondary text-xs tracking-wide uppercase">Wazuh Rule</div> |
| 17 | <div class="text-default text-lg leading-tight font-semibold">Rule {{ rule.id }}</div> |
| 18 | </div> |
| 19 | </div> |
| 20 | </template> |
| 21 | |
| 22 | <template #default> |
| 23 | <div class="flex flex-col gap-2"> |
| 24 | <div v-if="rule.description"> |
| 25 | {{ rule.description }} |
| 26 | </div> |
| 27 | <div class="flex flex-wrap gap-2"> |
| 28 | <Badge v-if="rule.status" type="splitted" color="success"> |
| 29 | <template #label>Status</template> |
| 30 | <template #value>{{ rule.status }}</template> |
| 31 | </Badge> |
| 32 | <Badge v-if="rule.firing_stats_available" type="splitted" :color="hitsBadgeColor"> |
| 33 | <template #label>Hits 30d</template> |
| 34 | <template #value>{{ rule.hits_30d.toLocaleString() }}</template> |
| 35 | </Badge> |
| 36 | <Badge v-if="rule.firing_stats_available" type="splitted"> |
| 37 | <template #label>Hits 7d</template> |
| 38 | <template #value>{{ rule.hits_7d.toLocaleString() }}</template> |
| 39 | </Badge> |
| 40 | <Badge v-if="rule.firing_stats_available && rule.last_seen" type="splitted" color="primary"> |
| 41 | <template #label>Last fired</template> |
| 42 | <template #value>{{ formatRelativeTime(rule.last_seen) }}</template> |
| 43 | </Badge> |
| 44 | <Badge v-if="rule.filename" type="splitted"> |
| 45 | <template #label>File</template> |
| 46 | <template #value>{{ rule.filename }}</template> |
| 47 | </Badge> |
| 48 | </div> |
| 49 | </div> |
| 50 | </template> |
| 51 | </CardEntity> |
| 52 | |
| 53 | <!-- Groups --> |
| 54 | <CardEntity v-if="rule.groups.length"> |
| 55 | <template #headerMain> |
| 56 | <SectionLabel icon="carbon:tag" label="Groups" /> |
| 57 | </template> |
| 58 | <template #default> |
| 59 | <div class="flex flex-wrap gap-1.5"> |
| 60 | <n-tag v-for="g of rule.groups" :key="g" size="small" type="primary"> |
| 61 | {{ g }} |
| 62 | </n-tag> |
| 63 | </div> |
| 64 | </template> |
| 65 | </CardEntity> |
| 66 | |
| 67 | <!-- MITRE ATT&CK --> |
| 68 | <CardEntity v-if="rule.mitre.length"> |
| 69 | <template #headerMain> |
| 70 | <SectionLabel icon="carbon:flag" label="MITRE ATT&CK" /> |
| 71 | </template> |
| 72 | <template #default> |
| 73 | <div class="flex flex-col gap-2"> |
| 74 | <div v-if="rule.mitre.length" class="flex flex-wrap gap-1.5"> |
| 75 | <n-tag v-for="t of rule.mitre" :key="t" size="small" type="warning"> |
| 76 | {{ t }} |
| 77 | </n-tag> |
| 78 | </div> |
| 79 | </div> |
| 80 | </template> |
| 81 | </CardEntity> |
| 82 | |
| 83 | <CardEntity v-if="rule.tactics.length"> |
| 84 | <template #headerMain> |
| 85 | <SectionLabel icon="carbon:radar" label="Tactics" /> |
| 86 | </template> |
| 87 | <template #default> |
| 88 | <div class="flex flex-col gap-2"> |
| 89 | <div v-if="rule.tactics.length" class="flex flex-wrap gap-1.5"> |
| 90 | <n-tag v-for="t of rule.tactics" :key="t" size="small"> |
| 91 | {{ t.toUpperCase() }} |
| 92 | </n-tag> |
| 93 | </div> |
| 94 | </div> |
| 95 | </template> |
| 96 | </CardEntity> |
| 97 | |
| 98 | <!-- Compliance — only render frameworks that actually have values. --> |
| 99 | <CardEntity v-if="hasCompliance"> |
| 100 | <template #headerMain> |
| 101 | <SectionLabel icon="carbon:certificate-check" label="Compliance" /> |
| 102 | </template> |
| 103 | <template #default> |
| 104 | <div class="divide-border flex flex-col gap-0 divide-y"> |
| 105 | <div v-for="[key, values] of complianceEntries" :key class="flex items-start gap-3 py-2"> |
| 106 | <div |
| 107 | class="text-secondary w-28 shrink-0 pt-0.5 text-xs font-semibold tracking-wide uppercase" |
| 108 | > |
| 109 | {{ key }} |
| 110 | </div> |
| 111 | <div class="flex flex-wrap gap-1"> |
| 112 | <n-tag v-for="v of values" :key="v" size="small"> |
| 113 | {{ v }} |
| 114 | </n-tag> |
| 115 | </div> |
| 116 | </div> |
| 117 | </div> |
| 118 | </template> |
| 119 | </CardEntity> |
| 120 | |
| 121 | <CardEntity v-if="rule.source_xml"> |
| 122 | <template #header> |
| 123 | <SectionLabel icon="carbon:code" label="Rule Source" /> |
| 124 | </template> |
| 125 | <template #default> |
| 126 | <CodeSource :code="rule.source_xml" /> |
| 127 | </template> |
| 128 | </CardEntity> |
| 129 | |
| 130 | <!-- File location footer --> |
| 131 | <CardEntity v-if="rule.relative_dirname" embedded> |
| 132 | <template #default> |
| 133 | <div class="flex items-center gap-2 text-xs"> |
| 134 | <Icon name="carbon:folder" :size="12" /> |
| 135 | <span class="text-secondary">Location:</span> |
| 136 | <code class="font-mono">{{ rule.relative_dirname }}/{{ rule.filename }}</code> |
| 137 | </div> |
| 138 | </template> |
| 139 | </CardEntity> |
| 140 | </div> |
| 141 | </n-spin> |
| 142 | </template> |
| 143 | |
| 144 | <script setup lang="ts"> |
| 145 | import type { TagProps } from "naive-ui" |
| 146 | import type { CatalogWazuhRuleDetailResponse } from "@/types/detectionCatalog.d" |
| 147 | import { NSpin, NTag, useMessage } from "naive-ui" |
| 148 | import { computed, defineComponent, h, onBeforeMount, ref, watch } from "vue" |
| 149 | import Api from "@/api" |
| 150 | import Badge from "@/components/common/Badge.vue" |
| 151 | import CardEntity from "@/components/common/cards/CardEntity.vue" |
| 152 | import CodeSource from "@/components/common/CodeSource.vue" |
| 153 | import Icon from "@/components/common/Icon.vue" |
| 154 | import dayjs from "@/utils/dayjs" |
| 155 | |
| 156 | const props = defineProps<{ ruleId: number }>() |
| 157 | |
| 158 | const SectionLabel = defineComponent({ |
| 159 | props: { icon: String, label: String }, |
| 160 | setup(props) { |
| 161 | return () => |
| 162 | h("div", { class: "flex items-center gap-2" }, [ |
| 163 | props.icon ? h(Icon, { name: props.icon, size: 14 }) : null, |
| 164 | h("span", { class: "text-secondary text-xs font-semibold tracking-wide uppercase" }, props.label) |
| 165 | ]) |
| 166 | } |
| 167 | }) |
| 168 | |
| 169 | const message = useMessage() |
| 170 | |
| 171 | const rule = ref<CatalogWazuhRuleDetailResponse | null>(null) |
| 172 | const loading = ref(false) |
| 173 | |
| 174 | // Severity classes for the big hero level badge — same buckets as the |
| 175 | // index column. Single source of truth at the top so they stay in sync. |
| 176 | function levelTagType(level: number | null | undefined): TagProps["type"] { |
| 177 | if (level === null || level === undefined) return "default" |
| 178 | if (level >= 12) return "error" |
| 179 | if (level >= 7) return "warning" |
| 180 | if (level >= 3) return "info" |
| 181 | return "default" |
| 182 | } |
| 183 | |
| 184 | // Pick a card status tint for the hero based on hit volume — lets the modal |
| 185 | // signal "this rule is hot right now" without screaming. |
| 186 | const hitsCardStatus = computed<"success" | "warning" | "error" | undefined>(() => { |
| 187 | if (!rule.value?.firing_stats_available) return undefined |
| 188 | const h = rule.value.hits_30d |
| 189 | if (h >= 10000) return "error" |
| 190 | if (h >= 1000) return "warning" |
| 191 | return undefined |
| 192 | }) |
| 193 | |
| 194 | const hitsBadgeColor = computed<"danger" | "warning" | "primary" | "success" | undefined>(() => { |
| 195 | const h = rule.value?.hits_30d |
| 196 | if (h === undefined || h === 0) return undefined |
| 197 | if (h >= 10000) return "danger" |
| 198 | if (h >= 1000) return "warning" |
| 199 | if (h >= 100) return "primary" |
| 200 | return "success" |
| 201 | }) |
| 202 | |
| 203 | const complianceEntries = computed<[string, string[]][]>(() => { |
| 204 | const c = rule.value?.compliance |
| 205 | if (!c) return [] |
| 206 | const labels: Record<string, string> = { |
| 207 | pci_dss: "PCI DSS", |
| 208 | gdpr: "GDPR", |
| 209 | hipaa: "HIPAA", |
| 210 | nist_800_53: "NIST 800-53", |
| 211 | tsc: "TSC", |
| 212 | gpg13: "GPG13" |
| 213 | } |
| 214 | return Object.entries(c) |
| 215 | .filter(([, v]) => Array.isArray(v) && v.length > 0) |
| 216 | .map(([k, v]) => [labels[k] ?? k, v as string[]]) |
| 217 | }) |
| 218 | |
| 219 | const hasCompliance = computed(() => complianceEntries.value.length > 0) |
| 220 | |
| 221 | function formatRelativeTime(iso: string): string { |
| 222 | return dayjs(iso).fromNow() |
| 223 | } |
| 224 | |
| 225 | function load(id: number) { |
| 226 | loading.value = true |
| 227 | rule.value = null |
| 228 | Api.detectionCatalog |
| 229 | .getWazuhRule(id) |
| 230 | .then(res => { |
| 231 | if (res.data?.success) rule.value = res.data |
| 232 | else message.warning(res.data?.message || "Failed to load Wazuh rule detail") |
| 233 | }) |
| 234 | .catch(err => { |
| 235 | const status = err.response?.status |
| 236 | if (status === 404) { |
| 237 | message.warning(`Wazuh rule ${id} not found in the cache`) |
| 238 | } else { |
| 239 | message.error( |
| 240 | err.response?.data?.detail || err.response?.data?.message || "Failed to load Wazuh rule detail" |
| 241 | ) |
| 242 | } |
| 243 | }) |
| 244 | .finally(() => { |
| 245 | loading.value = false |
| 246 | }) |
| 247 | } |
| 248 | |
| 249 | watch( |
| 250 | () => props.ruleId, |
| 251 | id => load(id) |
| 252 | ) |
| 253 | onBeforeMount(() => load(props.ruleId)) |
| 254 | </script> |