main
vue 254 lines 7.95 KB
Raw
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>