mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +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:
@@ -5,23 +5,59 @@ import {
|
||||
ModalFooter,
|
||||
} from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { SeverityChip } from '@/components/VulnerabilityScanSheet';
|
||||
import type { VulnSeverity } from '@/types/security';
|
||||
|
||||
/** Risk inputs a deploy gate can block on; mirrors the backend reason set. */
|
||||
export type PolicyBlockReason = 'severity' | 'kev' | 'fixable';
|
||||
|
||||
export interface PolicyBlockViolation {
|
||||
imageRef: string;
|
||||
severity: VulnSeverity | string;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
kevCount: number;
|
||||
fixableCount: number;
|
||||
/** Which inputs matched (empty when the image could not be scanned). */
|
||||
reasons: PolicyBlockReason[];
|
||||
scanId: number;
|
||||
}
|
||||
|
||||
export interface PolicyBlockPayload {
|
||||
error: string;
|
||||
policy: { id: number; name: string; maxSeverity: string } | null;
|
||||
policy:
|
||||
| {
|
||||
id: number;
|
||||
name: string;
|
||||
maxSeverity: string;
|
||||
// Active inputs (0/1). Absent on older control payloads, where the
|
||||
// dialog falls back to severity-only wording.
|
||||
blockOnSeverity?: number;
|
||||
blockOnKev?: number;
|
||||
blockOnFixable?: number;
|
||||
}
|
||||
| null;
|
||||
violations: PolicyBlockViolation[];
|
||||
}
|
||||
|
||||
const REASON_LABEL: Record<PolicyBlockReason, string> = {
|
||||
severity: 'Severity',
|
||||
kev: 'KEV',
|
||||
fixable: 'Fixable',
|
||||
};
|
||||
|
||||
/** Plain-language list of the inputs a policy blocks on, for the dialog copy. */
|
||||
function describePolicyInputs(policy: PolicyBlockPayload['policy']): string {
|
||||
if (!policy) return 'its scan policy conditions';
|
||||
const parts: string[] = [];
|
||||
if (policy.blockOnSeverity) parts.push(`severity at or above ${policy.maxSeverity}`);
|
||||
if (policy.blockOnKev) parts.push('a known-exploited CVE (KEV)');
|
||||
if (policy.blockOnFixable) parts.push('a fixable Critical/High finding');
|
||||
// Older payloads omit the flags entirely; describe the severity threshold.
|
||||
return parts.length > 0 ? parts.join(', ') : `severity at or above ${policy.maxSeverity}`;
|
||||
}
|
||||
|
||||
/** The only stack operations the backend scan-policy gate can reject. */
|
||||
export type PolicyBlockableAction = 'deploy' | 'update' | 'rollback';
|
||||
|
||||
@@ -52,7 +88,7 @@ export function PolicyBlockDialog({
|
||||
onBypass,
|
||||
}: PolicyBlockDialogProps) {
|
||||
const policyName = payload?.policy?.name ?? 'policy';
|
||||
const maxSeverity = payload?.policy?.maxSeverity ?? '';
|
||||
const inputsText = describePolicyInputs(payload?.policy ?? null);
|
||||
const violations = payload?.violations ?? [];
|
||||
|
||||
return (
|
||||
@@ -60,13 +96,12 @@ export function PolicyBlockDialog({
|
||||
<ModalDestructiveHeader
|
||||
kicker={`${stackName.toUpperCase()} · SCAN POLICY · BLOCKED`}
|
||||
title="Deploy blocked by security policy"
|
||||
description={`Policy ${policyName} blocks deploys when any image meets or exceeds ${maxSeverity}.`}
|
||||
description={`Policy ${policyName} blocks deploys on ${inputsText}.`}
|
||||
/>
|
||||
<ModalBody>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Policy <span className="font-medium text-foreground">{policyName}</span> blocks deploys
|
||||
when any image meets or exceeds{' '}
|
||||
<span className="font-medium text-foreground">{maxSeverity}</span>.{' '}
|
||||
on <span className="font-medium text-foreground">{inputsText}</span>.{' '}
|
||||
The following {violations.length === 1 ? 'image' : `${violations.length} images`} triggered the block.
|
||||
</p>
|
||||
<div className="border border-glass-border bg-card/60 shadow-card-bevel divide-y divide-glass-border">
|
||||
@@ -81,7 +116,18 @@ export function PolicyBlockDialog({
|
||||
<div className="font-mono text-sm truncate">{v.imageRef}</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle tabular-nums">
|
||||
{v.criticalCount} critical · {v.highCount} high
|
||||
{v.kevCount > 0 && <> · {v.kevCount} KEV</>}
|
||||
{v.fixableCount > 0 && <> · {v.fixableCount} fixable</>}
|
||||
</div>
|
||||
{(v.reasons ?? []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{(v.reasons ?? []).map((r) => (
|
||||
<Badge key={r} variant="destructive" className="text-[10px]">
|
||||
{REASON_LABEL[r]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SeverityChip severity={normalizeSeverity(String(v.severity))} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PolicyBlockDialog, type PolicyBlockPayload } from '../PolicyBlockDialog';
|
||||
|
||||
const payload: PolicyBlockPayload = {
|
||||
error: 'blocked',
|
||||
policy: { id: 1, name: 'prod-gate', maxSeverity: 'CRITICAL', blockOnSeverity: 0, blockOnKev: 1, blockOnFixable: 1 },
|
||||
violations: [
|
||||
{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 0, kevCount: 1, fixableCount: 1, reasons: ['kev', 'fixable'], scanId: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
describe('PolicyBlockDialog', () => {
|
||||
it('describes the active inputs (KEV + fixable, not the severity threshold)', () => {
|
||||
render(
|
||||
<PolicyBlockDialog open payload={payload} stackName="web" canBypass={false} bypassing={false} onClose={vi.fn()} onBypass={vi.fn()} />,
|
||||
);
|
||||
const desc = screen.getAllByText(/known-exploited CVE \(KEV\)/i);
|
||||
expect(desc.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/fixable Critical\/High finding/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders a reason badge per matched input on the violation row', () => {
|
||||
render(
|
||||
<PolicyBlockDialog open payload={payload} stackName="web" canBypass={false} bypassing={false} onClose={vi.fn()} onBypass={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('KEV')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fixable')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 KEV/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to severity wording when input flags are absent (older payload)', () => {
|
||||
const legacy: PolicyBlockPayload = {
|
||||
error: 'blocked',
|
||||
policy: { id: 1, name: 'old-gate', maxSeverity: 'HIGH' },
|
||||
violations: [{ imageRef: 'redis:7', severity: 'HIGH', criticalCount: 0, highCount: 1, kevCount: 0, fixableCount: 0, reasons: [], scanId: 2 }],
|
||||
};
|
||||
render(
|
||||
<PolicyBlockDialog open payload={legacy} stackName="web" canBypass={false} bypassing={false} onClose={vi.fn()} onBypass={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getAllByText(/severity at or above HIGH/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user