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';
|
if (firstLine && !firstLine.startsWith('{')) return 'cli-error';
|
||||||
return 'unknown';
|
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 { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||||
import { sendEventNotification } from './notifications';
|
import { sendEventNotification } from './notifications';
|
||||||
import { detectRemoteSocketPath } from './scanner-socket-detect';
|
import { detectRemoteSocketPath } from './scanner-socket-detect';
|
||||||
import { truncateForLog, classifyUnparseableOutput } from './scanner-output-core';
|
import { truncateForLog, classifyUnparseableOutput, pickScanDisplayName } from './scanner-output-core';
|
||||||
import {
|
import {
|
||||||
getHostDockerSocket,
|
getHostDockerSocket,
|
||||||
getHostDataDir,
|
getHostDataDir,
|
||||||
@@ -565,15 +565,21 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s
|
|||||||
return { vulnerabilities, summary };
|
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 {
|
try {
|
||||||
const imageInfo = await inspectImage(imageName, envId) as any;
|
const info = await inspectImage(imageRef, envId) as any;
|
||||||
// The Id field contains the full sha256:... hash
|
return {
|
||||||
return imageInfo.Id || imageName;
|
imageId: info.Id || imageRef,
|
||||||
|
displayName: pickScanDisplayName(imageRef, info.RepoTags)
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// If we can't inspect the image, fall back to the name
|
return { imageId: imageRef, displayName: imageRef };
|
||||||
return imageName;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,12 +926,12 @@ export async function scanWithGrype(
|
|||||||
|
|
||||||
const { vulnerabilities, summary } = parseGrypeOutput(output);
|
const { vulnerabilities, summary } = parseGrypeOutput(output);
|
||||||
|
|
||||||
// Get the actual SHA256 image ID for reliable caching
|
// SHA256 id for caching + a readable name for the record/notification.
|
||||||
const imageId = await getImageSha(imageName, envId);
|
const { imageId, displayName } = await resolveImageIdentity(imageName, envId);
|
||||||
|
|
||||||
const result: ScanResult = {
|
const result: ScanResult = {
|
||||||
imageId,
|
imageId,
|
||||||
imageName,
|
imageName: displayName,
|
||||||
scanner: 'grype',
|
scanner: 'grype',
|
||||||
scannedAt: new Date().toISOString(),
|
scannedAt: new Date().toISOString(),
|
||||||
vulnerabilities,
|
vulnerabilities,
|
||||||
@@ -1017,12 +1023,12 @@ export async function scanWithTrivy(
|
|||||||
|
|
||||||
const { vulnerabilities, summary } = parseTrivyOutput(output);
|
const { vulnerabilities, summary } = parseTrivyOutput(output);
|
||||||
|
|
||||||
// Get the actual SHA256 image ID for reliable caching
|
// SHA256 id for caching + a readable name for the record/notification.
|
||||||
const imageId = await getImageSha(imageName, envId);
|
const { imageId, displayName } = await resolveImageIdentity(imageName, envId);
|
||||||
|
|
||||||
const result: ScanResult = {
|
const result: ScanResult = {
|
||||||
imageId,
|
imageId,
|
||||||
imageName,
|
imageName: displayName,
|
||||||
scanner: 'trivy',
|
scanner: 'trivy',
|
||||||
scannedAt: new Date().toISOString(),
|
scannedAt: new Date().toISOString(),
|
||||||
vulnerabilities,
|
vulnerabilities,
|
||||||
@@ -1110,8 +1116,10 @@ export async function scanImage(
|
|||||||
unknown: Math.max(...results.map(r => r.summary.unknown))
|
unknown: Math.max(...results.map(r => r.summary.unknown))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send notifications (async, don't block return)
|
// Notify with the readable name the scan resolved (a tag when the image has one),
|
||||||
sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => {
|
// 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);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
console.error('[Scanner] Failed to send vulnerability notifications:', errorMsg);
|
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