mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation Bring vulnerability scanning, scan history, suppressions, Compose risks, secrets, policy packs, and scanner setup into one node-scoped Security command center instead of scattering them across Resources and Settings. - New top-level Security view with Overview, Images, Compose risks, Secrets, Policies, Suppressions, History, and Scanner setup tabs (status masthead + signal rail; controlled tabs with deep-link support). - Backend: GET /security/overview rollup and GET /security/policy-packs static catalog (auth-only, Community). DatabaseService gains an uncapped scan-status count and a node-eligible block-policy count, and getImageScanSummaries now projects secret and misconfig counts. - Reuse existing surfaces: the scan-history sheet, the control-governed suppression and acknowledgement panels, and the scan-detail sheet (now with an initial-tab prop so it opens on the matching finding type). - Extract a shared SeverityBadge (from Resources) and a TrivyManager (from Settings) so both surfaces render identical controls. - Resources "Scan history" now links into the Security page History tab. - Docs for the new Security surface and tests for the new endpoints, helpers, nav wiring, and tabs. * refactor: consolidate scanner and policy management onto the Security page Remove the Settings "Vulnerability Scanning" section now that the Security page covers the same ground, with every option preserved: - Scanner install / update / uninstall / auto-update live on the Scanner setup tab (TrivyManager). - Scan policies, the honor-suppressions toggle, and the replica managed-by-control / demote controls move into a new ScanPolicyManager on the Policies tab (paid; Community sees only the policy-pack catalog). - CVE suppressions and acknowledgements remain on the Suppressions tab. Wiring removed: the registry section and the now-empty Security settings group, the SectionId, the SettingsSectionContent case and the isPaid prop it was the sole consumer of, and SecuritySection itself. The dashboard configuration-status "Vulnerability scanning" row now navigates to the Security page Policies tab. Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept to the relevant Security page tabs. * fix: harden Security page scanner refresh, policy-load errors, and secret-only badges Address independent-review findings on the Security page: - Scanner setup now refreshes Trivy state when the active node changes, so the displayed scanner status matches the node TrivyManager's actions target (both follow x-node-id). Previously, switching nodes on the tab left stale state. - ScanPolicyManager surfaces an explicit error state on a failed policy fetch instead of falling through to a false "No scan policies configured". - The shared SeverityBadge and the Images findings column no longer label a scan "clean" when it has secrets or misconfigurations but no CVE severity (highest_severity is derived from vulnerabilities only); they show a "Findings" state and the secret/misconfig counts instead. - The Overview enforcement note points to the Policies tab, not the removed Settings section. - The History tab auto-opens the scan-history sheet only on a deep-link (mount with the History tab active), not on every manual tab selection. Adds tests for the badge secret/misconfig state and the policy-load error state.
This commit is contained in:
@@ -156,6 +156,7 @@ export default function EditorLayout() {
|
||||
const {
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
securityHistoryOpen, setSecurityHistoryOpen,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill,
|
||||
@@ -704,6 +705,8 @@ export default function EditorLayout() {
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
securityTab={securityTab}
|
||||
onSecurityTabChange={setSecurityTab}
|
||||
renderEditor={renderEditor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import HomeDashboard from '../HomeDashboard';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
|
||||
// Paid-tier views and the security-history overlay are loaded on demand.
|
||||
// Their internal PaidGate / CapabilityGate wrappers render
|
||||
@@ -39,6 +40,9 @@ const AuditLogView = lazy(() =>
|
||||
);
|
||||
const ScheduledOperationsView = lazy(() => import('../ScheduledOperationsView'));
|
||||
const AutoUpdateReadinessView = lazy(() => import('../AutoUpdateReadinessView'));
|
||||
const SecurityView = lazy(() =>
|
||||
import('../SecurityView').then(m => ({ default: m.SecurityView })),
|
||||
);
|
||||
|
||||
// Sized for the main workspace area (flex-1 with p-6 padding). Visible
|
||||
// only during the brief window between an unlocked view's chunk request
|
||||
@@ -81,6 +85,8 @@ export interface ViewRouterProps {
|
||||
onNavigateToStack: (stackFile: string) => void;
|
||||
onOpenSettingsSection: (section: SectionId) => void;
|
||||
onClearNotifications: () => void;
|
||||
securityTab: SecurityTab;
|
||||
onSecurityTabChange: (tab: SecurityTab) => void;
|
||||
// Render slot for the inline editor view. Kept as a callback so the
|
||||
// (large) editor JSX is only allocated when activeView === 'editor',
|
||||
// not on every parent render that lands on a different view.
|
||||
@@ -104,6 +110,8 @@ export function ViewRouter({
|
||||
onNavigateToStack,
|
||||
onOpenSettingsSection,
|
||||
onClearNotifications,
|
||||
securityTab,
|
||||
onSecurityTabChange,
|
||||
renderEditor,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
@@ -121,6 +129,16 @@ export function ViewRouter({
|
||||
if (activeView === 'resources') {
|
||||
return <ResourcesView />;
|
||||
}
|
||||
if (activeView === 'security') {
|
||||
// Node-scoped (not hub-only): scan/scanner data follows the active node
|
||||
// like Resources. The page itself is Community; per-tab gates handle
|
||||
// capability-missing nodes and the local-control governance tabs.
|
||||
return (
|
||||
<LazyView>
|
||||
<SecurityView activeTab={securityTab} onTabChange={onSecurityTabChange} />
|
||||
</LazyView>
|
||||
);
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Mirror the backend RBAC gate (system:console, admin-only). The nav
|
||||
// item is already admin-gated; this stops a non-admin who reaches the
|
||||
|
||||
@@ -332,4 +332,46 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.activeView).toBe('resources');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Security view: node-scoped, deep-linkable tab ──────────────────────────
|
||||
|
||||
it('includes the Security nav item for a community user', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('security');
|
||||
});
|
||||
|
||||
it('keeps Security visible on a remote node (node-scoped, not hub-only)', () => {
|
||||
mockActiveNode('remote');
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('security');
|
||||
});
|
||||
|
||||
it('defaults securityTab to overview', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.securityTab).toBe('overview');
|
||||
});
|
||||
|
||||
it('navigate to security with a tab sets securityTab then activeView (deep-link, no race)', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'history', nodeId: 4 } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeView).toBe('security');
|
||||
expect(result.current.securityTab).toBe('history');
|
||||
expect(result.current.filterNodeId).toBe(4);
|
||||
});
|
||||
|
||||
it('navigate to security without a tab defaults securityTab to overview', () => {
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
act(() => result.current.setSecurityTab('history'));
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security' } }),
|
||||
);
|
||||
});
|
||||
expect(result.current.activeView).toBe('security');
|
||||
expect(result.current.securityTab).toBe('overview');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Terminal, CloudDownload, Home, HardDrive, ScrollText,
|
||||
Activity, Radar, RefreshCw, Clock,
|
||||
Activity, Radar, RefreshCw, Clock, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -9,6 +9,7 @@ import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
|
||||
import type { SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
|
||||
|
||||
@@ -20,6 +21,7 @@ export type ActiveView =
|
||||
| 'templates'
|
||||
| 'global-observability'
|
||||
| 'fleet'
|
||||
| 'security'
|
||||
| 'audit-log'
|
||||
| 'scheduled-ops'
|
||||
| 'auto-updates'
|
||||
@@ -58,6 +60,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
|
||||
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
|
||||
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
|
||||
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
|
||||
const [securityHistoryOpen, setSecurityHistoryOpen] = useState(false);
|
||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||
@@ -91,6 +94,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
return;
|
||||
}
|
||||
if (detail.view === 'security') {
|
||||
// Set the target tab before switching the view so the controlled
|
||||
// SecurityView lands on it deterministically (no mount race).
|
||||
setSecurityTab(detail.tab ?? 'overview');
|
||||
setActiveView('security');
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
return;
|
||||
}
|
||||
setActiveView(detail.view as ActiveView);
|
||||
setFilterNodeId(detail.nodeId ?? null);
|
||||
};
|
||||
@@ -103,6 +114,9 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
{ value: 'fleet', label: 'Fleet', icon: Radar },
|
||||
{ value: 'resources', label: 'Resources', icon: HardDrive },
|
||||
// Security is a Community, node-scoped review surface (not hub-only), so
|
||||
// it shows for every authenticated user and on remote nodes too.
|
||||
{ value: 'security', label: 'Security', icon: ShieldCheck },
|
||||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||||
];
|
||||
// The aggregated Logs feed crosses every managed stack, so it is an
|
||||
@@ -137,6 +151,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
return {
|
||||
activeView, setActiveView,
|
||||
settingsSection, setSettingsSection,
|
||||
securityTab, setSecurityTab,
|
||||
securityHistoryOpen, setSecurityHistoryOpen,
|
||||
filterNodeId, setFilterNodeId,
|
||||
schedulePrefill, setSchedulePrefill,
|
||||
|
||||
@@ -13,6 +13,10 @@ describe('mobile treatments', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats the Security view as responsive (reflowed, not bespoke or desktop-only)', () => {
|
||||
expect(MOBILE_TREATMENTS.security).toBe('responsive');
|
||||
});
|
||||
|
||||
it('keeps BESPOKE_MOBILE_VIEWS in lockstep with the bespoke treatments', () => {
|
||||
const declaredBespoke = Object.entries(MOBILE_TREATMENTS)
|
||||
.filter(([, treatment]) => treatment === 'bespoke')
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MOBILE_TREATMENTS: Record<ActiveView, MobileTreatment> = {
|
||||
settings: 'bespoke',
|
||||
editor: 'detail',
|
||||
resources: 'responsive',
|
||||
security: 'responsive',
|
||||
templates: 'responsive',
|
||||
'global-observability': 'responsive',
|
||||
'auto-updates': 'responsive',
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions';
|
||||
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
|
||||
import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
|
||||
interface NodeSchedulingSummary {
|
||||
active_tasks: number;
|
||||
@@ -29,8 +30,10 @@ interface NodeSchedulingSummary {
|
||||
|
||||
export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
|
||||
export interface SenchoNavigateDetail {
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security-history';
|
||||
view: 'scheduled-ops' | 'auto-updates' | 'security-history' | 'security';
|
||||
nodeId?: number;
|
||||
/** Target tab when navigating to the Security view. */
|
||||
tab?: SecurityTab;
|
||||
}
|
||||
|
||||
export function NodeManager() {
|
||||
|
||||
@@ -16,11 +16,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen } from 'lucide-react';
|
||||
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager';
|
||||
import type { ScanSummary, VulnSeverity } from '@/types/security';
|
||||
import type { ScanSummary } from '@/types/security';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
@@ -227,84 +227,6 @@ function SenchoBadge() {
|
||||
|
||||
// ── Severity Badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
|
||||
CRITICAL: 'border-destructive/25 bg-destructive/8 text-destructive',
|
||||
HIGH: 'border-warning/25 bg-warning/8 text-warning',
|
||||
MEDIUM: 'border-warning/25 bg-warning/8 text-warning',
|
||||
LOW: 'border-border bg-muted/30 text-muted-foreground',
|
||||
UNKNOWN: 'border-border bg-muted/20 text-muted-foreground',
|
||||
CLEAN: 'border-success/25 bg-success/8 text-success',
|
||||
};
|
||||
|
||||
const SEVERITY_DOT_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
|
||||
CRITICAL: 'bg-destructive',
|
||||
HIGH: 'bg-warning',
|
||||
MEDIUM: 'bg-warning',
|
||||
LOW: 'bg-muted-foreground/60',
|
||||
UNKNOWN: 'bg-muted-foreground/40',
|
||||
CLEAN: 'bg-success',
|
||||
};
|
||||
|
||||
function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) {
|
||||
const key: VulnSeverity | 'CLEAN' = summary.highest_severity ?? 'CLEAN';
|
||||
const label = key === 'CLEAN' ? 'Clean' : key;
|
||||
const [relative, setRelative] = useState<string>('');
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000);
|
||||
setRelative(
|
||||
scanAge < 1 ? 'just now'
|
||||
: scanAge < 60 ? `${scanAge}m ago`
|
||||
: scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago`
|
||||
: `${Math.round(scanAge / 1440)}d ago`,
|
||||
);
|
||||
};
|
||||
compute();
|
||||
const id = setInterval(compute, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [summary.scanned_at]);
|
||||
|
||||
return (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
|
||||
SEVERITY_BADGE_CLASSES[key],
|
||||
)}
|
||||
>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
|
||||
{label}
|
||||
</button>
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" align="end" sideOffset={8}>
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
|
||||
<div className="font-mono tabular-nums text-xs space-y-1">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
|
||||
<div className="text-stat-value">{relative}</div>
|
||||
{summary.total > 0 && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
|
||||
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
|
||||
{summary.medium > 0 && <span className="text-warning">{summary.medium}M</span>}
|
||||
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
|
||||
</div>
|
||||
)}
|
||||
{summary.total === 0 && (
|
||||
<div className="text-success">No vulnerabilities</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quick Clean Prune Button ───────────────────────────────────────────────────
|
||||
|
||||
interface PruneButtonProps {
|
||||
@@ -920,7 +842,7 @@ export default function ResourcesView() {
|
||||
className="border-border"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'security-history' },
|
||||
detail: { view: 'security', tab: 'history' },
|
||||
}));
|
||||
}}
|
||||
title="View completed vulnerability scans and compare them"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Boxes, FileWarning, KeyRound, BookCheck, EyeOff, History as HistoryIcon, Wrench, Info,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { PageMasthead } from '@/components/ui/PageMasthead';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { deriveMasthead } from './security/securityMasthead';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import type { SecurityOverview, ScanSummary, ScanDetailTab, FleetRole } from '@/types/security';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { SecurityHistoryView } from './SecurityHistoryView';
|
||||
import { SuppressionsPanel } from './settings/SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
|
||||
import { OverviewTab } from './security/OverviewTab';
|
||||
import { ImagesTab } from './security/ImagesTab';
|
||||
import { FindingsTab } from './security/FindingsTab';
|
||||
import { PolicyPacksTab } from './security/PolicyPacksTab';
|
||||
import { ScanPolicyManager } from './security/ScanPolicyManager';
|
||||
import { ScannerSetupTab } from './security/ScannerSetupTab';
|
||||
|
||||
interface SecurityViewProps {
|
||||
activeTab: SecurityTab;
|
||||
onTabChange: (tab: SecurityTab) => void;
|
||||
}
|
||||
|
||||
export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const [overview, setOverview] = useState<SecurityOverview | null>(null);
|
||||
// 'unsupported' = the node has no overview endpoint (e.g. an older remote, 404);
|
||||
// 'failed' = a genuine error (5xx, network, malformed body) that must not read as benign.
|
||||
const [overviewLoadError, setOverviewLoadError] = useState<'unsupported' | 'failed' | null>(null);
|
||||
const [summaries, setSummaries] = useState<Record<string, ScanSummary>>({});
|
||||
const [summariesLoading, setSummariesLoading] = useState(true);
|
||||
const [summariesError, setSummariesError] = useState(false);
|
||||
const [isReplica, setIsReplica] = useState(false);
|
||||
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(null);
|
||||
const [inspectInitialTab, setInspectInitialTab] = useState<ScanDetailTab | undefined>(undefined);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => {
|
||||
setInspectInitialTab(initialTab);
|
||||
setInspectScanId(scanId);
|
||||
}, []);
|
||||
|
||||
// Active-node scoped data: overview rollup + image summaries follow x-node-id.
|
||||
// A failed fetch (5xx, network, malformed body) must surface as an error, never
|
||||
// as a benign "clean / no findings" view, which for a security surface is the
|
||||
// most dangerous misread. A 404 on /overview is the one benign case (an older
|
||||
// remote node that lacks the endpoint).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setSummariesLoading(true);
|
||||
setOverviewLoadError(null);
|
||||
setSummariesError(false);
|
||||
try {
|
||||
const [overviewRes, summariesRes] = await Promise.all([
|
||||
apiFetch('/security/overview'),
|
||||
apiFetch('/security/image-summaries'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (overviewRes.ok) {
|
||||
setOverview(await overviewRes.json());
|
||||
} else {
|
||||
setOverview(null);
|
||||
setOverviewLoadError(overviewRes.status === 404 ? 'unsupported' : 'failed');
|
||||
if (overviewRes.status !== 404) {
|
||||
console.warn('[Security] overview request failed:', overviewRes.status);
|
||||
}
|
||||
}
|
||||
if (summariesRes.ok) {
|
||||
setSummaries(await summariesRes.json());
|
||||
} else {
|
||||
setSummaries({});
|
||||
setSummariesError(true);
|
||||
console.warn('[Security] image-summaries request failed:', summariesRes.status);
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.warn('[Security] failed to load security data:', err);
|
||||
setOverview(null);
|
||||
setOverviewLoadError('failed');
|
||||
setSummaries({});
|
||||
setSummariesError(true);
|
||||
} finally {
|
||||
if (!cancelled) setSummariesLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [activeNode?.id]);
|
||||
|
||||
// Governance panels (suppressions/acks) are control-governed; probe the local
|
||||
// fleet role so a replica renders them read-only, mirroring Settings.
|
||||
useEffect(() => {
|
||||
if (isRemote) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/role', { localOnly: true });
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = await res.json();
|
||||
if (!cancelled && (data?.role === 'control' || data?.role === 'replica')) {
|
||||
setIsReplica((data.role as FleetRole) === 'replica');
|
||||
}
|
||||
} catch {
|
||||
// Treat as control on probe failure (read-only gate is best-effort).
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isRemote, activeNode?.id]);
|
||||
|
||||
// A deep-link to History (e.g. the Resources "Scan history" button, which
|
||||
// mounts this view with the History tab active) auto-opens the sheet once on
|
||||
// mount. Selecting the History tab manually shows the persistent launcher
|
||||
// body instead, so the sheet does not pop on every tab click; closing it
|
||||
// always leaves the launcher.
|
||||
const deepLinkedToHistory = useRef(activeTab === 'history');
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (deepLinkedToHistory.current) setHistoryOpen(true);
|
||||
}, []);
|
||||
|
||||
const { state, tone } = deriveMasthead(overview, overviewLoadError !== null);
|
||||
const pulsing = tone === 'live' && !!overview?.scanner.available;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<PageMasthead
|
||||
kicker="SECURITY"
|
||||
state={state}
|
||||
tone={tone}
|
||||
pulsing={pulsing}
|
||||
className="rounded-lg mb-4"
|
||||
metadata={overview ? [
|
||||
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
|
||||
{ label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' },
|
||||
{ label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' },
|
||||
] : undefined}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
|
||||
<TabsList className="mb-4 max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview"><LayoutDashboard className="w-4 h-4 mr-1.5" />Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="images">
|
||||
<TabsTrigger value="images"><Boxes className="w-4 h-4 mr-1.5" />Images</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="compose">
|
||||
<TabsTrigger value="compose"><FileWarning className="w-4 h-4 mr-1.5" />Compose risks</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets"><KeyRound className="w-4 h-4 mr-1.5" />Secrets</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||
<TabsHighlightItem value="policies">
|
||||
<TabsTrigger value="policies"><BookCheck className="w-4 h-4 mr-1.5" />Policies</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="suppressions">
|
||||
<TabsTrigger value="suppressions"><EyeOff className="w-4 h-4 mr-1.5" />Suppressions</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="history">
|
||||
<TabsTrigger value="history"><HistoryIcon className="w-4 h-4 mr-1.5" />History</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="scanner">
|
||||
<TabsTrigger value="scanner"><Wrench className="w-4 h-4 mr-1.5" />Scanner setup</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab overview={overview} loadError={overviewLoadError} onNavigate={onTabChange} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="images">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<ImagesTab summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compose">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<FindingsTab kind="misconfig" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="secrets">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<FindingsTab kind="secret" summaries={summaries} loading={summariesLoading} error={summariesError} onInspect={onInspect} />
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="policies">
|
||||
<div className="space-y-8">
|
||||
<PolicyPacksTab />
|
||||
<ScanPolicyManager />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="suppressions">
|
||||
{isRemote ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3">
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Managed on the local instance</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Suppressions and acknowledgements are managed on the local Sencho instance. Switch to the local node to view them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<SuppressionsPanel isReplica={isReplica} />
|
||||
<MisconfigAckPanel isReplica={isReplica} />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<CapabilityGate capability="vulnerability-scanning" featureName="Vulnerability scanning">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-sm">Scan history</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{overview
|
||||
? `${overview.scannedImages} image${overview.scannedImages === 1 ? '' : 's'} scanned · last scan ${overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}`
|
||||
: 'Browse completed scans and compare them.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
<HistoryIcon className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Open scan history
|
||||
</Button>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scanner">
|
||||
<ScannerSetupTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<SecurityHistoryView open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
initialTab={inspectInitialTab}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isAdmin}
|
||||
canExportSarif={isPaid && isAdmin}
|
||||
canCompare
|
||||
canManageSuppressions={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
VulnSeverity,
|
||||
SecretFinding,
|
||||
MisconfigFinding,
|
||||
ScanDetailTab,
|
||||
} from '@/types/security';
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
@@ -62,6 +63,13 @@ interface VulnerabilityScanSheetProps {
|
||||
canExportSarif?: boolean;
|
||||
canCompare?: boolean;
|
||||
canManageSuppressions?: boolean;
|
||||
/**
|
||||
* Tab to open on first load. Defaults to 'vulns' (with the existing
|
||||
* auto-switch to a populated tab when the scan has no vulnerabilities).
|
||||
* Callers that open the sheet from a secret/misconfig context pass the
|
||||
* matching tab so it lands there even when the scan also has CVEs.
|
||||
*/
|
||||
initialTab?: FindingTab;
|
||||
}
|
||||
|
||||
interface SuppressDialogState {
|
||||
@@ -80,7 +88,9 @@ interface AckDialogState {
|
||||
}
|
||||
|
||||
type SeverityFilter = 'ALL' | VulnSeverity;
|
||||
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
|
||||
// Single source of truth lives in types/security as ScanDetailTab; alias here so
|
||||
// the initialTab prop is provably the same type its callers (SecurityView) hold.
|
||||
type FindingTab = ScanDetailTab;
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -113,6 +123,7 @@ export function VulnerabilityScanSheet({
|
||||
canExportSarif = false,
|
||||
canCompare = false,
|
||||
canManageSuppressions: canManageSuppressionsProp = false,
|
||||
initialTab,
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [isReplica, setIsReplica] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -183,7 +194,11 @@ export function VulnerabilityScanSheet({
|
||||
setPage(0);
|
||||
setSecretsPage(0);
|
||||
setMisconfigsPage(0);
|
||||
if ((scanData.total_vulnerabilities ?? 0) === 0) {
|
||||
if (initialTab) {
|
||||
// Caller asked to land on a specific tab (e.g. opened from the
|
||||
// Secrets or Compose-risks list), which wins over the default.
|
||||
setTab(initialTab);
|
||||
} else if ((scanData.total_vulnerabilities ?? 0) === 0) {
|
||||
if ((scanData.misconfig_count ?? 0) > 0) setTab('misconfigs');
|
||||
else if ((scanData.secret_count ?? 0) > 0) setTab('secrets');
|
||||
else setTab('vulns');
|
||||
@@ -195,7 +210,7 @@ export function VulnerabilityScanSheet({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [scanId]);
|
||||
}, [scanId, initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setCompareOpen(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { useConfigurationStatus } from './useConfigurationStatus';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
|
||||
|
||||
interface ConfigurationStatusProps {
|
||||
onOpenSection?: (section: SectionId) => void;
|
||||
@@ -191,7 +192,9 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
value={formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
onClick={open('security')}
|
||||
onClick={() => window.dispatchEvent(
|
||||
new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, { detail: { view: 'security', tab: 'policies' } }),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function MobileSettings({ headerActions }: MobileSettingsProps) {
|
||||
<span className="font-display italic text-[30px] leading-[34px] text-stat-value">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden px-4 pb-8 pt-4 flex flex-col gap-6">
|
||||
<SettingsSectionContent sectionId={activeSection} isPaid={isPaid} onDirtyChange={NOOP} showDescription />
|
||||
<SettingsSectionContent sectionId={activeSection} onDirtyChange={NOOP} showDescription />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMemo } from 'react';
|
||||
import { KeyRound, FileWarning, AlertTriangle } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
||||
import type { ScanSummary, ScanDetailTab } from '@/types/security';
|
||||
|
||||
type FindingsKind = 'secret' | 'misconfig';
|
||||
|
||||
interface FindingsTabProps {
|
||||
kind: FindingsKind;
|
||||
summaries: Record<string, ScanSummary>;
|
||||
loading: boolean;
|
||||
/** True when the summaries fetch failed; render an error state, never a false "no findings". */
|
||||
error?: boolean;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
}
|
||||
|
||||
const COPY: Record<FindingsKind, {
|
||||
icon: typeof KeyRound;
|
||||
detailTab: ScanDetailTab;
|
||||
countField: 'secret_count' | 'misconfig_count';
|
||||
emptyTitle: string;
|
||||
emptyBody: string;
|
||||
intro?: string;
|
||||
}> = {
|
||||
secret: {
|
||||
icon: KeyRound,
|
||||
detailTab: 'secrets',
|
||||
countField: 'secret_count',
|
||||
emptyTitle: 'No secret findings',
|
||||
emptyBody: 'Trivy found no exposed credentials or keys in the scanned images on this node.',
|
||||
},
|
||||
misconfig: {
|
||||
icon: FileWarning,
|
||||
detailTab: 'misconfigs',
|
||||
countField: 'misconfig_count',
|
||||
emptyTitle: 'No Compose risks found',
|
||||
emptyBody: 'Scan a stack from Resources to surface misconfigurations like privileged containers, host mounts, or missing healthchecks.',
|
||||
intro: 'Compose risks are misconfigurations in your stack definitions, such as privileged containers, Docker socket mounts, host networking, broad bind mounts, or missing healthchecks. Open a result for the specific findings and how to fix them; the Policy packs tab explains each category.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Index of images/stacks that carry findings of the given kind. Rows open the
|
||||
* existing scan sheet on the matching detail tab. */
|
||||
export function FindingsTab({ kind, summaries, loading, error, onInspect }: FindingsTabProps) {
|
||||
const copy = COPY[kind];
|
||||
const Icon = copy.icon;
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
Object.values(summaries)
|
||||
// Both kinds filter on the kind's count; misconfig additionally requires a
|
||||
// stack/config scan (image_ref `stack:<name>`).
|
||||
.filter((s) => s[copy.countField] > 0 && (kind !== 'misconfig' || s.image_ref.startsWith('stack:')))
|
||||
.sort((a, b) => b.scanned_at - a.scanned_at),
|
||||
[summaries, kind, copy.countField],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">Scan results failed to load for this node. Try again shortly.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{copy.intro && <p className="text-sm text-muted-foreground max-w-2xl">{copy.intro}</p>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Icon className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">{copy.emptyTitle}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">{copy.emptyBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-card-border">
|
||||
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">
|
||||
{kind === 'misconfig' ? 'Stack' : 'Image'}
|
||||
</th>
|
||||
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Findings</th>
|
||||
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => {
|
||||
const label = kind === 'misconfig' ? s.image_ref.replace(/^stack:/, '') : s.image_ref;
|
||||
const count = s[copy.countField];
|
||||
return (
|
||||
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
|
||||
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, copy.detailTab)}>
|
||||
{label}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums text-xs text-stat-value">{count}</td>
|
||||
<td className="px-4 py-2.5 text-right max-md:hidden">
|
||||
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, copy.detailTab)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Boxes, AlertTriangle } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SeverityBadge } from '@/components/ui/SeverityBadge';
|
||||
import type { ScanSummary, ScanDetailTab } from '@/types/security';
|
||||
|
||||
interface ImagesTabProps {
|
||||
summaries: Record<string, ScanSummary>;
|
||||
loading: boolean;
|
||||
/** True when the summaries fetch failed; render an error state, never a false "clean". */
|
||||
error?: boolean;
|
||||
onInspect: (scanId: number, initialTab?: ScanDetailTab) => void;
|
||||
}
|
||||
|
||||
/** Latest-scan index for real images (stack/config scans live in Compose risks). */
|
||||
export function ImagesTab({ summaries, loading, error, onInspect }: ImagesTabProps) {
|
||||
const images = useMemo(
|
||||
() =>
|
||||
Object.values(summaries)
|
||||
.filter((s) => !s.image_ref.startsWith('stack:'))
|
||||
.sort((a, b) => b.scanned_at - a.scanned_at),
|
||||
[summaries],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">Couldn't load scan results</h3>
|
||||
<p className="text-sm text-muted-foreground">Scan results failed to load for this node. Try again shortly.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Boxes className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">No scanned images</h3>
|
||||
<p className="text-sm text-muted-foreground">Scan an image from Resources to see its findings here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-card-border">
|
||||
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Image</th>
|
||||
<th className="text-left font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2 max-md:hidden">Findings</th>
|
||||
<th className="text-right font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle px-4 py-2">Severity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{images.map((s) => (
|
||||
<tr key={s.image_ref} className="border-b border-card-border/40 last:border-0 hover:bg-glass-highlight">
|
||||
<td className="px-4 py-2.5 font-mono text-xs truncate max-w-0 w-full">
|
||||
<button type="button" className="hover:text-brand truncate block w-full text-left" onClick={() => onInspect(s.scan_id, 'vulns')}>
|
||||
{s.image_ref}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-xs text-stat-subtitle max-md:hidden">
|
||||
{s.critical > 0 && <span className="text-destructive mr-2">{s.critical}C</span>}
|
||||
{s.high > 0 && <span className="text-warning mr-2">{s.high}H</span>}
|
||||
{s.secret_count > 0 && <span className="text-warning mr-2">{s.secret_count} secret</span>}
|
||||
{s.misconfig_count > 0 && <span className="text-warning mr-2">{s.misconfig_count} misconfig</span>}
|
||||
{s.fixable > 0 && <span className="text-stat-subtitle">{s.fixable} fixable</span>}
|
||||
{s.total === 0 && s.secret_count === 0 && s.misconfig_count === 0 && <span className="text-success">clean</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<SeverityBadge summary={s} onClick={() => onInspect(s.scan_id, 'vulns')} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { SignalRail, type SignalTile } from '@/components/ui/SignalRail';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { SecurityOverview } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
|
||||
interface OverviewTabProps {
|
||||
overview: SecurityOverview | null;
|
||||
/** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */
|
||||
loadError: 'unsupported' | 'failed' | null;
|
||||
onNavigate: (tab: SecurityTab) => void;
|
||||
}
|
||||
|
||||
const STATUS_ROW_TONE: Record<'value' | 'warn' | 'subtitle', string> = {
|
||||
value: 'text-stat-value',
|
||||
warn: 'text-warning',
|
||||
subtitle: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
function StatusRow({ label, value, tone }: { label: string; value: string; tone?: 'value' | 'warn' | 'subtitle' }) {
|
||||
const toneClass = STATUS_ROW_TONE[tone ?? 'value'];
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-[var(--density-cell-y)]">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">{label}</span>
|
||||
<span className={`font-mono tabular-nums text-sm ${toneClass}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, onNavigate }: OverviewTabProps) {
|
||||
if (loadError === 'unsupported') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<ShieldOff className="w-12 h-12 text-muted-foreground/50 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">Overview unavailable on this node</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This node does not report a security overview. Browse images, history, and scanner setup directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError === 'failed') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<ShieldOff className="w-12 h-12 text-warning/60 mb-4" strokeWidth={1.5} />
|
||||
<h3 className="text-lg font-medium mb-1">Couldn't load the overview</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The security overview failed to load for this node. Switch nodes and back, or try again shortly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tiles: SignalTile[] = [
|
||||
{ kicker: 'Scanned images', value: String(overview.scannedImages) },
|
||||
{ kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' },
|
||||
{ kicker: 'Secrets', value: String(overview.secrets), tone: overview.secrets > 0 ? 'error' : 'value' },
|
||||
{ kicker: 'Misconfigs', value: String(overview.misconfigs), tone: overview.misconfigs > 0 ? 'warn' : 'value' },
|
||||
{ kicker: 'Stale', value: String(overview.staleScans), tone: overview.staleScans > 0 ? 'warn' : 'value' },
|
||||
{ kicker: 'Failed', value: String(overview.failedScans), tone: overview.failedScans > 0 ? 'error' : 'value' },
|
||||
];
|
||||
|
||||
const scannerValue = overview.scanner.available
|
||||
? `${overview.scanner.source}${overview.scanner.version ? ` · v${overview.scanner.version}` : ''}`
|
||||
: 'not installed';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Signal rail of supporting counts. Wrapped so a phone scrolls the rail
|
||||
instead of crushing the fixed columns. */}
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden max-md:overflow-x-auto">
|
||||
<div className="min-w-[640px]">
|
||||
<SignalRail tiles={tiles} className="border-b-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Scanner</h3>
|
||||
<StatusRow label="Status" value={scannerValue} tone={overview.scanner.available ? 'value' : 'warn'} />
|
||||
{overview.scanner.source === 'managed' && (
|
||||
<StatusRow label="Auto-update" value={overview.scanner.autoUpdate ? 'on' : 'off'} tone="subtitle" />
|
||||
)}
|
||||
<StatusRow
|
||||
label="Last scan"
|
||||
value={overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never'}
|
||||
tone="subtitle"
|
||||
/>
|
||||
{!overview.scanner.available && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate('scanner')}
|
||||
className="mt-2 text-xs text-brand hover:underline"
|
||||
>
|
||||
Set up the scanner
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-2">Deploy enforcement</h3>
|
||||
<StatusRow
|
||||
label="Block policies"
|
||||
value={String(overview.deployEnforcement.eligibleBlockPolicies)}
|
||||
tone={overview.deployEnforcement.eligibleBlockPolicies > 0 ? 'value' : 'subtitle'}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Honor suppressions"
|
||||
value={overview.deployEnforcement.honorSuppressionsOnDeploy ? 'on' : 'off'}
|
||||
tone="subtitle"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { PolicyPack, PolicyPackRule } from '@/types/security';
|
||||
|
||||
const SEVERITY_TEXT: Record<PolicyPackRule['severity'], string> = {
|
||||
CRITICAL: 'text-destructive',
|
||||
HIGH: 'text-warning',
|
||||
MEDIUM: 'text-warning',
|
||||
LOW: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
function EnforcementBadge({ enforcement }: { enforcement: PolicyPackRule['enforcement'] }) {
|
||||
const enforceable = enforcement === 'enforceable';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em]',
|
||||
enforceable
|
||||
? 'border-brand/30 bg-brand/10 text-brand'
|
||||
: 'border-card-border bg-muted/30 text-stat-subtitle',
|
||||
)}
|
||||
>
|
||||
{enforceable ? 'Enforceable' : 'Warning'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicyPacksTab() {
|
||||
const [packs, setPacks] = useState<PolicyPack[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// The catalog is global/static, so target the local control regardless
|
||||
// of which node is active.
|
||||
const res = await apiFetch('/security/policy-packs', { localOnly: true });
|
||||
if (!res.ok) throw new Error('Failed to load policy packs');
|
||||
const data = (await res.json()) as PolicyPack[];
|
||||
if (!cancelled) setPacks(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
// The catalog is a static, always-available route, so a failure here is a
|
||||
// real bug (routing/proxy/auth) worth a breadcrumb, not a silent empty state.
|
||||
console.error('[Security] Failed to load policy packs:', err);
|
||||
if (!cancelled) setError(true);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-16 text-center">
|
||||
Policy packs could not be loaded.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!packs) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Policy packs are curated security expectations for a deployment posture. Packs are advisory in
|
||||
Community: they explain what good looks like. Block-on-deploy enforcement is an Admiral capability.
|
||||
</p>
|
||||
|
||||
{packs.map((pack) => (
|
||||
<div key={pack.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel overflow-hidden">
|
||||
<div className="border-b border-card-border px-4 py-3">
|
||||
<h3 className="font-display italic text-[18px] leading-6 text-stat-value">{pack.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{pack.tagline}</p>
|
||||
<p className="text-xs text-stat-subtitle mt-1">{pack.tierCopy}</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-card-border/40">
|
||||
{pack.rules.map((rule) => (
|
||||
<li key={rule.id} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
<span className={cn('font-mono text-[10px] uppercase tracking-[0.18em]', SEVERITY_TEXT[rule.severity])}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<EnforcementBadge enforcement={rule.enforcement} />
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-1.5 text-xs sm:grid-cols-[7rem_1fr]">
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Checks</dt>
|
||||
<dd className="text-stat-subtitle">{rule.whatItChecks}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Why</dt>
|
||||
<dd className="text-stat-subtitle">{rule.why}</dd>
|
||||
<dt className="font-mono uppercase tracking-[0.18em] text-stat-subtitle">Fix</dt>
|
||||
<dd className="text-stat-subtitle">{rule.howToFix}</dd>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+167
-316
@@ -9,16 +9,14 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
import { useMastheadStats } from './MastheadStatsContext';
|
||||
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil, Info } from 'lucide-react';
|
||||
import { SettingsCallout } from '@/components/settings/SettingsCallout';
|
||||
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { SuppressionsPanel } from './SuppressionsPanel';
|
||||
import { MisconfigAckPanel } from './MisconfigAckPanel';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
|
||||
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
@@ -43,141 +41,70 @@ 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 }) {
|
||||
/**
|
||||
* Deploy-enforcement scan policies (block-on-deploy severity thresholds), the
|
||||
* honor-suppressions toggle, and the replica "managed by control" state. This
|
||||
* is the paid governance surface for the Security page Policies tab; it returns
|
||||
* null for Community (no enforcement management) so the catalog is all a
|
||||
* Community operator sees. Policies are control-governed: fetched localOnly and
|
||||
* shown only on the local node, mirroring how the rest of the fleet-governance
|
||||
* UI behaves.
|
||||
*/
|
||||
export function ScanPolicyManager() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { status: trivy, refresh: refreshTrivy } = useTrivyStatus();
|
||||
|
||||
const [policies, setPolicies] = useState<ScanPolicy[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<PolicyFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'honor-suppressions'>(null);
|
||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||
const [honorBusy, setHonorBusy] = useState(false);
|
||||
const [fleetRole, setFleetRole] = useState<FleetRole>('control');
|
||||
const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false);
|
||||
const [demoteConfirm, setDemoteConfirm] = useState(false);
|
||||
const [demoteBusy, setDemoteBusy] = useState(false);
|
||||
const isReplica = fleetRole === 'replica';
|
||||
|
||||
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 });
|
||||
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',
|
||||
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 handleHonorSuppressionsToggle = async (enabled: boolean) => {
|
||||
setTrivyBusy('honor-suppressions');
|
||||
try {
|
||||
const res = await apiFetch('/security/deploy-block-honor-suppressions', {
|
||||
method: 'PUT',
|
||||
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 () => {
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch('/security/policies', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPolicies(Array.isArray(data) ? data : []);
|
||||
if (!res.ok) {
|
||||
// A non-OK response must not read as "no policies configured", which
|
||||
// would falsely imply nothing is enforcing.
|
||||
setLoadError(true);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setPolicies(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load scan policies:', err);
|
||||
toast.error('Failed to load scan policies');
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaid) { setLoading(false); return; }
|
||||
if (isRemote) { setPolicies([]); setLoading(false); return; }
|
||||
if (!isPaid || isRemote) { setLoading(false); return; }
|
||||
fetchPolicies();
|
||||
}, [isPaid, isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaid || isRemote) return;
|
||||
void refreshTrivy();
|
||||
}, [activeNode?.id, refreshTrivy]);
|
||||
}, [isPaid, isRemote, activeNode?.id, refreshTrivy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemote) return;
|
||||
if (!isPaid || isRemote) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -199,7 +126,26 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isRemote]);
|
||||
}, [isPaid, isRemote]);
|
||||
|
||||
const handleHonorSuppressionsToggle = async (enabled: boolean) => {
|
||||
setHonorBusy(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/deploy-block-honor-suppressions', {
|
||||
method: 'PUT',
|
||||
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 {
|
||||
setHonorBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemote = async () => {
|
||||
setDemoteBusy(true);
|
||||
@@ -297,27 +243,35 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
useMastheadStats(
|
||||
loading
|
||||
? null
|
||||
: [
|
||||
...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []),
|
||||
{
|
||||
label: 'TRIVY',
|
||||
value: trivy.source === 'none' ? 'missing' : trivy.source,
|
||||
tone: trivy.source === 'none' ? 'warn' : 'value' as const,
|
||||
},
|
||||
],
|
||||
);
|
||||
// Enforcement management is a paid governance surface; Community sees only the
|
||||
// policy-pack catalog above it.
|
||||
if (!isPaid) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isPaid && isAdmin && !isRemote && !isReplica && (
|
||||
<div className="flex justify-end">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
|
||||
{isAdmin && !isRemote && !isReplica && (
|
||||
<SettingsPrimaryButton size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add policy
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRemote && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Managed on the local instance</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Scan policies are managed on the local Sencho instance. Switch to the local node to manage them.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -364,95 +318,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</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>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isAdmin && trivy.source === 'none' && (
|
||||
<SettingsPrimaryButton 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
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
{isAdmin && 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>
|
||||
)}
|
||||
{isAdmin && 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' && isAdmin && (
|
||||
<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>
|
||||
<TogglePill
|
||||
checked={trivy.autoUpdate}
|
||||
onChange={handleAutoUpdateToggle}
|
||||
disabled={trivyBusy !== null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRemote && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Scanner is per-node</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemote && loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
@@ -460,7 +325,15 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaid && !isRemote && !loading && policies.length === 0 && (
|
||||
{!isRemote && !loading && loadError && (
|
||||
<SettingsCallout
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
title="Couldn't load scan policies"
|
||||
subtitle="Scan policies failed to load. Try again shortly."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isRemote && !loading && !loadError && policies.length === 0 && (
|
||||
<SettingsCallout
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
title="No scan policies configured"
|
||||
@@ -468,7 +341,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPaid && !isRemote && !loading &&
|
||||
{!isRemote && !loading &&
|
||||
policies.map((policy) => (
|
||||
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -520,7 +393,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isPaid && isAdmin && !isRemote && (
|
||||
{isAdmin && !isRemote && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">Honor suppressions in deploy blocks</Label>
|
||||
@@ -531,117 +404,95 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
<TogglePill
|
||||
checked={trivy.honorSuppressionsOnDeploy}
|
||||
onChange={handleHonorSuppressionsToggle}
|
||||
disabled={trivyBusy !== null}
|
||||
disabled={honorBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
||||
|
||||
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
|
||||
|
||||
{isPaid && (
|
||||
<>
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
<ModalHeader
|
||||
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
|
||||
title={editingId ? 'Edit policy' : 'New policy'}
|
||||
description="Configure the severity threshold and scope for this scan policy."
|
||||
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
|
||||
<ModalHeader
|
||||
kicker={editingId ? 'SECURITY · EDIT POLICY' : 'SECURITY · NEW POLICY'}
|
||||
title={editingId ? 'Edit policy' : 'New policy'}
|
||||
description="Configure the severity threshold and scope for this scan policy."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-name">Name</Label>
|
||||
<Input
|
||||
id="policy-name"
|
||||
placeholder="Production block on critical"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-name">Name</Label>
|
||||
<Input
|
||||
id="policy-name"
|
||||
placeholder="Production block on critical"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
|
||||
<Input
|
||||
id="policy-pattern"
|
||||
placeholder="e.g. prod-* or leave blank for all"
|
||||
value={form.stack_pattern}
|
||||
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max severity</Label>
|
||||
<Combobox
|
||||
options={SEVERITY_OPTIONS}
|
||||
value={form.max_severity}
|
||||
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Block on deploy</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={form.block_on_deploy}
|
||||
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={form.enabled}
|
||||
onChange={(c) => setForm({ ...form, enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
||||
</SettingsPrimaryButton>
|
||||
}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="policy-pattern">Stack pattern (optional)</Label>
|
||||
<Input
|
||||
id="policy-pattern"
|
||||
placeholder="e.g. prod-* or leave blank for all"
|
||||
value={form.stack_pattern}
|
||||
onChange={(e) => setForm({ ...form, stack_pattern: e.target.value })}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteId != null}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
variant="destructive"
|
||||
kicker="SECURITY · DELETE · IRREVERSIBLE"
|
||||
title="Delete scan policy"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes the policy immediately. Existing scans are not affected.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob-style pattern matched against stack names. Leave blank to apply to all stacks.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max severity</Label>
|
||||
<Combobox
|
||||
options={SEVERITY_OPTIONS}
|
||||
value={form.max_severity}
|
||||
onValueChange={(v) => setForm({ ...form, max_severity: v as VulnSeverity })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Block on deploy</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={form.block_on_deploy}
|
||||
onChange={(c) => setForm({ ...form, block_on_deploy: c })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">Disabled policies are skipped during evaluation.</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={form.enabled}
|
||||
onChange={(c) => setForm({ ...form, enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
|
||||
</SettingsPrimaryButton>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
open={uninstallConfirm}
|
||||
onOpenChange={setUninstallConfirm}
|
||||
open={deleteId != null}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
variant="destructive"
|
||||
kicker="TRIVY · REMOVE · IRREVERSIBLE"
|
||||
title="Remove Trivy"
|
||||
confirmLabel="Remove"
|
||||
onConfirm={handleUninstallTrivy}
|
||||
kicker="SECURITY · DELETE · IRREVERSIBLE"
|
||||
title="Delete scan policy"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
|
||||
Removes the policy immediately. Existing scans are not affected.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { TrivyManager } from './TrivyManager';
|
||||
|
||||
/** Scanner install/update/health for the active node. Owns the single
|
||||
* useTrivyStatus instance and feeds the controlled TrivyManager. */
|
||||
export function ScannerSetupTab() {
|
||||
const { status, updateCheck, refresh, refreshUpdateCheck } = useTrivyStatus();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
// useTrivyStatus only refreshes on mount. Re-fetch when the active node
|
||||
// changes so the displayed scanner state matches the node TrivyManager's
|
||||
// actions target (both follow x-node-id); otherwise switching nodes while on
|
||||
// this tab would show node A's status while install/update hit node B.
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [activeNode?.id, refresh]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Vulnerability scanning uses Trivy, installed independently on each node. Manage the scanner for the
|
||||
active node here.
|
||||
</p>
|
||||
<TrivyManager
|
||||
status={status}
|
||||
updateCheck={updateCheck}
|
||||
refresh={refresh}
|
||||
refreshUpdateCheck={refreshUpdateCheck}
|
||||
/>
|
||||
{isRemote && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Scanner is per-node</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { ShieldCheck, Download, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import type { TrivyStatus, TrivyUpdateCheck, TrivySource } from '@/types/security';
|
||||
|
||||
const TRIVY_SOURCE_BADGES: Record<TrivySource, { 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<TrivySource, 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' },
|
||||
};
|
||||
|
||||
interface TrivyManagerProps {
|
||||
status: TrivyStatus;
|
||||
updateCheck: TrivyUpdateCheck | null;
|
||||
refresh: () => Promise<void>;
|
||||
refreshUpdateCheck: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scanner install/update/uninstall/auto-update controls for managed Trivy.
|
||||
* Controlled: the parent owns the single `useTrivyStatus` instance and passes
|
||||
* the status plus refresh callbacks, so a host that renders this alongside
|
||||
* other Trivy-derived UI (the Settings security section) keeps one source of
|
||||
* truth. Mounted by both the Settings security section and the Security page
|
||||
* Scanner setup tab.
|
||||
*/
|
||||
export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
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 });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Trivy ${op} failed`);
|
||||
}
|
||||
toast.success(success);
|
||||
await Promise.all([refresh(), refreshUpdateCheck()]);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || `Trivy ${op} failed`);
|
||||
} finally {
|
||||
toast.dismiss(toastId);
|
||||
setTrivyBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = () => runTrivyOp('install', '/security/trivy-install', 'POST');
|
||||
const handleUpdate = () => runTrivyOp('update', '/security/trivy-update', 'POST');
|
||||
const handleUninstall = 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',
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || 'Failed to update setting');
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to update setting');
|
||||
} finally {
|
||||
setTrivyBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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[status.source].variant} className="text-[10px] shrink-0">
|
||||
{TRIVY_SOURCE_BADGES[status.source].label}
|
||||
</Badge>
|
||||
{updateCheck?.updateAvailable && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
Update available to v{updateCheck.latest}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isAdmin && status.source === 'none' && (
|
||||
<SettingsPrimaryButton size="sm" onClick={handleInstall} 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
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
{isAdmin && status.source === 'managed' && updateCheck?.updateAvailable && (
|
||||
<Button size="sm" variant="outline" onClick={handleUpdate} 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>
|
||||
)}
|
||||
{isAdmin && status.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>
|
||||
|
||||
{status.source === 'managed' && status.version && (
|
||||
<div className="text-xs text-stat-subtitle font-mono">Version: v{status.version}</div>
|
||||
)}
|
||||
{TRIVY_SOURCE_DESCRIPTIONS[status.source] && (
|
||||
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[status.source]}</div>
|
||||
)}
|
||||
|
||||
{status.source === 'managed' && isAdmin && (
|
||||
<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>
|
||||
<TogglePill
|
||||
checked={status.autoUpdate}
|
||||
onChange={handleAutoUpdateToggle}
|
||||
disabled={trivyBusy !== null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={uninstallConfirm}
|
||||
onOpenChange={setUninstallConfirm}
|
||||
variant="destructive"
|
||||
kicker="TRIVY · REMOVE · IRREVERSIBLE"
|
||||
title="Remove Trivy"
|
||||
confirmLabel="Remove"
|
||||
onConfirm={handleUninstall}
|
||||
>
|
||||
<p className="text-sm text-stat-subtitle">
|
||||
Removes the managed Trivy binary. Vulnerability scanning stops working until Trivy is reinstalled or a host binary is provided.
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* FindingsTab is the shared index for Secrets and Compose risks. It filters the
|
||||
* lifted image summaries by kind and opens the scan sheet on the matching
|
||||
* detail tab (so a Secrets row lands on Secrets even when the scan has CVEs).
|
||||
*/
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FindingsTab } from '../FindingsTab';
|
||||
import type { ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(overrides: Partial<ScanSummary> & { image_ref: string; scan_id: number }): ScanSummary {
|
||||
return {
|
||||
highest_severity: 'HIGH',
|
||||
scanned_at: 1,
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('secret variant lists only images with secrets and opens the Secrets tab', async () => {
|
||||
const onInspect = vi.fn();
|
||||
const summaries = {
|
||||
'withsecret:1': summary({ image_ref: 'withsecret:1', scan_id: 10, secret_count: 2 }),
|
||||
'clean:1': summary({ image_ref: 'clean:1', scan_id: 11, secret_count: 0 }),
|
||||
};
|
||||
render(<FindingsTab kind="secret" summaries={summaries} loading={false} onInspect={onInspect} />);
|
||||
|
||||
expect(screen.getByText('withsecret:1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('clean:1')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('withsecret:1'));
|
||||
expect(onInspect).toHaveBeenCalledWith(10, 'secrets');
|
||||
});
|
||||
|
||||
it('misconfig variant lists only stack scans and opens the Misconfigs tab', async () => {
|
||||
const onInspect = vi.fn();
|
||||
const summaries = {
|
||||
'stack:web': summary({ image_ref: 'stack:web', scan_id: 20, misconfig_count: 3 }),
|
||||
'nginx:1': summary({ image_ref: 'nginx:1', scan_id: 21, misconfig_count: 0 }),
|
||||
};
|
||||
render(<FindingsTab kind="misconfig" summaries={summaries} loading={false} onInspect={onInspect} />);
|
||||
|
||||
// Stack name is shown without the "stack:" prefix.
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.queryByText('nginx:1')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('web'));
|
||||
expect(onInspect).toHaveBeenCalledWith(20, 'misconfigs');
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no findings of the kind', () => {
|
||||
render(<FindingsTab kind="secret" summaries={{}} loading={false} onInspect={vi.fn()} />);
|
||||
expect(screen.getByText('No secret findings')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* PolicyPacksTab renders the static catalog and, crucially, fetches it with
|
||||
* { localOnly: true } so the global catalog is available regardless of which
|
||||
* node is active.
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { PolicyPacksTab } from '../PolicyPacksTab';
|
||||
import type { PolicyPack } from '@/types/security';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
const PACKS: PolicyPack[] = [
|
||||
{
|
||||
id: 'homelab-baseline',
|
||||
name: 'Homelab baseline',
|
||||
tagline: 'Gentle defaults.',
|
||||
tierCopy: 'Advisory.',
|
||||
rules: [
|
||||
{ id: 'pin-image-tag', name: 'Pin image tags', severity: 'LOW', whatItChecks: 'tags', why: 'reproducible', howToFix: 'pin', enforcement: 'warning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'strict-production',
|
||||
name: 'Strict production',
|
||||
tagline: 'Zero tolerance.',
|
||||
tierCopy: 'Strict.',
|
||||
rules: [
|
||||
{ id: 'no-privileged', name: 'No privileged containers', severity: 'CRITICAL', whatItChecks: 'priv', why: 'escape', howToFix: 'drop', enforcement: 'enforceable' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedFetch.mockResolvedValue(jsonResponse(200, PACKS));
|
||||
});
|
||||
|
||||
it('fetches the catalog with localOnly and renders packs and rules', async () => {
|
||||
render(<PolicyPacksTab />);
|
||||
await waitFor(() => expect(screen.getByText('Homelab baseline')).toBeInTheDocument());
|
||||
expect(screen.getByText('Strict production')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pin image tags')).toBeInTheDocument();
|
||||
expect(screen.getByText('No privileged containers')).toBeInTheDocument();
|
||||
|
||||
expect(mockedFetch).toHaveBeenCalledWith('/security/policy-packs', { localOnly: true });
|
||||
});
|
||||
|
||||
it('labels rules as warning or enforceable', async () => {
|
||||
render(<PolicyPacksTab />);
|
||||
await waitFor(() => expect(screen.getByText('Warning')).toBeInTheDocument());
|
||||
expect(screen.getByText('Enforceable')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* ScanPolicyManager is the paid deploy-enforcement surface on the Security
|
||||
* Policies tab. Key guards: it renders nothing for Community, and a failed
|
||||
* policy fetch surfaces an error state instead of a false "No scan policies
|
||||
* configured".
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/context/LicenseContext');
|
||||
vi.mock('@/context/AuthContext');
|
||||
vi.mock('@/context/NodeContext');
|
||||
vi.mock('@/hooks/useTrivyStatus');
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import * as LicenseContext from '@/context/LicenseContext';
|
||||
import * as AuthContext from '@/context/AuthContext';
|
||||
import * as NodeContext from '@/context/NodeContext';
|
||||
import * as TrivyStatus from '@/hooks/useTrivyStatus';
|
||||
import { ScanPolicyManager } from '../ScanPolicyManager';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
function setup({ isPaid }: { isPaid: boolean }) {
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({ isPaid } as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
|
||||
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
|
||||
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, busy: false },
|
||||
updateCheck: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshUpdateCheck: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Fleet-role probe resolves to control by default; per-test override for policies.
|
||||
mockedFetch.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [])),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nothing for a Community operator (paid surface)', () => {
|
||||
setup({ isPaid: false });
|
||||
const { container } = render(<ScanPolicyManager />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('surfaces an error state when the policies fetch fails (no false "no policies")', async () => {
|
||||
setup({ isPaid: true });
|
||||
mockedFetch.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(500, {})),
|
||||
);
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText("Couldn't load scan policies")).toBeInTheDocument());
|
||||
expect(screen.queryByText('No scan policies configured')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when there are genuinely no policies', async () => {
|
||||
setup({ isPaid: true });
|
||||
render(<ScanPolicyManager />);
|
||||
await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument());
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The Security masthead state word is the headline posture signal an operator
|
||||
* reads first, so its derivation is locked here. Critical must beat High.
|
||||
*/
|
||||
import { it, expect } from 'vitest';
|
||||
import { deriveMasthead } from '../securityMasthead';
|
||||
import type { SecurityOverview } from '@/types/security';
|
||||
|
||||
function overview(o: Partial<SecurityOverview>): SecurityOverview {
|
||||
return {
|
||||
scannedImages: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
fixable: 0,
|
||||
secrets: 0,
|
||||
misconfigs: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
lastSuccessfulScanAt: null,
|
||||
scanner: { available: true, version: '1', source: 'managed', autoUpdate: false },
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 },
|
||||
...o,
|
||||
};
|
||||
}
|
||||
|
||||
it('reads Unknown/idle when there is no overview or a load error', () => {
|
||||
expect(deriveMasthead(null, false)).toEqual({ state: 'Unknown', tone: 'idle' });
|
||||
expect(deriveMasthead(overview({ critical: 5 }), true)).toEqual({ state: 'Unknown', tone: 'idle' });
|
||||
});
|
||||
|
||||
it('reads Critical/error when any critical finding exists (critical wins over high)', () => {
|
||||
expect(deriveMasthead(overview({ critical: 1, high: 9 }), false)).toEqual({ state: 'Critical', tone: 'error' });
|
||||
});
|
||||
|
||||
it('reads At risk/warn when there are highs but no criticals', () => {
|
||||
expect(deriveMasthead(overview({ critical: 0, high: 2 }), false)).toEqual({ state: 'At risk', tone: 'warn' });
|
||||
});
|
||||
|
||||
it('reads Secure/live when there are no critical or high findings', () => {
|
||||
expect(deriveMasthead(overview({ critical: 0, high: 0 }), false)).toEqual({ state: 'Secure', tone: 'live' });
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MastheadTone } from '@/components/ui/PageMasthead';
|
||||
import type { SecurityOverview } from '@/types/security';
|
||||
|
||||
/**
|
||||
* Derives the Security page masthead state word and tone from the overview.
|
||||
* Critical outranks High; an absent overview or a load error reads as Unknown.
|
||||
*/
|
||||
export function deriveMasthead(
|
||||
overview: SecurityOverview | null,
|
||||
error: boolean,
|
||||
): { state: string; tone: MastheadTone } {
|
||||
if (error || !overview) return { state: 'Unknown', tone: 'idle' };
|
||||
if (overview.critical > 0) return { state: 'Critical', tone: 'error' };
|
||||
if (overview.high > 0) return { state: 'At risk', tone: 'warn' };
|
||||
return { state: 'Secure', tone: 'live' };
|
||||
}
|
||||
@@ -194,7 +194,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
<div className="px-7 pt-6 pb-8 flex flex-col gap-6 min-w-0">
|
||||
<SettingsSectionContent
|
||||
sectionId={safeSection}
|
||||
isPaid={isPaid}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
showDescription
|
||||
/>
|
||||
|
||||
@@ -35,9 +35,6 @@ const UsersSection = lazy(() =>
|
||||
const WebhooksSection = lazy(() =>
|
||||
import('./WebhooksSection').then(m => ({ default: m.WebhooksSection })),
|
||||
);
|
||||
const SecuritySection = lazy(() =>
|
||||
import('./SecuritySection').then(m => ({ default: m.SecuritySection })),
|
||||
);
|
||||
const LabelsSection = lazy(() =>
|
||||
import('./LabelsSection').then(m => ({ default: m.LabelsSection })),
|
||||
);
|
||||
@@ -71,7 +68,6 @@ function SectionSkeleton() {
|
||||
|
||||
function renderSection(
|
||||
sectionId: SectionId,
|
||||
isPaid: boolean,
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void,
|
||||
) {
|
||||
switch (sectionId) {
|
||||
@@ -89,7 +85,6 @@ function renderSection(
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'webhooks': return <WebhooksSection />;
|
||||
case 'security': return <SecuritySection isPaid={isPaid} />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
|
||||
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
|
||||
@@ -105,7 +100,6 @@ function renderSection(
|
||||
|
||||
interface SettingsSectionContentProps {
|
||||
sectionId: SectionId;
|
||||
isPaid: boolean;
|
||||
onDirtyChange: (section: SectionId, dirty: boolean) => void;
|
||||
/** Render the section's lead description paragraph above the content. */
|
||||
showDescription?: boolean;
|
||||
@@ -117,14 +111,14 @@ interface SettingsSectionContentProps {
|
||||
* the desktop SettingsPage and the mobile settings screen so the section switch,
|
||||
* lazy splitting, and gating live in exactly one place.
|
||||
*/
|
||||
export function SettingsSectionContent({ sectionId, isPaid, onDirtyChange, showDescription }: SettingsSectionContentProps) {
|
||||
export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) {
|
||||
const item = getSettingsItem(sectionId);
|
||||
// Memoize the section element so unrelated re-renders of the host page (the
|
||||
// command palette opening, a dirty-flag toggle) do not re-render the active
|
||||
// section. onDirtyChange is stable from both call sites.
|
||||
const element = useMemo(
|
||||
() => renderSection(sectionId, isPaid, onDirtyChange),
|
||||
[sectionId, isPaid, onDirtyChange],
|
||||
() => renderSection(sectionId, onDirtyChange),
|
||||
[sectionId, onDirtyChange],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -63,7 +63,10 @@ describe('settings registry', () => {
|
||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||
expect(byId.get('notifications')?.label).toBe('Channels');
|
||||
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
|
||||
expect(byId.get('security')?.label).toBe('Vulnerability Scanning');
|
||||
});
|
||||
|
||||
it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => {
|
||||
expect(SETTINGS_ITEMS.some(i => (i.id as string) === 'security')).toBe(false);
|
||||
});
|
||||
|
||||
it('opens Registries to Community while keeping it admin-only', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export { SupportSection } from './SupportSection';
|
||||
export { AboutSection } from './AboutSection';
|
||||
export { RecoverySection } from './RecoverySection';
|
||||
|
||||
// Paid-tier sections (UsersSection, WebhooksSection, SecuritySection,
|
||||
// Paid-tier sections (UsersSection, WebhooksSection,
|
||||
// LabelsSection, CloudBackupSection, NotificationRoutingSection) are NOT
|
||||
// re-exported from this barrel. They are dynamically imported with
|
||||
// React.lazy in SettingsPage.tsx so their JSX, copy, and prop shapes do not
|
||||
|
||||
@@ -8,7 +8,6 @@ export type SettingsGroupId =
|
||||
| 'notifications'
|
||||
| 'automation'
|
||||
| 'organization'
|
||||
| 'security'
|
||||
| 'operations'
|
||||
| 'help';
|
||||
|
||||
@@ -27,7 +26,6 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
|
||||
{ id: 'notifications', label: 'Notifications', glyph: '\u25C7' },
|
||||
{ id: 'automation', label: 'Automation', glyph: '\u25C7' },
|
||||
{ id: 'organization', label: 'Organization', glyph: '\u25C7' },
|
||||
{ id: 'security', label: 'Security', glyph: '\u25C6' },
|
||||
{ id: 'operations', label: 'Operations', glyph: '\u25C7' },
|
||||
{ id: 'help', label: 'Help', glyph: '\u25C7' },
|
||||
];
|
||||
@@ -225,17 +223,6 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
},
|
||||
// Security
|
||||
{
|
||||
id: 'security',
|
||||
group: 'security',
|
||||
label: 'Vulnerability Scanning',
|
||||
description: 'Image scanning, suppressions, and posture defaults.',
|
||||
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening', 'vulnerability', 'misconfig'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
adminOnly: true,
|
||||
},
|
||||
// Operations
|
||||
{
|
||||
id: 'data-retention',
|
||||
|
||||
@@ -54,7 +54,6 @@ export type SectionId =
|
||||
| 'fleet-mesh'
|
||||
| 'notifications'
|
||||
| 'webhooks'
|
||||
| 'security'
|
||||
| 'cloud-backup'
|
||||
| 'developer'
|
||||
| 'data-retention'
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { SEVERITY_BADGE_CLASSES, SEVERITY_DOT_CLASSES } from '@/lib/severityStyles';
|
||||
import type { ScanSummary, VulnSeverity } from '@/types/security';
|
||||
|
||||
/**
|
||||
* Severity pill for a scanned image's latest summary. Shows the highest
|
||||
* severity (or "Clean") with a state dot and a cursor-follow tooltip carrying
|
||||
* the last-scanned time and a severity breakdown. Shared by the Resources view
|
||||
* and the Security page so the badge stays identical everywhere.
|
||||
*/
|
||||
export function SeverityBadge({ summary, onClick }: { summary: ScanSummary; onClick: () => void }) {
|
||||
// highest_severity is derived from vulnerabilities only, so a scan with
|
||||
// secrets or misconfigurations but zero CVEs would otherwise read "Clean".
|
||||
// Treat those as a non-clean "Findings" state.
|
||||
const hasNonVulnFindings = (summary.secret_count ?? 0) > 0 || (summary.misconfig_count ?? 0) > 0;
|
||||
const key: VulnSeverity | 'CLEAN' | 'FINDINGS' =
|
||||
summary.highest_severity ?? (hasNonVulnFindings ? 'FINDINGS' : 'CLEAN');
|
||||
const label = key === 'CLEAN' ? 'Clean' : key === 'FINDINGS' ? 'Findings' : key;
|
||||
const [relative, setRelative] = useState<string>('');
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
const scanAge = Math.round((Date.now() - summary.scanned_at) / 60000);
|
||||
setRelative(
|
||||
scanAge < 1 ? 'just now'
|
||||
: scanAge < 60 ? `${scanAge}m ago`
|
||||
: scanAge < 1440 ? `${Math.round(scanAge / 60)}h ago`
|
||||
: `${Math.round(scanAge / 1440)}d ago`,
|
||||
);
|
||||
};
|
||||
compute();
|
||||
const id = setInterval(compute, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [summary.scanned_at]);
|
||||
|
||||
return (
|
||||
<CursorProvider>
|
||||
<CursorContainer className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[10px] font-medium cursor-pointer hover:brightness-110 transition',
|
||||
SEVERITY_BADGE_CLASSES[key],
|
||||
)}
|
||||
>
|
||||
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_CLASSES[key])} />
|
||||
{label}
|
||||
</button>
|
||||
</CursorContainer>
|
||||
<Cursor>
|
||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||
</Cursor>
|
||||
<CursorFollow side="bottom" align="end" sideOffset={8}>
|
||||
<div className="bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-card-border shadow-md rounded-md px-3 py-2">
|
||||
<div className="font-mono tabular-nums text-xs space-y-1">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Last scanned</div>
|
||||
<div className="text-stat-value">{relative}</div>
|
||||
{summary.total > 0 && (
|
||||
<div className="flex gap-3 mt-1">
|
||||
{summary.critical > 0 && <span className="text-destructive">{summary.critical}C</span>}
|
||||
{summary.high > 0 && <span className="text-warning">{summary.high}H</span>}
|
||||
{summary.medium > 0 && <span className="text-warning">{summary.medium}M</span>}
|
||||
{summary.low > 0 && <span className="text-muted-foreground">{summary.low}L</span>}
|
||||
</div>
|
||||
)}
|
||||
{summary.total === 0 && hasNonVulnFindings && (
|
||||
<div className="flex gap-3 mt-1 text-warning">
|
||||
{(summary.secret_count ?? 0) > 0 && <span>{summary.secret_count} secret</span>}
|
||||
{(summary.misconfig_count ?? 0) > 0 && <span>{summary.misconfig_count} misconfig</span>}
|
||||
</div>
|
||||
)}
|
||||
{summary.total === 0 && !hasNonVulnFindings && (
|
||||
<div className="text-success">No findings</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CursorFollow>
|
||||
</CursorProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The severity badge was extracted from ResourcesView into a shared component so
|
||||
* Resources and the Security page render an identical pill. Lock its label
|
||||
* mapping (highest severity, or "Clean" when there are no findings).
|
||||
*/
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SeverityBadge } from '../SeverityBadge';
|
||||
import type { ScanSummary } from '@/types/security';
|
||||
|
||||
function summary(overrides: Partial<ScanSummary>): ScanSummary {
|
||||
return {
|
||||
image_ref: 'nginx:1',
|
||||
highest_severity: 'CRITICAL',
|
||||
scanned_at: 1,
|
||||
scan_id: 1,
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
unknown: 0,
|
||||
fixable: 0,
|
||||
secret_count: 0,
|
||||
misconfig_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders the highest severity and fires onClick', async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<SeverityBadge summary={summary({ highest_severity: 'CRITICAL', total: 5, critical: 5 })} onClick={onClick} />);
|
||||
const btn = screen.getByRole('button', { name: /CRITICAL/ });
|
||||
await userEvent.click(btn);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders "Clean" when there are no findings of any kind', () => {
|
||||
render(<SeverityBadge summary={summary({ highest_severity: null })} onClick={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: /Clean/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Findings" (not "Clean") for a secret-only scan with no CVE severity', () => {
|
||||
render(<SeverityBadge summary={summary({ highest_severity: null, secret_count: 2 })} onClick={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Clean/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Findings" for a misconfig-only scan', () => {
|
||||
render(<SeverityBadge summary={summary({ highest_severity: null, misconfig_count: 3 })} onClick={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: /Findings/ })).toBeInTheDocument();
|
||||
});
|
||||
Reference in New Issue
Block a user