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:
Dennis Nemec
2026-09-03 20:19:12 +02:00
parent 278b5e47a3
commit b984cc8c9f
31 changed files with 1674 additions and 29 deletions

View File

@ -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'

View File

@ -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')
})
})

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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"

View File

@ -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