Split the vulnerability view into host and container categories

The findings of the Debian host (OS packages and applications found in the
root filesystem) and of the container images in the cluster were mixed in
one flat table. They are now two prominent categories: a card each with its
own severity split and target count, acting as the primary selector, and a
table that adapts to the selection. Host findings show the package source
reported by the scanner (debian, gobinary, node-pkg …) instead of the
target, container findings show the image.

Backend: findings carry the scanner's package source, the list endpoint
takes ?scope=host|container, and the targets endpoint reports each target
with its category and open count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 19:47:47 +02:00
parent 21098cadf6
commit aba2c69416
15 changed files with 276 additions and 69 deletions

View File

@ -1,16 +1,25 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { api, ApiError } from '../api/client'
import type { Finding, JobRun, Severity, VulnSummary } from '../api/types'
import type {
Finding,
FindingScope,
JobRun,
Severity,
TargetSummary,
VulnSummary,
} from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import FindingTable from '../components/FindingTable.vue'
import ScopeTabs from '../components/ScopeTabs.vue'
const auth = useAuthStore()
const toast = useToastStore()
const summary = ref<VulnSummary | null>(null)
const findings = ref<Finding[]>([])
const targets = ref<string[]>([])
const targets = ref<TargetSummary[]>([])
const scope = ref<FindingScope>('host')
const minSeverity = ref<Severity>('low')
const target = ref('')
const status = ref('')
@ -23,21 +32,33 @@ const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Re
async function load() {
const q = new URLSearchParams()
q.set('scope', scope.value)
q.set('min_severity', minSeverity.value)
if (target.value) q.set('target', target.value)
if (status.value) q.set('status', status.value)
const [s, f, t] = await Promise.all([
api.get<VulnSummary>('/api/vulnerabilities/summary'),
api.get<Finding[]>(`/api/vulnerabilities?${q}`),
api.get<string[]>('/api/vulnerabilities/targets'),
api.get<TargetSummary[]>('/api/vulnerabilities/targets'),
])
summary.value = s
findings.value = f
targets.value = t
}
/// Targets of the selected category, for the filter dropdown.
const scopeTargets = computed(() =>
targets.value.filter((t) => (scope.value === 'host' ? t.kind === 'os' : t.kind === 'image')),
)
function selectScope(next: FindingScope) {
if (next === scope.value) return
scope.value = next
target.value = ''
}
onMounted(() => load().catch(fail))
onUnmounted(() => clearInterval(timer))
watch([minSeverity, target, status], () => load().catch(fail))
watch([scope, minSeverity, target, status], () => load().catch(fail))
async function scan() {
scanning.value = true
@ -69,13 +90,6 @@ async function setStatus(f: Finding, s: 'open' | 'acknowledged') {
fail(e)
}
}
const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] = [
{ key: 'critical', label: 'Critical', cls: 'text-red-700' },
{ key: 'high', label: 'High', cls: 'text-orange-600' },
{ key: 'medium', label: 'Medium', cls: 'text-amber-600' },
{ key: 'low', label: 'Low', cls: 'text-gray-600' },
]
</script>
<template>
@ -91,24 +105,22 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
</button>
</div>
<div v-if="summary" class="mt-6 grid gap-4 md:grid-cols-5">
<div v-for="t in tiles" :key="t.key" class="rounded-lg border border-gray-200 bg-white p-4">
<div class="text-xs uppercase text-gray-500">{{ t.label }}</div>
<div :data-testid="`count-${t.key}`" class="mt-1 text-2xl font-semibold" :class="t.cls">
{{ summary.total[t.key] }}
</div>
<div class="text-xs text-gray-500">
OS {{ summary.os[t.key] }} · images {{ summary.images[t.key] }}
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="text-xs uppercase text-gray-500">Last scan</div>
<div class="mt-1 text-sm font-medium">
{{ summary.last_scan ? new Date(summary.last_scan).toLocaleString() : 'never' }}
</div>
<div class="text-xs text-gray-500">scanner {{ summary.scanner }}</div>
</div>
</div>
<ScopeTabs
v-if="summary"
class="mt-6"
:model-value="scope"
:host="summary.os"
:container="summary.images"
:host-targets="targets.filter((t) => t.kind === 'os').length"
:container-targets="targets.filter((t) => t.kind === 'image').length"
@update:model-value="selectScope"
/>
<p v-if="summary" class="mt-3 text-sm text-gray-500">
Last scan {{ summary.last_scan ? new Date(summary.last_scan).toLocaleString() : 'never' }} ·
scanner
{{ summary.scanner }}
</p>
<div
v-if="scanRun && scanRun.status === 'running'"
@ -133,14 +145,18 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
</select>
</div>
<div>
<label for="f-target" class="block text-sm font-medium">Target</label>
<label for="f-target" class="block text-sm font-medium">{{
scope === 'host' ? 'Target' : 'Image'
}}</label>
<select
id="f-target"
v-model="target"
class="mt-1 max-w-xs rounded-md border border-gray-300 px-3 py-2"
>
<option value="">All targets</option>
<option v-for="t in targets" :key="t" :value="t">{{ t }}</option>
<option value="">{{ scope === 'host' ? 'All targets' : 'All images' }}</option>
<option v-for="t in scopeTargets" :key="t.target" :value="t.target">
{{ t.target }} ({{ t.open }})
</option>
</select>
</div>
<div>
@ -155,12 +171,15 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
<option value="acknowledged">Acknowledged</option>
</select>
</div>
<span class="pb-2 text-sm text-gray-500">{{ findings.length }} findings</span>
<span class="pb-2 text-sm text-gray-500">
{{ findings.length }} {{ scope === 'host' ? 'host' : 'container' }} findings
</span>
</div>
<FindingTable
class="mt-4"
:findings="findings"
:scope="scope"
:can-act="auth.isAdmin"
@status="setStatus"
@select="selected = $event"
@ -183,8 +202,13 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
</dd>
<dt class="text-gray-500">Fixed in</dt>
<dd class="col-span-2 font-mono">{{ selected.fixed_version ?? 'no fix available' }}</dd>
<dt class="text-gray-500">Target</dt>
<dd class="col-span-2 font-mono break-all">{{ selected.target }}</dd>
<dt class="text-gray-500">{{ selected.target_kind === 'os' ? 'Found on' : 'Image' }}</dt>
<dd class="col-span-2 font-mono break-all">
{{ selected.target_kind === 'os' ? 'Debian host' : selected.target }}
<span v-if="selected.source" class="ml-1 rounded bg-gray-100 px-1.5 py-0.5 text-xs">{{
selected.source
}}</span>
</dd>
<dt class="text-gray-500">First seen</dt>
<dd class="col-span-2">{{ new Date(selected.first_seen).toLocaleString() }}</dd>
<dt class="text-gray-500">Last seen</dt>