mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
feat(security): one-click managed Trivy install (#643)
* feat(security): one-click managed Trivy install Add a Vulnerability Scanner card to Settings, Security with install, update, uninstall, and auto-update controls (Admiral-only). The installer downloads a verified Trivy release into the existing data volume at /app/data/bin/trivy and defaults the cache to /app/data/trivy-cache, so no host mounts or extra env vars are required. Detection probes the managed path, a TRIVY_BIN override, and the host PATH, distinguishing managed vs host installs. A daily scheduled check surfaces available Trivy updates, installs them automatically when opted in, and dedupes notifications per version. * fix(frontend): silence react-hooks/set-state-in-effect in useTrivyStatus The initial status fetch and managed-source update check both call setState from the effect body. Match the existing pattern used in useDashboardData / SSOSection and disable the rule at the call site.
This commit is contained in:
@@ -465,7 +465,7 @@ export default function ResourcesView() {
|
||||
const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false);
|
||||
|
||||
// Vulnerability scanning state
|
||||
const trivy = useTrivyStatus();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({});
|
||||
const [scanningImageRef, setScanningImageRef] = useState<string | null>(null);
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
|
||||
@@ -28,8 +28,10 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil } from 'lucide-react';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import type { ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
|
||||
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
@@ -54,6 +56,24 @@ const EMPTY_FORM: PolicyFormState = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const TRIVY_SOURCE_BADGES: Record<'managed' | 'host' | 'none', { label: string; variant: 'outline' | 'secondary' }> = {
|
||||
managed: { label: 'Installed (managed)', variant: 'outline' },
|
||||
host: { label: 'Installed (host)', variant: 'outline' },
|
||||
none: { label: 'Not installed', variant: 'secondary' },
|
||||
};
|
||||
|
||||
const TRIVY_SOURCE_DESCRIPTIONS: Record<'managed' | 'host' | 'none', string | null> = {
|
||||
managed: null,
|
||||
host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.',
|
||||
none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.",
|
||||
};
|
||||
|
||||
const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = {
|
||||
install: { loading: 'Installing Trivy...', success: 'Trivy installed' },
|
||||
update: { loading: 'Updating Trivy...', success: 'Trivy updated' },
|
||||
uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' },
|
||||
};
|
||||
|
||||
export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -63,6 +83,63 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const { license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||
|
||||
const runTrivyOp = async (
|
||||
op: 'install' | 'update' | 'uninstall',
|
||||
path: string,
|
||||
method: 'POST' | 'DELETE',
|
||||
) => {
|
||||
const { loading, success } = TRIVY_OP_LABELS[op];
|
||||
setTrivyBusy(op);
|
||||
const toastId = toast.loading(loading);
|
||||
try {
|
||||
const res = await apiFetch(path, { method, localOnly: true });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Trivy ${op} failed`);
|
||||
}
|
||||
toast.success(success);
|
||||
await Promise.all([refreshTrivy(), refreshUpdateCheck()]);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || `Trivy ${op} failed`);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
setTrivyBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallTrivy = () => runTrivyOp('install', '/security/trivy-install', 'POST');
|
||||
const handleUpdateTrivy = () => runTrivyOp('update', '/security/trivy-update', 'POST');
|
||||
const handleUninstallTrivy = async () => {
|
||||
setUninstallConfirm(false);
|
||||
await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE');
|
||||
};
|
||||
|
||||
const handleAutoUpdateToggle = async (enabled: boolean) => {
|
||||
setTrivyBusy('auto-update');
|
||||
try {
|
||||
const res = await apiFetch('/security/trivy-auto-update', {
|
||||
method: 'PUT',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
await refreshTrivy();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to update setting');
|
||||
} finally {
|
||||
setTrivyBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPolicies = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/security/policies', { localOnly: true });
|
||||
@@ -194,6 +271,81 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">Vulnerability Scanner</span>
|
||||
<Badge variant={TRIVY_SOURCE_BADGES[trivy.source].variant} className="text-[10px] shrink-0">
|
||||
{TRIVY_SOURCE_BADGES[trivy.source].label}
|
||||
</Badge>
|
||||
{updateCheck?.updateAvailable && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
Update available to v{updateCheck.latest}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isAdmiral && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trivy.source === 'none' && (
|
||||
<Button size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'install' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Install Trivy
|
||||
</Button>
|
||||
)}
|
||||
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
|
||||
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'update' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{trivy.source === 'managed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setUninstallConfirm(true)}
|
||||
disabled={trivyBusy !== null}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trivy.source === 'managed' && trivy.version && (
|
||||
<div className="text-xs text-stat-subtitle font-mono">Version: v{trivy.version}</div>
|
||||
)}
|
||||
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
|
||||
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
|
||||
)}
|
||||
|
||||
{trivy.source === 'managed' && isAdmiral && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Auto-update Trivy</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check daily and install newer Trivy releases automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={trivy.autoUpdate}
|
||||
onCheckedChange={handleAutoUpdateToggle}
|
||||
disabled={trivyBusy !== null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
@@ -352,6 +504,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Trivy?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the managed Trivy binary. Vulnerability scanning will stop working until
|
||||
Trivy is reinstalled or a host binary is provided.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstallTrivy}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { TrivyStatus } from '@/types/security';
|
||||
import type { TrivyStatus, TrivyUpdateCheck } from '@/types/security';
|
||||
|
||||
export function useTrivyStatus(): TrivyStatus {
|
||||
const [status, setStatus] = useState<TrivyStatus>({ available: false, version: null });
|
||||
const INITIAL_STATUS: TrivyStatus = {
|
||||
available: false,
|
||||
version: null,
|
||||
source: 'none',
|
||||
autoUpdate: false,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
apiFetch('/security/trivy-status')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (cancelled || !d) return;
|
||||
setStatus({
|
||||
available: !!d.available,
|
||||
version: typeof d.version === 'string' ? d.version : null,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch Trivy status:', err);
|
||||
export interface UseTrivyStatusResult {
|
||||
status: TrivyStatus;
|
||||
updateCheck: TrivyUpdateCheck | null;
|
||||
refresh: () => Promise<void>;
|
||||
refreshUpdateCheck: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTrivyStatus(): UseTrivyStatusResult {
|
||||
const [status, setStatus] = useState<TrivyStatus>(INITIAL_STATUS);
|
||||
const [updateCheck, setUpdateCheck] = useState<TrivyUpdateCheck | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch('/security/trivy-status');
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
setStatus({
|
||||
available: !!d.available,
|
||||
version: typeof d.version === 'string' ? d.version : null,
|
||||
source: d.source === 'managed' || d.source === 'host' ? d.source : 'none',
|
||||
autoUpdate: !!d.autoUpdate,
|
||||
busy: !!d.busy,
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Trivy status:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
const refreshUpdateCheck = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch('/security/trivy-update-check');
|
||||
if (!r.ok) {
|
||||
setUpdateCheck(null);
|
||||
return;
|
||||
}
|
||||
const d = (await r.json()) as TrivyUpdateCheck;
|
||||
setUpdateCheck(d);
|
||||
} catch {
|
||||
setUpdateCheck(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.source === 'managed') {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void refreshUpdateCheck();
|
||||
} else {
|
||||
setUpdateCheck(null);
|
||||
}
|
||||
}, [status.source, refreshUpdateCheck]);
|
||||
|
||||
return { status, updateCheck, refresh, refreshUpdateCheck };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,21 @@ export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
|
||||
|
||||
export type TrivySource = 'managed' | 'host' | 'none';
|
||||
|
||||
export interface TrivyStatus {
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
source: TrivySource;
|
||||
autoUpdate: boolean;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface TrivyUpdateCheck {
|
||||
current: string | null;
|
||||
latest: string;
|
||||
updateAvailable: boolean;
|
||||
source: TrivySource;
|
||||
}
|
||||
|
||||
export interface VulnerabilityScan {
|
||||
|
||||
Reference in New Issue
Block a user