From 61bac080272579161fbcac31dbe8588d2cdb4e10 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 16 Apr 2026 21:29:44 -0400 Subject: [PATCH] feat(security): one-click managed Trivy install (#643) * feat(security): one-click managed Trivy install Add a Vulnerability Scanner card to Settings, Security with install, update, uninstall, and auto-update controls (Admiral-only). The installer downloads a verified Trivy release into the existing data volume at /app/data/bin/trivy and defaults the cache to /app/data/trivy-cache, so no host mounts or extra env vars are required. Detection probes the managed path, a TRIVY_BIN override, and the host PATH, distinguishing managed vs host installs. A daily scheduled check surfaces available Trivy updates, installs them automatically when opted in, and dedupes notifications per version. * fix(frontend): silence react-hooks/set-state-in-effect in useTrivyStatus The initial status fetch and managed-source update check both call setState from the effect body. Match the existing pattern used in useDashboardData / SSOSection and disable the rule at the call site. --- backend/src/index.ts | 107 +++++- backend/src/services/DatabaseService.ts | 2 + backend/src/services/SchedulerService.ts | 61 ++++ backend/src/services/TrivyInstaller.ts | 312 ++++++++++++++++++ backend/src/services/TrivyService.ts | 101 ++++-- .../trivy-settings-card.png | Bin 0 -> 9079 bytes docs/operations/trivy-setup.mdx | 82 +++-- frontend/src/components/ResourcesView.tsx | 2 +- .../components/settings/SecuritySection.tsx | 175 +++++++++- frontend/src/hooks/useTrivyStatus.ts | 84 +++-- frontend/src/types/security.ts | 12 + 11 files changed, 868 insertions(+), 70 deletions(-) create mode 100644 backend/src/services/TrivyInstaller.ts create mode 100644 docs/images/vulnerability-scanning/trivy-settings-card.png diff --git a/backend/src/index.ts b/backend/src/index.ts index 3ae3b952..abfc7791 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -70,6 +70,7 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing'; import SelfUpdateService from './services/SelfUpdateService'; import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService'; +import TrivyInstaller from './services/TrivyInstaller'; import { severityRank } from './utils/severity'; import { validateImageRef } from './utils/image-ref'; import semver from 'semver'; @@ -7248,9 +7249,113 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R // Vulnerability Scanning Routes // ========================= +const trivyInstallLimiter = rateLimit({ + ...rateLimitBase, + windowMs: 10 * 60 * 1000, + max: process.env.NODE_ENV === 'production' ? 5 : 50, + keyGenerator: rateLimitKeyGenerator, + message: { error: 'Too many install requests. Try again later.' }, +}); + app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => { const svc = TrivyService.getInstance(); - res.json({ available: svc.isTrivyAvailable(), version: svc.getVersion() }); + const installer = TrivyInstaller.getInstance(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + res.json({ + available: svc.isTrivyAvailable(), + version: svc.getVersion(), + source: svc.getSource(), + autoUpdate: settings.trivy_auto_update === '1', + busy: installer.isBusy(), + }); +}); + +app.post('/api/security/trivy-install', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() === 'host') { + res.status(409).json({ error: 'Trivy is already installed on the host PATH. Remove the host binary before managing it from Sencho.' }); + return; + } + if (svc.getSource() === 'managed') { + res.status(409).json({ error: 'Trivy is already installed. Use the update endpoint instead.' }); + return; + } + try { + const { version } = await TrivyInstaller.getInstance().install(); + await svc.detectTrivy(); + res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); + } catch (err) { + const msg = getErrorMessage(err, 'Install failed'); + console.error('[Security] Trivy install failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +app.delete('/api/security/trivy-install', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'No managed Trivy install to remove' }); + return; + } + try { + await TrivyInstaller.getInstance().uninstall(); + await svc.detectTrivy(); + res.json({ available: svc.isTrivyAvailable(), source: svc.getSource() }); + } catch (err) { + const msg = getErrorMessage(err, 'Uninstall failed'); + console.error('[Security] Trivy uninstall failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +app.get('/api/security/trivy-update-check', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'Update checks only apply to managed installs' }); + return; + } + try { + const result = await TrivyInstaller.getInstance().checkForUpdate(svc.getVersion(), svc.getSource()); + res.json(result); + } catch (err) { + const msg = getErrorMessage(err, 'Update check failed'); + console.error('[Security] Trivy update check failed:', msg); + res.status(502).json({ error: msg }); + } +}); + +app.post('/api/security/trivy-update', trivyInstallLimiter, authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmiral(req, res)) return; + const svc = TrivyService.getInstance(); + if (svc.getSource() !== 'managed') { + res.status(409).json({ error: 'Update only applies to managed installs' }); + return; + } + try { + const { version } = await TrivyInstaller.getInstance().update(); + await svc.detectTrivy(); + res.json({ version, source: svc.getSource(), available: svc.isTrivyAvailable() }); + } catch (err) { + const msg = getErrorMessage(err, 'Update failed'); + console.error('[Security] Trivy update failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +app.put('/api/security/trivy-auto-update', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmiral(req, res)) return; + const enabled = req.body?.enabled === true; + try { + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', enabled ? '1' : '0'); + res.json({ autoUpdate: enabled }); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to update setting'); + console.error('[Security] Trivy auto-update toggle failed:', msg); + res.status(500).json({ error: msg }); + } }); app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 632f520e..9676813c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -734,6 +734,8 @@ export class DatabaseService { stmt.run('developer_mode', '0'); stmt.run('metrics_retention_hours', '24'); stmt.run('log_retention_days', '30'); + stmt.run('trivy_auto_update', '0'); + stmt.run('trivy_last_notified_version', ''); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index b5699a50..84e79aee 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -13,6 +13,10 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; import TrivyService from './TrivyService'; +import TrivyInstaller from './TrivyInstaller'; + +const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; const TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000; const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000; @@ -20,7 +24,10 @@ const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000; export class SchedulerService { private static instance: SchedulerService; private intervalId: ReturnType | null = null; + private trivyUpdateIntervalId: ReturnType | null = null; + private trivyUpdateStartupTimer: ReturnType | null = null; private isProcessing = false; + private isCheckingTrivyUpdate = false; private runningTasks = new Set(); private lastTrivyRedetect = 0; @@ -38,6 +45,8 @@ export class SchedulerService { this.cleanupStaleRuns(); this.intervalId = setInterval(() => this.tick(), 60_000); setTimeout(() => this.tick(), 10_000); + this.trivyUpdateStartupTimer = setTimeout(() => this.runTrivyUpdateCheck(), TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS); + this.trivyUpdateIntervalId = setInterval(() => this.runTrivyUpdateCheck(), TRIVY_UPDATE_CHECK_INTERVAL_MS); console.log('[SchedulerService] Started'); } @@ -46,9 +55,61 @@ export class SchedulerService { clearInterval(this.intervalId); this.intervalId = null; } + if (this.trivyUpdateIntervalId) { + clearInterval(this.trivyUpdateIntervalId); + this.trivyUpdateIntervalId = null; + } + if (this.trivyUpdateStartupTimer) { + clearTimeout(this.trivyUpdateStartupTimer); + this.trivyUpdateStartupTimer = null; + } console.log('[SchedulerService] Stopped'); } + private async runTrivyUpdateCheck(): Promise { + if (this.isCheckingTrivyUpdate) return; + this.isCheckingTrivyUpdate = true; + try { + const trivy = TrivyService.getInstance(); + if (!trivy.isTrivyAvailable() || trivy.getSource() !== 'managed') return; + const db = DatabaseService.getInstance(); + const settings = db.getGlobalSettings(); + const autoUpdate = settings.trivy_auto_update === '1'; + const installer = TrivyInstaller.getInstance(); + if (installer.isBusy()) return; + const check = await installer.checkForUpdate(trivy.getVersion(), 'managed'); + if (!check.updateAvailable) return; + + if (autoUpdate) { + const previous = trivy.getVersion() ?? 'unknown'; + console.log(`[SchedulerService] Auto-updating Trivy from ${previous} to ${check.latest}`); + try { + await installer.update(); + await trivy.detectTrivy(); + NotificationService.getInstance().dispatchAlert( + 'info', + `Trivy updated from v${previous} to v${check.latest}`, + ); + db.updateGlobalSetting('trivy_last_notified_version', check.latest); + } catch (err) { + console.error('[SchedulerService] Trivy auto-update failed:', getErrorMessage(err, 'unknown error')); + } + } else { + const lastNotified = settings.trivy_last_notified_version || ''; + if (lastNotified === check.latest) return; + NotificationService.getInstance().dispatchAlert( + 'info', + `Trivy update available: v${check.latest} (currently v${check.current ?? 'unknown'})`, + ); + db.updateGlobalSetting('trivy_last_notified_version', check.latest); + } + } catch (err) { + console.warn('[SchedulerService] Trivy update check failed:', getErrorMessage(err, 'unknown error')); + } finally { + this.isCheckingTrivyUpdate = false; + } + } + private cleanupStaleRuns(): void { try { const count = DatabaseService.getInstance().markStaleRunsAsFailed(); diff --git a/backend/src/services/TrivyInstaller.ts b/backend/src/services/TrivyInstaller.ts new file mode 100644 index 00000000..6bede733 --- /dev/null +++ b/backend/src/services/TrivyInstaller.ts @@ -0,0 +1,312 @@ +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import semver from 'semver'; + +const execFileAsync = promisify(execFile); + +const GITHUB_RELEASES_LATEST = 'https://api.github.com/repos/aquasecurity/trivy/releases/latest'; +const GITHUB_DOWNLOAD_BASE = 'https://github.com/aquasecurity/trivy/releases/download'; +const USER_AGENT = 'sencho-trivy-installer'; +const DOWNLOAD_TIMEOUT_MS = 60 * 1000; +const GITHUB_API_TIMEOUT_MS = 15 * 1000; +const VERIFY_TIMEOUT_MS = 10 * 1000; +const LATEST_VERSION_TTL_MS = 60 * 60 * 1000; +const MIN_TRIVY_VERSION = '0.50.0'; + +export type TrivySource = 'managed' | 'host' | 'none'; + +export interface UpdateCheckResult { + current: string | null; + latest: string; + updateAvailable: boolean; + source: TrivySource; +} + +interface CachedLatest { + version: string; + fetchedAt: number; +} + +function resolveDataDir(): string { + return process.env.DATA_DIR || path.join(process.cwd(), 'data'); +} + +function archAssetTag(): string { + switch (process.arch) { + case 'x64': + return '64bit'; + case 'arm64': + return 'ARM64'; + case 'arm': + return 'ARM'; + default: + throw new Error(`Unsupported CPU architecture for managed Trivy install: ${process.arch}`); + } +} + +function stripLeadingV(tag: string): string { + return tag.startsWith('v') ? tag.slice(1) : tag; +} + +async function fetchWithTimeout(url: string, timeoutMs: number, headers: Record = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + headers: { 'User-Agent': USER_AGENT, ...headers }, + signal: controller.signal, + redirect: 'follow', + }); + } finally { + clearTimeout(timer); + } +} + +class TrivyInstaller { + private static instance: TrivyInstaller; + private busy = false; + private latestCache: CachedLatest | null = null; + + public static getInstance(): TrivyInstaller { + if (!TrivyInstaller.instance) { + TrivyInstaller.instance = new TrivyInstaller(); + } + return TrivyInstaller.instance; + } + + public isBusy(): boolean { + return this.busy; + } + + public binDir(): string { + return path.join(resolveDataDir(), 'bin'); + } + + public binaryPath(): string { + return path.join(this.binDir(), 'trivy'); + } + + public cacheDir(): string { + return path.join(resolveDataDir(), 'trivy-cache'); + } + + public isManagedInstalled(): boolean { + try { + fs.accessSync(this.binaryPath(), fs.constants.X_OK); + return true; + } catch { + return false; + } + } + + public async getManagedVersion(): Promise { + if (!this.isManagedInstalled()) return null; + try { + const { stdout } = await execFileAsync(this.binaryPath(), ['--version'], { timeout: VERIFY_TIMEOUT_MS }); + return parseTrivyVersionOutput(stdout); + } catch { + return null; + } + } + + public async fetchLatestVersion(force = false): Promise { + const now = Date.now(); + if (!force && this.latestCache && now - this.latestCache.fetchedAt < LATEST_VERSION_TTL_MS) { + return this.latestCache.version; + } + const response = await fetchWithTimeout(GITHUB_RELEASES_LATEST, GITHUB_API_TIMEOUT_MS, { + Accept: 'application/vnd.github+json', + }); + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}`); + } + const body = (await response.json()) as { tag_name?: string }; + const tag = typeof body.tag_name === 'string' ? body.tag_name : ''; + const version = stripLeadingV(tag); + if (!semver.valid(version)) { + throw new Error(`Could not parse Trivy release tag: "${tag}"`); + } + this.latestCache = { version, fetchedAt: now }; + return version; + } + + public async checkForUpdate(currentVersion: string | null, source: TrivySource): Promise { + const latest = await this.fetchLatestVersion(); + const updateAvailable = source === 'managed' && !!currentVersion && semver.valid(currentVersion) + ? semver.gt(latest, currentVersion) + : false; + return { current: currentVersion, latest, updateAvailable, source }; + } + + public async install(): Promise<{ version: string }> { + return this.acquire(async () => this.doInstall()); + } + + public async update(): Promise<{ version: string }> { + if (!this.isManagedInstalled()) { + throw new Error('No managed Trivy install to update'); + } + return this.acquire(async () => this.doInstall()); + } + + public async uninstall(): Promise { + await this.acquire(async () => { + const target = this.binaryPath(); + try { + fs.unlinkSync(target); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } + }); + } + + private async acquire(fn: () => Promise): Promise { + if (this.busy) { + throw new Error('Another Trivy install operation is in progress'); + } + this.busy = true; + try { + return await fn(); + } finally { + this.busy = false; + } + } + + private async doInstall(): Promise<{ version: string }> { + const version = await this.fetchLatestVersion(true); + if (semver.lt(version, MIN_TRIVY_VERSION)) { + throw new Error(`Fetched Trivy version ${version} is below minimum ${MIN_TRIVY_VERSION}`); + } + const archTag = archAssetTag(); + const assetName = `trivy_${version}_Linux-${archTag}.tar.gz`; + const checksumsName = `trivy_${version}_checksums.txt`; + const tarballUrl = `${GITHUB_DOWNLOAD_BASE}/v${version}/${assetName}`; + const checksumsUrl = `${GITHUB_DOWNLOAD_BASE}/v${version}/${checksumsName}`; + + const binDir = this.binDir(); + fs.mkdirSync(binDir, { recursive: true }); + const staging = path.join(binDir, `.trivy-install-${process.pid}-${Date.now()}`); + fs.mkdirSync(staging, { recursive: true }); + const tarballPath = path.join(staging, assetName); + + try { + await downloadToFile(tarballUrl, tarballPath); + const checksumsBody = await downloadToString(checksumsUrl); + const expected = findChecksum(checksumsBody, assetName); + if (!expected) { + throw new Error(`Checksum for ${assetName} not found in ${checksumsName}`); + } + const actual = await sha256File(tarballPath); + if (actual.toLowerCase() !== expected.toLowerCase()) { + throw new Error(`Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`); + } + await extractTrivyBinary(tarballPath, staging); + const extracted = path.join(staging, 'trivy'); + if (!fs.existsSync(extracted)) { + throw new Error('Trivy binary not found in extracted tarball'); + } + fs.chmodSync(extracted, 0o755); + const target = this.binaryPath(); + fs.renameSync(extracted, target); + const verified = await verifyBinary(target); + if (!verified) { + throw new Error('Installed Trivy binary failed --version verification'); + } + return { version: verified }; + } finally { + try { + fs.rmSync(staging, { recursive: true, force: true }); + } catch { + /* noop */ + } + } + } +} + +function parseTrivyVersionOutput(stdout: string): string { + const match = stdout.match(/Version:\s*([^\s\n]+)/i); + if (match) return match[1]; + return stdout.split('\n')[0]?.trim() || 'unknown'; +} + +function findChecksum(body: string, assetName: string): string | null { + for (const line of body.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[1] === assetName) return parts[0]; + } + return null; +} + +async function downloadToFile(url: string, dest: string): Promise { + const response = await fetchWithTimeout(url, DOWNLOAD_TIMEOUT_MS); + if (!response.ok || !response.body) { + throw new Error(`Download failed (${response.status}) for ${url}`); + } + const writer = fs.createWriteStream(dest, { mode: 0o600 }); + try { + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!writer.write(Buffer.from(value))) { + await new Promise((resolve) => writer.once('drain', resolve)); + } + } + } finally { + await new Promise((resolve, reject) => { + writer.end((err?: Error | null) => (err ? reject(err) : resolve())); + }); + } +} + +async function downloadToString(url: string): Promise { + const response = await fetchWithTimeout(url, DOWNLOAD_TIMEOUT_MS); + if (!response.ok) { + throw new Error(`Download failed (${response.status}) for ${url}`); + } + return response.text(); +} + +async function sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} + +async function extractTrivyBinary(tarball: string, targetDir: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn('tar', ['-xzf', tarball, '-C', targetDir, 'trivy'], { stdio: 'ignore' }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('tar extraction timed out')); + }, DOWNLOAD_TIMEOUT_MS); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.on('exit', (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`tar exited with code ${code}`)); + }); + }); +} + +async function verifyBinary(binaryPath: string): Promise { + try { + const { stdout } = await execFileAsync(binaryPath, ['--version'], { timeout: VERIFY_TIMEOUT_MS }); + return parseTrivyVersionOutput(stdout); + } catch { + return null; + } +} + +export default TrivyInstaller; diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 0d2f6ae9..32de876a 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -12,6 +12,8 @@ import { } from './DatabaseService'; import { RegistryService } from './RegistryService'; import { disableCapability, enableCapability } from './CapabilityRegistry'; +import TrivyInstaller, { type TrivySource } from './TrivyInstaller'; +import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { SEVERITY_ORDER } from '../utils/severity'; @@ -144,10 +146,12 @@ export function parseTrivyOutput(raw: string): { class TrivyService { private static instance: TrivyService; - private available = false; private version: string | null = null; - private detectionTimestamp = 0; + private binaryPath: string | null = null; + private source: TrivySource = 'none'; private scanningImages: Set = new Set(); + private cacheDirEnsured: string | null = null; + private detectionTimestamp = 0; public static getInstance(): TrivyService { if (!TrivyService.instance) { @@ -158,42 +162,66 @@ class TrivyService { async initialize(): Promise { await this.detectTrivy(); - if (!this.available) { - disableCapability('vulnerability-scanning'); - console.log('[Trivy] Binary not found on PATH; vulnerability scanning disabled'); + if (this.source === 'none') { + console.log('[Trivy] Binary not found; vulnerability scanning disabled'); } else { - console.log(`[Trivy] Available (version ${this.version})`); + console.log(`[Trivy] Available (version ${this.version}, source ${this.source})`); } } - async detectTrivy(): Promise<{ available: boolean; version: string | null }> { + async detectTrivy(): Promise<{ available: boolean; version: string | null; source: TrivySource }> { const started = Date.now(); - const wasAvailable = this.available; + const wasAvailable = this.source !== 'none'; + const candidates: Array<{ path: string; source: TrivySource }> = []; + const managedPath = TrivyInstaller.getInstance().binaryPath(); try { - const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 }); - const match = stdout.match(/Version:\s*([^\s\n]+)/i); - this.version = match ? match[1] : stdout.split('\n')[0]?.trim() || 'unknown'; - this.available = true; + fs.accessSync(managedPath, fs.constants.X_OK); + candidates.push({ path: managedPath, source: 'managed' }); } catch { - this.available = false; + /* not installed */ + } + const envOverride = process.env.TRIVY_BIN; + if (envOverride) { + candidates.push({ path: envOverride, source: 'host' }); + } + candidates.push({ path: 'trivy', source: 'host' }); + + let detected = false; + for (const candidate of candidates) { + try { + const { stdout } = await execFileAsync(candidate.path, ['--version'], { timeout: 5000 }); + const match = stdout.match(/Version:\s*([^\s\n]+)/i); + this.version = match ? match[1] : stdout.split('\n')[0]?.trim() || 'unknown'; + this.binaryPath = candidate.path; + this.source = candidate.source; + detected = true; + break; + } catch { + /* try next */ + } + } + if (!detected) { this.version = null; + this.binaryPath = null; + this.source = 'none'; } this.detectionTimestamp = Date.now(); + const isAvailable = this.source !== 'none'; diag( - `detectTrivy: available=${this.available} version=${this.version ?? 'null'} tookMs=${ + `detectTrivy: available=${isAvailable} source=${this.source} version=${this.version ?? 'null'} tookMs=${ this.detectionTimestamp - started }`, ); - if (this.available && !wasAvailable) { + if (isAvailable && !wasAvailable) { enableCapability('vulnerability-scanning'); console.log( - `[Trivy] Binary detected on PATH; vulnerability scanning enabled (version ${this.version})`, + `[Trivy] Binary detected (source=${this.source}); vulnerability scanning enabled (version ${this.version})`, ); - } else if (!this.available && wasAvailable) { + } else if (!isAvailable && wasAvailable) { disableCapability('vulnerability-scanning'); console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled'); } - return { available: this.available, version: this.version }; + return { available: isAvailable, version: this.version, source: this.source }; } getDetectionTimestamp(): number { @@ -201,19 +229,38 @@ class TrivyService { } isTrivyAvailable(): boolean { - return this.available; + return this.source !== 'none'; } getVersion(): string | null { return this.version; } + getSource(): TrivySource { + return this.source; + } + + private ensureCacheDir(): string { + const cacheDir = process.env.TRIVY_CACHE_DIR || TrivyInstaller.getInstance().cacheDir(); + if (this.cacheDirEnsured !== cacheDir) { + try { + fs.mkdirSync(cacheDir, { recursive: true }); + } catch { + /* best-effort; Trivy will surface a clearer error on scan */ + } + this.cacheDirEnsured = cacheDir; + } + return cacheDir; + } + private async buildEnv( sendWarning?: (msg: string) => void, ): Promise<{ env: Record; cleanup: () => void }> { const registries = DatabaseService.getInstance().getRegistries(); + const cacheDir = this.ensureCacheDir(); const baseEnv: Record = { ...process.env, + TRIVY_CACHE_DIR: cacheDir, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', @@ -273,7 +320,8 @@ class TrivyService { nodeId: number, options: { useCache?: boolean; digest?: string | null } = {}, ): Promise { - if (!this.available) { + const binary = this.binaryPath; + if (!binary) { throw new Error('Trivy is not available on this host'); } const key = this.scanKey(nodeId, imageRef); @@ -343,7 +391,7 @@ class TrivyService { imageRef, ]; const execStart = Date.now(); - const { stdout } = await execFileAsync('trivy', args, { + const { stdout } = await execFileAsync(binary, args, { env, timeout: SCAN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, @@ -497,7 +545,7 @@ class TrivyService { ); return stored; } catch (error) { - const msg = (error as Error).message || 'Scan failed'; + const msg = getErrorMessage(error, 'Scan failed'); db.updateVulnerabilityScan(scanId, { status: 'failed', error: msg, @@ -523,7 +571,7 @@ class TrivyService { nodeId: number, triggeredBy: VulnScanTrigger = 'scheduled', ): Promise<{ scanned: number; skipped: number; failed: number }> { - if (!this.available) { + if (this.source === 'none') { throw new Error('Trivy is not available on this host'); } const images = await DockerController.getInstance(nodeId).getImages(); @@ -552,7 +600,7 @@ class TrivyService { scanned++; } catch (err) { failed++; - console.warn(`[Trivy] Failed to scan ${ref}:`, (err as Error).message); + console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error')); } await new Promise((r) => setTimeout(r, 300)); } @@ -560,13 +608,14 @@ class TrivyService { } async generateSBOM(imageRef: string, format: SbomFormat): Promise { - if (!this.available) { + const binary = this.binaryPath; + if (!binary) { throw new Error('Trivy is not available on this host'); } const { env, cleanup } = await this.buildEnv(); try { const { stdout } = await execFileAsync( - 'trivy', + binary, ['image', '--format', format, '--quiet', '--no-progress', imageRef], { env, diff --git a/docs/images/vulnerability-scanning/trivy-settings-card.png b/docs/images/vulnerability-scanning/trivy-settings-card.png new file mode 100644 index 0000000000000000000000000000000000000000..b1613d3d31094daac2997681f7f3c04581704c74 GIT binary patch literal 9079 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 literal 0 HcmV?d00001 diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 95b8bc4f..c76875e3 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -3,17 +3,54 @@ title: Installing Trivy description: Install and mount the Trivy CLI so Sencho can scan container images for vulnerabilities. --- -Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image; operators provide it via a bind mount or a custom image so Sencho can find it on `PATH`. Once Trivy is available, the scanning UI appears automatically. +Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image. You have three ways to provide it, in order of convenience: + +1. **One-click install from Settings → Security** (recommended). +2. Bind mount a host Trivy binary into the container. +3. Build a custom Sencho image with Trivy baked in. + +Once Trivy is available through any of these options, the scanning UI appears automatically. ## Why Trivy is not bundled -Trivy's vulnerability database updates multiple times per day and is around 100 MB. Bundling Trivy would force every Sencho instance to carry an out-of-date database in its image, then re-download on first scan. By keeping Trivy external, you can: +Trivy's vulnerability database updates multiple times per day and is around 100 MB. Bundling Trivy would force every Sencho instance to carry an out-of-date database in its image, then re-download on first scan. Keeping Trivy external lets you: -- Pick the Trivy version you want and upgrade it on your own schedule -- Persist the Trivy cache (the vulnerability DB) across Sencho container restarts -- Pre-seed an air-gapped cache for environments without internet access +- Pick the Trivy version you want and upgrade it on your own schedule. +- Persist the Trivy cache (the vulnerability DB) across Sencho container restarts. +- Pre-seed an air-gapped cache for environments without internet access. -## Installing Trivy on the host +## Option 1: One-click install (recommended) + +Sencho can install and manage Trivy for you without any extra bind mounts or environment variables. + +1. Go to **Settings → Security**. +2. Under **Vulnerability Scanner**, click **Install Trivy**. +3. Wait for the status to flip to **Installed (managed)**. The version appears next to the badge. + + + Vulnerability Scanner card in Settings, Security section, with Install Trivy button + + +Behind the scenes: + +- The Trivy binary is downloaded into Sencho's existing data volume at `/app/data/bin/trivy`. No host filesystem changes. +- The vulnerability database cache defaults to `/app/data/trivy-cache` so it persists across container restarts. +- Downloads are verified against the official Trivy checksum file before the binary is put in place. +- The installed version survives Sencho image upgrades because it lives on the mounted data volume. + +### Updating the managed install + +When a newer Trivy release is available, Settings → Security shows an **Update available** badge next to the version. Click **Update** to pull the latest release. + +To update automatically instead, toggle **Auto-update Trivy** on. Sencho checks for new releases once a day and installs them in the background. You'll get an in-app notification each time a new version is installed, or when an update is available and auto-update is off. + +The install, update, and uninstall buttons are Admiral-only. Skipper and Community instances see the scanner status, but install actions require an Admiral license. + +### Removing the managed install + +Click **Uninstall** next to the status badge. Sencho removes the binary from `/app/data/bin/trivy`. The vulnerability database cache at `/app/data/trivy-cache` is left in place in case you reinstall later; delete it manually if you want to reclaim the disk space. + +## Option 2: Installing Trivy on the host ### Linux (Debian / Ubuntu) @@ -51,11 +88,11 @@ trivy --version The command should print a `Version: X.Y.Z` line. Note the path that `which trivy` (or `where trivy` on Windows) returns; you will mount that path into the Sencho container. -## Making Trivy available to Sencho +## Making a host-installed Trivy available to Sencho -Sencho runs inside a container and looks for `trivy` on its own `PATH`. There are two approaches: +If you already installed Trivy on the host (Option 2) and prefer to manage it externally, Sencho runs inside a container and looks for `trivy` on its own `PATH`. There are two ways to expose it: -### Option 1: Bind mount the host binary +### Bind mount the host binary Mount the host's Trivy binary into the Sencho container. This is the simplest option when Sencho and Trivy share the same CPU architecture. @@ -81,7 +118,7 @@ The `trivy-cache` volume persists the vulnerability database across Sencho conta Adjust the first path if `which trivy` on the host prints something other than `/usr/local/bin/trivy` (for example `/usr/bin/trivy` on some distributions). -### Option 2: Build a custom Sencho image +### Option 3: Build a custom Sencho image If the host's Trivy binary is not ABI-compatible with the Sencho container (for example because you are running macOS host binaries or a different glibc version), install Trivy inside the image instead: @@ -104,9 +141,11 @@ docker compose up -d ## Persisting the vulnerability database -Trivy downloads a ~100 MB vulnerability database on first run and refreshes it every six hours. Without a persistent cache, every Sencho restart re-downloads the database, wasting bandwidth and adding 10–30 seconds to the first scan. +Trivy downloads a ~100 MB vulnerability database on first run and refreshes it every six hours. Without a persistent cache, every Sencho restart re-downloads the database, wasting bandwidth and adding 10 to 30 seconds to the first scan. -Set `TRIVY_CACHE_DIR` to a directory inside a named or bind-mounted volume (see Option 1 above). The directory must be writable by the Sencho process. +The managed install (Option 1) writes its cache to `/app/data/trivy-cache` inside Sencho's data volume automatically, so no extra configuration is needed. + +For Options 2 and 3, set `TRIVY_CACHE_DIR` to a directory inside a named or bind-mounted volume. The directory must be writable by the Sencho process. ## Air-gapped environments @@ -142,13 +181,10 @@ Plan to refresh the bundle on a schedule (weekly is typical) so CVE data stays c ## Verifying Sencho detects Trivy -After restarting Sencho with the mount in place: +1. Open **Settings → Security**. The **Vulnerability Scanner** card shows the current status and version. +2. Open the **Resources** tab. If Trivy is detected, a shield icon appears in the Actions column of the **Images** panel next to the delete icon on every row. -1. Open the **Resources** tab. -2. Look at the **Images** panel. If Trivy is detected, a shield icon appears in the Actions column next to the delete icon on every row. -3. You can also check **Settings → Support** for an explicit Trivy availability status. - -If the shield icon is missing, see the troubleshooting section below. +If the scanner shows as not installed after using Option 2 or 3, see the troubleshooting section below. ### Runtime detection @@ -170,11 +206,17 @@ If the command returns "not found", the mount path inside the container is wrong ### Binary exists but reports an exec format error -This means the host binary is not ABI-compatible with the Sencho image. Use Option 2 (custom image) instead; the `install.sh` script pulls the right architecture-specific build. +This means the host binary is not ABI-compatible with the Sencho image. Use the one-click install (Option 1) or a custom image (Option 3); both pull the right architecture-specific build. ### Scans take a long time on first run -The first scan after a Trivy install downloads the vulnerability database. Expect 10–30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted. +The first scan after a Trivy install downloads the vulnerability database. Expect 10 to 30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted. + +### Install button is disabled + +The install, update, and uninstall buttons require an Admiral license. If you hold a Skipper or Community license, the card shows current status only; use Option 2 or 3 to add Trivy manually. + +The install button is also hidden when a host-installed Trivy is already detected on `PATH`. Remove the host binary (or drop the bind mount) to switch to the managed install. ### Private registry images fail to scan diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 951af7a3..6d48d4a8 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -465,7 +465,7 @@ export default function ResourcesView() { const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); // Vulnerability scanning state - const trivy = useTrivyStatus(); + const { status: trivy } = useTrivyStatus(); const [scanSummaries, setScanSummaries] = useState>({}); const [scanningImageRef, setScanningImageRef] = useState(null); const [inspectScanId, setInspectScanId] = useState(null); diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index db516f76..bed5f691 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -28,8 +28,10 @@ import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { PaidGate } from '@/components/PaidGate'; import { TierBadge } from '@/components/TierBadge'; -import { ShieldCheck, Plus, Trash2, Pencil } from 'lucide-react'; +import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2 } from 'lucide-react'; import type { ScanPolicy, VulnSeverity } from '@/types/security'; +import { useLicense } from '@/context/LicenseContext'; +import { useTrivyStatus } from '@/hooks/useTrivyStatus'; const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [ { value: 'CRITICAL', label: 'Critical' }, @@ -54,6 +56,24 @@ const EMPTY_FORM: PolicyFormState = { enabled: true, }; +const TRIVY_SOURCE_BADGES: Record<'managed' | 'host' | 'none', { label: string; variant: 'outline' | 'secondary' }> = { + managed: { label: 'Installed (managed)', variant: 'outline' }, + host: { label: 'Installed (host)', variant: 'outline' }, + none: { label: 'Not installed', variant: 'secondary' }, +}; + +const TRIVY_SOURCE_DESCRIPTIONS: Record<'managed' | 'host' | 'none', string | null> = { + managed: null, + host: 'Managed externally via the host binary. Install and updates are handled outside Sencho.', + none: "Install Trivy into Sencho's data volume to enable image vulnerability scanning. No host mounts required.", +}; + +const TRIVY_OP_LABELS: Record<'install' | 'update' | 'uninstall', { loading: string; success: string }> = { + install: { loading: 'Installing Trivy...', success: 'Trivy installed' }, + update: { loading: 'Updating Trivy...', success: 'Trivy updated' }, + uninstall: { loading: 'Removing Trivy...', success: 'Trivy removed' }, +}; + export function SecuritySection({ isPaid }: { isPaid: boolean }) { const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(true); @@ -63,6 +83,63 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { const [saving, setSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); + const { license } = useLicense(); + const isAdmiral = isPaid && license?.variant === 'admiral'; + const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); + const [trivyBusy, setTrivyBusy] = useState(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, localOnly: true }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || `Trivy ${op} failed`); + } + toast.success(success); + await Promise.all([refreshTrivy(), refreshUpdateCheck()]); + } catch (err) { + toast.error((err as Error)?.message || `Trivy ${op} failed`); + } finally { + toast.dismiss(toastId); + setTrivyBusy(null); + } + }; + + const handleInstallTrivy = () => runTrivyOp('install', '/security/trivy-install', 'POST'); + const handleUpdateTrivy = () => runTrivyOp('update', '/security/trivy-update', 'POST'); + const handleUninstallTrivy = async () => { + setUninstallConfirm(false); + await runTrivyOp('uninstall', '/security/trivy-install', 'DELETE'); + }; + + const handleAutoUpdateToggle = async (enabled: boolean) => { + setTrivyBusy('auto-update'); + try { + const res = await apiFetch('/security/trivy-auto-update', { + method: 'PUT', + localOnly: true, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update setting'); + } + await refreshTrivy(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update setting'); + } finally { + setTrivyBusy(null); + } + }; + const fetchPolicies = async () => { try { const res = await apiFetch('/security/policies', { localOnly: true }); @@ -194,6 +271,81 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { +
+
+
+ + Vulnerability Scanner + + {TRIVY_SOURCE_BADGES[trivy.source].label} + + {updateCheck?.updateAvailable && ( + + Update available to v{updateCheck.latest} + + )} +
+ {isAdmiral && ( +
+ {trivy.source === 'none' && ( + + )} + {trivy.source === 'managed' && updateCheck?.updateAvailable && ( + + )} + {trivy.source === 'managed' && ( + + )} +
+ )} +
+ + {trivy.source === 'managed' && trivy.version && ( +
Version: v{trivy.version}
+ )} + {TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && ( +
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}
+ )} + + {trivy.source === 'managed' && isAdmiral && ( +
+
+ +

+ Check daily and install newer Trivy releases automatically. +

+
+ +
+ )} +
+ {loading && (
@@ -352,6 +504,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { + + + + + Remove Trivy? + + This removes the managed Trivy binary. Vulnerability scanning will stop working until + Trivy is reinstalled or a host binary is provided. + + + + Cancel + + Remove + + + +
); } diff --git a/frontend/src/hooks/useTrivyStatus.ts b/frontend/src/hooks/useTrivyStatus.ts index 0d54382c..51ea00fa 100644 --- a/frontend/src/hooks/useTrivyStatus.ts +++ b/frontend/src/hooks/useTrivyStatus.ts @@ -1,28 +1,70 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; -import type { TrivyStatus } from '@/types/security'; +import type { TrivyStatus, TrivyUpdateCheck } from '@/types/security'; -export function useTrivyStatus(): TrivyStatus { - const [status, setStatus] = useState({ available: false, version: null }); +const INITIAL_STATUS: TrivyStatus = { + available: false, + version: null, + source: 'none', + autoUpdate: false, + busy: false, +}; - useEffect(() => { - let cancelled = false; - apiFetch('/security/trivy-status') - .then((r) => (r.ok ? r.json() : null)) - .then((d) => { - if (cancelled || !d) return; - setStatus({ - available: !!d.available, - version: typeof d.version === 'string' ? d.version : null, - }); - }) - .catch((err) => { - console.error('Failed to fetch Trivy status:', err); +export interface UseTrivyStatusResult { + status: TrivyStatus; + updateCheck: TrivyUpdateCheck | null; + refresh: () => Promise; + refreshUpdateCheck: () => Promise; +} + +export function useTrivyStatus(): UseTrivyStatusResult { + const [status, setStatus] = useState(INITIAL_STATUS); + const [updateCheck, setUpdateCheck] = useState(null); + + const refresh = useCallback(async () => { + try { + const r = await apiFetch('/security/trivy-status'); + if (!r.ok) return; + const d = await r.json(); + setStatus({ + available: !!d.available, + version: typeof d.version === 'string' ? d.version : null, + source: d.source === 'managed' || d.source === 'host' ? d.source : 'none', + autoUpdate: !!d.autoUpdate, + busy: !!d.busy, }); - return () => { - cancelled = true; - }; + } catch (err) { + console.error('Failed to fetch Trivy status:', err); + } }, []); - return status; + const refreshUpdateCheck = useCallback(async () => { + try { + const r = await apiFetch('/security/trivy-update-check'); + if (!r.ok) { + setUpdateCheck(null); + return; + } + const d = (await r.json()) as TrivyUpdateCheck; + setUpdateCheck(d); + } catch { + setUpdateCheck(null); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void refresh(); + }, [refresh]); + + useEffect(() => { + if (status.source === 'managed') { + // eslint-disable-next-line react-hooks/set-state-in-effect + void refreshUpdateCheck(); + } else { + setUpdateCheck(null); + } + }, [status.source, refreshUpdateCheck]); + + return { status, updateCheck, refresh, refreshUpdateCheck }; } diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 041bd988..5a4cd8d5 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -2,9 +2,21 @@ export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy'; +export type TrivySource = 'managed' | 'host' | 'none'; + export interface TrivyStatus { available: boolean; version: string | null; + source: TrivySource; + autoUpdate: boolean; + busy: boolean; +} + +export interface TrivyUpdateCheck { + current: string | null; + latest: string; + updateAvailable: boolean; + source: TrivySource; } export interface VulnerabilityScan {