diff --git a/src/lib/server/scanner-output-core.ts b/src/lib/server/scanner-output-core.ts index 84aeaf5..bf66acb 100644 --- a/src/lib/server/scanner-output-core.ts +++ b/src/lib/server/scanner-output-core.ts @@ -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 !== ':'); + 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 +} diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index 5572cfa..56364e8 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -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 { +/** + * 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); }); diff --git a/tests/scan-display-name.test.ts b/tests/scan-display-name.test.ts new file mode 100644 index 0000000..71526a6 --- /dev/null +++ b/tests/scan-display-name.test.ts @@ -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, [':'])).toBe(DIGEST); + expect(pickScanDisplayName(BARE, [])).toBe(BARE); // bare 64-hex, no sha256: prefix + }); +});