fix: name matched risk inputs in policy scan banner and alerts (#1473)

The pre-deploy gate names the inputs that matched a scan policy (a
known-exploited CVE, a fixable Critical/High, or a severity threshold),
but the informational post-scan surfaces still framed every violation as
a severity ceiling. The scan detail banner read "blocks severities at or
above X, highest severity is Y" and the scheduled-scan alert read
"<severity> exceeds <maxSeverity>", which is wrong for a KEV- or
fixable-only policy that never gated on severity.

Persist the matched reasons on the policy evaluation, carry them on the
scheduled-scan violation, and render them on the banner so every policy
surface names the input that actually matched. Evaluations persisted
before this change carry no reasons: the parser defaults the field to an
empty array and the banner falls back to a plain violation notice.
This commit is contained in:
Anso
2026-06-26 16:43:26 -04:00
committed by GitHub
parent d9b7911f12
commit 1de49f8b1a
9 changed files with 150 additions and 39 deletions
+19 -6
View File
@@ -3,7 +3,7 @@ import path from 'path';
import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk';
import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import type { BackendScheduledAction } from './scheduledActionRegistry';
@@ -601,6 +601,10 @@ export interface PolicyEvaluation {
policyId: number;
policyName: string;
maxSeverity: VulnSeverity;
// Inputs that actually matched (severity / kev / fixable). Empty when not
// violated. Lets the scan banner name the reason instead of always citing a
// severity threshold the policy may not have gated on.
reasons: PolicyBlockReason[];
violated: boolean;
evaluatedAt: number;
}
@@ -640,6 +644,8 @@ export interface VulnerabilityScan {
// a clean vuln read can never be a secret-only or config scan in disguise.
export const VULN_BEARING_SCANNER_SETS = ['vuln', 'vuln,secret'] as const;
const VALID_BLOCK_REASONS: ReadonlySet<PolicyBlockReason> = new Set(['severity', 'kev', 'fixable']);
export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null {
if (!raw) return null;
try {
@@ -647,6 +653,12 @@ export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEva
if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') {
return null;
}
// Rows persisted before reason tracking lack `reasons`; default to empty
// and keep only the known inputs so a stray stored value cannot reach the
// banner or alert text.
parsed.reasons = Array.isArray(parsed.reasons)
? parsed.reasons.filter((r) => VALID_BLOCK_REASONS.has(r))
: [];
return parsed;
} catch {
return null;
@@ -5527,24 +5539,25 @@ export class DatabaseService {
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
if (!policy) return null;
const inputs = policyInputs(policy);
let violated: boolean;
let reasons: PolicyBlockReason[];
if (!inputs.blockOnKev && !inputs.blockOnFixable) {
// Severity-only banner from the stored aggregate: no per-finding read.
violated = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity);
const severityHit = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity);
reasons = severityHit ? ['severity'] : [];
} else {
// Best-effort banner: scores the raw findings without honoring
// suppressions or the truncation fail-closed rule. The pre-deploy gate
// is authoritative; this only drives the informational scan banner.
const findings = this.getAllVulnerabilityDetails(scan.id);
const intel = inputs.blockOnKev ? this.getCveIntel(findings.map((f) => f.vulnerability_id)) : null;
const outcome = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs);
violated = outcome.reasons.length > 0;
reasons = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs).reasons;
}
return {
policyId: policy.id,
policyName: policy.name,
maxSeverity: policy.max_severity,
violated,
reasons,
violated: reasons.length > 0,
evaluatedAt: Date.now(),
};
}
+1 -1
View File
@@ -954,7 +954,7 @@ export class SchedulerService {
NotificationService.getInstance().dispatchAlert(
'warning',
'scan_finding',
`Policy "${v.policyName}" violated by ${v.imageRef}: ${v.severity} exceeds ${v.maxSeverity}`,
`Policy "${v.policyName}" violated by ${v.imageRef}: matched ${summarizeBlockReasons([v])}`,
{ actor: 'system:scheduler' },
);
}
+19 -18
View File
@@ -9,6 +9,7 @@ import {
VulnSeverity,
VulnScanTrigger,
VulnerabilityScan,
parsePolicyEvaluation,
} from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
@@ -19,6 +20,7 @@ import { FleetSyncService } from './FleetSyncService';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { SEVERITY_ORDER } from '../utils/severity';
import type { PolicyBlockReason } from '../utils/policy-risk';
const execFileAsync = promisify(execFile);
@@ -138,6 +140,9 @@ export interface ScanAllNodeImagesViolation {
severity: VulnSeverity;
policyName: string;
maxSeverity: VulnSeverity;
// Inputs that matched (severity / kev / fixable), so the scheduled-scan
// alert names the reason rather than always citing a severity threshold.
reasons: PolicyBlockReason[];
}
export interface ScanAllNodeImagesResult {
@@ -1189,24 +1194,20 @@ class TrivyService {
};
const collectViolation = (row: VulnerabilityScan | null): void => {
if (!row || !row.policy_evaluation) return;
try {
const parsed = JSON.parse(row.policy_evaluation) as {
violated: boolean;
policyName: string;
maxSeverity: VulnSeverity;
};
if (parsed.violated) {
violations.push({
imageRef: row.image_ref,
scanId: row.id,
severity: row.highest_severity ?? 'UNKNOWN',
policyName: parsed.policyName,
maxSeverity: parsed.maxSeverity,
});
}
} catch {
// Ignore malformed evaluation JSON; presence is informational.
if (!row) return;
// Shared parser tolerates malformed JSON (returns null) and normalizes
// reasons, so the scheduled-scan alert names the same validated inputs
// as the banner.
const parsed = parsePolicyEvaluation(row.policy_evaluation);
if (parsed?.violated) {
violations.push({
imageRef: row.image_ref,
scanId: row.id,
severity: row.highest_severity ?? 'UNKNOWN',
policyName: parsed.policyName,
maxSeverity: parsed.maxSeverity,
reasons: parsed.reasons,
});
}
};