1.0.37
This commit is contained in:
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user