main
vue 206 lines 6 KB
Raw
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>