mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
feat(security): action-posture Security dashboard with exploit intel and triage (#1424)
* feat(security): reframe masthead as action posture, not worst-CVE severity Derive the Security masthead from an action posture (Action needed / Monitoring / Secure / Unknown) instead of raw scanner severity, and label the raw Critical/High counts as scanner detections. "Secure" now means nothing is actionable right now, never a claim that no vulnerabilities exist; Unknown covers a missing scanner or a node with no completed scan. Phase-1 bootstrap: "actionable" is approximated from the overview facts that already exist (fixable findings, secrets, misconfigs); a later phase moves the bucketing to the backend. * feat(security): derive overview action posture from triaged facts Add deriveSecurityPosture as the single bucketing function and extend /security/overview with posture facts (fixableCriticalHigh, dangerousCompose, accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders that later phases populate) and the derived posture verb. Suppression- and acknowledgement-aware counts come from one bounded read-time pass over the latest-scan Critical/High findings, grouped per image so the existing read-time filters apply unchanged. The pass is capped and flags posturePartial, so a large node degrades gracefully instead of scanning every detail row. The masthead now prefers the backend posture and keeps the local bootstrap only as a fallback for older remote nodes reached through the proxy. * feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer) parseTrivyOutput now keeps the per-finding fields Trivy already returns and we previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS (score + vector, preferring the NVD source then falling back), vendor severity, package URL, package path, and layer digest. Persisted on vulnerability_details via additive nullable columns (guarded ALTER), bound null when absent, and carried through the cached-scan reconstruction path. These fields separate scary from exploitable and feed the action posture and the per-finding evidence tags. Field paths verified against Trivy's documented image-scan JSON; covered by parse and insert/read round-trip tests. * feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS) Add CveIntelService, a daily background cache of CISA KEV membership and FIRST EPSS scores stored in a new cve_intel table and joined to findings at read time by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on scans already stored). EPSS is fetched only for CVE ids present in stored findings, batched; both feeds are best-effort and keep the last cache on failure, so the Security page degrades gracefully offline. Wired into startup/shutdown like the other background services. The overview now counts known-exploited Critical/High findings, and KEV membership escalates posture to Action needed even when no fix is available. A per-instance "Exploit intelligence" toggle on the scanner setup surface lets air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps running but skips the fetch body when it is off. * feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS) The vulnerabilities endpoint joins read-time exploit intel (KEV membership and EPSS score) onto each finding by CVE id, and the scan sheet renders evidence tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix / end-of-life, and the CVSS score. Severity becomes one signal among several so an operator can tell scary from exploitable, with no invented composite score. * feat(security): evolve CVE suppressions into triage decisions Layer a triage status and optional OpenVEX justification onto CVE suppressions. Statuses: needs review / affected / not affected / accepted risk / fixed / false positive / ignored. Dismissing states (not affected, accepted, fixed, false positive, ignored) stop a finding from driving the action posture; needs review and affected stay actionable and are surfaced as counts. Existing rows default to "accepted" (the prior suppress behavior), so nothing changes for them. The overview now reports needsReview / notAffected / accepted as distinct facts derived from the triage status. The decision replicates across the fleet (snapshot + replicated-insert carry status + justification) so a replica's posture matches the control node. The inline suppress dialog gains a triage decision selector; the read-time filter surfaces the status and justification on every finding. * feat(security): export fleet triage decisions as OpenVEX (Admiral) Add an OpenVEX exporter that turns the instance's CVE triage decisions into a standard VEX document (not_affected / fixed / affected / under_investigation, with justifications), and a GET /security/vex/export endpoint to download it. Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid) plus admin, mirroring the SARIF export gate; the Suppressions panel shows an Export VEX action only on Admiral. * docs(security): document action posture, evidence tags, exploit intel, and triage Update the Security page and CVE suppressions docs for the action-posture masthead (scanner detections vs product posture), per-finding evidence tags (KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV + FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and OpenVEX export of fleet triage decisions. * test(security): match intel hosts exactly in CveIntelService test Route the fetch stub and its call assertions by exact hostname (www.cisa.gov / api.first.org) instead of a domain substring check. Resolves the js/incomplete-url-substring-sanitization code-scanning alerts on the test's URL routing; behavior is unchanged.
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { PageMasthead, type MastheadTone } from '@/components/ui/PageMasthead';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { deriveMasthead } from './security/securityMasthead';
|
||||
import { deriveMasthead, SCANNER_DETECTIONS_NOTE } from './security/securityMasthead';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
@@ -334,7 +334,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
tone={tone}
|
||||
pulsing={pulsing}
|
||||
size="hero"
|
||||
className="rounded-lg mb-4"
|
||||
className="rounded-lg mb-2"
|
||||
subtitle={subtitle}
|
||||
metadata={overview ? [
|
||||
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
|
||||
@@ -342,6 +342,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
|
||||
{ label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' },
|
||||
] : undefined}
|
||||
/>
|
||||
<p className="mb-4 max-w-3xl font-mono text-[11px] leading-snug text-stat-subtitle">
|
||||
{SCANNER_DETECTIONS_NOTE}
|
||||
</p>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
|
||||
<TabsList className="mb-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -53,8 +53,20 @@ import type {
|
||||
SecretFinding,
|
||||
MisconfigFinding,
|
||||
ScanDetailTab,
|
||||
TriageStatus,
|
||||
} from '@/types/security';
|
||||
|
||||
// Triage decision options for the suppress dialog (value -> label). 'accepted'
|
||||
// is the default: a plain suppress is an accepted risk.
|
||||
const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [
|
||||
{ value: 'accepted', label: 'Accepted risk' },
|
||||
{ value: 'not_affected', label: 'Not affected' },
|
||||
{ value: 'false_positive', label: 'False positive' },
|
||||
{ value: 'needs_review', label: 'Needs review' },
|
||||
{ value: 'fixed', label: 'Fixed' },
|
||||
{ value: 'ignored', label: 'Ignored until expiry' },
|
||||
];
|
||||
|
||||
interface VulnerabilityScanSheetProps {
|
||||
scanId: number | null;
|
||||
onClose: () => void;
|
||||
@@ -78,6 +90,7 @@ interface SuppressDialogState {
|
||||
imagePattern: string;
|
||||
reason: string;
|
||||
expiresInDays: string;
|
||||
status: TriageStatus;
|
||||
}
|
||||
|
||||
interface AckDialogState {
|
||||
@@ -115,6 +128,47 @@ function SeverityChip({ severity }: { severity: VulnSeverity }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EVIDENCE_TAG_CLASSES = {
|
||||
danger: 'text-destructive border-destructive/40 bg-destructive/10',
|
||||
warn: 'text-warning border-warning/40 bg-warning/10',
|
||||
muted: 'text-stat-subtitle border-border bg-muted/30',
|
||||
neutral: 'text-stat-value border-border bg-muted/20',
|
||||
} as const;
|
||||
|
||||
function EvidenceTag({ tone, children }: { tone: keyof typeof EVIDENCE_TAG_CLASSES; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded border px-1.5 py-px text-[9px] font-mono uppercase tracking-[0.1em]', EVIDENCE_TAG_CLASSES[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Small, independently-verifiable evidence atoms per finding. Severity is one
|
||||
* signal among several, not the only one: these surface exploit intel (KEV,
|
||||
* EPSS), vendor status, and the CVSS score so an operator can tell scary from
|
||||
* exploitable without an invented composite priority number.
|
||||
*/
|
||||
function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
|
||||
const tags: ReactNode[] = [];
|
||||
if (d.kev) tags.push(<EvidenceTag key="kev" tone="danger">KEV</EvidenceTag>);
|
||||
if (typeof d.epss_score === 'number') {
|
||||
tags.push(
|
||||
<EvidenceTag key="epss" tone={d.epss_score >= 0.1 ? 'warn' : 'muted'}>
|
||||
EPSS {Math.round(d.epss_score * 100)}%
|
||||
</EvidenceTag>,
|
||||
);
|
||||
}
|
||||
if (d.status === 'will_not_fix' || d.status === 'end_of_life') {
|
||||
tags.push(<EvidenceTag key="wontfix" tone="muted">{"Won't fix"}</EvidenceTag>);
|
||||
}
|
||||
if (typeof d.cvss_score === 'number') {
|
||||
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
|
||||
}
|
||||
if (tags.length === 0) return null;
|
||||
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
|
||||
}
|
||||
|
||||
export function VulnerabilityScanSheet({
|
||||
scanId,
|
||||
onClose,
|
||||
@@ -319,6 +373,7 @@ export function VulnerabilityScanSheet({
|
||||
imagePattern: '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
status: 'accepted',
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -350,6 +405,7 @@ export function VulnerabilityScanSheet({
|
||||
image_pattern: suppressForm.imagePattern.trim() || null,
|
||||
reason,
|
||||
expires_at: expiresAt,
|
||||
status: suppressForm.status,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -737,28 +793,31 @@ export function VulnerabilityScanSheet({
|
||||
key={d.id}
|
||||
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
|
||||
>
|
||||
<TableCell className="font-mono text-xs tabular-nums">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
<TableCell className="font-mono text-xs tabular-nums align-top">
|
||||
<span className="flex flex-col">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
</span>
|
||||
<EvidenceTags d={d} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
@@ -1070,6 +1129,24 @@ export function VulnerabilityScanSheet({
|
||||
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppress-status">Triage decision</Label>
|
||||
<select
|
||||
id="suppress-status"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={suppressForm.status}
|
||||
onChange={(e) =>
|
||||
setSuppressForm((f) => (f ? { ...f, status: e.target.value as TriageStatus } : f))
|
||||
}
|
||||
>
|
||||
{TRIAGE_STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppress-reason">Reason</Label>
|
||||
<textarea
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
|
||||
import { SCANNER_DETECTIONS_NOTE } from './securityMasthead';
|
||||
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import {
|
||||
@@ -124,8 +125,14 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
|
||||
)
|
||||
)}
|
||||
|
||||
{/* The masthead hides its stat cluster on a phone; restate it here. */}
|
||||
{isMobile && <SecuritySevStrip overview={overview} />}
|
||||
{/* The masthead hides its stat cluster on a phone; restate it here, framed
|
||||
as scanner detections rather than posture. */}
|
||||
{isMobile && (
|
||||
<div className="space-y-2">
|
||||
<SecuritySevStrip overview={overview} />
|
||||
<p className="font-mono text-[10px] leading-snug text-stat-subtitle">{SCANNER_DETECTIONS_NOTE}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts lead the dashboard. */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
|
||||
@@ -46,7 +46,7 @@ interface TrivyManagerProps {
|
||||
*/
|
||||
export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory'>(null);
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory' | 'cve-intel'>(null);
|
||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||
|
||||
const runTrivyOp = async (
|
||||
@@ -118,6 +118,25 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
|
||||
}
|
||||
};
|
||||
|
||||
const handleCveIntelToggle = async (enabled: boolean) => {
|
||||
setTrivyBusy('cve-intel');
|
||||
try {
|
||||
const res = await apiFetch('/security/cve-intel-enabled', {
|
||||
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">
|
||||
@@ -207,6 +226,22 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
|
||||
<div>
|
||||
<Label className="text-sm">Exploit intelligence (KEV + EPSS)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fetch CISA Known Exploited Vulnerabilities and EPSS scores daily to prioritize findings. Reaches cisa.gov and api.first.org; turn off for air-gapped hosts.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={status.cveIntelEnabled}
|
||||
onChange={handleCveIntelToggle}
|
||||
disabled={trivyBusy !== null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -34,7 +34,7 @@ function setup({ isPaid }: { isPaid: boolean }) {
|
||||
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, preDeployScanAdvisory: false, busy: false },
|
||||
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
|
||||
updateCheck: null,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
refreshUpdateCheck: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* The Security masthead state word is the headline posture signal an operator
|
||||
* reads first, so its derivation is locked here. Critical must beat High.
|
||||
* reads first, so its derivation is locked here. Posture is an action verdict
|
||||
* (Action needed / Monitoring / Secure / Unknown), not a raw severity count: a
|
||||
* page is never "Secure" merely because counts are non-zero, and never "Action
|
||||
* needed" merely because a Critical exists with nothing to do about it.
|
||||
*/
|
||||
import { it, expect } from 'vitest';
|
||||
import { deriveMasthead } from '../securityMasthead';
|
||||
@@ -8,7 +11,7 @@ import type { SecurityOverview } from '@/types/security';
|
||||
|
||||
function overview(o: Partial<SecurityOverview>): SecurityOverview {
|
||||
return {
|
||||
scannedImages: 0,
|
||||
scannedImages: 1,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
fixable: 0,
|
||||
@@ -16,7 +19,8 @@ function overview(o: Partial<SecurityOverview>): SecurityOverview {
|
||||
misconfigs: 0,
|
||||
staleScans: 0,
|
||||
failedScans: 0,
|
||||
lastSuccessfulScanAt: null,
|
||||
// Default to "has completed a scan" so cases exercise posture, not Unknown.
|
||||
lastSuccessfulScanAt: 1700000000000,
|
||||
scanner: { available: true, version: '1', source: 'managed', autoUpdate: false },
|
||||
deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 },
|
||||
...o,
|
||||
@@ -28,14 +32,51 @@ it('reads Unknown/idle when there is no overview or a load error', () => {
|
||||
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 Unknown when the scanner is unavailable, even with no findings', () => {
|
||||
expect(
|
||||
deriveMasthead(overview({ scanner: { available: false, version: null, source: 'none', autoUpdate: false } }), false),
|
||||
).toEqual({ state: 'Unknown', tone: 'idle' });
|
||||
});
|
||||
|
||||
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 Unknown when no scan has ever completed', () => {
|
||||
expect(deriveMasthead(overview({ lastSuccessfulScanAt: null }), false)).toEqual({ state: 'Unknown', tone: 'idle' });
|
||||
});
|
||||
|
||||
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' });
|
||||
it('reads Action needed/error when a fix is available (even if counts look severe)', () => {
|
||||
expect(deriveMasthead(overview({ critical: 9, high: 9, fixable: 1 }), false)).toEqual({
|
||||
state: 'Action needed',
|
||||
tone: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads Action needed when a secret is detected', () => {
|
||||
expect(deriveMasthead(overview({ secrets: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
|
||||
});
|
||||
|
||||
it('reads Action needed when a misconfiguration is detected', () => {
|
||||
expect(deriveMasthead(overview({ misconfigs: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
|
||||
});
|
||||
|
||||
it('reads Monitoring/warn when criticals/highs exist but nothing is actionable', () => {
|
||||
expect(deriveMasthead(overview({ critical: 3, high: 7, fixable: 0 }), false)).toEqual({
|
||||
state: 'Monitoring',
|
||||
tone: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads Secure/live when a scan completed and nothing is actionable or severe', () => {
|
||||
expect(deriveMasthead(overview({}), false)).toEqual({ state: 'Secure', tone: 'live' });
|
||||
});
|
||||
|
||||
it('prefers the backend posture over the local bootstrap when present', () => {
|
||||
// Bootstrap from these facts would read Action needed (fixable > 0); the
|
||||
// authoritative backend verdict wins.
|
||||
expect(deriveMasthead(overview({ fixable: 5, posture: 'Monitoring' }), false)).toEqual({
|
||||
state: 'Monitoring',
|
||||
tone: 'warn',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the local bootstrap when the node reports no posture', () => {
|
||||
expect(deriveMasthead(overview({ fixable: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
|
||||
});
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import type { MastheadTone } from '@/components/ui/PageMasthead';
|
||||
import type { SecurityOverview } from '@/types/security';
|
||||
import type { SecurityOverview, SecurityPostureState } from '@/types/security';
|
||||
|
||||
export type SecurityPosture = SecurityPostureState;
|
||||
|
||||
const POSTURE_TONE: Record<SecurityPosture, MastheadTone> = {
|
||||
'Action needed': 'error',
|
||||
Monitoring: 'warn',
|
||||
Secure: 'live',
|
||||
Unknown: 'idle',
|
||||
};
|
||||
|
||||
/** Standing reframe shown near the masthead: raw counts are scanner detections,
|
||||
* not the product posture. Kept short enough for a one-to-two-line caption. */
|
||||
export const SCANNER_DETECTIONS_NOTE =
|
||||
'Scanner detections show vulnerable components present in images, not proven exploitable risk. Posture weighs fix availability, exposure, and exploit intelligence.';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Derives the Security masthead from action posture, not raw severity. Raw
|
||||
* Critical/High counts are scanner detections shown separately; they no longer
|
||||
* decide the headline. "Secure" means nothing is actionable right now, never a
|
||||
* claim that no vulnerabilities exist.
|
||||
*
|
||||
* The backend computes the authoritative `posture` (one bucketing function), so
|
||||
* this prefers `overview.posture` when present. The local bootstrap below is the
|
||||
* fallback for an older remote node reached through the proxy that does not
|
||||
* report posture: "actionable" is approximated from the overview facts that
|
||||
* already exist (fixable findings, secrets, misconfigs); Unknown covers a
|
||||
* missing scanner or a node that has never completed a scan.
|
||||
*/
|
||||
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' };
|
||||
): { state: SecurityPosture; tone: MastheadTone } {
|
||||
const posture = resolvePosture(overview, error);
|
||||
return { state: posture, tone: POSTURE_TONE[posture] };
|
||||
}
|
||||
|
||||
function resolvePosture(overview: SecurityOverview | null, error: boolean): SecurityPosture {
|
||||
if (error || !overview) return 'Unknown';
|
||||
if (overview.posture && overview.posture in POSTURE_TONE) return overview.posture;
|
||||
if (!overview.scanner.available || overview.lastSuccessfulScanAt === null) return 'Unknown';
|
||||
if (overview.fixable > 0 || overview.secrets > 0 || overview.misconfigs > 0) return 'Action needed';
|
||||
if (overview.critical > 0 || overview.high > 0) return 'Monitoring';
|
||||
return 'Secure';
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2, Download } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import type { CveSuppression } from '@/types/security';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
const PAGE_SIZE = 8;
|
||||
@@ -38,6 +39,7 @@ interface SuppressionsPanelProps {
|
||||
|
||||
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const [rows, setRows] = useState<CveSuppression[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -148,6 +150,24 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
const handleExportVex = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/security/vex/export', { localOnly: true });
|
||||
if (!res.ok) throw new Error('Failed to export VEX');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sencho-fleet.openvex.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to export VEX');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FleetTabHeading
|
||||
@@ -155,10 +175,18 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
|
||||
action={
|
||||
isAdmin && !isReplica ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPaid && (
|
||||
<Button size="sm" variant="outline" onClick={handleExportVex}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Export VEX
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add suppression
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,10 @@ vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => ({ isAdmin: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => ({ isPaid: false }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { SuppressionsPanel } from '../SuppressionsPanel';
|
||||
|
||||
Reference in New Issue
Block a user