main
vue 202 lines 5.25 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-4">
3 <!-- Title row + framing copy -->
4 <div class="flex flex-col gap-1">
5 <h3 class="text-lg font-semibold">Coverage Gaps</h3>
6 <p class="text-secondary text-sm">
7 MITRE ATT&amp;CK techniques no rule covers — across both the CoPilot Searches corpus and the Wazuh
8 ruleset. Sub-techniques are collapsed into their parents (coverage of T1059.001 counts as coverage for
9 T1059). Use this list to spot where new detection authoring would expand your coverage.
10 </p>
11 </div>
12
13 <!-- COVERAGE HERO STATS - same CardLink pattern used by the catalog header. -->
14 <div v-if="!loading" class="grid grid-cols-1 gap-4 @2xl:grid-cols-3">
15 <CardLink
16 v-for="tile in coverageStatTiles"
17 :key="tile.id"
18 :title="tile.label"
19 :value="tile.value"
20 :icon-left="tile.icon"
21 :color="tile.color"
22 :subtitle="tile.sub"
23 size="small"
24 />
25 </div>
26
27 <div class="flex flex-wrap items-center gap-2">
28 <n-input
29 v-model:value="filter"
30 size="small"
31 placeholder="Filter by technique ID, name, or tactic…"
32 clearable
33 class="min-w-80 flex-1"
34 >
35 <template #prefix><Icon name="carbon:search" /></template>
36 </n-input>
37
38 <Badge type="splitted" color="primary" class="shrink-0">
39 <template #label>Showing</template>
40 <template #value>{{ filteredGaps.length }} / {{ gaps.length }}</template>
41 </Badge>
42 </div>
43
44 <n-data-table :columns :data="filteredGaps" :loading size="small" :pagination :scroll-x="100" />
45 </div>
46 </template>
47
48 <script setup lang="tsx">
49 import type { DataTableColumns } from "naive-ui"
50 import type { CardLinkColor } from "@/components/common/cards/CardLink.vue"
51 import type { CatalogCoverageGapRow } from "@/types/detectionCatalog.d"
52 import { NButton, NDataTable, NInput, NTag, useMessage } from "naive-ui"
53 import { computed, onBeforeMount, ref } from "vue"
54 import Api from "@/api"
55 import Badge from "@/components/common/Badge.vue"
56 import CardLink from "@/components/common/cards/CardLink.vue"
57 import Icon from "@/components/common/Icon.vue"
58
59 interface CoverageStatTile {
60 id: string
61 label: string
62 value: string
63 icon: string
64 sub: string
65 color: CardLinkColor
66 }
67
68 const message = useMessage()
69
70 const gaps = ref<CatalogCoverageGapRow[]>([])
71 const gap_count = ref(0)
72 const total_techniques = ref(0)
73 const covered_count = ref(0)
74 const coverage_pct = ref(0)
75
76 const loading = ref(false)
77 const filter = ref("")
78
79 const ShieldIcon = "carbon:ibm-cloud-security-groups"
80 const CoveredIcon = "carbon:checkmark-outline"
81 const GapsIcon = "carbon:warning-square"
82
83 const pagination = {
84 pageSize: 25,
85 pageSizes: [10, 25, 50, 100],
86 showSizePicker: true
87 }
88
89 const filteredGaps = computed<CatalogCoverageGapRow[]>(() => {
90 const q = filter.value.trim().toLowerCase()
91 if (!q) return gaps.value
92 return gaps.value.filter(g =>
93 [g.technique_id, g.technique_name, ...(g.tactics || [])].join(" ").toLowerCase().includes(q)
94 )
95 })
96
97 const coverageStatTiles = computed<CoverageStatTile[]>(() => [
98 {
99 id: "coverage",
100 label: "Coverage",
101 value: `${coverage_pct.value}%`,
102 icon: ShieldIcon,
103 sub: "Across both corpora",
104 color: "warning"
105 },
106 {
107 id: "covered-techniques",
108 label: "Covered techniques",
109 value: covered_count.value.toLocaleString(),
110 icon: CoveredIcon,
111 sub: `of ${total_techniques.value.toLocaleString()} total`,
112 color: "success"
113 },
114 {
115 id: "gaps",
116 label: "Gaps",
117 value: gap_count.value.toLocaleString(),
118 icon: GapsIcon,
119 sub: "Techniques with no rule",
120 color: "danger"
121 }
122 ])
123
124 const columns: DataTableColumns<CatalogCoverageGapRow> = [
125 {
126 title: "Technique ID",
127 key: "technique_id",
128 width: 150,
129 fixed: "left",
130 sorter: (a, b) => a.technique_id.localeCompare(b.technique_id),
131 render: row =>
132 row.url ? (
133 <NButton
134 tag="a"
135 // @ts-expect-error tag="a" forwards native anchor attrs omitted from ButtonProps
136 rel="noopener"
137 href={row.url}
138 target="_blank"
139 size="tiny"
140 icon-placement="right"
141 secondary
142 type="primary"
143 v-slots={{
144 icon: () => <Icon name="carbon:launch" />
145 }}
146 >
147 {row.technique_id}
148 </NButton>
149 ) : (
150 <span class="text-secondary font-mono text-xs">{row.technique_id}</span>
151 )
152 },
153 {
154 title: "Technique",
155 key: "technique_name",
156 width: 400,
157 sorter: (a, b) => a.technique_name.localeCompare(b.technique_name)
158 },
159 {
160 title: "Tactics",
161 key: "tactics",
162 minWidth: 300,
163 render: row =>
164 row.tactics.length ? (
165 <div class="flex flex-wrap gap-1">
166 {row.tactics.map(t => (
167 <NTag key={t} type="warning" size="small">
168 {t.toUpperCase()}
169 </NTag>
170 ))}
171 </div>
172 ) : (
173 <span class="text-tertiary text-xs"></span>
174 )
175 }
176 ]
177
178 function load() {
179 loading.value = true
180 Api.detectionCatalog
181 .listCoverageGaps()
182 .then(res => {
183 if (res.data?.success) {
184 gaps.value = res.data.gaps || []
185 gap_count.value = res.data.gap_count
186 total_techniques.value = res.data.total_techniques
187 covered_count.value = res.data.covered_count
188 coverage_pct.value = res.data.coverage_pct
189 } else {
190 message.warning(res.data?.message || "Failed to load coverage gaps")
191 }
192 })
193 .catch(err => {
194 message.error(err.response?.data?.detail || err.response?.data?.message || "Failed to load coverage gaps")
195 })
196 .finally(() => {
197 loading.value = false
198 })
199 }
200
201 onBeforeMount(load)
202 </script>