main
vue 1,212 lines 33.5 KB
Raw
1 <template>
2 <div class="flex flex-col gap-3">
3 <div class="flex flex-wrap items-center justify-end gap-2">
4 <div class="flex min-w-80 grow gap-2">
5 <n-popover overlap placement="bottom-start">
6 <template #trigger>
7 <div class="bg-default rounded-lg">
8 <n-button size="small" class="cursor-help!">
9 <template #icon>
10 <Icon :name="InfoIcon" />
11 </template>
12 </n-button>
13 </div>
14 </template>
15 <div v-if="coverage" class="flex flex-col gap-2">
16 <div class="box">
17 Tactics:
18 <code>{{ coverage.stats.total_tactics }}</code>
19 </div>
20 <div class="box">
21 Techniques:
22 <code>{{ coverage.stats.total_techniques }}</code>
23 </div>
24 <div class="box">
25 Covered:
26 <code>{{ coverage.stats.covered_techniques }}</code>
27 </div>
28 <div class="box">
29 Rules in scope:
30 <code>{{ coverage.stats.total_rules }}</code>
31 </div>
32 </div>
33 </n-popover>
34
35 <n-input
36 v-model:value="searchQuery"
37 size="small"
38 placeholder="Search techniques or rule names..."
39 class="max-w-120"
40 clearable
41 >
42 <template #prefix>
43 <Icon :name="SearchIcon" />
44 </template>
45 </n-input>
46
47 <n-popover :show="showFilters" trigger="manual" overlap placement="bottom-start" class="px-0!">
48 <template #trigger>
49 <div class="bg-default rounded-lg">
50 <n-badge :show="anyFiltersActive" dot type="success" :offset="[-4, 0]">
51 <n-button size="small" @click="showFilters = !showFilters">
52 <template #icon>
53 <Icon :name="FilterIcon" />
54 </template>
55 </n-button>
56 </n-badge>
57 </div>
58 </template>
59 <div class="divide-border flex w-50 flex-col gap-0 divide-y">
60 <div class="flex flex-col gap-2.5 px-3 pt-1 pb-3">
61 <n-select
62 v-model:value="selectedPlatform"
63 :options="platformOptions"
64 size="small"
65 placeholder="Platform"
66 class="w-full"
67 clearable
68 :consistent-menu-width="false"
69 />
70 <n-select
71 v-model:value="selectedSeverity"
72 :options="severityOptions"
73 clearable
74 size="small"
75 placeholder="Severity"
76 class="w-full"
77 :consistent-menu-width="false"
78 />
79 <n-select
80 v-model:value="selectedStatus"
81 :options="statusOptions"
82 clearable
83 size="small"
84 placeholder="Status"
85 class="w-full"
86 :consistent-menu-width="false"
87 />
88 <n-checkbox v-model:checked="hasGraylogFilter" size="small">
89 <span class="text-xs">Graylog Only</span>
90 </n-checkbox>
91 </div>
92 <div class="flex justify-between gap-2 px-3 pt-2">
93 <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
94 <n-button size="small" secondary @click="resetFilters">Reset</n-button>
95 </div>
96 </div>
97 </n-popover>
98
99 <n-checkbox v-model:checked="onlyCovered" size="small" class="shrink-0! self-center whitespace-nowrap">
100 <span class="text-xs">Only covered</span>
101 </n-checkbox>
102 </div>
103
104 <n-tooltip placement="bottom-end">
105 <template #trigger>
106 <n-button size="small" :disabled="!coverage" @click="exportCoverageCsv">
107 <template #icon>
108 <Icon :name="ExportIcon" />
109 </template>
110 Export CSV
111 </n-button>
112 </template>
113 Download a CSV of the current coverage (one row per technique and sub-technique, with rule counts and
114 IDs).
115 </n-tooltip>
116
117 <n-tooltip placement="bottom-end">
118 <template #trigger>
119 <n-button size="small" :loading="refreshing" @click="handleRefresh">
120 <template #icon>
121 <Icon :name="RefreshIcon" />
122 </template>
123 Refresh Matrix
124 </n-button>
125 </template>
126 Force a re-fetch of the MITRE ATT&amp;CK STIX bundle from
127 <code>github.com/mitre/cti</code>
128 , bypassing the 24-hour cache. Use this if MITRE published a new release and you want the matrix to pick
129 it up immediately.
130 </n-tooltip>
131 </div>
132
133 <div class="legend">
134 <span class="text-tertiary text-xs">Rules:</span>
135 <div v-for="step of legendSteps" :key="step.label" class="legend-item">
136 <span class="legend-swatch" :class="step.cls" />
137 <span class="text-secondary text-xs">{{ step.label }}</span>
138 </div>
139 <div v-if="coverage" class="text-secondary ml-auto text-xs">
140 <strong>{{ coverage.stats.covered_techniques }}</strong>
141 /
142 <strong>{{ coverage.stats.total_techniques }}</strong>
143 techniques ·
144 <strong>{{ coverage.stats.total_rules }}</strong>
145 rules
146 </div>
147 </div>
148
149 <div class="matrix-scroll-wrap">
150 <!-- Subtle top progress bar replaces the heavy spin overlay during refetches. -->
151 <div v-if="loading && coverage" class="matrix-progress" />
152
153 <div class="matrix-scroll" :class="{ 'matrix-scroll-loading': loading && coverage }">
154 <n-empty
155 v-if="!loading && coverage && filteredTactics.length === 0"
156 description="No techniques match your filters."
157 class="matrix-empty"
158 >
159 <template #extra>
160 <n-button size="small" @click="clearAllFilters">Clear filters</n-button>
161 </template>
162 </n-empty>
163
164 <n-spin v-else-if="loading && !coverage" show class="matrix-initial-load" />
165
166 <div v-else class="matrix-grid">
167 <div v-for="tactic of filteredTactics" :key="tactic.id" class="tactic-column">
168 <div class="tactic-header" :class="{ 'tactic-uncovered': isTacticUncovered(tactic) }">
169 <div class="flex items-center justify-between gap-2">
170 <div class="tactic-name">{{ tactic.name }}</div>
171 <span
172 class="tactic-coverage"
173 :class="{ 'tactic-coverage-zero': isTacticUncovered(tactic) }"
174 :title="`${tacticStats(tactic).covered} of ${tacticStats(tactic).total} techniques covered by CoPilot rules`"
175 >
176 {{ tacticStats(tactic).covered }}/{{ tacticStats(tactic).total }}
177 </span>
178 </div>
179 <div class="text-tertiary text-xs">{{ tactic.techniques.length }} shown</div>
180 </div>
181
182 <div class="technique-list">
183 <n-popover
184 v-for="tech of tactic.techniques"
185 :key="tactic.id + tech.id"
186 trigger="hover"
187 :delay="350"
188 :duration="80"
189 :show-arrow="false"
190 placement="right"
191 :disabled="tech.total_rule_count === 0"
192 >
193 <template #trigger>
194 <div
195 class="technique-cell"
196 :class="[
197 cellClass(tech),
198 {
199 'cell-cross-tactic':
200 hoveredTechniqueId === tech.id && hoveredTacticId !== tactic.id
201 }
202 ]"
203 :title="cellTooltip(tech)"
204 @click="openTechnique(tactic, tech)"
205 @mouseenter="onCellEnter(tactic.id, tech.id)"
206 @mouseleave="onCellLeave"
207 >
208 <div class="technique-row">
209 <div class="technique-id">{{ tech.id }}</div>
210 <n-tag
211 v-if="tech.total_rule_count > 0"
212 size="tiny"
213 round
214 :bordered="false"
215 class="count-tag"
216 >
217 {{ tech.total_rule_count }}
218 </n-tag>
219 </div>
220 <div class="technique-name">{{ tech.name }}</div>
221
222 <div
223 v-if="tech.subtechniques.length"
224 class="technique-sub-toggle"
225 @click.stop="toggleExpand(tactic.id, tech.id)"
226 >
227 <Icon
228 :name="expanded[tactic.id + tech.id] ? ChevronDown : ChevronRight"
229 :size="10"
230 />
231 {{ tech.subtechniques.length }} sub
232 </div>
233
234 <div v-if="expanded[tactic.id + tech.id]" class="subtechnique-list" @click.stop>
235 <n-popover
236 v-for="sub of visibleSubs(tech, tactic.id + tech.id)"
237 :key="sub.id"
238 trigger="hover"
239 :delay="350"
240 :duration="80"
241 :show-arrow="false"
242 placement="right"
243 :disabled="sub.rule_count === 0"
244 >
245 <template #trigger>
246 <div
247 class="subtechnique-cell"
248 :class="cellClass(sub)"
249 :title="subCellTooltip(sub)"
250 @click="openSubTechnique(tactic, tech, sub)"
251 >
252 <div class="technique-row">
253 <div class="subtechnique-id">{{ sub.id }}</div>
254 <n-tag
255 v-if="sub.rule_count > 0"
256 size="tiny"
257 round
258 :bordered="false"
259 class="count-tag"
260 >
261 {{ sub.rule_count }}
262 </n-tag>
263 </div>
264 <div class="subtechnique-name">{{ sub.name }}</div>
265 </div>
266 </template>
267
268 <RulePreviewList :rule-ids="sub.rule_ids" :index="rulesIndex" />
269 </n-popover>
270
271 <div
272 v-if="tech.subtechniques.length > SUB_PREVIEW_LIMIT"
273 class="show-all-subs"
274 @click.stop="toggleShowAllSubs(tactic.id + tech.id)"
275 >
276 {{
277 showAllSubs[tactic.id + tech.id]
278 ? `Show fewer`
279 : `Show all ${tech.subtechniques.length}`
280 }}
281 </div>
282 </div>
283 </div>
284 </template>
285
286 <RulePreviewList
287 :rule-ids="tech.rule_ids"
288 :index="rulesIndex"
289 :extra-via-subs="tech.total_rule_count - tech.rule_count"
290 />
291 </n-popover>
292
293 <n-empty
294 v-if="!tactic.techniques.length"
295 description="No techniques"
296 class="py-4"
297 size="small"
298 />
299 </div>
300 </div>
301 </div>
302 </div>
303 </div>
304
305 <TechniqueDrawer
306 v-model:show="drawerOpen"
307 :technique="selectedTechnique"
308 :sub-technique="selectedSubTechnique"
309 @update:show="onDrawerToggle"
310 />
311
312 <!-- Direct-from-hover rule detail modal: skips the drawer entirely
313 when the user clicks a rule name inside the hover preview. -->
314 <n-modal
315 v-model:show="quickRuleOpen"
316 preset="card"
317 :style="{ maxWidth: 'min(750px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
318 title="Detection Rule"
319 :bordered="false"
320 segmented
321 >
322 <RuleCardContent v-if="quickRuleId" :rule-id="quickRuleId" />
323 </n-modal>
324 </div>
325 </template>
326
327 <script setup lang="ts">
328 import type {
329 MitreCoverageQuery,
330 MitreCoverageResponse,
331 MitreRuleIndexEntry,
332 MitreSubTechnique,
333 MitreTactic,
334 MitreTechnique,
335 PlatformFilter,
336 RuleSeverity,
337 RuleStatus
338 } from "@/types/copilotSearches.d"
339 import { useLocalStorage, watchDebounced } from "@vueuse/core"
340 import {
341 NBadge,
342 NButton,
343 NCheckbox,
344 NEmpty,
345 NInput,
346 NModal,
347 NPopover,
348 NSelect,
349 NSpin,
350 NTag,
351 NTooltip,
352 useMessage
353 } from "naive-ui"
354 import { computed, h, onMounted, ref, watch } from "vue"
355 import { useRoute, useRouter } from "vue-router"
356 import Api from "@/api"
357 import Icon from "@/components/common/Icon.vue"
358 import RuleCardContent from "./RuleCardContent.vue"
359 import TechniqueDrawer from "./TechniqueDrawer.vue"
360
361 const InfoIcon = "carbon:information"
362 const SearchIcon = "carbon:search"
363 const RefreshIcon = "carbon:renew"
364 const FilterIcon = "carbon:filter-edit"
365 const ExportIcon = "carbon:download"
366 const ChevronRight = "carbon:chevron-right"
367 const ChevronDown = "carbon:chevron-down"
368
369 const route = useRoute()
370 const router = useRouter()
371 const message = useMessage()
372
373 const loading = ref(false)
374 const refreshing = ref(false)
375 const coverage = ref<MitreCoverageResponse | null>(null)
376 const onlyCovered = useLocalStorage("copilot-searches/matrix/only-covered", false)
377 const searchQuery = ref("")
378 const expanded = useLocalStorage<Record<string, boolean>>("copilot-searches/matrix/expanded", {})
379 const showAllSubs = ref<Record<string, boolean>>({})
380
381 const SUB_PREVIEW_LIMIT = 5
382
383 function visibleSubs(tech: MitreTechnique, key: string) {
384 if (showAllSubs.value[key]) return tech.subtechniques
385 return tech.subtechniques.slice(0, SUB_PREVIEW_LIMIT)
386 }
387 function toggleShowAllSubs(key: string) {
388 showAllSubs.value[key] = !showAllSubs.value[key]
389 }
390
391 const selectedPlatform = ref<PlatformFilter | null>(null)
392 const selectedSeverity = ref<RuleSeverity | null>(null)
393 const selectedStatus = ref<RuleStatus | null>(null)
394 const hasGraylogFilter = ref(false)
395 const showFilters = ref(false)
396
397 const drawerOpen = ref(false)
398 const selectedTechnique = ref<MitreTechnique | null>(null)
399 const selectedSubTechnique = ref<MitreSubTechnique | null>(null)
400 const selectedTacticIdForDeepLink = ref<string | null>(null)
401
402 // Direct-from-hover rule modal
403 const quickRuleOpen = ref(false)
404 const quickRuleId = ref<string | null>(null)
405 function openQuickRule(ruleId: string) {
406 quickRuleId.value = ruleId
407 quickRuleOpen.value = true
408 }
409
410 const hoveredTechniqueId = ref<string | null>(null)
411 const hoveredTacticId = ref<string | null>(null)
412
413 // Suppresses the filter-change watcher during the initial URL→ref hydration
414 // so we don't fire a duplicate fetch right after the first load.
415 const ready = ref(false)
416
417 const platformOptions = [
418 { label: "Linux", value: "linux" },
419 { label: "Windows", value: "windows" },
420 { label: "PowerShell", value: "powershell" },
421 { label: "CVE", value: "cve" }
422 ]
423 const severityOptions = [
424 { label: "Low", value: "low" },
425 { label: "Medium", value: "medium" },
426 { label: "High", value: "high" },
427 { label: "Critical", value: "critical" }
428 ]
429 const statusOptions = [
430 { label: "Production", value: "production" },
431 { label: "Experimental", value: "experimental" },
432 { label: "Deprecated", value: "deprecated" }
433 ]
434
435 const anyFiltersActive = computed(
436 () => !!selectedPlatform.value || !!selectedSeverity.value || !!selectedStatus.value || !!hasGraylogFilter.value
437 )
438
439 const rulesIndex = computed<Record<string, MitreRuleIndexEntry>>(() => coverage.value?.rules_index ?? {})
440
441 /**
442 * Match against rule names/IDs via the in-memory rules_index. Used to surface
443 * techniques whose rules — not whose own name — match the search query.
444 */
445 function ruleIdsMatch(ruleIds: string[], q: string): boolean {
446 const idx = rulesIndex.value
447 for (const id of ruleIds) {
448 if (id.toLowerCase().includes(q)) return true
449 const r = idx[id]
450 if (r && r.name.toLowerCase().includes(q)) return true
451 }
452 return false
453 }
454
455 const filteredTactics = computed<MitreTactic[]>(() => {
456 if (!coverage.value) return []
457 const q = searchQuery.value.trim().toLowerCase()
458 const tactics = coverage.value.tactics.map(tactic => ({
459 ...tactic,
460 techniques: tactic.techniques.filter(tech => {
461 if (onlyCovered.value && tech.total_rule_count === 0) return false
462 if (q) {
463 const techHaystack = `${tech.id} ${tech.name}`.toLowerCase()
464 const techMatches = techHaystack.includes(q)
465 const ruleMatches =
466 ruleIdsMatch(tech.rule_ids, q) || tech.subtechniques.some(s => ruleIdsMatch(s.rule_ids, q))
467 if (!techMatches && !ruleMatches) return false
468 }
469 return true
470 })
471 }))
472 // When the user is actively searching, drop tactics with no matches so the
473 // matrix collapses to just the relevant columns. Without an active search,
474 // we keep empty tactics visible (they're informative on their own).
475 return q ? tactics.filter(t => t.techniques.length > 0) : tactics
476 })
477
478 const legendSteps = [
479 { label: "0", cls: "cov-empty" },
480 { label: "1", cls: "cov-1" },
481 { label: "2-3", cls: "cov-2" },
482 { label: "4-7", cls: "cov-3" },
483 { label: "8+", cls: "cov-4" }
484 ] as const
485
486 function tacticStats(tactic: MitreTactic) {
487 const source = coverage.value?.tactics.find(t => t.id === tactic.id)?.techniques ?? tactic.techniques
488 const total = source.length
489 const covered = source.filter(t => t.total_rule_count > 0).length
490 return { total, covered }
491 }
492
493 function isTacticUncovered(tactic: MitreTactic): boolean {
494 const { covered, total } = tacticStats(tactic)
495 return total > 0 && covered === 0
496 }
497
498 function cellClass(item: MitreTechnique | MitreSubTechnique) {
499 const count = "total_rule_count" in item ? item.total_rule_count : item.rule_count
500 if (count === 0) return `cov-empty`
501 if (count === 1) return `cov-1`
502 if (count <= 3) return `cov-2`
503 if (count <= 7) return `cov-3`
504 return `cov-4`
505 }
506
507 function cellTooltip(tech: MitreTechnique) {
508 if (tech.total_rule_count === 0) return `${tech.id} ${tech.name} — no CoPilot rules`
509 const subDelta = tech.total_rule_count - tech.rule_count
510 return subDelta
511 ? `${tech.id} ${tech.name}${tech.rule_count} direct, +${subDelta} via sub-techniques`
512 : `${tech.id} ${tech.name}${tech.rule_count} rule(s)`
513 }
514 function subCellTooltip(sub: MitreSubTechnique) {
515 return sub.rule_count ? `${sub.id} ${sub.name}${sub.rule_count} rule(s)` : `${sub.id} ${sub.name} — no rules`
516 }
517
518 function toggleExpand(tacticId: string, techId: string) {
519 const k = tacticId + techId
520 const willOpen = !expanded.value[k]
521 if (willOpen) {
522 // Auto-collapse other expanded techniques in the same tactic so columns
523 // don't sprawl vertically when several are open at once.
524 for (const otherKey of Object.keys(expanded.value)) {
525 if (otherKey.startsWith(tacticId) && otherKey !== k) {
526 expanded.value[otherKey] = false
527 }
528 }
529 }
530 expanded.value[k] = willOpen
531 }
532
533 function clearAllFilters() {
534 selectedPlatform.value = null
535 selectedSeverity.value = null
536 selectedStatus.value = null
537 hasGraylogFilter.value = false
538 searchQuery.value = ""
539 onlyCovered.value = false
540 }
541
542 function exportCoverageCsv() {
543 if (!coverage.value) return
544 const rows: string[][] = [
545 [
546 "tactic_id",
547 "tactic_name",
548 "technique_id",
549 "technique_name",
550 "rule_count_direct",
551 "rule_count_total",
552 "rule_ids"
553 ]
554 ]
555 for (const tactic of coverage.value.tactics) {
556 for (const tech of tactic.techniques) {
557 rows.push([
558 tactic.id,
559 tactic.name,
560 tech.id,
561 tech.name,
562 String(tech.rule_count),
563 String(tech.total_rule_count),
564 tech.rule_ids.join("|")
565 ])
566 for (const sub of tech.subtechniques) {
567 rows.push([
568 tactic.id,
569 tactic.name,
570 sub.id,
571 sub.name,
572 String(sub.rule_count),
573 String(sub.rule_count),
574 sub.rule_ids.join("|")
575 ])
576 }
577 }
578 }
579 const csv = rows.map(r => r.map(cell => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n")
580 const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" })
581 const url = URL.createObjectURL(blob)
582 const link = document.createElement("a")
583 link.href = url
584 link.download = `copilot-mitre-coverage-${new Date().toISOString().slice(0, 10)}.csv`
585 document.body.appendChild(link)
586 link.click()
587 document.body.removeChild(link)
588 URL.revokeObjectURL(url)
589 }
590
591 function onCellEnter(tacticId: string, techId: string) {
592 hoveredTacticId.value = tacticId
593 hoveredTechniqueId.value = techId
594 }
595 function onCellLeave() {
596 hoveredTacticId.value = null
597 hoveredTechniqueId.value = null
598 }
599
600 function resetFilters() {
601 selectedPlatform.value = null
602 selectedSeverity.value = null
603 selectedStatus.value = null
604 hasGraylogFilter.value = false
605 showFilters.value = false
606 }
607
608 function openTechnique(tactic: MitreTactic, tech: MitreTechnique) {
609 selectedTacticIdForDeepLink.value = tactic.id
610 selectedTechnique.value = tech
611 selectedSubTechnique.value = null
612 drawerOpen.value = true
613 syncRouteFromSelection()
614 }
615 function openSubTechnique(tactic: MitreTactic, tech: MitreTechnique, sub: MitreSubTechnique) {
616 selectedTacticIdForDeepLink.value = tactic.id
617 selectedTechnique.value = tech
618 selectedSubTechnique.value = sub
619 drawerOpen.value = true
620 syncRouteFromSelection()
621 }
622
623 function onDrawerToggle(open: boolean) {
624 if (!open) {
625 // Drawer just closed — drop the technique deep-link query.
626 const next = { ...route.query }
627 delete next.technique
628 delete next.sub
629 router.replace({ query: next })
630 }
631 }
632
633 function syncRouteFromSelection() {
634 if (!selectedTechnique.value) return
635 const next: Record<string, string> = { ...(route.query as Record<string, string>) }
636 next.view = "matrix"
637 next.technique = selectedTechnique.value.id
638 if (selectedSubTechnique.value) next.sub = selectedSubTechnique.value.id
639 else delete next.sub
640 router.replace({ query: next })
641 }
642
643 function syncFiltersToUrl() {
644 const next: Record<string, string> = { ...(route.query as Record<string, string>) }
645 if (selectedPlatform.value) next.platform = selectedPlatform.value
646 else delete next.platform
647 if (selectedSeverity.value) next.severity = selectedSeverity.value
648 else delete next.severity
649 if (selectedStatus.value) next.status = selectedStatus.value
650 else delete next.status
651 if (hasGraylogFilter.value) next.has_graylog = "true"
652 else delete next.has_graylog
653 router.replace({ query: next })
654 }
655
656 function applyFiltersFromUrl() {
657 const q = route.query
658 const platform = q.platform as string | undefined
659 const severity = q.severity as string | undefined
660 const status = q.status as string | undefined
661
662 const validPlatforms: PlatformFilter[] = ["all", "linux", "windows", "powershell", "cve"]
663 const validSeverities: RuleSeverity[] = ["low", "medium", "high", "critical"]
664 const validStatuses: RuleStatus[] = ["production", "experimental", "deprecated"]
665
666 selectedPlatform.value =
667 platform && (validPlatforms as string[]).includes(platform) ? (platform as PlatformFilter) : null
668 selectedSeverity.value =
669 severity && (validSeverities as string[]).includes(severity) ? (severity as RuleSeverity) : null
670 selectedStatus.value = status && (validStatuses as string[]).includes(status) ? (status as RuleStatus) : null
671 hasGraylogFilter.value = q.has_graylog === "true"
672 }
673
674 function applyDeepLinkFromRoute() {
675 if (!coverage.value) return
676 const techId = (route.query.technique as string | undefined)?.toUpperCase()
677 const subId = (route.query.sub as string | undefined)?.toUpperCase()
678 if (!techId) return
679
680 for (const tactic of coverage.value.tactics) {
681 const tech = tactic.techniques.find(t => t.id === techId)
682 if (!tech) continue
683 if (subId) {
684 const sub = tech.subtechniques.find(s => s.id === subId)
685 if (sub) {
686 expanded.value[tactic.id + tech.id] = true
687 openSubTechnique(tactic, tech, sub)
688 return
689 }
690 }
691 openTechnique(tactic, tech)
692 return
693 }
694 }
695
696 async function load(opts: { preserveDeepLink?: boolean } = {}) {
697 loading.value = true
698 const query: MitreCoverageQuery = {
699 platform: selectedPlatform.value || undefined,
700 severity: selectedSeverity.value || undefined,
701 status: selectedStatus.value || undefined,
702 has_graylog: hasGraylogFilter.value || undefined
703 }
704 try {
705 const res = await Api.copilotSearches.getMitreCoverage(query)
706 if (res.data?.success) {
707 coverage.value = res.data
708 if (opts.preserveDeepLink) applyDeepLinkFromRoute()
709 } else {
710 message.warning(res.data?.message || "Failed to load MITRE coverage")
711 }
712 } catch (err: any) {
713 message.error(err.response?.data?.message || "Failed to load MITRE coverage")
714 } finally {
715 loading.value = false
716 }
717 }
718
719 async function handleRefresh() {
720 refreshing.value = true
721 try {
722 await Api.copilotSearches.refreshMitreMatrix()
723 await load()
724 message.success("MITRE matrix refreshed")
725 } catch (err: any) {
726 message.error(err.response?.data?.message || "Failed to refresh MITRE matrix")
727 } finally {
728 refreshing.value = false
729 }
730 }
731
732 watchDebounced(
733 [selectedPlatform, selectedSeverity, selectedStatus, hasGraylogFilter],
734 () => {
735 if (!ready.value) return
736 syncFiltersToUrl()
737 load()
738 },
739 { debounce: 250 }
740 )
741
742 // React to deep-link URL changes (back/forward, paste-link, etc.).
743 watch(
744 () => [route.query.technique, route.query.sub] as const,
745 () => applyDeepLinkFromRoute()
746 )
747
748 onMounted(async () => {
749 applyFiltersFromUrl()
750 await load({ preserveDeepLink: true })
751 ready.value = true
752 })
753
754 // ---------------------------------------------------------------------------
755 // Hover preview list — inline component. Rule rows are clickable; clicking
756 // one opens the existing rule-detail modal directly without going through
757 // the technique drawer.
758 // ---------------------------------------------------------------------------
759 const platformIcon: Record<string, string> = {
760 linux: "logos:linux-tux",
761 windows: "logos:microsoft-icon",
762 powershell: "vscode-icons:file-type-powershell",
763 cve: "carbon:security",
764 unknown: "carbon:help"
765 }
766
767 function RulePreviewList(props: {
768 ruleIds: string[]
769 index: Record<string, MitreRuleIndexEntry>
770 extraViaSubs?: number
771 }) {
772 const ids = props.ruleIds || []
773 if (!ids.length) {
774 return h("div", { class: "preview-empty text-secondary text-xs" }, "No rules")
775 }
776 const shown = ids.slice(0, 6)
777 const remainder = ids.length - shown.length
778 return h("div", { class: "preview-wrap flex flex-col gap-1" }, [
779 h(
780 "div",
781 { class: "text-tertiary text-xs uppercase tracking-wide" },
782 `${ids.length} rule${ids.length === 1 ? "" : "s"}${
783 props.extraViaSubs ? ` · +${props.extraViaSubs} via sub-techniques` : ""
784 }`
785 ),
786 ...shown.map(id => {
787 const entry = props.index[id]
788 const platform = (entry?.platform || "unknown").toLowerCase()
789 const iconName = platformIcon[platform] || platformIcon.unknown
790 const dataSources = entry?.data_sources || []
791 return h(
792 "div",
793 {
794 class: "preview-row flex flex-col gap-1",
795 key: id,
796 onClick: (e: MouseEvent) => {
797 e.stopPropagation()
798 openQuickRule(id)
799 },
800 title: "Click to open rule details"
801 },
802 [
803 h("div", { class: "flex items-center gap-2" }, [
804 h(Icon as any, { name: iconName, size: 14, class: "preview-platform shrink-0" }),
805 h("span", { class: "preview-name text-default text-xs" }, entry?.name || id),
806 entry?.severity
807 ? h(
808 "span",
809 { class: `preview-sev preview-sev-${entry.severity.toLowerCase()} text-xs` },
810 entry.severity
811 )
812 : null
813 ]),
814 dataSources.length
815 ? h(
816 "div",
817 { class: "preview-sources flex flex-wrap items-center gap-1" },
818 dataSources.map(s => h("span", { class: "preview-source text-xs", key: s }, s))
819 )
820 : null
821 ]
822 )
823 }),
824 remainder > 0
825 ? h("div", { class: "text-tertiary text-xs" }, `+ ${remainder} more — click cell to view all`)
826 : null
827 ])
828 }
829 </script>
830
831 <style scoped lang="scss">
832 .legend {
833 display: flex;
834 flex-wrap: wrap;
835 align-items: center;
836 gap: 10px;
837 padding: 6px 10px;
838 background: var(--bg-secondary-color);
839 border: 1px solid var(--border-color);
840 border-radius: var(--border-radius);
841 }
842
843 .legend-item {
844 display: inline-flex;
845 align-items: center;
846 gap: 5px;
847 }
848
849 .legend-swatch {
850 display: inline-block;
851 width: 14px;
852 height: 14px;
853 border-radius: 3px;
854 border: 1px solid var(--border-color);
855 }
856
857 .tactic-coverage {
858 font-family: var(--font-family-mono, monospace);
859 font-size: 0.7rem;
860 font-weight: 600;
861 color: var(--fg-secondary-color);
862 background: var(--bg-default-color);
863 border: 1px solid var(--border-color);
864 border-radius: 3px;
865 padding: 1px 6px;
866 white-space: nowrap;
867 }
868
869 .matrix-scroll-wrap {
870 position: relative;
871 }
872
873 /* Subtle indeterminate progress bar shown during filter refetches in place
874 of a heavy spin overlay. Sits at the top of the scroll container and
875 doesn't shift the layout when it appears/disappears. */
876 .matrix-progress {
877 position: absolute;
878 left: 0;
879 right: 0;
880 top: 0;
881 height: 2px;
882 overflow: hidden;
883 background: rgba(var(--primary-color-rgb) / 0.1);
884 z-index: 3;
885 pointer-events: none;
886 border-radius: var(--border-radius) var(--border-radius) 0 0;
887 }
888 .matrix-progress::after {
889 content: "";
890 position: absolute;
891 top: 0;
892 left: -40%;
893 width: 40%;
894 height: 100%;
895 background: var(--primary-color);
896 animation: matrix-progress-slide 1.1s ease-in-out infinite;
897 }
898 @keyframes matrix-progress-slide {
899 0% {
900 left: -40%;
901 }
902 100% {
903 left: 100%;
904 }
905 }
906
907 /* Matrix scrolls inside its own bounded box so the horizontal scrollbar
908 is always reachable without scrolling the whole page. Height adapts to
909 the viewport minus app chrome + our toolbar/legend rows. */
910 .matrix-scroll {
911 overflow: auto;
912 max-height: calc(100vh - 260px);
913 min-height: 420px;
914 padding-bottom: 4px;
915 border: 1px solid var(--border-color);
916 border-radius: var(--border-radius);
917 background: var(--bg-secondary-color);
918 transition: opacity 0.18s ease;
919 }
920
921 /* During a filter refetch, fade existing data slightly so the user sees
922 the fresh load is happening without the matrix disappearing. */
923 .matrix-scroll-loading {
924 opacity: 0.55;
925 }
926
927 .matrix-empty {
928 height: 100%;
929 min-height: 380px;
930 display: flex;
931 align-items: center;
932 justify-content: center;
933 }
934
935 .matrix-initial-load {
936 display: flex;
937 align-items: center;
938 justify-content: center;
939 min-height: 380px;
940 width: 100%;
941 }
942
943 .matrix-grid {
944 display: flex;
945 gap: 6px;
946 min-width: max-content;
947 padding: 4px;
948 }
949
950 .tactic-column {
951 width: 200px;
952 flex-shrink: 0;
953 display: flex;
954 flex-direction: column;
955 }
956
957 /* Tactic headers stick to the top of the scroll container so the column
958 label is always visible while scrolling vertically through techniques. */
959 .tactic-header {
960 position: sticky;
961 top: 0;
962 z-index: 2;
963 padding: 8px 10px;
964 background: var(--bg-secondary-color);
965 border: 1px solid var(--border-color);
966 border-radius: 6px 6px 0 0;
967 border-bottom-width: 2px;
968 }
969
970 /* Tactic with no covered techniques — soft warning border so coverage gaps
971 surface at a glance without screaming. */
972 .tactic-header.tactic-uncovered {
973 border-color: rgba(var(--warning-color-rgb) / 0.55);
974 border-bottom-color: rgba(var(--warning-color-rgb) / 0.7);
975 background: rgba(var(--warning-color-rgb) / 0.06);
976 }
977
978 .tactic-coverage-zero {
979 color: var(--warning-color);
980 border-color: rgba(var(--warning-color-rgb) / 0.55);
981 background: rgba(var(--warning-color-rgb) / 0.08);
982 }
983
984 .tactic-name {
985 font-weight: 600;
986 font-size: 0.85rem;
987 color: var(--fg-default-color);
988 }
989
990 .technique-list {
991 display: flex;
992 flex-direction: column;
993 gap: 3px;
994 padding-top: 3px;
995 }
996
997 .technique-cell {
998 padding: 6px 8px;
999 border-radius: 4px;
1000 cursor: pointer;
1001 transition:
1002 background-color 0.12s,
1003 border-color 0.12s,
1004 box-shadow 0.12s;
1005 font-size: 0.75rem;
1006 border: 1px solid var(--border-color);
1007 background: var(--bg-default-color);
1008 }
1009
1010 .technique-cell:hover {
1011 border-color: rgba(var(--primary-color-rgb) / 0.6);
1012 background: rgba(var(--primary-color-rgb) / 0.08);
1013 }
1014
1015 /* Same technique appearing in another tactic column — gets a soft outline
1016 so you can see cross-tactic membership at a glance. */
1017 .cell-cross-tactic {
1018 box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb) / 0.45);
1019 }
1020
1021 .technique-row {
1022 display: flex;
1023 justify-content: space-between;
1024 align-items: center;
1025 gap: 6px;
1026 }
1027
1028 .technique-id {
1029 font-weight: 600;
1030 font-family: var(--font-family-mono, monospace);
1031 color: var(--fg-default-color);
1032 font-size: 0.72rem;
1033 }
1034
1035 .technique-name {
1036 font-size: 0.7rem;
1037 color: var(--fg-secondary-color);
1038 margin-top: 2px;
1039 line-height: 1.25;
1040 }
1041
1042 .count-tag {
1043 font-weight: 700;
1044 min-width: 22px;
1045 justify-content: center;
1046 }
1047
1048 .technique-sub-toggle {
1049 margin-top: 4px;
1050 font-size: 0.65rem;
1051 color: var(--fg-tertiary-color);
1052 cursor: pointer;
1053 user-select: none;
1054 display: inline-flex;
1055 align-items: center;
1056 gap: 3px;
1057 padding: 2px 4px;
1058 border-radius: 3px;
1059 width: fit-content;
1060 }
1061
1062 .technique-sub-toggle:hover {
1063 color: var(--primary-color);
1064 background: rgba(var(--primary-color-rgb) / 0.08);
1065 }
1066
1067 .subtechnique-list {
1068 margin-top: 4px;
1069 padding-left: 6px;
1070 display: flex;
1071 flex-direction: column;
1072 gap: 2px;
1073 border-left: 2px solid var(--border-color);
1074 }
1075
1076 .subtechnique-cell {
1077 padding: 4px 6px;
1078 border-radius: 3px;
1079 cursor: pointer;
1080 font-size: 0.7rem;
1081 border: 1px solid var(--border-color);
1082 background: var(--bg-default-color);
1083 transition:
1084 background-color 0.12s,
1085 border-color 0.12s;
1086 }
1087
1088 .subtechnique-cell:hover {
1089 border-color: rgba(var(--primary-color-rgb) / 0.6);
1090 background: rgba(var(--primary-color-rgb) / 0.08);
1091 }
1092
1093 .subtechnique-id {
1094 font-family: var(--font-family-mono, monospace);
1095 font-weight: 600;
1096 font-size: 0.65rem;
1097 color: var(--fg-default-color);
1098 }
1099
1100 .subtechnique-name {
1101 font-size: 0.65rem;
1102 color: var(--fg-secondary-color);
1103 line-height: 1.25;
1104 }
1105
1106 .show-all-subs {
1107 margin-top: 2px;
1108 padding: 3px 6px;
1109 font-size: 0.65rem;
1110 color: var(--fg-tertiary-color);
1111 cursor: pointer;
1112 border-radius: 3px;
1113 user-select: none;
1114 text-align: center;
1115 border: 1px dashed var(--border-color);
1116 }
1117 .show-all-subs:hover {
1118 color: var(--primary-color);
1119 border-color: rgba(var(--primary-color-rgb) / 0.5);
1120 background: rgba(var(--primary-color-rgb) / 0.06);
1121 }
1122
1123 /* Coverage heat — subtle brand-tinted backgrounds, neutral borders so the
1124 grid still reads as a grid. Text never goes white-on-orange. */
1125 .cov-empty {
1126 background: var(--bg-default-color);
1127 }
1128 .cov-1 {
1129 background: rgba(var(--primary-color-rgb) / 0.07);
1130 }
1131 .cov-2 {
1132 background: rgba(var(--primary-color-rgb) / 0.16);
1133 }
1134 .cov-3 {
1135 background: rgba(var(--primary-color-rgb) / 0.28);
1136 }
1137 .cov-4 {
1138 background: rgba(var(--primary-color-rgb) / 0.45);
1139 }
1140 </style>
1141
1142 <style lang="scss">
1143 /* Unscoped: applies to the inline RulePreviewList rendered inside n-popover bodies,
1144 which sit outside the component tree. */
1145 .preview-wrap {
1146 max-width: 360px;
1147 }
1148 .preview-row {
1149 cursor: pointer;
1150 padding: 2px 4px;
1151 border-radius: 3px;
1152 transition: background-color 0.1s;
1153 }
1154 .preview-row:hover {
1155 background: rgba(var(--primary-color-rgb) / 0.1);
1156 }
1157 .preview-row:hover .preview-name {
1158 color: var(--primary-color);
1159 }
1160 .preview-row .preview-name {
1161 flex: 1;
1162 overflow: hidden;
1163 text-overflow: ellipsis;
1164 white-space: nowrap;
1165 }
1166 .preview-platform {
1167 opacity: 0.85;
1168 }
1169
1170 .preview-sources {
1171 margin-left: 22px;
1172 }
1173 .preview-source {
1174 font-size: 0.6rem;
1175 font-weight: 500;
1176 letter-spacing: 0.02em;
1177 color: var(--fg-tertiary-color);
1178 background: var(--bg-default-color);
1179 border: 1px solid var(--border-color);
1180 border-radius: 3px;
1181 padding: 1px 5px;
1182 }
1183 .preview-sev {
1184 font-size: 0.65rem;
1185 font-weight: 600;
1186 text-transform: uppercase;
1187 padding: 1px 6px;
1188 border-radius: 3px;
1189 border: 1px solid var(--border-color);
1190 color: var(--fg-secondary-color);
1191 }
1192 .preview-sev-low {
1193 color: var(--info-color);
1194 border-color: rgba(var(--info-color-rgb) / 0.4);
1195 background: rgba(var(--info-color-rgb) / 0.1);
1196 }
1197 .preview-sev-medium {
1198 color: var(--warning-color);
1199 border-color: rgba(var(--warning-color-rgb) / 0.4);
1200 background: rgba(var(--warning-color-rgb) / 0.1);
1201 }
1202 .preview-sev-high {
1203 color: var(--error-color);
1204 border-color: rgba(var(--error-color-rgb) / 0.4);
1205 background: rgba(var(--error-color-rgb) / 0.1);
1206 }
1207 .preview-sev-critical {
1208 color: var(--error-color);
1209 border-color: var(--error-color);
1210 background: rgba(var(--error-color-rgb) / 0.18);
1211 }
1212 </style>