Link image findings to their workloads and suggest a fixing image update
Container findings now name the workloads that run the image and link into the Kubernetes view, which highlights them. An update check asks the registry for newer tags of the same variant, scans the newest one and records which of the open findings are gone in it. The image row then shows the candidate tag, how many findings it fixes and how many remain, marks those CVEs in the expanded list, and offers to roll every workload over to it. The check runs as a job, nightly for all running images or on demand for one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
36
frontend/e2e/image-updates.spec.ts
Normal file
36
frontend/e2e/image-updates.spec.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function scanAndOpenContainers(page: Page) {
|
||||
await page.goto('/login')
|
||||
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 })
|
||||
await page.getByTestId('scope-container').click()
|
||||
}
|
||||
|
||||
test('an image finding links to the workload running it', async ({ page }) => {
|
||||
await scanAndOpenContainers(page)
|
||||
const row = page.getByRole('row', { name: /gitea\/gitea/ }).first()
|
||||
await expect(row).toContainText('gitea/deployment/gitea')
|
||||
|
||||
await row.getByRole('link', { name: 'gitea/deployment/gitea' }).click()
|
||||
await expect(page).toHaveURL(/\/cluster\?image=/)
|
||||
await expect(page.getByText('Showing the workloads that run')).toBeVisible()
|
||||
await expect(page.getByRole('row', { name: /gitea gitea deployment/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test('checking an image suggests a newer tag and marks the CVEs it fixes', async ({ page }) => {
|
||||
await scanAndOpenContainers(page)
|
||||
const row = page.getByRole('row', { name: /gitea\/gitea/ }).first()
|
||||
await row.getByRole('button', { name: 'Check for update' }).click()
|
||||
await expect(row).toContainText('9.9.9', { timeout: 20_000 })
|
||||
await expect(row).toContainText('fixes 1')
|
||||
|
||||
await row.click()
|
||||
await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toContainText('fixed in 9.9.9')
|
||||
await expect(row.getByRole('button', { name: /Update to 9.9.9/ })).toBeVisible()
|
||||
})
|
||||
@ -162,6 +162,27 @@ export interface FindingGroup {
|
||||
packages: number
|
||||
}
|
||||
|
||||
export interface ImageUpdate {
|
||||
image: string
|
||||
candidate: string
|
||||
checked_at: string
|
||||
fixed: string[]
|
||||
fixed_counts: SeverityCounts
|
||||
candidate_total: number
|
||||
}
|
||||
|
||||
export interface ImageGroup extends FindingGroup {
|
||||
workloads: WorkloadRef[]
|
||||
running: boolean
|
||||
update: ImageUpdate | null
|
||||
}
|
||||
|
||||
export interface WorkloadRef {
|
||||
namespace: string
|
||||
kind: WorkloadKind
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TargetSummary {
|
||||
target: string
|
||||
kind: 'os' | 'image'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import FindingGroups from './FindingGroups.vue'
|
||||
import type { Finding, FindingGroup } from '../api/types'
|
||||
import type { Finding, FindingGroup, ImageGroup } from '../api/types'
|
||||
|
||||
const group = (over: Partial<FindingGroup>): FindingGroup => ({
|
||||
key: 'openssl',
|
||||
@ -103,3 +103,91 @@ describe('FindingGroups', () => {
|
||||
expect(w.text()).toContain('No findings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FindingGroups for images', () => {
|
||||
const image = (over: Partial<ImageGroup> = {}): ImageGroup => ({
|
||||
...group({
|
||||
key: 'gitea/gitea:1.22.3',
|
||||
kind: 'image',
|
||||
source: '',
|
||||
installed: '',
|
||||
packages: 12,
|
||||
total: 40,
|
||||
}),
|
||||
workloads: [{ namespace: 'gitea', kind: 'deployment', name: 'gitea' }],
|
||||
running: true,
|
||||
update: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
it('links each image to the workloads that run it', () => {
|
||||
const w = mount(FindingGroups, {
|
||||
props: { groups: [image()], scope: 'container', canAct: true, load: vi.fn() },
|
||||
global: { stubs: { RouterLink: { template: '<a :href="to"><slot /></a>', props: ['to'] } } },
|
||||
})
|
||||
const link = w.get('a')
|
||||
expect(link.text()).toBe('gitea/deployment/gitea')
|
||||
expect(link.attributes('href')).toBe('/cluster?image=gitea%2Fgitea%3A1.22.3')
|
||||
})
|
||||
|
||||
it('suggests the checked update and how much it fixes', () => {
|
||||
const withUpdate = image({
|
||||
update: {
|
||||
image: 'gitea/gitea:1.22.3',
|
||||
candidate: 'gitea/gitea:1.25.1',
|
||||
checked_at: '2026-09-03T08:00:00Z',
|
||||
fixed: ['CVE-1', 'CVE-2'],
|
||||
fixed_counts: { critical: 1, high: 1, medium: 0, low: 0, unknown: 0 },
|
||||
candidate_total: 5,
|
||||
},
|
||||
})
|
||||
const w = mount(FindingGroups, {
|
||||
props: { groups: [withUpdate], scope: 'container', canAct: true, load: vi.fn() },
|
||||
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
|
||||
})
|
||||
const text = w.text()
|
||||
expect(text).toContain('1.25.1')
|
||||
expect(text).toContain('fixes 2')
|
||||
expect(w.find('button[name=update-image]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('offers a check when nothing is known yet and hides the actions from viewers', () => {
|
||||
const w = mount(FindingGroups, {
|
||||
props: { groups: [image()], scope: 'container', canAct: true, load: vi.fn() },
|
||||
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
|
||||
})
|
||||
expect(w.find('button[name=check-image]').exists()).toBe(true)
|
||||
expect(w.find('button[name=update-image]').exists()).toBe(false)
|
||||
|
||||
const viewer = mount(FindingGroups, {
|
||||
props: { groups: [image()], scope: 'container', canAct: false, load: vi.fn() },
|
||||
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
|
||||
})
|
||||
expect(viewer.find('button[name=check-image]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks the CVEs that the checked update fixes', async () => {
|
||||
const withUpdate = image({
|
||||
update: {
|
||||
image: 'gitea/gitea:1.22.3',
|
||||
candidate: 'gitea/gitea:1.25.1',
|
||||
checked_at: '2026-09-03T08:00:00Z',
|
||||
fixed: ['CVE-2024-1'],
|
||||
fixed_counts: { critical: 1, high: 0, medium: 0, low: 0, unknown: 0 },
|
||||
candidate_total: 0,
|
||||
},
|
||||
})
|
||||
const load = vi.fn().mockResolvedValue([finding('CVE-2024-1'), finding('CVE-2024-9')])
|
||||
const w = mount(FindingGroups, {
|
||||
props: { groups: [withUpdate], scope: 'container', canAct: true, load },
|
||||
global: { stubs: { RouterLink: { template: '<a><slot /></a>' } } },
|
||||
})
|
||||
await w.findAll('tbody tr')[0].trigger('click')
|
||||
await flushPromises()
|
||||
const cveRows = w.findAll('table table tbody tr')
|
||||
const fixedRow = cveRows.find((r) => r.text().includes('CVE-2024-1'))!
|
||||
expect(fixedRow.text()).toContain('fixed in 1.25.1')
|
||||
const otherRow = cveRows.find((r) => r.text().includes('CVE-2024-9'))!
|
||||
expect(otherRow.text()).not.toContain('fixed in')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Finding, FindingGroup, FindingScope, SeverityCounts } from '../api/types'
|
||||
import type { Finding, FindingGroup, FindingScope, ImageGroup, SeverityCounts } from '../api/types'
|
||||
import FindingTable from './FindingTable.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@ -13,8 +13,16 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
status: [f: Finding, status: 'open' | 'acknowledged']
|
||||
select: [f: Finding]
|
||||
check: [group: ImageGroup]
|
||||
update: [group: ImageGroup]
|
||||
}>()
|
||||
|
||||
/** Container groups carry cluster usage; host groups do not. */
|
||||
const asImage = (g: FindingGroup) =>
|
||||
({ workloads: [], running: false, update: null, ...g }) as ImageGroup
|
||||
/** Tag part of an image reference, for compact display. */
|
||||
const tagOf = (image: string) => image.split(':').pop() ?? image
|
||||
|
||||
const open = ref<Set<string>>(new Set())
|
||||
const loaded = ref<Record<string, Finding[]>>({})
|
||||
const loading = ref<Set<string>>(new Set())
|
||||
@ -62,6 +70,7 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
||||
<th>{{ scope === 'host' ? 'Installed' : '' }}</th>
|
||||
<th>Findings</th>
|
||||
<th>Severity</th>
|
||||
<th v-if="scope === 'container'">Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -73,7 +82,22 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
||||
@click="toggle(g)"
|
||||
>
|
||||
<td class="py-2 text-gray-400">{{ open.has(g.key) ? '▾' : '▸' }}</td>
|
||||
<td class="max-w-[24rem] truncate font-mono font-medium" :title="g.key">{{ g.key }}</td>
|
||||
<td class="max-w-[24rem]">
|
||||
<div class="truncate font-mono font-medium" :title="g.key">{{ g.key }}</div>
|
||||
<div v-if="scope === 'container'" class="mt-0.5 text-xs" @click.stop>
|
||||
<template v-if="asImage(g).workloads.length">
|
||||
<RouterLink
|
||||
v-for="w in asImage(g).workloads"
|
||||
:key="`${w.namespace}/${w.name}`"
|
||||
:to="`/cluster?image=${encodeURIComponent(g.key)}`"
|
||||
class="mr-2 text-blue-600 hover:underline"
|
||||
>
|
||||
{{ w.namespace }}/{{ w.kind }}/{{ w.name }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">not running on the cluster</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs text-gray-600">
|
||||
<span v-if="scope === 'host'" class="rounded bg-gray-100 px-1.5 py-0.5 font-mono">{{
|
||||
g.source || 'unknown'
|
||||
@ -102,6 +126,39 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="scope === 'container'" class="whitespace-nowrap text-xs" @click.stop>
|
||||
<template v-if="asImage(g).update">
|
||||
<div>
|
||||
<span class="font-mono font-medium text-green-700">{{
|
||||
tagOf(asImage(g).update!.candidate)
|
||||
}}</span>
|
||||
<span class="ml-1 text-gray-600"
|
||||
>fixes {{ asImage(g).update!.fixed.length }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-gray-500">
|
||||
{{ asImage(g).update!.fixed_counts.critical }} critical ·
|
||||
{{ asImage(g).update!.candidate_total }} remain
|
||||
</div>
|
||||
<button
|
||||
v-if="canAct && asImage(g).running"
|
||||
name="update-image"
|
||||
class="mt-0.5 text-blue-600 hover:underline"
|
||||
@click="emit('update', asImage(g))"
|
||||
>
|
||||
Update to {{ tagOf(asImage(g).update!.candidate) }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="canAct"
|
||||
name="check-image"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="emit('check', asImage(g))"
|
||||
>
|
||||
Check for update
|
||||
</button>
|
||||
<span v-else class="text-gray-400">not checked</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- layout row: the nested table carries the semantics -->
|
||||
<tr v-if="open.has(g.key)" :key="`${g.key}-detail`" role="presentation">
|
||||
@ -120,6 +177,8 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
||||
:scope="scope"
|
||||
:can-act="canAct"
|
||||
context="group"
|
||||
:fixed-by="asImage(g).update?.fixed ?? []"
|
||||
:fixed-in="asImage(g).update ? tagOf(asImage(g).update!.candidate) : ''"
|
||||
@status="(f, s) => emit('status', f, s)"
|
||||
@select="(f) => emit('select', f)"
|
||||
/>
|
||||
@ -127,7 +186,9 @@ const shown = (c: SeverityCounts) => chips.filter((s) => c[s.key] > 0)
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="groups.length === 0">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No findings.</td>
|
||||
<td :colspan="scope === 'container' ? 7 : 6" class="py-6 text-center text-gray-500">
|
||||
No findings.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -7,8 +7,14 @@ const props = defineProps<{
|
||||
scope: FindingScope
|
||||
/** 'group' hides the column that the surrounding group row already names. */
|
||||
context?: 'flat' | 'group'
|
||||
/** CVE ids that a checked image update fixes. */
|
||||
fixedBy?: string[]
|
||||
/** Tag of that update, shown next to the marked findings. */
|
||||
fixedIn?: string
|
||||
}>()
|
||||
|
||||
const fixedByUpdate = (cve: string) => (props.fixedBy ?? []).includes(cve)
|
||||
|
||||
const inGroup = () => props.context === 'group'
|
||||
defineEmits<{ status: [f: Finding, status: 'open' | 'acknowledged']; select: [f: Finding] }>()
|
||||
|
||||
@ -48,7 +54,16 @@ const sev: Record<string, string> = {
|
||||
f.severity
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap font-mono">{{ f.cve_id }}</td>
|
||||
<td class="whitespace-nowrap font-mono">
|
||||
{{ f.cve_id }}
|
||||
<span
|
||||
v-if="fixedByUpdate(f.cve_id)"
|
||||
class="ml-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-800"
|
||||
:title="`This finding is gone in ${fixedIn}`"
|
||||
>
|
||||
fixed in {{ fixedIn }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="!(inGroup() && scope === 'host')"
|
||||
class="max-w-[14rem] truncate font-mono"
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Workload } from '../api/types'
|
||||
|
||||
defineProps<{ workloads: Workload[]; canAct: boolean }>()
|
||||
const props = defineProps<{ workloads: Workload[]; canAct: boolean; highlight?: string }>()
|
||||
|
||||
const runsHighlighted = (w: Workload) =>
|
||||
!!props.highlight && w.containers.some((c) => c.image === props.highlight)
|
||||
defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload] }>()
|
||||
</script>
|
||||
|
||||
@ -23,7 +26,10 @@ defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload]
|
||||
v-for="w in workloads"
|
||||
:key="`${w.namespace}/${w.kind}/${w.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="w.ready < w.desired ? 'bg-red-50' : ''"
|
||||
:class="[
|
||||
w.ready < w.desired ? 'bg-red-50' : '',
|
||||
runsHighlighted(w) ? 'bg-blue-50 ring-1 ring-blue-200' : '',
|
||||
]"
|
||||
>
|
||||
<td class="py-1.5">{{ w.namespace }}</td>
|
||||
<td class="font-mono">{{ w.name }}</td>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { ClusterOverview, Workload } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@ -9,6 +10,9 @@ import WorkloadTable from '../components/WorkloadTable.vue'
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const overview = ref<ClusterOverview | null>(null)
|
||||
const route = useRoute()
|
||||
/** Set when coming from a vulnerability finding: highlight the workloads of that image. */
|
||||
const highlighted = computed(() => (typeof route.query.image === 'string' ? route.query.image : ''))
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
@ -93,10 +97,17 @@ function setImage(w: Workload) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="highlighted" class="mt-8 rounded-md bg-blue-50 p-3 text-sm text-blue-900">
|
||||
Showing the workloads that run <span class="font-mono">{{ highlighted }}</span
|
||||
>.
|
||||
<RouterLink to="/cluster" class="ml-2 underline">Show all</RouterLink>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 text-lg font-medium">Workloads</h2>
|
||||
<WorkloadTable
|
||||
class="mt-3"
|
||||
:workloads="overview.workloads"
|
||||
:highlight="highlighted"
|
||||
:can-act="auth.isAdmin"
|
||||
@restart="restart"
|
||||
@scale="scale"
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
Finding,
|
||||
FindingGroup,
|
||||
FindingScope,
|
||||
ImageGroup,
|
||||
JobRun,
|
||||
Severity,
|
||||
TargetSummary,
|
||||
@ -49,6 +50,41 @@ async function load() {
|
||||
groupsRef.value?.reset()
|
||||
}
|
||||
|
||||
/** Start an update check for one image and reload once it finished. */
|
||||
async function checkImage(g: ImageGroup) {
|
||||
try {
|
||||
const run = await api.post<JobRun>('/api/cluster/images/check', { image: g.key })
|
||||
toast.success(`Checking ${g.key} for a newer tag`)
|
||||
const poll = setInterval(async () => {
|
||||
const r = await api.get<JobRun>(`/api/jobs/${run.id}`)
|
||||
if (r.status === 'running') return
|
||||
clearInterval(poll)
|
||||
if (r.status === 'success') await load()
|
||||
else toast.error('Update check failed, see Jobs for the log')
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Roll every workload that runs the image over to the checked candidate. */
|
||||
async function updateImage(g: ImageGroup) {
|
||||
const candidate = g.update?.candidate
|
||||
if (!candidate) return
|
||||
const where = g.workloads.map((w) => `${w.namespace}/${w.name}`).join(', ')
|
||||
if (!confirm(`Update ${where} to ${candidate}? This restarts the workload.`)) return
|
||||
try {
|
||||
for (const w of g.workloads) {
|
||||
await api.post(`/api/cluster/workloads/${w.namespace}/${w.kind}/${w.name}/image`, {
|
||||
image: candidate,
|
||||
})
|
||||
}
|
||||
toast.success(`Rollout to ${candidate} started`)
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Findings of one group, loaded when its row is expanded. */
|
||||
async function loadGroup(group: FindingGroup): Promise<Finding[]> {
|
||||
const q = new URLSearchParams()
|
||||
@ -201,6 +237,8 @@ async function setStatus(f: Finding, s: 'open' | 'acknowledged') {
|
||||
:load="loadGroup"
|
||||
@status="setStatus"
|
||||
@select="selected = $event"
|
||||
@check="checkImage"
|
||||
@update="updateImage"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user