| 1 | <template> |
| 2 | <n-drawer v-model:show="showLocal" :width="drawerWidth" placement="right"> |
| 3 | <n-drawer-content :title="drawerTitle" closable> |
| 4 | <template v-if="technique"> |
| 5 | <div class="mb-3 flex flex-col gap-1"> |
| 6 | <div class="text-secondary text-sm"> |
| 7 | <a v-if="technique.url" :href="technique.url" target="_blank" rel="noopener"> |
| 8 | {{ technique.id }} — view on attack.mitre.org ↗ |
| 9 | </a> |
| 10 | </div> |
| 11 | <div v-if="subTechnique" class="text-secondary text-sm"> |
| 12 | Sub-technique: |
| 13 | <a v-if="subTechnique.url" :href="subTechnique.url" target="_blank" rel="noopener"> |
| 14 | {{ subTechnique.id }} {{ subTechnique.name }} ↗ |
| 15 | </a> |
| 16 | <span v-else>{{ subTechnique.id }} {{ subTechnique.name }}</span> |
| 17 | </div> |
| 18 | </div> |
| 19 | |
| 20 | <div |
| 21 | v-if="provisionableCount > 0" |
| 22 | class="border-default bg-secondary mb-3 flex items-center justify-between gap-2 rounded-md border p-2" |
| 23 | > |
| 24 | <div class="text-secondary text-xs"> |
| 25 | <strong>{{ provisionableCount }}</strong> |
| 26 | of |
| 27 | <strong>{{ rules.length }}</strong> |
| 28 | rule{{ rules.length === 1 ? "" : "s" }} have a Graylog query available. |
| 29 | </div> |
| 30 | <n-button |
| 31 | size="small" |
| 32 | type="primary" |
| 33 | secondary |
| 34 | :disabled="loading || !provisionableCount" |
| 35 | @click="showBulkModal = true" |
| 36 | > |
| 37 | <template #icon> |
| 38 | <Icon :name="ProvisionIcon" /> |
| 39 | </template> |
| 40 | Provision all |
| 41 | </n-button> |
| 42 | </div> |
| 43 | |
| 44 | <n-spin :show="loading"> |
| 45 | <div v-if="rules.length" class="grid grid-cols-1 gap-3"> |
| 46 | <RuleCard |
| 47 | v-for="rule of rules" |
| 48 | :key="rule.id" |
| 49 | :rule |
| 50 | embedded |
| 51 | :provisioned="provisionedMap[rule.id] === true" |
| 52 | /> |
| 53 | </div> |
| 54 | <n-empty |
| 55 | v-else-if="!loading" |
| 56 | description="No CoPilot Search rules cover this technique yet." |
| 57 | class="h-40 justify-center" |
| 58 | /> |
| 59 | </n-spin> |
| 60 | </template> |
| 61 | </n-drawer-content> |
| 62 | |
| 63 | <BulkProvisionModal |
| 64 | v-model:show="showBulkModal" |
| 65 | :rule-ids="provisionableRules.map(r => r.id)" |
| 66 | @success="onBulkSuccess" |
| 67 | /> |
| 68 | </n-drawer> |
| 69 | </template> |
| 70 | |
| 71 | <script setup lang="ts"> |
| 72 | import type { |
| 73 | BulkProvisionGraylogAlertResponse, |
| 74 | MitreSubTechnique, |
| 75 | MitreTechnique, |
| 76 | RuleSummary |
| 77 | } from "@/types/copilotSearches.d" |
| 78 | import { NButton, NDrawer, NDrawerContent, NEmpty, NSpin, useMessage } from "naive-ui" |
| 79 | import { computed, ref, watch } from "vue" |
| 80 | import Api from "@/api" |
| 81 | import Icon from "@/components/common/Icon.vue" |
| 82 | import BulkProvisionModal from "./BulkProvisionModal.vue" |
| 83 | import RuleCard from "./RuleCard.vue" |
| 84 | |
| 85 | const props = defineProps<{ |
| 86 | show: boolean |
| 87 | technique: MitreTechnique | null |
| 88 | subTechnique?: MitreSubTechnique | null |
| 89 | }>() |
| 90 | |
| 91 | const emit = defineEmits<{ |
| 92 | (e: "update:show", value: boolean): void |
| 93 | }>() |
| 94 | |
| 95 | const showLocal = computed({ |
| 96 | get: () => props.show, |
| 97 | set: v => emit("update:show", v) |
| 98 | }) |
| 99 | |
| 100 | const message = useMessage() |
| 101 | const rules = ref<RuleSummary[]>([]) |
| 102 | const provisionedMap = ref<Record<string, boolean>>({}) |
| 103 | const loading = ref(false) |
| 104 | const drawerWidth = computed(() => Math.min(820, window.innerWidth - 40)) |
| 105 | |
| 106 | // Severity ordering: critical > high > medium > low > unknown |
| 107 | const SEVERITY_RANK: Record<string, number> = { |
| 108 | critical: 4, |
| 109 | high: 3, |
| 110 | medium: 2, |
| 111 | low: 1 |
| 112 | } |
| 113 | function sortBySeverity(list: RuleSummary[]): RuleSummary[] { |
| 114 | return [...list].sort((a, b) => { |
| 115 | const sa = SEVERITY_RANK[(a.severity || "").toLowerCase()] ?? 0 |
| 116 | const sb = SEVERITY_RANK[(b.severity || "").toLowerCase()] ?? 0 |
| 117 | if (sa !== sb) return sb - sa |
| 118 | return (a.name || "").localeCompare(b.name || "") |
| 119 | }) |
| 120 | } |
| 121 | |
| 122 | const ProvisionIcon = "carbon:add-alt" |
| 123 | |
| 124 | const drawerTitle = computed(() => { |
| 125 | if (!props.technique) return "Technique" |
| 126 | if (props.subTechnique) return `${props.subTechnique.id} ${props.subTechnique.name}` |
| 127 | return `${props.technique.id} ${props.technique.name}` |
| 128 | }) |
| 129 | |
| 130 | const ruleIdsToLoad = computed<string[]>(() => { |
| 131 | if (!props.technique) return [] |
| 132 | return props.subTechnique ? props.subTechnique.rule_ids : props.technique.rule_ids |
| 133 | }) |
| 134 | |
| 135 | const provisionableRules = computed<RuleSummary[]>(() => rules.value.filter(r => r.has_graylog_query)) |
| 136 | const provisionableCount = computed(() => provisionableRules.value.length) |
| 137 | |
| 138 | const showBulkModal = ref(false) |
| 139 | |
| 140 | function onBulkSuccess(res: BulkProvisionGraylogAlertResponse) { |
| 141 | // Reflect new "in Graylog" state immediately on the visible rules list, |
| 142 | // so the chip pops the moment the result modal closes. |
| 143 | const next = { ...provisionedMap.value } |
| 144 | for (const r of res.results) { |
| 145 | if (r.status === "provisioned" || r.status === "skipped") { |
| 146 | next[r.rule_id] = true |
| 147 | } |
| 148 | } |
| 149 | provisionedMap.value = next |
| 150 | } |
| 151 | |
| 152 | async function loadRules() { |
| 153 | const ids = ruleIdsToLoad.value |
| 154 | if (!ids.length) { |
| 155 | rules.value = [] |
| 156 | provisionedMap.value = {} |
| 157 | return |
| 158 | } |
| 159 | loading.value = true |
| 160 | provisionedMap.value = {} |
| 161 | try { |
| 162 | // Load rules and provisioning status in parallel — provisioning is best-effort: |
| 163 | // if Graylog is unreachable the chip just doesn't show, no error to the user. |
| 164 | const [rulesRes, statusRes] = await Promise.all([ |
| 165 | Api.copilotSearches.getRulesByIds(ids), |
| 166 | Api.copilotSearches.checkGraylogProvisioningStatus(ids).catch(() => null) |
| 167 | ]) |
| 168 | |
| 169 | if (rulesRes.data?.success) { |
| 170 | rules.value = sortBySeverity(rulesRes.data.rules || []) |
| 171 | if (rulesRes.data.missing?.length) { |
| 172 | message.warning( |
| 173 | `Some rules could not be loaded (${rulesRes.data.missing.length}). Try refreshing the cache.` |
| 174 | ) |
| 175 | } |
| 176 | } else { |
| 177 | message.warning(rulesRes.data?.message || "Failed to load rules for this technique") |
| 178 | } |
| 179 | |
| 180 | if (statusRes?.data?.success && !statusRes.data.warning) { |
| 181 | provisionedMap.value = statusRes.data.provisioned || {} |
| 182 | } |
| 183 | } catch (err: any) { |
| 184 | message.error(err.response?.data?.message || "Failed to load rules for this technique") |
| 185 | } finally { |
| 186 | loading.value = false |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | watch( |
| 191 | () => [props.show, ruleIdsToLoad.value] as const, |
| 192 | ([open]) => { |
| 193 | if (open) loadRules() |
| 194 | }, |
| 195 | { immediate: true, deep: true } |
| 196 | ) |
| 197 | |
| 198 | // Close the bulk modal automatically when the drawer's context changes — |
| 199 | // the modal itself resets its own internal state on next open. |
| 200 | watch( |
| 201 | () => [props.technique?.id, props.subTechnique?.id] as const, |
| 202 | () => { |
| 203 | showBulkModal.value = false |
| 204 | } |
| 205 | ) |
| 206 | </script> |