feat(security): enforce scan policies as a pre-deploy gate (#719)

Policies with block_on_deploy=1 now scan every stack image before
docker compose up runs and reject the deploy with HTTP 409 on violation.
The UI opens a dialog listing offending images; admins can override per
deploy with ?ignorePolicy=true, and every bypass is recorded in the
audit log with the originating route, actor, policy, and image list.

When Trivy is not installed on the target node the gate fails open with
a warning notification, so teams are never locked out by tooling state.
Post-deploy and scheduled scans still evaluate matching policies and
dispatch warnings on violations to surface drift on long-running stacks.

Public API additions: policy and suppression CRUD under /api/security,
plus the documented 409 block-response shape on all deploy paths.
This commit is contained in:
Anso
2026-04-21 00:14:11 -04:00
committed by GitHub
parent aa10db1d09
commit 661b9c638b
17 changed files with 1772 additions and 44 deletions
+62 -15
View File
@@ -29,6 +29,7 @@ import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
import { Checkbox } from './ui/checkbox';
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { TopBar } from './TopBar';
@@ -179,6 +180,8 @@ export default function EditorLayout() {
const { status: trivy } = useTrivyStatus();
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null);
const [policyBypassing, setPolicyBypassing] = useState(false);
const [copiedDigest, setCopiedDigest] = useState<string | null>(null);
const copiedDigestTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -1261,31 +1264,35 @@ export default function EditorLayout() {
}
};
const deployStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'deploy');
const runDeploy = async (stackName: string, stackFile: string, ignorePolicy: boolean): Promise<void> => {
const previousStatus = stackStatuses[stackFile];
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/deploy`, {
method: 'POST',
});
const path = ignorePolicy
? `/stacks/${stackName}/deploy?ignorePolicy=true`
: `/stacks/${stackName}/deploy`;
const response = await apiFetch(path, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Deploy failed');
const rawBody = await response.text();
if (response.status === 409) {
let parsed: PolicyBlockPayload | null = null;
try { parsed = JSON.parse(rawBody) as PolicyBlockPayload; } catch { /* not JSON */ }
if (parsed && parsed.policy && Array.isArray(parsed.violations)) {
setPolicyBlock({ stackName, payload: parsed });
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
toast.error(`Deploy blocked by policy "${parsed.policy.name}"`);
return;
}
}
throw new Error(rawBody || 'Deploy failed');
}
toast.success("Stack deployed successfully!");
// Refresh containers after deploy
setPolicyBlock(null);
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
// Refresh backup info
if (isPaid) {
try {
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
@@ -1297,12 +1304,41 @@ export default function EditorLayout() {
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
const msg = (error as Error).message || 'Failed to deploy stack';
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
}
};
const deployStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!selectedFile || isStackBusy(selectedFile)) return;
const stackFile = selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'deploy');
try {
await runDeploy(stackName, stackFile, false);
} finally {
clearStackAction(stackFile);
refreshStacks(true);
}
};
const bypassPolicyAndDeploy = async () => {
if (!policyBlock) return;
const stackFile = `${policyBlock.stackName}.yml`;
const existingFile = selectedFile && selectedFile.startsWith(policyBlock.stackName + '.')
? selectedFile
: stackFile;
setPolicyBypassing(true);
setStackAction(existingFile, 'deploy');
try {
await runDeploy(policyBlock.stackName, existingFile, true);
} finally {
setPolicyBypassing(false);
clearStackAction(existingFile);
refreshStacks(true);
}
};
const stopStack = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -2845,6 +2881,17 @@ export default function EditorLayout() {
stackName={alertSheetStack}
/>
{/* Pre-deploy policy block */}
<PolicyBlockDialog
open={policyBlock !== null}
payload={policyBlock?.payload ?? null}
stackName={policyBlock?.stackName ?? ''}
canBypass={isAdmin}
bypassing={policyBypassing}
onClose={() => setPolicyBlock(null)}
onBypass={bypassPolicyAndDeploy}
/>
{/* Stack Auto-Heal Sheet */}
<StackAutoHealSheet
stackName={autoHealStackName ?? ''}
@@ -0,0 +1,116 @@
import { ShieldAlert } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { SeverityChip } from '@/components/VulnerabilityScanSheet';
import type { VulnSeverity } from '@/types/security';
export interface PolicyBlockViolation {
imageRef: string;
severity: VulnSeverity | string;
criticalCount: number;
highCount: number;
scanId: number;
}
export interface PolicyBlockPayload {
error: string;
policy: { id: number; name: string; maxSeverity: string } | null;
violations: PolicyBlockViolation[];
}
interface PolicyBlockDialogProps {
open: boolean;
payload: PolicyBlockPayload | null;
stackName: string;
canBypass: boolean;
bypassing: boolean;
onClose: () => void;
onBypass: () => void;
}
const KNOWN_SEVERITIES: VulnSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
function normalizeSeverity(value: string): VulnSeverity {
const upper = value.toUpperCase();
return (KNOWN_SEVERITIES as string[]).includes(upper) ? (upper as VulnSeverity) : 'UNKNOWN';
}
export function PolicyBlockDialog({
open,
payload,
stackName,
canBypass,
bypassing,
onClose,
onBypass,
}: PolicyBlockDialogProps) {
const policyName = payload?.policy?.name ?? 'policy';
const maxSeverity = payload?.policy?.maxSeverity ?? '';
const violations = payload?.violations ?? [];
return (
<AlertDialog open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
<AlertDialogContent className="sm:max-w-xl">
<AlertDialogHeader>
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle flex items-center gap-2">
<ShieldAlert className="h-3.5 w-3.5 text-destructive" aria-hidden />
Policy block · {stackName}
</div>
<AlertDialogTitle>Deploy blocked by security policy</AlertDialogTitle>
<AlertDialogDescription>
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>.
The following {violations.length === 1 ? 'image' : `${violations.length} images`} triggered the block.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="border border-glass-border bg-card/60 shadow-card-bevel divide-y divide-glass-border">
{violations.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted-foreground">
No violation details were returned. Check the scan history for this stack for more context.
</div>
) : (
violations.map((v) => (
<div key={`${v.imageRef}-${v.scanId}`} className="px-4 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<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 &middot; {v.highCount} high
</div>
</div>
<SeverityChip severity={normalizeSeverity(String(v.severity))} />
</div>
))
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Close</AlertDialogCancel>
{canBypass ? (
<Button
variant="destructive"
disabled={bypassing}
onClick={(e) => {
e.preventDefault();
onBypass();
}}
>
{bypassing ? 'Deploying…' : 'Deploy anyway'}
</Button>
) : (
<AlertDialogAction disabled>Admin required to bypass</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}