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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user