mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
feat(security): polish scan sheets, fix CVE links, surface policy violations (#721)
* feat(security): polish scan sheets, fix CVE links, surface policy violations Adds cveUrl helper that rewrites Trivy's 404-ing avd.aquasec.com links to cve.org for CVE-prefixed IDs (GHSA and misconfig URLs pass through unchanged). Redesigns both scan sheets with shadow-card-bevel chips, tracked-mono kickers, severity row tinting with a left accent rail, and tabular-nums timestamps. Surfaces a destructive policy-violation banner on scans whose policy_evaluation row flags a block, and fixes the compare sheet's delta ribbon so CRITICAL net-positive deltas render in destructive (not warning) tone. Backend parses the JSON policy_evaluation column at the API boundary so the UI receives a structured object. * chore(security): suppress CVE-2026-32281 and CVE-2026-32283 in Trivy scan Both CVEs affect Go stdlib crypto/x509 and TLS in Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). No upstream static binary has been released with the patched Go 1.26.2 or 1.25.9 runtimes yet. Exposure analysis: the Docker CLI and compose plugin connect to the local Docker socket (Unix socket, no TLS) and to public registries with well-known CAs. Neither CVE is exploitable in this configuration. Added alongside sibling entries already in .trivyignore for the same binary versions. Revisit on next Docker CLI and Compose upstream release.
This commit is contained in:
@@ -35,6 +35,23 @@
|
||||
# release.
|
||||
CVE-2026-32282
|
||||
|
||||
# Justification: Go stdlib crypto/x509 certificate chain building DoS via
|
||||
# crafted certificate. Affects the same Go 1.26.1 (CLI) and 1.25.8 (Compose)
|
||||
# runtimes. The Docker CLI and compose plugin validate certificates only from
|
||||
# well-known registry CAs and the local Docker socket; they never parse
|
||||
# attacker-controlled certificate chains at runtime. Blocked on upstream Go
|
||||
# rebuild; revisit on next CLI/Compose release.
|
||||
CVE-2026-32281
|
||||
|
||||
# Justification: Go stdlib TLS stack exhaustion via repeated KeyUpdate messages
|
||||
# from a peer. Affects the same Go 1.26.1 (CLI) and 1.25.8 (Compose) runtimes.
|
||||
# The Docker CLI connects to the local Docker socket (Unix socket, not TLS) and
|
||||
# to public registries using standard TLS with well-known CAs. An attacker
|
||||
# would need to be an active TLS peer on those connections to send crafted
|
||||
# KeyUpdate messages, which is not possible in our runtime environment.
|
||||
# Blocked on upstream Go rebuild; revisit on next CLI/Compose release.
|
||||
CVE-2026-32283
|
||||
|
||||
# Justification: Go stdlib crypto/x509 certificate chain building DoS.
|
||||
# Affects the same Go 1.26.1 (CLI) and 1.25.8 (Compose) runtimes as above.
|
||||
# The Docker CLI and compose plugin do not perform x509 chain validation
|
||||
|
||||
+10
-3
@@ -20,7 +20,7 @@ import httpProxy from 'http-proxy';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import path from 'path';
|
||||
import { HostTerminalService } from './services/HostTerminalService';
|
||||
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType } from './services/DatabaseService';
|
||||
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
import { MonitorService } from './services/MonitorService';
|
||||
import { AutoHealService } from './services/AutoHealService';
|
||||
@@ -7946,6 +7946,13 @@ app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
function shapeScanForResponse(scan: VulnerabilityScan): Omit<VulnerabilityScan, 'policy_evaluation'> & {
|
||||
policy_evaluation: ReturnType<typeof parsePolicyEvaluation>;
|
||||
} {
|
||||
const { policy_evaluation, ...rest } = scan;
|
||||
return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) };
|
||||
}
|
||||
|
||||
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
@@ -7967,7 +7974,7 @@ app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) =>
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
res.json({ ...result, items: result.items.map(shapeScanForResponse) });
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to list scans:', error);
|
||||
res.status(500).json({ error: 'Failed to list scans' });
|
||||
@@ -7983,7 +7990,7 @@ app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Respo
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
res.json(scan);
|
||||
res.json(shapeScanForResponse(scan));
|
||||
});
|
||||
|
||||
app.get(
|
||||
|
||||
@@ -45,7 +45,7 @@ Navigate to the **Resources** tab and open the **Images** panel. When Trivy is a
|
||||
4. Click the badge to open the scan results drawer.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/scan-results.png" alt="Vulnerability scan results drawer showing CVE table with severity, package, and fix columns" />
|
||||
<img src="/images/vulnerability-scanning/scan-details-sheet.png" alt="Vulnerability scan results drawer showing CVE table with severity, package, and fix columns alongside a policy violation banner" />
|
||||
</Frame>
|
||||
|
||||
### Reading severity badges
|
||||
@@ -68,10 +68,14 @@ The drawer shows a full breakdown of the most recent scan for an image and group
|
||||
|
||||
- **Summary**: counts per severity (critical, high, medium, low), total vulnerabilities, how many have a fix available, the Trivy version used, and when the scan ran.
|
||||
- **Vulnerabilities tab**: severity filter pills narrow the table, paginated list of every CVE found, including:
|
||||
- **CVE ID** (linked to the upstream advisory)
|
||||
- **CVE ID** (CVE-prefixed identifiers link to [cve.org](https://www.cve.org); GHSA identifiers link to the GitHub Advisory Database)
|
||||
- **Package** name and installed version
|
||||
- **Severity** badge
|
||||
- **Fixed version** with a green indicator if a fix is available
|
||||
|
||||
Critical and high rows in the table are tinted with a left accent rail so the rows that need attention catch the eye even before you read the severity column.
|
||||
|
||||
If the scan was evaluated against a [scan policy](#scan-policies) and the highest severity meets or exceeds the policy threshold, a destructive **Policy violation** banner appears at the top of the drawer naming the policy and the threshold it crossed.
|
||||
- **Secrets tab**: hardcoded credentials or keys detected in the image filesystem, with severity, rule, title, and the file/line location. Secret values are redacted: only the first eight characters of the match are stored.
|
||||
- **Misconfigs tab**: misconfiguration findings with severity, check ID, title, target file, and a suggested resolution. For image scans this tab is empty; for stack config scans (see below) it is the primary view.
|
||||
|
||||
@@ -301,9 +305,9 @@ Compare any two completed scans for an image to see what changed between them.
|
||||
|
||||
The comparison sheet shows:
|
||||
|
||||
- A **delta ribbon** summarizing the net change per severity (CRITICAL, HIGH, MEDIUM, LOW).
|
||||
- A **delta ribbon** summarizing the net change per severity (CRITICAL, HIGH, MEDIUM, LOW). Net-positive deltas on CRITICAL render in destructive red so a regression on the worst tier is immediately visible.
|
||||
- Filter pills to switch between **Added** (new findings since the baseline), **Removed** (resolved findings), and **Unchanged** (findings present in both).
|
||||
- A sorted table of CVEs with severity, affected package, and direct links to Trivy's primary URL when available.
|
||||
- A sorted table of CVEs with severity, affected package, and direct links to the upstream advisory. CVE-prefixed identifiers resolve to [cve.org](https://www.cve.org); GHSA identifiers resolve to the GitHub Advisory Database. Critical and high rows carry the same left-rail tint as the scan results drawer for visual continuity.
|
||||
|
||||
Comparisons are scoped to a single node; scans taken on different nodes cannot be compared against each other.
|
||||
|
||||
@@ -311,6 +315,10 @@ Cross-image comparisons (picking scans from two different image references) are
|
||||
|
||||
Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this limit, the sheet shows a banner indicating the comparison may be incomplete.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/scan-compare-sheet.png" alt="Compare scans sheet with delta ribbon, Added/Removed/Unchanged filter pills, and a diff table tinted by severity" />
|
||||
</Frame>
|
||||
|
||||
## How it works
|
||||
|
||||
1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -26,6 +26,8 @@ import {
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cveUrl } from '@/lib/cveUrl';
|
||||
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import type {
|
||||
ScanCompareResult,
|
||||
@@ -63,9 +65,25 @@ function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record<VulnSe
|
||||
return counts;
|
||||
}
|
||||
|
||||
function formatDelta(added: number, removed: number): { text: string; tone: 'success' | 'warning' | 'muted' } {
|
||||
type DeltaTone = 'success' | 'warning' | 'destructive' | 'muted';
|
||||
|
||||
const DELTA_TONE_CLASS: Record<DeltaTone, string> = {
|
||||
destructive: 'text-destructive border-destructive/40 bg-destructive/10',
|
||||
warning: 'text-warning border-warning/40 bg-warning/10',
|
||||
success: 'text-success border-success/40 bg-success/10',
|
||||
muted: 'text-muted-foreground border-border bg-muted/30',
|
||||
};
|
||||
|
||||
function formatDelta(
|
||||
severity: VulnSeverity,
|
||||
added: number,
|
||||
removed: number,
|
||||
): { text: string; tone: DeltaTone } {
|
||||
const net = added - removed;
|
||||
if (net > 0) return { text: `+${net}`, tone: 'warning' };
|
||||
if (net > 0) {
|
||||
const tone: DeltaTone = severity === 'CRITICAL' ? 'destructive' : 'warning';
|
||||
return { text: `+${net}`, tone };
|
||||
}
|
||||
if (net < 0) return { text: `${net}`, tone: 'success' };
|
||||
return { text: '0', tone: 'muted' };
|
||||
}
|
||||
@@ -130,12 +148,13 @@ export function ScanComparisonSheet({
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
|
||||
<SheetHeader className="p-6 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 pr-6">
|
||||
<GitCompare className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm truncate">
|
||||
Compare scans
|
||||
</span>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Scan comparison
|
||||
</div>
|
||||
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
|
||||
<GitCompare className="w-5 h-5 text-muted-foreground not-italic" strokeWidth={1.5} />
|
||||
Diff
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
|
||||
@@ -154,15 +173,15 @@ export function ScanComparisonSheet({
|
||||
<div className="px-6 py-4 border-b space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Baseline</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
|
||||
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
|
||||
<div className="text-stat-subtitle">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Current</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
|
||||
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
|
||||
<div className="text-stat-subtitle">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,23 +210,18 @@ export function ScanComparisonSheet({
|
||||
{addedCounts && removedCounts && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => {
|
||||
const delta = formatDelta(addedCounts[sev], removedCounts[sev]);
|
||||
const toneClass =
|
||||
delta.tone === 'warning'
|
||||
? 'text-warning border-warning/40 bg-warning/10'
|
||||
: delta.tone === 'success'
|
||||
? 'text-success border-success/40 bg-success/10'
|
||||
: 'text-muted-foreground border-border bg-muted/30';
|
||||
const delta = formatDelta(sev, addedCounts[sev], removedCounts[sev]);
|
||||
return (
|
||||
<span
|
||||
key={sev}
|
||||
aria-label={`${sev} delta ${delta.text}`}
|
||||
data-tone={delta.tone}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums',
|
||||
toneClass,
|
||||
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums shadow-card-bevel',
|
||||
DELTA_TONE_CLASS[delta.tone],
|
||||
)}
|
||||
>
|
||||
<span className="uppercase tracking-wide text-[10px]">{sev}</span>
|
||||
<span className="uppercase tracking-[0.18em] text-[10px]">{sev}</span>
|
||||
<span>{delta.text}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -297,24 +311,23 @@ export function ScanComparisonSheet({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">CVE</TableHead>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[110px]">Status</TableHead>
|
||||
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((v, idx) => {
|
||||
const baseRowClass =
|
||||
filter === 'added'
|
||||
? 'bg-destructive/5'
|
||||
: filter === 'removed'
|
||||
? 'bg-success/5'
|
||||
: 'opacity-70';
|
||||
const rowClass = cn(baseRowClass, v.suppressed && 'opacity-60');
|
||||
const href = cveUrl(v.vulnerability_id, v.primary_url);
|
||||
const rowClass = cn(
|
||||
SEVERITY_ROW_TINT[v.severity],
|
||||
filter === 'unchanged' && 'opacity-75',
|
||||
v.suppressed && 'opacity-60',
|
||||
);
|
||||
return (
|
||||
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<TableCell className="font-mono text-xs tabular-nums">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{v.suppressed && (
|
||||
<ShieldOff
|
||||
@@ -323,9 +336,9 @@ export function ScanComparisonSheet({
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{v.primary_url ? (
|
||||
{href ? (
|
||||
<a
|
||||
href={v.primary_url}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hover:underline"
|
||||
|
||||
@@ -46,6 +46,8 @@ import { ScanComparisonSheet } from './ScanComparisonSheet';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cveUrl } from '@/lib/cveUrl';
|
||||
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
||||
import type {
|
||||
VulnerabilityScan,
|
||||
VulnerabilityDetail,
|
||||
@@ -88,7 +90,7 @@ function SeverityChip({ severity }: { severity: VulnSeverity }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-wide',
|
||||
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel',
|
||||
SEVERITY_CLASSES[severity],
|
||||
)}
|
||||
>
|
||||
@@ -372,10 +374,13 @@ export function VulnerabilityScanSheet({
|
||||
return (
|
||||
<Sheet open={scanId != null} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="sm:max-w-2xl flex flex-col p-0">
|
||||
<SheetHeader className="p-6 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 pr-6">
|
||||
<ShieldCheck className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm truncate">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 pr-14 border-b border-border space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Vulnerability scan · {scan?.triggered_by ?? '-'}
|
||||
</div>
|
||||
<SheetTitle className="flex items-center gap-2 font-display italic text-2xl">
|
||||
<ShieldCheck className="w-5 h-5 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-mono text-base truncate not-italic">
|
||||
{scan?.image_ref ?? 'Loading...'}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
@@ -396,29 +401,55 @@ export function VulnerabilityScanSheet({
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Summary stats */}
|
||||
<div className="px-6 py-4 border-b space-y-3">
|
||||
{scan.policy_evaluation?.violated && (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative rounded border border-destructive/40 bg-destructive/10 px-3 py-2 pl-4 shadow-card-bevel"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 bottom-0 w-[3px] bg-destructive/80 rounded-l"
|
||||
/>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-destructive">
|
||||
Policy violation
|
||||
</div>
|
||||
<div className="text-sm text-stat-value mt-0.5">
|
||||
<span className="font-mono">{scan.policy_evaluation.policyName}</span> blocks
|
||||
severities at or above{' '}
|
||||
<span className="font-mono tabular-nums">
|
||||
{scan.policy_evaluation.maxSeverity}
|
||||
</span>
|
||||
. This scan's highest severity is{' '}
|
||||
<span className="font-mono tabular-nums">
|
||||
{scan.highest_severity ?? 'UNKNOWN'}
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{scan.critical_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.CRITICAL)}>
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.CRITICAL)}>
|
||||
{scan.critical_count} CRITICAL
|
||||
</span>
|
||||
)}
|
||||
{scan.high_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.HIGH)}>
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.HIGH)}>
|
||||
{scan.high_count} HIGH
|
||||
</span>
|
||||
)}
|
||||
{scan.medium_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.MEDIUM)}>
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.MEDIUM)}>
|
||||
{scan.medium_count} MEDIUM
|
||||
</span>
|
||||
)}
|
||||
{scan.low_count > 0 && (
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums', SEVERITY_CLASSES.LOW)}>
|
||||
<span className={cn('rounded border px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel', SEVERITY_CLASSES.LOW)}>
|
||||
{scan.low_count} LOW
|
||||
</span>
|
||||
)}
|
||||
{scan.total_vulnerabilities === 0 && (
|
||||
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase">
|
||||
<span className="rounded border border-success/40 bg-success/10 text-success px-2 py-1 text-xs font-mono tabular-nums uppercase tracking-[0.18em] shadow-card-bevel">
|
||||
No vulnerabilities
|
||||
</span>
|
||||
)}
|
||||
@@ -426,20 +457,20 @@ export function VulnerabilityScanSheet({
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Total</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Total</div>
|
||||
<div className="font-mono tabular-nums text-stat-value">{scan.total_vulnerabilities}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Fixable</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Fixable</div>
|
||||
<div className="font-mono tabular-nums text-success">{scan.fixable_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Triggered</div>
|
||||
<div className="font-mono text-stat-value">{scan.triggered_by}</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Triggered</div>
|
||||
<div className="font-mono tabular-nums text-stat-value">{scan.triggered_by}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-stat-subtitle uppercase tracking-wide">Scanned</div>
|
||||
<div className="font-mono text-stat-value">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Scanned</div>
|
||||
<div className="font-mono tabular-nums text-stat-value">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,7 +552,7 @@ export function VulnerabilityScanSheet({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-stat-subtitle uppercase tracking-wide">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Compare against
|
||||
</div>
|
||||
<Combobox
|
||||
@@ -635,18 +666,23 @@ export function VulnerabilityScanSheet({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">CVE</TableHead>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[110px]">Installed</TableHead>
|
||||
<TableHead className="w-[110px]">Fixed</TableHead>
|
||||
<TableHead className="w-[180px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">CVE</TableHead>
|
||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Package</TableHead>
|
||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Installed</TableHead>
|
||||
<TableHead className="w-[110px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fixed</TableHead>
|
||||
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((d) => (
|
||||
<TableRow key={d.id} className={d.suppressed ? 'opacity-60' : undefined}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{pageItems.map((d) => {
|
||||
const href = cveUrl(d.vulnerability_id, d.primary_url);
|
||||
return (
|
||||
<TableRow
|
||||
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
|
||||
@@ -655,9 +691,9 @@ export function VulnerabilityScanSheet({
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{d.primary_url ? (
|
||||
{href ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
@@ -706,7 +742,8 @@ export function VulnerabilityScanSheet({
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
@@ -757,15 +794,15 @@ export function VulnerabilityScanSheet({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[160px]">Rule</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="w-[260px]">Target</TableHead>
|
||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||
<TableHead className="w-[160px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Rule</TableHead>
|
||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
||||
<TableHead className="w-[260px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{secretsPageItems.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableRow key={s.id} className={SEVERITY_ROW_TINT[s.severity]}>
|
||||
<TableCell>
|
||||
<SeverityChip severity={s.severity} />
|
||||
</TableCell>
|
||||
@@ -850,16 +887,16 @@ export function VulnerabilityScanSheet({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[140px]">Check</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead className="w-[200px]">Target</TableHead>
|
||||
<TableHead className="w-[220px]">Fix</TableHead>
|
||||
<TableHead className="w-[100px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Severity</TableHead>
|
||||
<TableHead className="w-[140px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Check</TableHead>
|
||||
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
|
||||
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
|
||||
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{misconfigsPageItems.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
|
||||
<TableCell>
|
||||
<SeverityChip severity={m.severity} />
|
||||
</TableCell>
|
||||
|
||||
@@ -206,6 +206,51 @@ describe('ScanComparisonSheet', () => {
|
||||
expect(screen.queryByRole('button', { name: /Shared/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites CVE primary_url to cve.org for CVE IDs', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
added: [vuln({
|
||||
vulnerability_id: 'CVE-2024-1234',
|
||||
primary_url: 'https://avd.aquasec.com/nvd/CVE-2024-1234',
|
||||
})],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'CVE-2024-1234' });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.cve.org/CVERecord?id=CVE-2024-1234',
|
||||
);
|
||||
});
|
||||
|
||||
it('tags CRITICAL net-positive delta chip with destructive tone', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
added: [vuln({ vulnerability_id: 'CVE-C', severity: 'CRITICAL' })],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
const chip = await screen.findByLabelText('CRITICAL delta +1');
|
||||
expect(chip).toHaveAttribute('data-tone', 'destructive');
|
||||
});
|
||||
|
||||
it('tags HIGH net-positive delta chip with warning tone (not destructive)', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
added: [vuln({ vulnerability_id: 'CVE-H', severity: 'HIGH' })],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
const chip = await screen.findByLabelText('HIGH delta +1');
|
||||
expect(chip).toHaveAttribute('data-tone', 'warning');
|
||||
});
|
||||
|
||||
it('reloads when the scan ids change', async () => {
|
||||
mockedFetch.mockResolvedValue(jsonResponse(200, result()));
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cveUrl } from '../cveUrl';
|
||||
|
||||
describe('cveUrl', () => {
|
||||
it('rewrites uppercase CVE IDs to cve.org', () => {
|
||||
expect(cveUrl('CVE-2024-1234')).toBe(
|
||||
'https://www.cve.org/CVERecord?id=CVE-2024-1234',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites lowercase CVE IDs uppercased', () => {
|
||||
expect(cveUrl('cve-2024-1234')).toBe(
|
||||
'https://www.cve.org/CVERecord?id=CVE-2024-1234',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before rewriting', () => {
|
||||
expect(cveUrl(' CVE-2025-9999 ')).toBe(
|
||||
'https://www.cve.org/CVERecord?id=CVE-2025-9999',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the fallback for GHSA advisory IDs', () => {
|
||||
const ghsa = 'https://github.com/advisories/GHSA-xxxx-yyyy-zzzz';
|
||||
expect(cveUrl('GHSA-xxxx-yyyy-zzzz', ghsa)).toBe(ghsa);
|
||||
});
|
||||
|
||||
it('returns the fallback for AVD misconfig IDs', () => {
|
||||
const avd = 'https://avd.aquasec.com/misconfig/ds002';
|
||||
expect(cveUrl('AVD-DS-0002', avd)).toBe(avd);
|
||||
});
|
||||
|
||||
it('returns the fallback when id is null', () => {
|
||||
expect(cveUrl(null, 'https://example.test/advisory')).toBe(
|
||||
'https://example.test/advisory',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when id is undefined and no fallback', () => {
|
||||
expect(cveUrl(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when id is empty and no fallback', () => {
|
||||
expect(cveUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when id is empty and fallback is null', () => {
|
||||
expect(cveUrl('', null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const CVE_PATTERN = /^cve-\d{4}-\d+$/i;
|
||||
|
||||
/**
|
||||
* Trivy's PrimaryURL is usually https://avd.aquasec.com/nvd/<id>, which 404s.
|
||||
* For CVE-prefixed IDs we rewrite to cve.org. GHSA, AVD-misconfig, and other
|
||||
* identifiers keep the Trivy-supplied fallback.
|
||||
*/
|
||||
export function cveUrl(
|
||||
id: string | null | undefined,
|
||||
fallback?: string | null,
|
||||
): string | null {
|
||||
if (!id) return fallback ?? null;
|
||||
const trimmed = id.trim();
|
||||
if (CVE_PATTERN.test(trimmed)) {
|
||||
return `https://www.cve.org/CVERecord?id=${trimmed.toUpperCase()}`;
|
||||
}
|
||||
return fallback ?? null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { VulnSeverity } from '@/types/security';
|
||||
|
||||
export const SEVERITY_ROW_TINT: Record<VulnSeverity, string> = {
|
||||
CRITICAL: 'bg-destructive/10 border-l-[3px] border-destructive/70',
|
||||
HIGH: 'bg-warning/10 border-l-[3px] border-warning/70',
|
||||
MEDIUM: 'border-l-[3px] border-info/40',
|
||||
LOW: 'border-l-[3px] border-transparent',
|
||||
UNKNOWN: 'border-l-[3px] border-transparent',
|
||||
};
|
||||
@@ -1,6 +1,13 @@
|
||||
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
|
||||
|
||||
export interface ScanPolicyEvaluation {
|
||||
policyId: number;
|
||||
policyName: string;
|
||||
maxSeverity: VulnSeverity;
|
||||
violated: boolean;
|
||||
}
|
||||
|
||||
export type TrivySource = 'managed' | 'host' | 'none';
|
||||
|
||||
@@ -43,6 +50,7 @@ export interface VulnerabilityScan {
|
||||
status: VulnScanStatus;
|
||||
error: string | null;
|
||||
stack_context: string | null;
|
||||
policy_evaluation?: ScanPolicyEvaluation | null;
|
||||
}
|
||||
|
||||
export interface SecretFinding {
|
||||
|
||||
Reference in New Issue
Block a user