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
+2
View File
@@ -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;
+61
View File
@@ -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();
+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';
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,