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 00000000..b1613d3d Binary files /dev/null and b/docs/images/vulnerability-scanning/trivy-settings-card.png differ 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 {