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