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.
This commit is contained in:
Anso
2026-04-16 21:29:44 -04:00
committed by GitHub
parent 759776792d
commit 61bac08027
11 changed files with 868 additions and 70 deletions
+106 -1
View File
@@ -70,6 +70,7 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing'; import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
import SelfUpdateService from './services/SelfUpdateService'; import SelfUpdateService from './services/SelfUpdateService';
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService'; import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
import TrivyInstaller from './services/TrivyInstaller';
import { severityRank } from './utils/severity'; import { severityRank } from './utils/severity';
import { validateImageRef } from './utils/image-ref'; import { validateImageRef } from './utils/image-ref';
import semver from 'semver'; import semver from 'semver';
@@ -7248,9 +7249,113 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R
// Vulnerability Scanning Routes // 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) => { app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => {
const svc = TrivyService.getInstance(); 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 => { app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => {
+2
View File
@@ -734,6 +734,8 @@ export class DatabaseService {
stmt.run('developer_mode', '0'); stmt.run('developer_mode', '0');
stmt.run('metrics_retention_hours', '24'); stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30'); 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 // Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
+61
View File
@@ -13,6 +13,10 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot
import { NodeRegistry } from './NodeRegistry'; import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService'; import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService'; 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 TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000;
const STALE_SCAN_THRESHOLD_MS = 15 * 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 { export class SchedulerService {
private static instance: SchedulerService; private static instance: SchedulerService;
private intervalId: ReturnType<typeof setInterval> | null = null; private intervalId: ReturnType<typeof setInterval> | null = null;
private trivyUpdateIntervalId: ReturnType<typeof setInterval> | null = null;
private trivyUpdateStartupTimer: ReturnType<typeof setTimeout> | null = null;
private isProcessing = false; private isProcessing = false;
private isCheckingTrivyUpdate = false;
private runningTasks = new Set<number>(); private runningTasks = new Set<number>();
private lastTrivyRedetect = 0; private lastTrivyRedetect = 0;
@@ -38,6 +45,8 @@ export class SchedulerService {
this.cleanupStaleRuns(); this.cleanupStaleRuns();
this.intervalId = setInterval(() => this.tick(), 60_000); this.intervalId = setInterval(() => this.tick(), 60_000);
setTimeout(() => this.tick(), 10_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'); console.log('[SchedulerService] Started');
} }
@@ -46,9 +55,61 @@ export class SchedulerService {
clearInterval(this.intervalId); clearInterval(this.intervalId);
this.intervalId = null; 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'); console.log('[SchedulerService] Stopped');
} }
private async runTrivyUpdateCheck(): Promise<void> {
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 { private cleanupStaleRuns(): void {
try { try {
const count = DatabaseService.getInstance().markStaleRunsAsFailed(); const count = DatabaseService.getInstance().markStaleRunsAsFailed();
+312
View File
@@ -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<string, string> = {}): Promise<Response> {
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<string | null> {
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<string> {
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<UpdateCheckResult> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<void> {
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<void>((resolve) => writer.once('drain', resolve));
}
}
} finally {
await new Promise<void>((resolve, reject) => {
writer.end((err?: Error | null) => (err ? reject(err) : resolve()));
});
}
}
async function downloadToString(url: string): Promise<string> {
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<string> {
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<void> {
await new Promise<void>((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<string | null> {
try {
const { stdout } = await execFileAsync(binaryPath, ['--version'], { timeout: VERIFY_TIMEOUT_MS });
return parseTrivyVersionOutput(stdout);
} catch {
return null;
}
}
export default TrivyInstaller;
+75 -26
View File
@@ -12,6 +12,8 @@ import {
} from './DatabaseService'; } from './DatabaseService';
import { RegistryService } from './RegistryService'; import { RegistryService } from './RegistryService';
import { disableCapability, enableCapability } from './CapabilityRegistry'; import { disableCapability, enableCapability } from './CapabilityRegistry';
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug'; import { isDebugEnabled } from '../utils/debug';
import { SEVERITY_ORDER } from '../utils/severity'; import { SEVERITY_ORDER } from '../utils/severity';
@@ -144,10 +146,12 @@ export function parseTrivyOutput(raw: string): {
class TrivyService { class TrivyService {
private static instance: TrivyService; private static instance: TrivyService;
private available = false;
private version: string | null = null; private version: string | null = null;
private detectionTimestamp = 0; private binaryPath: string | null = null;
private source: TrivySource = 'none';
private scanningImages: Set<string> = new Set(); private scanningImages: Set<string> = new Set();
private cacheDirEnsured: string | null = null;
private detectionTimestamp = 0;
public static getInstance(): TrivyService { public static getInstance(): TrivyService {
if (!TrivyService.instance) { if (!TrivyService.instance) {
@@ -158,42 +162,66 @@ class TrivyService {
async initialize(): Promise<void> { async initialize(): Promise<void> {
await this.detectTrivy(); await this.detectTrivy();
if (!this.available) { if (this.source === 'none') {
disableCapability('vulnerability-scanning'); console.log('[Trivy] Binary not found; vulnerability scanning disabled');
console.log('[Trivy] Binary not found on PATH; vulnerability scanning disabled');
} else { } 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 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 { try {
const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 }); fs.accessSync(managedPath, fs.constants.X_OK);
const match = stdout.match(/Version:\s*([^\s\n]+)/i); candidates.push({ path: managedPath, source: 'managed' });
this.version = match ? match[1] : stdout.split('\n')[0]?.trim() || 'unknown';
this.available = true;
} catch { } 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.version = null;
this.binaryPath = null;
this.source = 'none';
} }
this.detectionTimestamp = Date.now(); this.detectionTimestamp = Date.now();
const isAvailable = this.source !== 'none';
diag( diag(
`detectTrivy: available=${this.available} version=${this.version ?? 'null'} tookMs=${ `detectTrivy: available=${isAvailable} source=${this.source} version=${this.version ?? 'null'} tookMs=${
this.detectionTimestamp - started this.detectionTimestamp - started
}`, }`,
); );
if (this.available && !wasAvailable) { if (isAvailable && !wasAvailable) {
enableCapability('vulnerability-scanning'); enableCapability('vulnerability-scanning');
console.log( 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'); disableCapability('vulnerability-scanning');
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled'); 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 { getDetectionTimestamp(): number {
@@ -201,19 +229,38 @@ class TrivyService {
} }
isTrivyAvailable(): boolean { isTrivyAvailable(): boolean {
return this.available; return this.source !== 'none';
} }
getVersion(): string | null { getVersion(): string | null {
return this.version; 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( private async buildEnv(
sendWarning?: (msg: string) => void, sendWarning?: (msg: string) => void,
): Promise<{ env: Record<string, string | undefined>; cleanup: () => void }> { ): Promise<{ env: Record<string, string | undefined>; cleanup: () => void }> {
const registries = DatabaseService.getInstance().getRegistries(); const registries = DatabaseService.getInstance().getRegistries();
const cacheDir = this.ensureCacheDir();
const baseEnv: Record<string, string | undefined> = { const baseEnv: Record<string, string | undefined> = {
...process.env, ...process.env,
TRIVY_CACHE_DIR: cacheDir,
PATH: PATH:
process.env.PATH || process.env.PATH ||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
@@ -273,7 +320,8 @@ class TrivyService {
nodeId: number, nodeId: number,
options: { useCache?: boolean; digest?: string | null } = {}, options: { useCache?: boolean; digest?: string | null } = {},
): Promise<TrivyScanResult> { ): Promise<TrivyScanResult> {
if (!this.available) { const binary = this.binaryPath;
if (!binary) {
throw new Error('Trivy is not available on this host'); throw new Error('Trivy is not available on this host');
} }
const key = this.scanKey(nodeId, imageRef); const key = this.scanKey(nodeId, imageRef);
@@ -343,7 +391,7 @@ class TrivyService {
imageRef, imageRef,
]; ];
const execStart = Date.now(); const execStart = Date.now();
const { stdout } = await execFileAsync('trivy', args, { const { stdout } = await execFileAsync(binary, args, {
env, env,
timeout: SCAN_TIMEOUT_MS, timeout: SCAN_TIMEOUT_MS,
maxBuffer: 64 * 1024 * 1024, maxBuffer: 64 * 1024 * 1024,
@@ -497,7 +545,7 @@ class TrivyService {
); );
return stored; return stored;
} catch (error) { } catch (error) {
const msg = (error as Error).message || 'Scan failed'; const msg = getErrorMessage(error, 'Scan failed');
db.updateVulnerabilityScan(scanId, { db.updateVulnerabilityScan(scanId, {
status: 'failed', status: 'failed',
error: msg, error: msg,
@@ -523,7 +571,7 @@ class TrivyService {
nodeId: number, nodeId: number,
triggeredBy: VulnScanTrigger = 'scheduled', triggeredBy: VulnScanTrigger = 'scheduled',
): Promise<{ scanned: number; skipped: number; failed: number }> { ): Promise<{ scanned: number; skipped: number; failed: number }> {
if (!this.available) { if (this.source === 'none') {
throw new Error('Trivy is not available on this host'); throw new Error('Trivy is not available on this host');
} }
const images = await DockerController.getInstance(nodeId).getImages(); const images = await DockerController.getInstance(nodeId).getImages();
@@ -552,7 +600,7 @@ class TrivyService {
scanned++; scanned++;
} catch (err) { } catch (err) {
failed++; 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)); await new Promise((r) => setTimeout(r, 300));
} }
@@ -560,13 +608,14 @@ class TrivyService {
} }
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> { async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
if (!this.available) { const binary = this.binaryPath;
if (!binary) {
throw new Error('Trivy is not available on this host'); throw new Error('Trivy is not available on this host');
} }
const { env, cleanup } = await this.buildEnv(); const { env, cleanup } = await this.buildEnv();
try { try {
const { stdout } = await execFileAsync( const { stdout } = await execFileAsync(
'trivy', binary,
['image', '--format', format, '--quiet', '--no-progress', imageRef], ['image', '--format', format, '--quiet', '--no-progress', imageRef],
{ {
env, env,
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+62 -20
View File
@@ -3,17 +3,54 @@ title: Installing Trivy
description: Install and mount the Trivy CLI so Sencho can scan container images for vulnerabilities. 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 ## 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 - Pick the Trivy version you want and upgrade it on your own schedule.
- Persist the Trivy cache (the vulnerability DB) across Sencho container restarts - Persist the Trivy cache (the vulnerability DB) across Sencho container restarts.
- Pre-seed an air-gapped cache for environments without internet access - 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.
<Frame>
<img src="/images/vulnerability-scanning/trivy-settings-card.png" alt="Vulnerability Scanner card in Settings, Security section, with Install Trivy button" />
</Frame>
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) ### 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. 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. 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). 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: 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 ## 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 1030 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 ## 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 ## 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. If the scanner shows as not installed after using Option 2 or 3, see the troubleshooting section below.
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.
### Runtime detection ### 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 ### 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 ### Scans take a long time on first run
The first scan after a Trivy install downloads the vulnerability database. Expect 1030 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 ### Private registry images fail to scan
+1 -1
View File
@@ -465,7 +465,7 @@ export default function ResourcesView() {
const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false);
// Vulnerability scanning state // Vulnerability scanning state
const trivy = useTrivyStatus(); const { status: trivy } = useTrivyStatus();
const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({}); const [scanSummaries, setScanSummaries] = useState<Record<string, ScanSummary>>({});
const [scanningImageRef, setScanningImageRef] = useState<string | null>(null); const [scanningImageRef, setScanningImageRef] = useState<string | null>(null);
const [inspectScanId, setInspectScanId] = useState<number | null>(null); const [inspectScanId, setInspectScanId] = useState<number | null>(null);
@@ -28,8 +28,10 @@ import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { PaidGate } from '@/components/PaidGate'; import { PaidGate } from '@/components/PaidGate';
import { TierBadge } from '@/components/TierBadge'; 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 type { ScanPolicy, VulnSeverity } from '@/types/security';
import { useLicense } from '@/context/LicenseContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [ const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' }, { value: 'CRITICAL', label: 'Critical' },
@@ -54,6 +56,24 @@ const EMPTY_FORM: PolicyFormState = {
enabled: true, 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 }) { export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const [policies, setPolicies] = useState<ScanPolicy[]>([]); const [policies, setPolicies] = useState<ScanPolicy[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -63,6 +83,63 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const { license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(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 () => { const fetchPolicies = async () => {
try { try {
const res = await apiFetch('/security/policies', { localOnly: true }); const res = await apiFetch('/security/policies', { localOnly: true });
@@ -194,6 +271,81 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</Button> </Button>
</div> </div>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">Vulnerability Scanner</span>
<Badge variant={TRIVY_SOURCE_BADGES[trivy.source].variant} className="text-[10px] shrink-0">
{TRIVY_SOURCE_BADGES[trivy.source].label}
</Badge>
{updateCheck?.updateAvailable && (
<Badge variant="secondary" className="text-[10px] shrink-0">
Update available to v{updateCheck.latest}
</Badge>
)}
</div>
{isAdmiral && (
<div className="flex items-center gap-2 shrink-0">
{trivy.source === 'none' && (
<Button size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'install' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Install Trivy
</Button>
)}
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
{trivyBusy === 'update' ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
)}
Update
</Button>
)}
{trivy.source === 'managed' && (
<Button
size="sm"
variant="ghost"
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setUninstallConfirm(true)}
disabled={trivyBusy !== null}
>
Uninstall
</Button>
)}
</div>
)}
</div>
{trivy.source === 'managed' && trivy.version && (
<div className="text-xs text-stat-subtitle font-mono">Version: v{trivy.version}</div>
)}
{TRIVY_SOURCE_DESCRIPTIONS[trivy.source] && (
<div className="text-xs text-stat-subtitle">{TRIVY_SOURCE_DESCRIPTIONS[trivy.source]}</div>
)}
{trivy.source === 'managed' && isAdmiral && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Auto-update Trivy</Label>
<p className="text-xs text-muted-foreground">
Check daily and install newer Trivy releases automatically.
</p>
</div>
<Switch
checked={trivy.autoUpdate}
onCheckedChange={handleAutoUpdateToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
{loading && ( {loading && (
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" /> <Skeleton className="h-20 w-full rounded-lg" />
@@ -352,6 +504,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Trivy?</AlertDialogTitle>
<AlertDialogDescription>
This removes the managed Trivy binary. Vulnerability scanning will stop working until
Trivy is reinstalled or a host binary is provided.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstallTrivy}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
+63 -21
View File
@@ -1,28 +1,70 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import type { TrivyStatus } from '@/types/security'; import type { TrivyStatus, TrivyUpdateCheck } from '@/types/security';
export function useTrivyStatus(): TrivyStatus { const INITIAL_STATUS: TrivyStatus = {
const [status, setStatus] = useState<TrivyStatus>({ available: false, version: null }); available: false,
version: null,
source: 'none',
autoUpdate: false,
busy: false,
};
useEffect(() => { export interface UseTrivyStatusResult {
let cancelled = false; status: TrivyStatus;
apiFetch('/security/trivy-status') updateCheck: TrivyUpdateCheck | null;
.then((r) => (r.ok ? r.json() : null)) refresh: () => Promise<void>;
.then((d) => { refreshUpdateCheck: () => Promise<void>;
if (cancelled || !d) return; }
setStatus({
available: !!d.available, export function useTrivyStatus(): UseTrivyStatusResult {
version: typeof d.version === 'string' ? d.version : null, const [status, setStatus] = useState<TrivyStatus>(INITIAL_STATUS);
}); const [updateCheck, setUpdateCheck] = useState<TrivyUpdateCheck | null>(null);
})
.catch((err) => { const refresh = useCallback(async () => {
console.error('Failed to fetch Trivy status:', err); 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 () => { } catch (err) {
cancelled = true; 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 };
} }
+12
View File
@@ -2,9 +2,21 @@ export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy'; export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy';
export type TrivySource = 'managed' | 'host' | 'none';
export interface TrivyStatus { export interface TrivyStatus {
available: boolean; available: boolean;
version: string | null; version: string | null;
source: TrivySource;
autoUpdate: boolean;
busy: boolean;
}
export interface TrivyUpdateCheck {
current: string | null;
latest: string;
updateAvailable: boolean;
source: TrivySource;
} }
export interface VulnerabilityScan { export interface VulnerabilityScan {