main
vue 302 lines 8.65 KB
Raw
1 <template>
2 <n-spin :show="loading" class="min-h-50">
3 <div v-if="story" class="flex flex-col gap-6">
4 <CardEntity>
5 <template #headerMain>
6 <div class="flex flex-col gap-1">
7 <div class="text-secondary text-xs tracking-wide uppercase">Analytic Story</div>
8 <h2 class="text-default text-xl leading-tight font-semibold">
9 {{ story.name }}
10 </h2>
11 </div>
12 </template>
13
14 <template #default>
15 <div class="flex flex-wrap gap-2">
16 <Badge v-if="story.date" type="splitted" color="success">
17 <template #label>Updated</template>
18 <template #value>{{ formatDate(story.date, dFormats.date) }}</template>
19 </Badge>
20 <Badge v-if="story.authors.length" type="splitted" color="primary">
21 <template #label>
22 {{ story.authors.length === 1 ? "Author" : "Authors" }}
23 </template>
24 <template #value>{{ story.authors.join(", ") }}</template>
25 </Badge>
26 <Badge type="splitted">
27 <template #label>Detections</template>
28 <template #value>{{ story.detection_count }}</template>
29 </Badge>
30 <Badge v-if="story.products.length" type="splitted">
31 <template #label>
32 {{ story.products.length === 1 ? "Product" : "Products" }}
33 </template>
34 <template #value>{{ story.products.join(", ") }}</template>
35 </Badge>
36 <Badge v-if="story.version !== null" type="splitted">
37 <template #label>Version</template>
38 <template #value>v{{ story.version }}</template>
39 </Badge>
40 <Badge v-if="story.id" type="splitted">
41 <template #label>ID</template>
42 <template #value>
43 {{ story.id }}
44 </template>
45 </Badge>
46 </div>
47 </template>
48 </CardEntity>
49
50 <!-- Description -->
51 <CardEntity>
52 <template #headerMain>
53 <div class="flex items-center gap-2">
54 <Icon name="carbon:document" :size="14" />
55 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Description</span>
56 </div>
57 </template>
58 <template #default>
59 {{ story.description }}
60 </template>
61 </CardEntity>
62
63 <!-- Why it matters -->
64 <CardEntity status="warning">
65 <template #headerMain>
66 <div class="flex items-center gap-2">
67 <Icon name="carbon:warning-alt" :size="14" />
68 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Why It Matters</span>
69 </div>
70 </template>
71 <template #default>
72 {{ story.why_it_matters }}
73 </template>
74 </CardEntity>
75
76 <!-- MITRE tactics (if any resolved) -->
77 <CardEntity v-if="story.tactics.length">
78 <template #headerMain>
79 <div class="flex items-center gap-2">
80 <Icon name="carbon:flag" :size="14" />
81 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">
82 MITRE ATT&CK Tactics
83 </span>
84 </div>
85 </template>
86 <template #default>
87 <div class="flex flex-wrap gap-2">
88 <n-tag v-for="t of story.tactics" :key="t" type="warning">
89 {{ t.toUpperCase() }}
90 </n-tag>
91 </div>
92 </template>
93 </CardEntity>
94
95 <!-- Detections table -->
96 <CardEntity>
97 <template #header>
98 <div class="flex flex-wrap items-center justify-between gap-2">
99 <div class="flex items-center gap-2">
100 <Icon name="carbon:radar" :size="14" />
101 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Detections</span>
102 </div>
103 <Badge type="splitted" size="small">
104 <template #label>Count</template>
105 <template #value>{{ story.detections.length }}</template>
106 </Badge>
107 </div>
108 </template>
109 <template #default>
110 <n-data-table :columns="detectionColumns" :data="story.detections" size="small" />
111 </template>
112 </CardEntity>
113
114 <!-- Data sources -->
115 <CardEntity>
116 <template #headerMain>
117 <div class="flex items-center gap-2">
118 <Icon name="carbon:data-base" :size="14" />
119 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">Data Sources</span>
120 </div>
121 </template>
122 <template #default>
123 <div v-if="story.data_sources.length" class="flex flex-wrap gap-2">
124 <n-tag v-for="ds of story.data_sources" :key="ds" type="primary">
125 {{ ds }}
126 </n-tag>
127 </div>
128 <p v-else class="text-sm">No data sources declared by member detections.</p>
129 </template>
130 </CardEntity>
131
132 <!-- References -->
133 <CardEntity>
134 <template #headerMain>
135 <div class="flex items-center gap-2">
136 <Icon name="carbon:link" :size="14" />
137 <span class="text-secondary text-xs font-semibold tracking-wide uppercase">References</span>
138 </div>
139 </template>
140 <template #default>
141 <div v-if="story.references.length" class="flex flex-col items-start gap-2">
142 <n-button
143 v-for="item of story.references"
144 :key="item"
145 type="primary"
146 size="small"
147 secondary
148 @click="openReference(item)"
149 >
150 <template #icon>
151 <Icon name="carbon:launch" :size="12" />
152 </template>
153 {{ item }}
154 </n-button>
155 </div>
156 <p v-else class="text-sm">No references provided by member detections.</p>
157 </template>
158 </CardEntity>
159 </div>
160
161 <n-modal
162 v-model:show="showRuleModal"
163 preset="card"
164 :style="{ maxWidth: 'min(820px, 92vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
165 title="Detection Rule"
166 :bordered="false"
167 segmented
168 >
169 <RuleCardContent v-if="modalRuleId" :rule-id="modalRuleId" />
170 </n-modal>
171 </n-spin>
172 </template>
173
174 <script setup lang="tsx">
175 import type { DataTableColumns } from "naive-ui"
176 import type { CatalogStoryDetailResponse, CatalogStoryDetection } from "@/types/detectionCatalog.d"
177 import { NButton, NDataTable, NModal, NSpin, NTag, useMessage } from "naive-ui"
178 import { onBeforeMount, ref, watch } from "vue"
179 import Api from "@/api"
180 import Badge from "@/components/common/Badge.vue"
181 import CardEntity from "@/components/common/cards/CardEntity.vue"
182 import Icon from "@/components/common/Icon.vue"
183 import RuleCardContent from "@/components/copilotSearches/RuleCardContent.vue"
184 import { useSettingsStore } from "@/stores/settings"
185 import { getApiErrorMessage } from "@/utils"
186 import { formatDate } from "@/utils/format"
187
188 const props = defineProps<{ storyName: string }>()
189
190 const emits = defineEmits<{
191 (e: "error", message: string): void
192 }>()
193
194 const message = useMessage()
195 const story = ref<CatalogStoryDetailResponse | null>(null)
196 const loading = ref(false)
197 const dFormats = useSettingsStore().dateFormat
198 const showRuleModal = ref(false)
199 const modalRuleId = ref<string | null>(null)
200
201 function openRuleDetail(ruleId: string) {
202 modalRuleId.value = ruleId
203 showRuleModal.value = true
204 }
205
206 function openReference(reference: string) {
207 window.open(reference, "_blank")
208 }
209
210 function typeBadgeColor(t: string): "error" | "warning" | "primary" | undefined {
211 const type = (t || "").toLowerCase()
212 if (type === "ttp") return "error"
213 if (type === "anomaly") return "warning"
214 if (type === "hunting") return "primary"
215 return undefined
216 }
217
218 const detectionColumns: DataTableColumns<CatalogStoryDetection> = [
219 {
220 title: "Name",
221 key: "name",
222 render: row => (
223 <span class="text-primary cursor-pointer underline" onClick={() => openRuleDetail(row.id)}>
224 {row.name}
225 </span>
226 )
227 },
228 {
229 title: "Technique",
230 key: "tactics",
231 render: row => {
232 const ids = row.mitre_attack_id || []
233 if (!ids.length && !row.tactics.length) {
234 return <span class="text-secondary text-xs"></span>
235 }
236 return (
237 <div class="flex flex-wrap gap-1">
238 {row.tactics.map(t => (
239 <NTag key={t} type="warning" size="small">
240 {t.toUpperCase()}
241 </NTag>
242 ))}
243 {ids.map(id => (
244 <NTag key={id} type="info" size="small">
245 {id}
246 </NTag>
247 ))}
248 </div>
249 )
250 }
251 },
252 {
253 title: "Type",
254 key: "type",
255 width: 120,
256 render: row => {
257 const color = typeBadgeColor(row.type)
258 const text = (row.type || "").toUpperCase()
259 return (
260 <NTag type={color} size="small" bordered={false}>
261 {text}
262 </NTag>
263 )
264 }
265 }
266 ]
267
268 function load(name: string) {
269 loading.value = true
270 story.value = null
271 Api.detectionCatalog
272 .getStory(name)
273 .then(res => {
274 if (res.data?.success) {
275 story.value = res.data
276 } else {
277 message.warning(res.data?.message || "Failed to load story detail")
278 }
279 })
280 .catch(err => {
281 const status = err.response?.status
282 let errorMessage = getApiErrorMessage(err) || "Failed to load story detail"
283 if (status === 404) {
284 errorMessage = `No detections found for story '${name}'`
285 message.warning(errorMessage)
286 } else {
287 message.error(errorMessage)
288 }
289
290 emits("error", errorMessage)
291 })
292 .finally(() => {
293 loading.value = false
294 })
295 }
296
297 watch(
298 () => props.storyName,
299 name => load(name)
300 )
301 onBeforeMount(() => load(props.storyName))
302 </script>