mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
+106
-1
@@ -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<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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
private trivyUpdateIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private trivyUpdateStartupTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isProcessing = false;
|
||||
private isCheckingTrivyUpdate = false;
|
||||
private runningTasks = new Set<number>();
|
||||
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<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 {
|
||||
try {
|
||||
const count = DatabaseService.getInstance().markStaleRunsAsFailed();
|
||||
|
||||
@@ -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;
|
||||
@@ -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<string> = 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<void> {
|
||||
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<string, string | undefined>; cleanup: () => void }> {
|
||||
const registries = DatabaseService.getInstance().getRegistries();
|
||||
const cacheDir = this.ensureCacheDir();
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
...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<TrivyScanResult> {
|
||||
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<string> {
|
||||
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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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.
|
||||
|
||||
<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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<Record<string, ScanSummary>>({});
|
||||
const [scanningImageRef, setScanningImageRef] = useState<string | 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 { 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<ScanPolicy[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -63,6 +83,63 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
const res = await apiFetch('/security/policies', { localOnly: true });
|
||||
@@ -194,6 +271,81 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</Button>
|
||||
</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 && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
@@ -352,6 +504,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<TrivyStatus>({ 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<void>;
|
||||
refreshUpdateCheck: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTrivyStatus(): UseTrivyStatusResult {
|
||||
const [status, setStatus] = useState<TrivyStatus>(INITIAL_STATUS);
|
||||
const [updateCheck, setUpdateCheck] = useState<TrivyUpdateCheck | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user