mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
feat(security): make CVE suppressions optionally honored by deploy-block policies (#1269)
* feat(security): make CVE suppressions optionally honored by deploy-block policies
Block-on-deploy policies evaluate the raw scan result, so a CVE an admin
has accepted in CVE Suppressions still blocks the deploy. Add an opt-in,
per-instance toggle ("Honor suppressions in deploy blocks", Settings ->
Security) that, when on, re-derives each image's severity from the
suppression-filtered findings before comparing to the policy threshold. A
deploy that proceeds only because suppressions dropped it below the gate is
recorded in the audit log. Default off, so the strict raw-scan behavior is
unchanged unless an operator enables it.
The setting governs the instance that runs the deploy and is not
fleet-replicated. The gate fails safe: a suppression-read error or an
empty detail set falls back to raw scan severity rather than dropping it.
Also surface a previously swallowed error in the CVE suppressions and
misconfig acknowledgement settings panels so a failed list load shows a
toast instead of an empty list.
* fix(security): gate on raw severity when preflight detail rows are truncated
The suppression-aware deploy gate re-derived image severity from the stored
vulnerability_details rows, assuming any non-empty set was complete. A cached
pre-deploy scan keeps the full aggregate counts but copies only a bounded slice
of detail rows, so recomputing from that slice could drop an unsuppressed
blocking CVE below the threshold and let a deploy through.
Guard the recompute: when the loaded detail rows do not match the scan's total
finding count, gate on the raw scan severity (never drops severity). Suppression
awareness still applies for scans whose details are stored in full, which is the
common case.
This commit is contained in:
@@ -1248,6 +1248,7 @@ export class DatabaseService {
|
||||
stmt.run('scan_history_per_image_limit', '50');
|
||||
stmt.run('trivy_auto_update', '0');
|
||||
stmt.run('trivy_last_notified_version', '');
|
||||
stmt.run('deploy_block_honor_suppressions', '0');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
@@ -3810,6 +3811,17 @@ export class DatabaseService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* All findings for a scan, unpaginated. Used by the pre-deploy policy gate
|
||||
* to re-derive severity from suppression-filtered findings, where every row
|
||||
* must be considered rather than a single display page.
|
||||
*/
|
||||
public getAllVulnerabilityDetails(scanId: number): VulnerabilityDetail[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM vulnerability_details WHERE scan_id = ?')
|
||||
.all(scanId) as VulnerabilityDetail[];
|
||||
}
|
||||
|
||||
public insertSecretFindings(
|
||||
scanId: number,
|
||||
findings: Array<Omit<SecretFinding, 'id' | 'scan_id'>>,
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
*/
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import type { ScanPolicy, VulnSeverity } from './DatabaseService';
|
||||
import type { ScanPolicy, VulnSeverity, VulnerabilityScan } from './DatabaseService';
|
||||
import { FleetSyncService } from './FleetSyncService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import TrivyService from './TrivyService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
import { isSeverityAtLeast, severityRank } from '../utils/severity';
|
||||
import { applySuppressions } from '../utils/suppression-filter';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -75,6 +76,121 @@ export function _resetTrivyMissingNotificationStateForTests(): void {
|
||||
trivyMissingNotifiedAt.clear();
|
||||
}
|
||||
|
||||
type PreflightScan = Pick<VulnerabilityScan, 'id' | 'highest_severity' | 'critical_count' | 'high_count' | 'total_vulnerabilities'>;
|
||||
|
||||
interface ImageSeverityEvaluation {
|
||||
/** Highest non-suppressed severity; UNKNOWN means no severity remains. */
|
||||
severity: VulnSeverity;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
/** CVE IDs suppressed for this image; only populated when honoring suppressions. */
|
||||
suppressedCves: string[];
|
||||
}
|
||||
|
||||
interface SuppressionPass {
|
||||
imageRef: string;
|
||||
cves: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an image's effective severity for a policy decision. With
|
||||
* honorSuppressions off this returns the stored scan's raw severity and counts
|
||||
* (the historical behavior). With it on, the scan's findings are filtered
|
||||
* through the active CVE suppressions for that image and severity + counts are
|
||||
* re-derived from what remains, so an accepted CVE no longer drives a block.
|
||||
*/
|
||||
function evaluateImageSeverity(
|
||||
scan: PreflightScan,
|
||||
imageRef: string,
|
||||
honorSuppressions: boolean,
|
||||
): ImageSeverityEvaluation {
|
||||
const raw: ImageSeverityEvaluation = {
|
||||
severity: scan.highest_severity ?? 'UNKNOWN',
|
||||
criticalCount: scan.critical_count,
|
||||
highCount: scan.high_count,
|
||||
suppressedCves: [],
|
||||
};
|
||||
if (!honorSuppressions) return raw;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
let findings;
|
||||
let suppressions;
|
||||
try {
|
||||
findings = db.getAllVulnerabilityDetails(scan.id);
|
||||
suppressions = db.getCveSuppressions();
|
||||
} catch (err) {
|
||||
// A suppression-read failure must never drop severity. Fall back to the
|
||||
// raw scan, which still gates: an accepted CVE stays blocking rather
|
||||
// than slipping a deploy through on a transient DB error.
|
||||
console.error('[Policy] Suppression re-derivation failed for %s; gating on raw scan severity:', sanitizeForLog(imageRef), sanitizeForLog(getErrorMessage(err, 'db read failed')));
|
||||
return raw;
|
||||
}
|
||||
// The stored detail rows must reproduce the scan's full finding set before a
|
||||
// recompute can be trusted. A cache-hit preflight scan keeps the complete
|
||||
// aggregate counts but copies only the first N detail rows, so recomputing
|
||||
// from a truncated set could drop an unsuppressed blocking CVE below the
|
||||
// threshold. When the counts disagree (including an empty detail table for a
|
||||
// non-empty scan), gate on the raw scan severity, which never drops severity.
|
||||
if (findings.length !== scan.total_vulnerabilities) {
|
||||
if (scan.total_vulnerabilities > 0) {
|
||||
console.warn(
|
||||
'[Policy] Scan %d detail rows (%d) do not match its total (%d); gating on raw scan severity',
|
||||
scan.id, findings.length, scan.total_vulnerabilities,
|
||||
);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
const enriched = applySuppressions(findings, imageRef, suppressions);
|
||||
let severity: VulnSeverity = 'UNKNOWN';
|
||||
let criticalCount = 0;
|
||||
let highCount = 0;
|
||||
const suppressedCves = new Set<string>();
|
||||
for (const f of enriched) {
|
||||
if (f.suppressed) {
|
||||
suppressedCves.add(f.vulnerability_id);
|
||||
continue;
|
||||
}
|
||||
if (severityRank(f.severity) > severityRank(severity)) severity = f.severity;
|
||||
if (f.severity === 'CRITICAL') criticalCount++;
|
||||
else if (f.severity === 'HIGH') highCount++;
|
||||
}
|
||||
return { severity, criticalCount, highCount, suppressedCves: [...suppressedCves] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A deploy that would have been blocked on raw severity but proceeded because
|
||||
* suppressions dropped every image below the threshold is a security-relevant
|
||||
* event: record it so the suppression-driven pass is traceable in the audit log.
|
||||
*/
|
||||
function recordSuppressionPassAudit(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
policy: ScanPolicy,
|
||||
passes: SuppressionPass[],
|
||||
opts: PolicyEnforcementOptions,
|
||||
): void {
|
||||
const cves = [...new Set(passes.flatMap((p) => p.cves))];
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: opts.actor,
|
||||
method: opts.auditMethod ?? 'POST',
|
||||
path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`,
|
||||
status_code: 200,
|
||||
node_id: nodeId,
|
||||
ip_address: opts.ip ?? '',
|
||||
summary: `policy.suppression_pass stack="${stackName}" policy="${policy.name}" images=[${passes.map((p) => p.imageRef).join(',')}] cves=[${cves.join(',')}]`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Policy] Failed to record suppression-pass audit entry:', err);
|
||||
}
|
||||
console.warn(
|
||||
'[Policy] Deploy for "%s" allowed by suppressions: %d image(s) would have met %s (policy "%s")',
|
||||
sanitizeForLog(stackName), passes.length, policy.max_severity, sanitizeForLog(policy.name),
|
||||
);
|
||||
}
|
||||
|
||||
export async function enforcePolicyPreDeploy(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
@@ -141,15 +257,18 @@ export async function enforcePolicyForImageRefs(
|
||||
return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true };
|
||||
}
|
||||
|
||||
const honorSuppressions = db.getGlobalSettings()['deploy_block_honor_suppressions'] === '1';
|
||||
|
||||
const debug = isDebugEnabled();
|
||||
if (debug) {
|
||||
console.log(
|
||||
'[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d)',
|
||||
sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length,
|
||||
'[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d, honorSuppressions=%s)',
|
||||
sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length, honorSuppressions,
|
||||
);
|
||||
}
|
||||
|
||||
const violations: PolicyViolation[] = [];
|
||||
const suppressionPasses: SuppressionPass[] = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
if (!validateImageRef(imageRef)) {
|
||||
if (failClosedInvalidRefs) {
|
||||
@@ -165,21 +284,28 @@ export async function enforcePolicyForImageRefs(
|
||||
}
|
||||
try {
|
||||
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
|
||||
const severity = scan.highest_severity ?? 'UNKNOWN';
|
||||
const evaluated = evaluateImageSeverity(scan, imageRef, honorSuppressions);
|
||||
const rawSeverity = scan.highest_severity ?? 'UNKNOWN';
|
||||
if (debug) {
|
||||
console.log(
|
||||
'[Policy:debug] %s scanned: highest=%s vs max=%s',
|
||||
sanitizeForLog(imageRef), severity, policy.max_severity,
|
||||
'[Policy:debug] %s scanned: effective=%s raw=%s vs max=%s',
|
||||
sanitizeForLog(imageRef), evaluated.severity, rawSeverity, policy.max_severity,
|
||||
);
|
||||
}
|
||||
if (isSeverityAtLeast(severity, policy.max_severity)) {
|
||||
if (isSeverityAtLeast(evaluated.severity, policy.max_severity)) {
|
||||
violations.push({
|
||||
imageRef,
|
||||
severity,
|
||||
criticalCount: scan.critical_count,
|
||||
highCount: scan.high_count,
|
||||
severity: evaluated.severity,
|
||||
criticalCount: evaluated.criticalCount,
|
||||
highCount: evaluated.highCount,
|
||||
scanId: scan.id,
|
||||
});
|
||||
} else if (
|
||||
honorSuppressions &&
|
||||
evaluated.suppressedCves.length > 0 &&
|
||||
isSeverityAtLeast(rawSeverity, policy.max_severity)
|
||||
) {
|
||||
suppressionPasses.push({ imageRef, cves: evaluated.suppressedCves });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'pre-flight scan failed');
|
||||
@@ -195,6 +321,9 @@ export async function enforcePolicyForImageRefs(
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
if (suppressionPasses.length > 0) {
|
||||
recordSuppressionPassAudit(stackName, nodeId, policy, suppressionPasses, opts);
|
||||
}
|
||||
return { ok: true, bypassed: false, policy, violations: [] };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user