mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(security): gate deploys on exploitation risk, not just severity (#1432)
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV) and on a fixable Critical/High finding, in addition to an optional severity threshold. New policies default risk-first (KEV and fixable on, severity off); existing policies keep their severity-only behavior. CVSS stays captured for context but is never the sole basis for a block, and a finding whose exploitability cannot be confirmed is treated as risky rather than safe (incomplete scan detail fails closed on KEV/fixable inputs). The decision logic is shared between the pre-deploy gate and the informational post-scan banner via a pure helper, so the two never disagree. Block messages and the block dialog now name the conditions an image matched. Backend and frontend gates move together, the new inputs replicate across the fleet, and a blocking policy with no active input is rejected on both sides.
This commit is contained in:
@@ -3,6 +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 type { AuditStatsInput } from './AuditAnomalyService';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
|
||||
|
||||
@@ -687,6 +688,12 @@ export interface ScanPolicy {
|
||||
block_on_deploy: number;
|
||||
enabled: number;
|
||||
replicated_from_control: number;
|
||||
/** Block when an image's highest non-suppressed severity meets max_severity. */
|
||||
block_on_severity: number;
|
||||
/** Block when any non-suppressed CVE is in the CISA known-exploited (KEV) set. */
|
||||
block_on_kev: number;
|
||||
/** Block when any non-suppressed Critical/High finding has a fix available. */
|
||||
block_on_fixable: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -815,6 +822,7 @@ export class DatabaseService {
|
||||
this.migrateNotificationRoutesMatchers();
|
||||
this.migrateNotificationHistoryContext();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
this.migrateScanPolicyRiskColumns();
|
||||
this.migrateSecretMisconfigColumns();
|
||||
this.migrateAgentsAndNotificationsNodeId();
|
||||
this.migratePolicyEvaluationColumn();
|
||||
@@ -1148,6 +1156,9 @@ export class DatabaseService {
|
||||
max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
|
||||
block_on_deploy INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
block_on_severity INTEGER NOT NULL DEFAULT 1,
|
||||
block_on_kev INTEGER NOT NULL DEFAULT 0,
|
||||
block_on_fixable INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -1754,6 +1765,17 @@ export class DatabaseService {
|
||||
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk-based deploy-gate inputs. The defaults preserve existing rows as
|
||||
* severity-only (block_on_severity=1, KEV/fixable off); new policies set
|
||||
* these explicitly to the risk-first posture at their create path.
|
||||
*/
|
||||
private migrateScanPolicyRiskColumns(): void {
|
||||
this.tryAddColumn('scan_policies', 'block_on_severity', 'INTEGER NOT NULL DEFAULT 1');
|
||||
this.tryAddColumn('scan_policies', 'block_on_kev', 'INTEGER NOT NULL DEFAULT 0');
|
||||
this.tryAddColumn('scan_policies', 'block_on_fixable', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
private migrateSecretMisconfigColumns(): void {
|
||||
this.tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||
this.tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||
@@ -5048,8 +5070,8 @@ export class DatabaseService {
|
||||
const now = Date.now();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
policy.name,
|
||||
@@ -5059,6 +5081,9 @@ export class DatabaseService {
|
||||
policy.max_severity,
|
||||
policy.block_on_deploy,
|
||||
policy.enabled,
|
||||
policy.block_on_severity,
|
||||
policy.block_on_kev,
|
||||
policy.block_on_fixable,
|
||||
policy.replicated_from_control ?? 0,
|
||||
now,
|
||||
now,
|
||||
@@ -5081,7 +5106,8 @@ export class DatabaseService {
|
||||
if (!existing) return null;
|
||||
const ALLOWED_COLUMNS = new Set([
|
||||
'name', 'node_id', 'node_identity', 'stack_pattern', 'max_severity',
|
||||
'block_on_deploy', 'enabled', 'replicated_from_control',
|
||||
'block_on_deploy', 'enabled', 'block_on_severity', 'block_on_kev',
|
||||
'block_on_fixable', 'replicated_from_control',
|
||||
]);
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
@@ -5132,8 +5158,8 @@ export class DatabaseService {
|
||||
const now = Date.now();
|
||||
const deleteStmt = this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1');
|
||||
const insertStmt = this.db.prepare(
|
||||
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((policies: ScanPolicy[]) => {
|
||||
deleteStmt.run();
|
||||
@@ -5146,6 +5172,11 @@ export class DatabaseService {
|
||||
p.max_severity,
|
||||
p.block_on_deploy,
|
||||
p.enabled,
|
||||
// A legacy control omits the risk columns; default replicated rows
|
||||
// to severity-only so an older control's intent is preserved.
|
||||
p.block_on_severity ?? 1,
|
||||
p.block_on_kev ?? 0,
|
||||
p.block_on_fixable ?? 0,
|
||||
p.created_at ?? now,
|
||||
p.updated_at ?? now,
|
||||
);
|
||||
@@ -5221,11 +5252,25 @@ export class DatabaseService {
|
||||
): PolicyEvaluation | null {
|
||||
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
|
||||
if (!policy) return null;
|
||||
const inputs = policyInputs(policy);
|
||||
let violated: boolean;
|
||||
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);
|
||||
} 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;
|
||||
}
|
||||
return {
|
||||
policyId: policy.id,
|
||||
policyName: policy.name,
|
||||
maxSeverity: policy.max_severity,
|
||||
violated: isSeverityAtLeast(scan.highest_severity, policy.max_severity),
|
||||
violated,
|
||||
evaluatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user