diff --git a/.trivyignore b/.trivyignore index f2a99aef..d0b35674 100644 --- a/.trivyignore +++ b/.trivyignore @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts index 4f871dce..18f9a811 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 & { + policy_evaluation: ReturnType; +} { + 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( diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index e4cccaf1..a371b34e 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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. - Vulnerability scan results drawer showing CVE table with severity, package, and fix columns + Vulnerability scan results drawer showing CVE table with severity, package, and fix columns alongside a policy violation banner ### 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. + + Compare scans sheet with delta ribbon, Added/Removed/Unchanged filter pills, and a diff table tinted by severity + + ## How it works 1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability. diff --git a/docs/images/vulnerability-scanning/scan-compare-sheet.png b/docs/images/vulnerability-scanning/scan-compare-sheet.png new file mode 100644 index 00000000..49dca00b Binary files /dev/null and b/docs/images/vulnerability-scanning/scan-compare-sheet.png differ diff --git a/docs/images/vulnerability-scanning/scan-details-sheet.png b/docs/images/vulnerability-scanning/scan-details-sheet.png new file mode 100644 index 00000000..814453c1 Binary files /dev/null and b/docs/images/vulnerability-scanning/scan-details-sheet.png differ diff --git a/frontend/src/components/ScanComparisonSheet.tsx b/frontend/src/components/ScanComparisonSheet.tsx index 46c8483f..008149c7 100644 --- a/frontend/src/components/ScanComparisonSheet.tsx +++ b/frontend/src/components/ScanComparisonSheet.tsx @@ -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 = { + 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 ( !o && onClose()}> - - - - - Compare scans - + +
+ Scan comparison +
+ + + Diff Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings. @@ -154,15 +173,15 @@ export function ScanComparisonSheet({
-
Baseline
+
Baseline
{data.scanA.image_ref}
-
{new Date(data.scanA.scanned_at).toLocaleString()}
+
{new Date(data.scanA.scanned_at).toLocaleString()}
-
Current
+
Current
{data.scanB.image_ref}
-
{new Date(data.scanB.scanned_at).toLocaleString()}
+
{new Date(data.scanB.scanned_at).toLocaleString()}
@@ -191,23 +210,18 @@ export function ScanComparisonSheet({ {addedCounts && removedCounts && (
{(['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 ( - {sev} + {sev} {delta.text} ); @@ -297,24 +311,23 @@ export function ScanComparisonSheet({ - CVE - Package - Severity - Status + CVE + Package + Severity + Status {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 ( - + {v.suppressed && ( )} - {v.primary_url ? ( + {href ? ( @@ -372,10 +374,13 @@ export function VulnerabilityScanSheet({ return ( !open && onClose()}> - - - - + +
+ Vulnerability scan ยท {scan?.triggered_by ?? '-'} +
+ + + {scan?.image_ref ?? 'Loading...'} @@ -396,29 +401,55 @@ export function VulnerabilityScanSheet({
{/* Summary stats */}
+ {scan.policy_evaluation?.violated && ( +
+
+ )}
{scan.critical_count > 0 && ( - + {scan.critical_count} CRITICAL )} {scan.high_count > 0 && ( - + {scan.high_count} HIGH )} {scan.medium_count > 0 && ( - + {scan.medium_count} MEDIUM )} {scan.low_count > 0 && ( - + {scan.low_count} LOW )} {scan.total_vulnerabilities === 0 && ( - + No vulnerabilities )} @@ -426,20 +457,20 @@ export function VulnerabilityScanSheet({
)} @@ -757,15 +794,15 @@ export function VulnerabilityScanSheet({ - Severity - Rule - Title - Target + Severity + Rule + Title + Target {secretsPageItems.map((s) => ( - + @@ -850,16 +887,16 @@ export function VulnerabilityScanSheet({
- Severity - Check - Title - Target - Fix + Severity + Check + Title + Target + Fix {misconfigsPageItems.map((m) => ( - + diff --git a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx index 2e9849d7..7099aeec 100644 --- a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx +++ b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx @@ -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( {}} />); + + 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( {}} />); + + 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( {}} />); + + 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())); diff --git a/frontend/src/lib/__tests__/cveUrl.test.ts b/frontend/src/lib/__tests__/cveUrl.test.ts new file mode 100644 index 00000000..7bb451db --- /dev/null +++ b/frontend/src/lib/__tests__/cveUrl.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/cveUrl.ts b/frontend/src/lib/cveUrl.ts new file mode 100644 index 00000000..c3ec8242 --- /dev/null +++ b/frontend/src/lib/cveUrl.ts @@ -0,0 +1,18 @@ +const CVE_PATTERN = /^cve-\d{4}-\d+$/i; + +/** + * Trivy's PrimaryURL is usually https://avd.aquasec.com/nvd/, 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; +} diff --git a/frontend/src/lib/severityStyles.ts b/frontend/src/lib/severityStyles.ts new file mode 100644 index 00000000..8a97a695 --- /dev/null +++ b/frontend/src/lib/severityStyles.ts @@ -0,0 +1,9 @@ +import type { VulnSeverity } from '@/types/security'; + +export const SEVERITY_ROW_TINT: Record = { + 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', +}; diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 7a3585ea..c876bcd1 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -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 {