main
vue 339 lines 10.1 KB
Raw
1 <template>
2 <div class="flex flex-col gap-6">
3 <div class="flex flex-col gap-2">
4 <p>Paste a raw log, see which Wazuh rule would fire (via Wazuh logtest)</p>
5
6 <div v-if="history.length" class="flex justify-end">
7 <n-popover placement="left-start" class="p-3!">
8 <template #trigger>
9 <div class="text-secondary flex items-center gap-2 text-xs">
10 <Icon name="carbon:time" />
11 History
12 </div>
13 </template>
14
15 <div class="flex flex-col gap-4">
16 <n-scrollbar trigger="none" style="max-height: 200px">
17 <div class="flex flex-col gap-2">
18 <Badge
19 v-for="(item, idx) of history"
20 :key="idx"
21 point-cursor
22 type="splitted"
23 @click="restoreFromHistory(item)"
24 >
25 <template #label>
26 <span
27 v-if="item.matched"
28 class="text-primary max-w-20 truncate font-mono text-xs"
29 :title="item.rule_id?.toString() ?? '?'"
30 >
31 {{ item.rule_id ?? "?" }}
32 </span>
33 <span v-else class="text-tertiary font-mono text-xs">no match</span>
34 </template>
35 <template #value>
36 <span class="text-secondary max-w-40 truncate text-xs" :title="item.event">
37 {{ item.event }}
38 </span>
39 </template>
40 </Badge>
41 </div>
42 </n-scrollbar>
43 <div class="flex justify-end">
44 <n-button size="tiny" secondary @click="clearHistory">Clear history</n-button>
45 </div>
46 </div>
47 </n-popover>
48 </div>
49
50 <n-input
51 v-model:value="event"
52 type="textarea"
53 placeholder="Paste a single log line — auditd / syslog / Windows EventChannel / Suricata eve.json etc."
54 :autosize="{ minRows: 3, maxRows: 8 }"
55 />
56
57 <div class="flex flex-wrap items-center justify-between gap-3">
58 <n-input-group size="small" class="flex-1">
59 <n-input-group-label size="small" class="text-secondary!">Format</n-input-group-label>
60 <n-select v-model:value="logFormat" :options="logFormatOptions" size="small" />
61 </n-input-group>
62
63 <div class="flex items-center gap-2">
64 <n-button type="primary" size="small" :loading="testing" :disabled="!event.trim()" @click="runTest">
65 <template #icon><Icon name="carbon:play" /></template>
66 Test against Wazuh
67 </n-button>
68 </div>
69 </div>
70 </div>
71
72 <div v-if="result" class="flex flex-col gap-2">
73 <div class="flex justify-end">
74 <n-button size="tiny" quaternary @click="clearResult">Clear test result</n-button>
75 </div>
76
77 <n-alert v-if="result.unavailable_reason" type="error" show-icon>
78 <template #header>Logtest failed</template>
79 {{ result.unavailable_reason }}
80 </n-alert>
81
82 <CardEntity v-else-if="!result.matched" size="small" status="warning">
83 <div class="flex items-start gap-3">
84 <Icon name="carbon:information" :size="18" class="text-warning mt-0.5" />
85 <div class="flex flex-col gap-1">
86 <div class="text-sm font-semibold">No rule matched</div>
87 <div class="text-secondary text-xs leading-relaxed">
88 Wazuh saw the log but no analyst-facing rule fired. Try a different "Format" — most
89 agent-forwarded logs use
90 <code>syslog</code>
91 ; pure JSON payloads use
92 <code>json</code>
93 .
94 </div>
95 </div>
96 </div>
97 </CardEntity>
98
99 <CardEntity v-else-if="result.rule" size="small" status="success">
100 <template #headerMain>
101 <div class="flex flex-wrap items-center justify-between gap-2">
102 <div class="flex items-center gap-2">
103 <Icon name="carbon:checkmark-filled" :size="16" class="text-success" />
104 <span class="text-success text-sm font-semibold tracking-wide uppercase">Match</span>
105 </div>
106 </div>
107 </template>
108 <template #headerExtra>
109 <n-button
110 v-if="result.rule.id !== null"
111 size="tiny"
112 secondary
113 @click="emit('open-rule', result.rule.id)"
114 >
115 <template #icon><Icon name="carbon:view" /></template>
116 View in catalog
117 </n-button>
118 </template>
119
120 <template #default>
121 <div class="flex flex-col gap-2">
122 <div class="text-sm">
123 {{ result.rule.description }}
124 </div>
125 <div class="flex items-center gap-2">
126 <Badge type="splitted" color="success" size="small">
127 <template #label>Rule</template>
128 <template #value>{{ result.rule.id }}</template>
129 </Badge>
130 <Badge type="splitted" :color="levelBadgeColor(result.rule.level)" size="small">
131 <template #label>Level</template>
132 <template #value>{{ result.rule.level ?? "" }}</template>
133 </Badge>
134 </div>
135 </div>
136 </template>
137
138 <template #mainExtra>
139 <div class="flex flex-wrap gap-x-8 gap-y-5">
140 <div v-if="result.rule.groups.length" class="flex flex-col gap-1">
141 <span class="text-secondary text-[10px] tracking-wider uppercase">Groups</span>
142 <div class="flex flex-wrap gap-1.5">
143 <n-tag v-for="item of result.rule.groups" :key="item" size="small">
144 {{ item }}
145 </n-tag>
146 </div>
147 </div>
148 <div v-if="result.rule.mitre.length" class="flex flex-col gap-1">
149 <span class="text-secondary text-[10px] tracking-wider uppercase">MITRE ATT&CK</span>
150 <div class="flex flex-wrap gap-1.5">
151 <n-tag v-for="item of result.rule.mitre" :key="item" size="small">
152 {{ item }}
153 </n-tag>
154 </div>
155 </div>
156 <div v-if="result.tactics.length" class="flex flex-col gap-1">
157 <span class="text-secondary text-[10px] tracking-wider uppercase">Tactics</span>
158 <div class="flex flex-wrap gap-1.5">
159 <n-tag v-for="item of result.tactics" :key="item" size="small">
160 {{ item.toUpperCase() }}
161 </n-tag>
162 </div>
163 </div>
164 </div>
165 </template>
166 </CardEntity>
167
168 <n-collapse v-if="result.alert" arrow-placement="right">
169 <n-collapse-item name="alert" class="[&_.n-collapse-item\_\_content-inner]:pt-2!">
170 <template #header>
171 <span class="text-secondary text-sm">Decoded alert envelope</span>
172 </template>
173 <CodeSource :code="result.alert" />
174 </n-collapse-item>
175 </n-collapse>
176 </div>
177 </div>
178 </template>
179
180 <script setup lang="ts">
181 import type { CatalogLogTestResponse } from "@/types/detectionCatalog.d"
182 import { useStorage } from "@vueuse/core"
183 import {
184 NAlert,
185 NButton,
186 NCollapse,
187 NCollapseItem,
188 NInput,
189 NInputGroup,
190 NInputGroupLabel,
191 NPopover,
192 NScrollbar,
193 NSelect,
194 NTag,
195 useMessage
196 } from "naive-ui"
197 import { ref, watch } from "vue"
198 import Api from "@/api"
199 import Badge from "@/components/common/Badge.vue"
200 import CardEntity from "@/components/common/cards/CardEntity.vue"
201 import CodeSource from "@/components/common/CodeSource.vue"
202 import Icon from "@/components/common/Icon.vue"
203
204 const emit = defineEmits<{ (e: "open-rule", ruleId: number): void }>()
205 const HISTORY_STORAGE_KEY = "detectionCatalog.logtest.history"
206 const HISTORY_MAX = 5
207
208 interface LogTestHistoryItem {
209 event: string
210 log_format: string
211 matched: boolean
212 rule_id: number | null
213 rule_description: string
214 timestamp: string
215 }
216
217 const message = useMessage()
218
219 const event = ref("")
220 const logFormat = ref("syslog")
221
222 // Wazuh's most common log formats. Covers ~95% of analyst pastes — full list
223 // would have ~30 entries and feel overwhelming for a one-off test.
224 const logFormatOptions = [
225 { label: "syslog", value: "syslog" },
226 { label: "json", value: "json" },
227 { label: "snort-full", value: "snort-full" },
228 { label: "squid", value: "squid" },
229 { label: "apache", value: "apache" },
230 { label: "iis", value: "iis" },
231 { label: "audit", value: "audit" },
232 { label: "djb-multilog", value: "djb-multilog" },
233 { label: "eventlog (Windows EVT)", value: "eventlog" },
234 { label: "eventchannel (Windows EventChannel)", value: "eventchannel" }
235 ]
236
237 const testing = ref(false)
238 const result = ref<CatalogLogTestResponse | null>(null)
239 const historyStorage = useStorage<unknown>(HISTORY_STORAGE_KEY, [], localStorage)
240 const history = ref<LogTestHistoryItem[]>([])
241
242 function clearResult() {
243 result.value = null
244 }
245
246 function syncHistoryFromStorage() {
247 try {
248 const parsed = historyStorage.value
249 history.value = Array.isArray(parsed)
250 ? parsed
251 .filter(item => typeof item?.event === "string" && typeof item?.log_format === "string")
252 .slice(0, HISTORY_MAX)
253 : []
254 } catch {
255 /* Storage unavailable/corrupted: history is best-effort. */
256 history.value = []
257 }
258 }
259
260 function persistHistory() {
261 try {
262 historyStorage.value = history.value
263 } catch {
264 /* QuotaExceeded / disabled — history is best-effort. */
265 }
266 }
267
268 function recordInHistory(res: CatalogLogTestResponse) {
269 const item: LogTestHistoryItem = {
270 event: event.value,
271 log_format: logFormat.value,
272 matched: res.matched,
273 rule_id: res.rule?.id ?? null,
274 rule_description: res.rule?.description ?? "",
275 timestamp: new Date().toISOString()
276 }
277 // De-dupe consecutive identical pastes.
278 const dup = history.value[0]
279 if (dup && dup.event === item.event && dup.log_format === item.log_format) {
280 history.value[0] = item
281 } else {
282 history.value = [item, ...history.value].slice(0, HISTORY_MAX)
283 }
284 persistHistory()
285 }
286
287 function restoreFromHistory(item: LogTestHistoryItem) {
288 event.value = item.event
289 logFormat.value = item.log_format
290 result.value = null
291 }
292
293 function clearHistory() {
294 history.value = []
295 persistHistory()
296 }
297
298 function runTest() {
299 if (!event.value.trim()) return
300 testing.value = true
301 Api.detectionCatalog
302 .runLogTest({
303 event: event.value,
304 log_format: logFormat.value,
305 location: "logtest"
306 })
307 .then(res => {
308 if (res.data?.success) {
309 result.value = res.data
310 recordInHistory(res.data)
311 } else {
312 message.warning(res.data?.message || "Logtest returned an unexpected response")
313 }
314 })
315 .catch(err => {
316 const detail = err.response?.data?.detail || err.response?.data?.message
317 message.error(detail || "Logtest request failed")
318 })
319 .finally(() => {
320 testing.value = false
321 })
322 }
323
324 function levelBadgeColor(level: number | null): "danger" | "warning" | "primary" | "success" | undefined {
325 if (level === null || level === undefined) return undefined
326 if (level >= 12) return "danger"
327 if (level >= 7) return "warning"
328 if (level >= 3) return "primary"
329 return "success"
330 }
331
332 watch(
333 () => historyStorage.value,
334 () => {
335 syncHistoryFromStorage()
336 },
337 { immediate: true }
338 )
339 </script>