This commit is contained in:
jarek committed 2026-07-11 15:31:52 +02:00
1 parent 685132ff89
commit c718ac9a84
101 files changed
+5097 -642

No files matched your search

@@ -0,0 +1,122 @@
/**
* Vulnerability block decision with a fresh current-image scan.
*
* Kept separate from update-utils.ts so that the pure decision logic
* (shouldBlockUpdate, combineScanSummaries) stays free of DB/scanner runtime
* deps and remains unit-testable. This module is allowed to touch the DB and
* the scanner.
*/
import type { VulnerabilityCriteria } from '../../db';
import { type VulnerabilitySeverity, scanImage } from '../../scanner';
import { getCombinedScanForImage, saveVulnerabilityScan } from '../../db';
import { shouldBlockUpdate, combineScanSummaries } from './update-utils';
/**
* Resolve the block decision for a new image, handling the 'more_than_current'
* criteria robustly.
*
* For 'more_than_current' the new image's vuln count is compared against the
* CURRENT image's count. The current count comes from the scan cache, which can
* be (a) stale — vuln DBs grow, so an old cached scan understates the current
* image and falsely makes the new image look worse (#1022), or (b) missing — no
* scan exists, so the comparison is skipped and a genuinely more-vulnerable
* update slips through. Both are fixed here: when the cached comparison would
* block (or there is no cache), the current image is RE-SCANNED fresh and the
* comparison is redone against up-to-date numbers.
*
* For all other criteria this is a thin wrapper around shouldBlockUpdate.
*
* @param newSummary combined scan summary of the new image
* @param currentImageId sha256 image id of the currently-running image
* @param envId environment id (for scanner settings + cache scoping)
* @param criteria the configured vulnerability criteria
* @param log log sink for human-readable progress
*/
export async function resolveBlockDecision(
newSummary: VulnerabilitySeverity,
currentImageId: string,
envId: number | null | undefined,
criteria: VulnerabilityCriteria,
log: (msg: string) => void
): Promise<{ blocked: boolean; reason: string }> {
if (criteria !== 'more_than_current') {
return shouldBlockUpdate(criteria, newSummary);
}
const total = (s: VulnerabilitySeverity) => s.critical + s.high + s.medium + s.low;
const newTotal = total(newSummary);
// 1. Start from the cached scan of the current image, if any.
let currentSummary: VulnerabilitySeverity | undefined;
let currentFromCache = false;
try {
const cached = await getCombinedScanForImage(currentImageId, envId ?? null);
if (cached) {
currentSummary = cached;
currentFromCache = true;
log(`more_than_current: current image cached scan = ${total(cached)} vulns (${cached.critical}C/${cached.high}H/${cached.medium}M/${cached.low}L)`);
} else {
log(`more_than_current: no cached scan for current image`);
}
} catch (err: any) {
log(`more_than_current: cache lookup failed (${err.message})`);
}
const wouldBlock = currentSummary !== undefined && newTotal > total(currentSummary);
// 2. Re-scan the current image when the cached comparison would block, or
// when there is no cached scan at all. A fresh scan of the SAME current
// image is the only trustworthy basis for blocking (#1022).
if (!currentFromCache || wouldBlock) {
log(
currentFromCache
? `more_than_current: cached comparison would block (new ${newTotal} > current ${total(currentSummary!)}) — re-scanning current image to confirm`
: `more_than_current: scanning current image (${currentImageId.substring(0, 19)}) for comparison`
);
try {
const results = await scanImage(currentImageId, envId ?? undefined, (p) => {
if (p.message) log(` [${p.scanner || 'scan'}] ${p.message}`);
});
if (results.length > 0) {
const fresh = combineScanSummaries(results.map((r) => ({ summary: r.summary })));
log(`more_than_current: current image fresh scan = ${total(fresh)} vulns (${fresh.critical}C/${fresh.high}H/${fresh.medium}M/${fresh.low}L)`);
currentSummary = fresh;
// Persist so the next cycle starts from a current value.
for (const r of results) {
try {
await saveVulnerabilityScan({
environmentId: envId ?? null,
imageId: currentImageId,
imageName: r.imageName,
scanner: r.scanner,
scannedAt: r.scannedAt,
scanDuration: r.scanDuration,
criticalCount: r.summary.critical,
highCount: r.summary.high,
mediumCount: r.summary.medium,
lowCount: r.summary.low,
negligibleCount: r.summary.negligible,
unknownCount: r.summary.unknown,
vulnerabilities: r.vulnerabilities,
error: r.error ?? null
});
} catch { /* ignore save errors */ }
}
} else {
log(`more_than_current: current image scan returned no results`);
}
} catch (err: any) {
log(`more_than_current: current image scan failed (${err.message})`);
}
}
const decision = shouldBlockUpdate('more_than_current', newSummary, currentSummary);
const curTotal = currentSummary ? total(currentSummary) : 'unknown';
log(
decision.blocked
? `more_than_current: BLOCKED — new ${newTotal} > current ${curTotal}`
: `more_than_current: allowed — new ${newTotal} <= current ${curTotal}${currentSummary === undefined ? ' (current count unavailable; not blocking)' : ''}`
);
return decision;
}
@@ -15,8 +15,7 @@ import {
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog,
saveVulnerabilityScan,
getCombinedScanForImage
saveVulnerabilityScan
} from '../../db';
import {
pullImage,
@@ -37,7 +36,8 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { resolveBlockDecision } from './block-decision';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
@@ -150,54 +150,16 @@ async function scanAndCheckBlock(ctx: ScanContext): Promise<ScanOutcome> {
}
}
// Handle 'more_than_current' criteria - need to get/scan current image
let currentScanSummary: VulnerabilitySeverity | undefined;
if (vulnerabilityCriteria === 'more_than_current') {
log(`Looking up cached scan for current image...`);
try {
const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null);
if (cachedScan) {
currentScanSummary = cachedScan;
log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
} else {
log(`No cached scan found, scanning current image...`);
const currentScanResults = await scanImage(currentImageId, envId, (progress) => {
const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
if (progress.message) log(`${tag} ${progress.message}`);
});
if (currentScanResults.length > 0) {
currentScanSummary = combineScanSummaries(currentScanResults);
log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
// Save for future use
for (const result of currentScanResults) {
try {
await saveVulnerabilityScan({
environmentId: envId ?? null,
imageId: currentImageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
} catch { /* ignore */ }
}
}
}
} catch (cacheError: any) {
log(`Warning: Could not get current scan: ${cacheError.message}`);
}
}
// Check if update should be blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary);
// Decide whether to block. For 'more_than_current' this re-scans the current
// image so the comparison uses up-to-date numbers (#1022) and works on a
// cold cache.
const { blocked, reason } = await resolveBlockDecision(
scanSummary,
currentImageId,
envId,
vulnerabilityCriteria,
log
);
if (blocked) {
log(`UPDATE BLOCKED: ${reason}`);
@@ -339,13 +301,9 @@ export async function runContainerUpdate(
return;
}
// Get the full container config to extract the image name (tag)
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
// Podman pod-infra containers may have an empty Config.Image — silently
// skip them before the "Could not determine image name" failure (#1221).
if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
// Podman pod-infra containers (#1221): silently skip before any inspect /
// image-resolution that would fail with "Could not determine image name".
if (isPodmanInfraContainer(containerName)) {
log(`Skipping Podman pod-infra container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
@@ -356,6 +314,10 @@ export async function runContainerUpdate(
return;
}
// Get the full container config to extract the image name (tag)
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
if (!imageNameFromConfig) {
log(`Could not determine image name from container config`);
await updateScheduleExecution(execution.id, {
@@ -30,7 +30,8 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { resolveBlockDecision } from './block-decision';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
@@ -110,7 +111,7 @@ export async function runEnvUpdateCheckJob(
const allContainers = await listContainers(true, environmentId);
// Skip hidden + Podman pod-infra (#1083, #1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
);
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
@@ -324,8 +325,16 @@ export async function runEnvUpdateCheckJob(
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
// Decide whether to block. For 'more_than_current' this
// re-scans the current image so the comparison uses
// up-to-date numbers (#1022) and works on a cold cache.
const { blocked, reason } = await resolveBlockDecision(
scanSummary,
update.currentImageId,
environmentId,
vulnerabilityCriteria,
(m) => { void log(` ${m}`); }
);
if (blocked) {
scanBlocked = true;
blockReason = reason;
+8 -12
View File
@@ -128,19 +128,15 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
}
/**
* Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
* The image (`localhost/podman-pause`) is generated locally and never published
* to any registry, so update checks always fail noisily. The label is the
* authoritative signal — but Podman may set Config.Image empty for these
* containers, so the name fallback catches that too.
* Podman creates a hidden "pod-infra" container per pod, always named
* `<pod-id-or-name>-infra` (#1221). The infra image is locally generated
* and can be overridden via --infra-image, so neither image nor label is
* universal — the name suffix is the only reliable signal.
*
* Anchored to the end so user containers like "my-infrastructure" don't match.
*/
export function isPodmanInfraContainer(
imageName: string | undefined,
labels: Record<string, string> | undefined
): boolean {
if (labels?.['io.podman.annotations.infra'] === 'true') return true;
if (imageName && /podman-pause/i.test(imageName)) return true;
return false;
export function isPodmanInfraContainer(containerName: string | undefined): boolean {
return !!containerName && /[-_]infra$/i.test(containerName);
}
/**