vulnerability scans and alerts show the image tag, not just its digest

This commit is contained in:
Jarek Krochmalski committed 2026-09-13 14:50:02 +02:00
1 parent 2810a97f39
commit 0bb4db3724
3 files changed
+96 -16

No files matched your search

+27
View File
@@ -28,3 +28,30 @@ export function classifyUnparseableOutput(output: string): 'truncated' | 'cli-er
if (firstLine && !firstLine.startsWith('{')) return 'cli-error';
return 'unknown';
}
/** A bare image ID: `sha256:<64 hex>` or just the 64-hex digest. */
function isBareDigest(ref: string): boolean {
return /^(sha256:)?[0-9a-f]{64}$/.test(ref);
}
/** A Dockhand-internal temporary tag (`...-dockhand-pending` / `-dockhand-update`) applied
* during the safe auto-update scan - a poor label to show a user, preferred only if no real
* tag exists. */
function isTempTag(tag: string): boolean {
return /-dockhand-(pending|update)(:|$)/.test(tag);
}
/**
* The human-readable name a scan should record and notify with. Scans are driven by whatever
* ref the caller passed - the auto-update path passes a bare image ID, which then shows in
* notifications as `Image "sha256:..." has N critical`. When the ref is a bare digest, prefer
* a real RepoTag from the image inspect; fall back to a Dockhand temp tag, then to the digest
* itself. A ref that already carries a tag/name is kept as-is.
*/
export function pickScanDisplayName(imageRef: string, repoTags: string[] | undefined | null): string {
if (!isBareDigest(imageRef)) return imageRef; // caller passed a tag/name - trust it
const tags = (repoTags || []).filter((t) => typeof t === 'string' && t && t !== '<none>:<none>');
if (tags.length === 0) return imageRef; // genuinely untagged - the digest is all we have
const realTag = tags.find((t) => !isTempTag(t));
return realTag ?? tags[0]; // prefer a real tag over a -dockhand-pending temp tag
}
+24 -16
View File
@@ -17,7 +17,7 @@ import {
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { detectRemoteSocketPath } from './scanner-socket-detect';
import { truncateForLog, classifyUnparseableOutput } from './scanner-output-core';
import { truncateForLog, classifyUnparseableOutput, pickScanDisplayName } from './scanner-output-core';
import {
getHostDockerSocket,
getHostDataDir,
@@ -565,15 +565,21 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s
return { vulnerabilities, summary };
}
// Get the SHA256 image ID for a given image name/tag
async function getImageSha(imageName: string, envId?: number): Promise<string> {
/**
* Resolve the stable image ID AND a human-readable display name from ONE inspect.
* The scan is driven by `imageRef`, which the auto-update path passes as a bare digest; that
* digest would otherwise be stored and shown in notifications. `displayName` prefers a real
* RepoTag (see pickScanDisplayName); `imageId` is the SHA used for cache keying (unchanged).
*/
async function resolveImageIdentity(imageRef: string, envId?: number): Promise<{ imageId: string; displayName: string }> {
try {
const imageInfo = await inspectImage(imageName, envId) as any;
// The Id field contains the full sha256:... hash
return imageInfo.Id || imageName;
const info = await inspectImage(imageRef, envId) as any;
return {
imageId: info.Id || imageRef,
displayName: pickScanDisplayName(imageRef, info.RepoTags)
};
} catch {
// If we can't inspect the image, fall back to the name
return imageName;
return { imageId: imageRef, displayName: imageRef };
}
}
@@ -920,12 +926,12 @@ export async function scanWithGrype(
const { vulnerabilities, summary } = parseGrypeOutput(output);
// Get the actual SHA256 image ID for reliable caching
const imageId = await getImageSha(imageName, envId);
// SHA256 id for caching + a readable name for the record/notification.
const { imageId, displayName } = await resolveImageIdentity(imageName, envId);
const result: ScanResult = {
imageId,
imageName,
imageName: displayName,
scanner: 'grype',
scannedAt: new Date().toISOString(),
vulnerabilities,
@@ -1017,12 +1023,12 @@ export async function scanWithTrivy(
const { vulnerabilities, summary } = parseTrivyOutput(output);
// Get the actual SHA256 image ID for reliable caching
const imageId = await getImageSha(imageName, envId);
// SHA256 id for caching + a readable name for the record/notification.
const { imageId, displayName } = await resolveImageIdentity(imageName, envId);
const result: ScanResult = {
imageId,
imageName,
imageName: displayName,
scanner: 'trivy',
scannedAt: new Date().toISOString(),
vulnerabilities,
@@ -1110,8 +1116,10 @@ export async function scanImage(
unknown: Math.max(...results.map(r => r.summary.unknown))
};
// Send notifications (async, don't block return)
sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => {
// Notify with the readable name the scan resolved (a tag when the image has one),
// not the bare digest the caller may have passed in.
const notifyName = results[0]?.imageName || imageName;
sendVulnerabilityNotifications(notifyName, combinedSummary, envId).catch(err => {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[Scanner] Failed to send vulnerability notifications:', errorMsg);
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'bun:test';
import { pickScanDisplayName } from '../src/lib/server/scanner-output-core';
const DIGEST = 'sha256:' + 'c'.repeat(64);
const BARE = 'a'.repeat(64);
describe('pickScanDisplayName', () => {
it('keeps a ref that already carries a tag/name (never overrides a real name)', () => {
expect(pickScanDisplayName('nginx:latest', ['nginx:latest'])).toBe('nginx:latest');
expect(pickScanDisplayName('registry.io/app:1.2', [])).toBe('registry.io/app:1.2');
// even if the caller's tag is not in RepoTags, trust what they passed
expect(pickScanDisplayName('nginx:latest', ['other:tag'])).toBe('nginx:latest');
});
it('resolves a bare digest to its RepoTag', () => {
expect(pickScanDisplayName(DIGEST, ['invoiceninja/invoiceninja:latest']))
.toBe('invoiceninja/invoiceninja:latest');
expect(pickScanDisplayName(BARE, ['nginx:1.25'])).toBe('nginx:1.25');
});
it('prefers a real tag over a Dockhand temp tag', () => {
expect(pickScanDisplayName(DIGEST, [
'invoiceninja/invoiceninja:latest-dockhand-pending',
'invoiceninja/invoiceninja:latest'
])).toBe('invoiceninja/invoiceninja:latest');
// order-independent
expect(pickScanDisplayName(DIGEST, [
'app:latest',
'app:latest-dockhand-update'
])).toBe('app:latest');
});
it('falls back to a temp tag when it is the only tag (still better than a digest)', () => {
expect(pickScanDisplayName(DIGEST, ['app:latest-dockhand-pending']))
.toBe('app:latest-dockhand-pending');
});
it('falls back to the digest when the image is genuinely untagged', () => {
expect(pickScanDisplayName(DIGEST, [])).toBe(DIGEST);
expect(pickScanDisplayName(DIGEST, null)).toBe(DIGEST);
expect(pickScanDisplayName(DIGEST, undefined)).toBe(DIGEST);
expect(pickScanDisplayName(DIGEST, ['<none>:<none>'])).toBe(DIGEST);
expect(pickScanDisplayName(BARE, [])).toBe(BARE); // bare 64-hex, no sha256: prefix
});
});