| 1 | <template> |
| 2 | <SegmentedPage toolbar-height="60px" toolbar-height-mobile="50px" padding="16px" enable-resize> |
| 3 | <template #sidebar-header> |
| 4 | <n-button v-if="areAllTacticsSelected" :focusable="false" @click="toggleAllTactics(false)"> |
| 5 | <template #icon> |
| 6 | <Icon name="carbon:checkbox" :size="16" /> |
| 7 | </template> |
| 8 | Unselect all |
| 9 | </n-button> |
| 10 | <n-button v-else type="primary" :focusable="false" @click="toggleAllTactics(true)"> |
| 11 | <template #icon> |
| 12 | <Icon name="carbon:checkbox-checked" :size="16" /> |
| 13 | </template> |
| 14 | Select all |
| 15 | </n-button> |
| 16 | </template> |
| 17 | <template #sidebar-content> |
| 18 | <n-spin :show="loading"> |
| 19 | <div class="flex flex-col gap-4"> |
| 20 | <div v-for="tactic of tacticsList" :key="tactic.id" class="flex items-center gap-3"> |
| 21 | <n-checkbox |
| 22 | :checked="isTacticSelected(tactic.id)" |
| 23 | @update-checked="toggleTacticSelect(tactic.id)" |
| 24 | > |
| 25 | <div class="flex items-center gap-2"> |
| 26 | <span>{{ tactic.name }}</span> |
| 27 | <code class="whitespace-nowrap">{{ tactic.count }}</code> |
| 28 | </div> |
| 29 | </n-checkbox> |
| 30 | </div> |
| 31 | </div> |
| 32 | <n-empty v-if="!tacticsList.length" description="No tactics available" class="h-48 justify-center" /> |
| 33 | </n-spin> |
| 34 | </template> |
| 35 | <template #main-toolbar> |
| 36 | <div class="flex items-center gap-4"> |
| 37 | <n-input v-model:value="textFilter" placeholder="Search by technique name" clearable> |
| 38 | <template #prefix> |
| 39 | <Icon name="carbon:search" :size="16" /> |
| 40 | </template> |
| 41 | </n-input> |
| 42 | <div v-if="hasNoCountAlerts" class="max-w-32 min-w-32"> |
| 43 | <n-checkbox v-model:checked="hideNoAlertsTechniques" class="items-center!" size="large"> |
| 44 | <span class="text-xs/tight">Hide techniques with no alerts</span> |
| 45 | </n-checkbox> |
| 46 | </div> |
| 47 | </div> |
| 48 | </template> |
| 49 | <template #main-content> |
| 50 | <n-spin :show="loading"> |
| 51 | <div class="grid-auto-fill-250 grid gap-2"> |
| 52 | <TechniqueAlertCard |
| 53 | v-for="technique of filteredTechniques" |
| 54 | :key="technique.technique_id" |
| 55 | :entity="technique" |
| 56 | class="flex" |
| 57 | /> |
| 58 | </div> |
| 59 | <n-empty v-if="!filteredTechniques.length" description="No items found" class="h-48 justify-center" /> |
| 60 | </n-spin> |
| 61 | </template> |
| 62 | </SegmentedPage> |
| 63 | </template> |
| 64 | |
| 65 | <script setup lang="ts"> |
| 66 | import type { MitreTechniquesAlertsQuery, MitreTechniquesAlertsQueryTimeRange } from "@/api/endpoints/wazuh/mitre" |
| 67 | import type { MitreTechnique } from "@/types/mitre.d" |
| 68 | import { watchDebounced } from "@vueuse/core" |
| 69 | import axios from "axios" |
| 70 | import { NButton, NCheckbox, NEmpty, NInput, NSpin, useMessage } from "naive-ui" |
| 71 | import { computed, ref, toRefs, watch } from "vue" |
| 72 | import Api from "@/api" |
| 73 | import Icon from "@/components/common/Icon.vue" |
| 74 | import SegmentedPage from "@/components/common/SegmentedPage.vue" |
| 75 | import TechniqueAlertCard from "../TechniqueAlert/TechniqueAlertCard.vue" |
| 76 | |
| 77 | const props = defineProps<{ |
| 78 | filters?: { type: string; value: string }[] |
| 79 | }>() |
| 80 | |
| 81 | const { filters } = toRefs(props) |
| 82 | const loading = ref(false) |
| 83 | const message = useMessage() |
| 84 | const techniquesList = ref<MitreTechnique[]>([]) |
| 85 | const currentPage = ref(1) |
| 86 | const hideNoAlertsTechniques = ref(false) |
| 87 | const textFilter = ref<string | null>(null) |
| 88 | let abortController: AbortController | null = null |
| 89 | |
| 90 | const selectedTactics = ref<string[]>([]) |
| 91 | |
| 92 | const tacticsList = computed(() => { |
| 93 | const list: { name: string; id: string; count: number }[] = [] |
| 94 | |
| 95 | for (const technique of techniquesList.value) { |
| 96 | for (const tactic of technique.tactics) { |
| 97 | const savedTactic = list.find(o => o.id === tactic.id) |
| 98 | if (savedTactic) { |
| 99 | savedTactic.count += technique.count |
| 100 | } else { |
| 101 | list.push({ |
| 102 | name: tactic.name, |
| 103 | id: tactic.id, |
| 104 | count: technique.count |
| 105 | }) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | return list |
| 111 | }) |
| 112 | |
| 113 | const filteredTechniques = computed(() => { |
| 114 | return techniquesList.value |
| 115 | .filter(a => { |
| 116 | for (const tactic of a.tactics) { |
| 117 | if (selectedTactics.value.includes(tactic.id)) { |
| 118 | return true |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | return false |
| 123 | }) |
| 124 | .filter(a => !textFilter.value || a.technique_name.toLowerCase().includes(textFilter.value.toLowerCase())) |
| 125 | .filter(a => !hideNoAlertsTechniques.value || (hideNoAlertsTechniques.value && a.count)) |
| 126 | }) |
| 127 | |
| 128 | const areAllTacticsSelected = computed(() => { |
| 129 | return selectedTactics.value.length === tacticsList.value.length |
| 130 | }) |
| 131 | |
| 132 | const hasNoCountAlerts = computed(() => !!techniquesList.value.filter(o => !o.count).length) |
| 133 | |
| 134 | function isTacticSelected(id: string) { |
| 135 | return selectedTactics.value.includes(id) |
| 136 | } |
| 137 | |
| 138 | function toggleTacticSelect(id: string) { |
| 139 | const index = selectedTactics.value.findIndex(o => o === id) |
| 140 | |
| 141 | if (selectedTactics.value.includes(id)) { |
| 142 | selectedTactics.value.splice(index, 1) |
| 143 | } else { |
| 144 | selectedTactics.value.push(id) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | function toggleAllTactics(state: boolean) { |
| 149 | if (state) { |
| 150 | selectedTactics.value = tacticsList.value.map(o => o.id) |
| 151 | } else { |
| 152 | selectedTactics.value = [] |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | function resetList() { |
| 157 | techniquesList.value = [] |
| 158 | currentPage.value = 1 |
| 159 | getList() |
| 160 | } |
| 161 | |
| 162 | function nextPage() { |
| 163 | currentPage.value++ |
| 164 | getList() |
| 165 | } |
| 166 | |
| 167 | function getList() { |
| 168 | abortController?.abort() |
| 169 | abortController = new AbortController() |
| 170 | |
| 171 | loading.value = true |
| 172 | |
| 173 | const query: MitreTechniquesAlertsQuery = { |
| 174 | time_range: filters.value?.find(o => o.type === "time_range")?.value as |
| 175 | | MitreTechniquesAlertsQueryTimeRange |
| 176 | | undefined, |
| 177 | size: 300, |
| 178 | page: currentPage.value, |
| 179 | rule_level: filters.value?.find(o => o.type === "rule_level")?.value, |
| 180 | rule_group: filters.value?.find(o => o.type === "rule_group")?.value, |
| 181 | mitre_field: filters.value?.find(o => o.type === "mitre_field")?.value, |
| 182 | index_pattern: filters.value?.find(o => o.type === "index_pattern")?.value |
| 183 | } |
| 184 | |
| 185 | Api.wazuh.mitre |
| 186 | .getMitreTechniquesAlerts(query, abortController.signal) |
| 187 | .then(res => { |
| 188 | loading.value = false |
| 189 | |
| 190 | if (res.data.success) { |
| 191 | techniquesList.value = [...techniquesList.value, ...res.data.techniques] |
| 192 | if (res.data.total_pages > currentPage.value) { |
| 193 | nextPage() |
| 194 | } |
| 195 | } else { |
| 196 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 197 | } |
| 198 | }) |
| 199 | .catch(err => { |
| 200 | if (!axios.isCancel(err)) { |
| 201 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 202 | loading.value = false |
| 203 | } |
| 204 | }) |
| 205 | } |
| 206 | |
| 207 | watch( |
| 208 | tacticsList, |
| 209 | () => { |
| 210 | toggleAllTactics(true) |
| 211 | }, |
| 212 | { deep: true, immediate: true } |
| 213 | ) |
| 214 | |
| 215 | watchDebounced(filters, resetList, { |
| 216 | deep: true, |
| 217 | debounce: 300, |
| 218 | immediate: true |
| 219 | }) |
| 220 | // MOCK |
| 221 | /* |
| 222 | techniquesList.value = techniques |
| 223 | */ |
| 224 | </script> |