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:
Anso
2026-06-12 10:41:39 -04:00
committed by GitHub
parent 77f1611971
commit 2a4955f56d
51 changed files with 2559 additions and 509 deletions
@@ -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>
);
}
@@ -0,0 +1,514 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Skeleton } from '@/components/ui/skeleton';
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, Info } from 'lucide-react';
import { SettingsCallout } from '@/components/settings/SettingsCallout';
import { SettingsPrimaryButton } from '@/components/settings/SettingsActions';
import { useNodes } from '@/context/NodeContext';
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' },
{ value: 'HIGH', label: 'High' },
{ value: 'MEDIUM', label: 'Medium' },
{ value: 'LOW', label: 'Low' },
];
interface PolicyFormState {
name: string;
stack_pattern: string;
max_severity: VulnSeverity;
block_on_deploy: boolean;
enabled: boolean;
}
const EMPTY_FORM: PolicyFormState = {
name: '',
stack_pattern: '',
max_severity: 'CRITICAL',
block_on_deploy: false,
enabled: true,
};
/**
* 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 [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 fetchPolicies = async () => {
setLoadError(false);
try {
const res = await apiFetch('/security/policies', { localOnly: true });
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 || isRemote) { setLoading(false); return; }
fetchPolicies();
}, [isPaid, isRemote]);
useEffect(() => {
if (!isPaid || isRemote) return;
void refreshTrivy();
}, [isPaid, isRemote, activeNode?.id, refreshTrivy]);
useEffect(() => {
if (!isPaid || isRemote) return;
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/fleet/role', { localOnly: true });
if (!res.ok) {
if (!cancelled) setFleetRoleProbeFailed(true);
return;
}
const data = await res.json();
if (cancelled) return;
if (data?.role === 'control' || data?.role === 'replica') {
setFleetRole(data.role);
setFleetRoleProbeFailed(false);
} else {
setFleetRoleProbeFailed(true);
}
} catch {
if (!cancelled) setFleetRoleProbeFailed(true);
}
})();
return () => { cancelled = true; };
}, [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);
try {
const res = await apiFetch('/fleet/role/demote', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ confirm: true }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Demote failed');
}
toast.success('Replica demoted to control');
setFleetRole('control');
setDemoteConfirm(false);
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Demote failed');
} finally {
setDemoteBusy(false);
}
};
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (policy: ScanPolicy) => {
setEditingId(policy.id);
setForm({
name: policy.name,
stack_pattern: policy.stack_pattern ?? '',
max_severity: policy.max_severity,
block_on_deploy: policy.block_on_deploy === 1,
enabled: policy.enabled === 1,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('Policy name is required');
return;
}
setSaving(true);
try {
const payload = {
name: form.name.trim(),
stack_pattern: form.stack_pattern.trim() || null,
max_severity: form.max_severity,
block_on_deploy: form.block_on_deploy ? 1 : 0,
enabled: form.enabled ? 1 : 0,
};
const url = editingId ? `/security/policies/${editingId}` : '/security/policies';
const method = editingId ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
localOnly: true,
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to save policy');
}
toast.success(editingId ? 'Policy updated' : 'Policy created');
setDialogOpen(false);
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to save policy');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (deleteId == null) return;
try {
const res = await apiFetch(`/security/policies/${deleteId}`, {
method: 'DELETE',
localOnly: true,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to delete policy');
}
toast.success('Policy deleted');
fetchPolicies();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete policy');
} finally {
setDeleteId(null);
}
};
// 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-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>
)}
{!isRemote && isReplica && (
<div
role="status"
aria-live="polite"
className="flex items-start justify-between gap-3 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
>
<div className="flex items-start gap-2">
<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 by control node</div>
<p className="text-xs text-muted-foreground mt-0.5">
Security policies replicate from the control Sencho instance. View them here for audit; edit them on the control.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setDemoteConfirm(true)}
disabled={demoteBusy}
>
Demote to control
</Button>
</div>
)}
{!isRemote && fleetRoleProbeFailed && !isReplica && (
<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">Fleet role could not be determined</div>
<p className="text-xs text-muted-foreground mt-0.5">
Treating this instance as a control. Refresh the page to retry.
</p>
</div>
</div>
)}
{!isRemote && loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
)}
{!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"
subtitle="Add one to enforce severity thresholds across your fleet."
/>
)}
{!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">
<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 truncate">{policy.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">
max: {policy.max_severity}
</Badge>
{policy.block_on_deploy === 1 && (
<Badge variant="destructive" className="text-[10px] shrink-0">
block
</Badge>
)}
{policy.enabled === 0 && (
<Badge variant="secondary" className="text-[10px] shrink-0">
disabled
</Badge>
)}
</div>
{isAdmin && !isReplica && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(policy)}
>
<Pencil className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteId(policy.id)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
Scope: {policy.stack_pattern ? (
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">{policy.stack_pattern}</code>
) : (
<span className="italic">all stacks</span>
)}
</div>
</div>
))}
{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>
<p className="text-xs text-muted-foreground mt-0.5">
When on, a suppressed CVE no longer counts toward a block-on-deploy policy, so an accepted finding will not stop a deploy on this instance. Off by default: policies block on the raw scan result.
</p>
</div>
<TogglePill
checked={trivy.honorSuppressionsOnDeploy}
onChange={handleHonorSuppressionsToggle}
disabled={honorBusy}
/>
</div>
)}
<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 })}
/>
</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>
}
/>
</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>
</ConfirmModal>
<ConfirmModal
open={demoteConfirm}
onOpenChange={setDemoteConfirm}
variant="destructive"
kicker="FLEET · DEMOTE · IRREVERSIBLE"
title="Demote replica to control"
confirmLabel={demoteBusy ? 'Demoting...' : 'Demote'}
onConfirm={handleDemote}
>
<p className="text-sm text-stat-subtitle">
Removes every replicated scan policy and CVE suppression mirrored from the control. Local edits to security policies on this instance become available again.
</p>
</ConfirmModal>
</div>
);
}
@@ -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' };
}