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