vulnerability scans and alerts show the image tag, not just its digest
This commit is contained in:
3 files changed
+96
-16
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user