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:
@ -34,7 +34,7 @@ test('no page scrolls horizontally, with data and on a narrow window', async ({
|
||||
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
||||
await page.goto('/vulnerabilities')
|
||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
|
||||
for (const width of [1440, 1280, 1024, 900]) {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
@ -51,7 +51,7 @@ test('the findings table scrolls inside its own container', async ({ page }) =>
|
||||
await login(page)
|
||||
await page.goto('/vulnerabilities')
|
||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
|
||||
expect(await pageOverflow(page)).toBe(0)
|
||||
const scrollable = await page
|
||||
|
||||
@ -5,6 +5,7 @@ async function scan(page: Page) {
|
||||
await page.getByLabel('Email').fill('admin@example.com')
|
||||
await page.getByLabel('Password').fill('admin-password-123')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
||||
await page.goto('/vulnerabilities')
|
||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
@ -30,10 +31,15 @@ test('host and container findings are separated into two categories', async ({ p
|
||||
await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0)
|
||||
await expect(page.getByTestId('findings-scroll')).toContainText('Image')
|
||||
await expect(page.getByText('gitea')).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /gitea\/gitea/ }).first()).toBeVisible()
|
||||
|
||||
// the target filter only offers targets of the selected category
|
||||
const options = await page.getByLabel('Target').locator('option').allTextContents()
|
||||
expect(options.some((o) => o.includes('gitea'))).toBe(true)
|
||||
expect(options).not.toContain('os')
|
||||
// the filter is labelled per category and only offers targets of that category
|
||||
const images = await page.getByLabel('Image', { exact: true }).locator('option').allTextContents()
|
||||
expect(images.some((o) => o.includes('gitea'))).toBe(true)
|
||||
expect(images.some((o) => o.startsWith('os'))).toBe(false)
|
||||
|
||||
await page.getByTestId('scope-host').click()
|
||||
const hosts = await page.getByLabel('Target', { exact: true }).locator('option').allTextContents()
|
||||
expect(hosts.some((o) => o.startsWith('os'))).toBe(true)
|
||||
expect(hosts.some((o) => o.includes('gitea'))).toBe(false)
|
||||
})
|
||||
|
||||
@ -8,7 +8,7 @@ test('admin runs a scan, filters findings and acknowledges one', async ({ page }
|
||||
await page.getByRole('navigation').getByRole('link', { name: 'Vulnerabilities' }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible()
|
||||
|
||||
await page.getByLabel('Minimum severity').selectOption('critical')
|
||||
|
||||
@ -33,7 +33,7 @@ const findings = [
|
||||
|
||||
describe('FindingTable', () => {
|
||||
it('renders severity, target, fix version and status', () => {
|
||||
const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } })
|
||||
const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'container' } })
|
||||
const rows = w.findAll('tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].text()).toContain('critical')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Finding } from '../api/types'
|
||||
import type { Finding, FindingScope } from '../api/types'
|
||||
|
||||
defineProps<{ findings: Finding[]; canAct: boolean }>()
|
||||
defineProps<{ findings: Finding[]; canAct: boolean; scope: FindingScope }>()
|
||||
defineEmits<{ status: [f: Finding, status: 'open' | 'acknowledged']; select: [f: Finding] }>()
|
||||
|
||||
const sev: Record<string, string> = {
|
||||
@ -23,7 +23,7 @@ const sev: Record<string, string> = {
|
||||
<th>Package</th>
|
||||
<th>Installed</th>
|
||||
<th>Fixed in</th>
|
||||
<th>Target</th>
|
||||
<th>{{ scope === 'host' ? 'Source' : 'Image' }}</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@ -52,7 +52,18 @@ const sev: Record<string, string> = {
|
||||
>
|
||||
{{ f.fixed_version ?? '–' }}
|
||||
</td>
|
||||
<td class="max-w-[18rem] truncate font-mono text-xs" :title="f.target">{{ f.target }}</td>
|
||||
<td
|
||||
class="max-w-[18rem] truncate text-xs"
|
||||
:title="scope === 'host' ? f.source : f.target"
|
||||
>
|
||||
<span
|
||||
v-if="scope === 'host'"
|
||||
class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-gray-700"
|
||||
>
|
||||
{{ f.source || 'unknown' }}
|
||||
</span>
|
||||
<span v-else class="font-mono">{{ f.target }}</span>
|
||||
</td>
|
||||
<td>{{ f.status }}</td>
|
||||
<td class="space-x-3 whitespace-nowrap pl-2 text-right">
|
||||
<button
|
||||
|
||||
75
frontend/src/components/ScopeTabs.vue
Normal file
75
frontend/src/components/ScopeTabs.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { FindingScope, SeverityCounts } from '../api/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FindingScope
|
||||
host: SeverityCounts
|
||||
container: SeverityCounts
|
||||
hostTargets: number
|
||||
containerTargets: number
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [scope: FindingScope] }>()
|
||||
|
||||
const total = (c: SeverityCounts) => c.critical + c.high + c.medium + c.low + c.unknown
|
||||
|
||||
const cards = () => [
|
||||
{
|
||||
scope: 'host' as const,
|
||||
title: 'Host',
|
||||
subtitle: 'OS, packages and applications on the Debian server',
|
||||
counts: props.host,
|
||||
targets: `${props.hostTargets} scanned root filesystem${props.hostTargets === 1 ? '' : 's'}`,
|
||||
},
|
||||
{
|
||||
scope: 'container' as const,
|
||||
title: 'Containers',
|
||||
subtitle: 'Container images running in the Kubernetes cluster',
|
||||
counts: props.container,
|
||||
targets: `${props.containerTargets} images`,
|
||||
},
|
||||
]
|
||||
|
||||
const chips = [
|
||||
{ key: 'critical' as const, label: 'Critical', cls: 'bg-red-600 text-white' },
|
||||
{ key: 'high' as const, label: 'High', cls: 'bg-orange-500 text-white' },
|
||||
{ key: 'medium' as const, label: 'Medium', cls: 'bg-amber-300 text-amber-900' },
|
||||
{ key: 'low' as const, label: 'Low', cls: 'bg-gray-200 text-gray-700' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<button
|
||||
v-for="c in cards()"
|
||||
:key="c.scope"
|
||||
type="button"
|
||||
:data-testid="`scope-${c.scope}`"
|
||||
:aria-pressed="modelValue === c.scope"
|
||||
class="rounded-lg border p-4 text-left transition"
|
||||
:class="
|
||||
modelValue === c.scope
|
||||
? 'border-blue-500 bg-white ring-2 ring-blue-200'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 hover:bg-white'
|
||||
"
|
||||
@click="emit('update:modelValue', c.scope)"
|
||||
>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-lg font-semibold">{{ c.title }}</span>
|
||||
<span class="text-sm text-gray-500">{{ total(c.counts) }} open</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm text-gray-600">{{ c.subtitle }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="s in chips"
|
||||
:key="s.key"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="s.cls"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span :data-testid="`scope-${c.scope}-${s.key}`">{{ c.counts[s.key] }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">{{ c.targets }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user