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);
});