mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
29ed0524c1
Enrich scheduled vulnerability scan completion notifications with per-severity CVE counts so recipients can triage from the message body alone. Expose the scan action in the schedule creation UI, require an explicit node_id, and harden fire-and-forget alert dispatches so a failing webhook cannot crash the scheduler. Notification body now reports scanned/skipped/failed counts plus critical/high/medium totals aggregated across fresh and cached scans, reflecting the current node posture rather than only what was newly scanned on this run.
970 lines
34 KiB
TypeScript
970 lines
34 KiB
TypeScript
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import DockerController from './DockerController';
|
|
import {
|
|
DatabaseService,
|
|
VulnSeverity,
|
|
VulnScanTrigger,
|
|
VulnerabilityScan,
|
|
} from './DatabaseService';
|
|
import { FileSystemService } from './FileSystemService';
|
|
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';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
|
|
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function diag(msg: string, ...args: unknown[]): void {
|
|
if (isDebugEnabled()) console.log(`[Trivy:diag] ${msg}`, ...args);
|
|
}
|
|
|
|
interface TrivyRawVulnerability {
|
|
VulnerabilityID?: string;
|
|
PkgName?: string;
|
|
InstalledVersion?: string;
|
|
FixedVersion?: string;
|
|
Severity?: string;
|
|
Title?: string;
|
|
Description?: string;
|
|
PrimaryURL?: string;
|
|
}
|
|
|
|
interface TrivyRawSecret {
|
|
RuleID?: string;
|
|
Category?: string;
|
|
Severity?: string;
|
|
Title?: string;
|
|
StartLine?: number;
|
|
EndLine?: number;
|
|
Match?: string;
|
|
}
|
|
|
|
interface TrivyRawMisconfig {
|
|
ID?: string;
|
|
AVDID?: string;
|
|
Type?: string;
|
|
Severity?: string;
|
|
Title?: string;
|
|
Description?: string;
|
|
Message?: string;
|
|
Resolution?: string;
|
|
PrimaryURL?: string;
|
|
}
|
|
|
|
interface TrivyRawResult {
|
|
Target?: string;
|
|
Vulnerabilities?: TrivyRawVulnerability[];
|
|
Secrets?: TrivyRawSecret[];
|
|
Misconfigurations?: TrivyRawMisconfig[];
|
|
}
|
|
|
|
interface TrivyRawOutput {
|
|
Metadata?: {
|
|
OS?: { Family?: string; Name?: string };
|
|
ImageID?: string;
|
|
RepoDigests?: string[];
|
|
};
|
|
Results?: TrivyRawResult[];
|
|
}
|
|
|
|
export interface ScanAllNodeImagesSeverityTotals {
|
|
critical: number;
|
|
high: number;
|
|
medium: number;
|
|
low: number;
|
|
unknown: number;
|
|
}
|
|
|
|
export interface ScanAllNodeImagesResult {
|
|
scanned: number;
|
|
skipped: number;
|
|
failed: number;
|
|
severity: ScanAllNodeImagesSeverityTotals;
|
|
}
|
|
|
|
export interface TrivyVulnerability {
|
|
vulnerabilityId: string;
|
|
pkgName: string;
|
|
installedVersion: string;
|
|
fixedVersion: string | null;
|
|
severity: VulnSeverity;
|
|
title: string;
|
|
description: string;
|
|
primaryUrl: string | null;
|
|
}
|
|
|
|
export interface TrivySecret {
|
|
ruleId: string;
|
|
category: string | null;
|
|
severity: VulnSeverity;
|
|
title: string | null;
|
|
target: string;
|
|
startLine: number | null;
|
|
endLine: number | null;
|
|
matchExcerpt: string | null;
|
|
}
|
|
|
|
export interface TrivyMisconfig {
|
|
ruleId: string;
|
|
checkId: string | null;
|
|
severity: VulnSeverity;
|
|
title: string | null;
|
|
message: string | null;
|
|
resolution: string | null;
|
|
target: string;
|
|
primaryUrl: string | null;
|
|
}
|
|
|
|
export type TrivyScanner = 'vuln' | 'secret';
|
|
|
|
export interface TrivyScanResult {
|
|
imageRef: string;
|
|
imageDigest: string | null;
|
|
scannedAt: number;
|
|
totalVulnerabilities: number;
|
|
criticalCount: number;
|
|
highCount: number;
|
|
mediumCount: number;
|
|
lowCount: number;
|
|
unknownCount: number;
|
|
fixableCount: number;
|
|
secretCount: number;
|
|
scannersUsed: string;
|
|
highestSeverity: VulnSeverity | null;
|
|
vulnerabilities: TrivyVulnerability[];
|
|
secrets: TrivySecret[];
|
|
metadata: {
|
|
os: string | null;
|
|
trivyVersion: string | null;
|
|
scanDurationMs: number;
|
|
};
|
|
}
|
|
|
|
export interface TrivyComposeScanResult {
|
|
stackName: string;
|
|
scannedAt: number;
|
|
highestSeverity: VulnSeverity | null;
|
|
criticalCount: number;
|
|
highCount: number;
|
|
mediumCount: number;
|
|
lowCount: number;
|
|
unknownCount: number;
|
|
misconfigCount: number;
|
|
misconfigs: TrivyMisconfig[];
|
|
metadata: {
|
|
trivyVersion: string | null;
|
|
scanDurationMs: number;
|
|
};
|
|
}
|
|
|
|
export type SbomFormat = 'spdx-json' | 'cyclonedx';
|
|
|
|
// Keep scanners in canonical order so the DB value is comparable as-is.
|
|
export function normalizeScanners(input?: readonly TrivyScanner[]): TrivyScanner[] {
|
|
const set = new Set<TrivyScanner>(input && input.length > 0 ? input : ['vuln']);
|
|
const out: TrivyScanner[] = [];
|
|
for (const s of ['vuln', 'secret'] as const) if (set.has(s)) out.push(s);
|
|
return out;
|
|
}
|
|
|
|
export function redactSecretMatch(match: string | undefined | null): string | null {
|
|
if (!match) return null;
|
|
const trimmed = match.trim();
|
|
if (!trimmed) return null;
|
|
const head = trimmed.slice(0, 8);
|
|
return trimmed.length > 8 ? `${head}...` : head;
|
|
}
|
|
|
|
function normalizeSeverity(raw: string | undefined): VulnSeverity {
|
|
const s = (raw ?? '').toUpperCase();
|
|
if (s === 'CRITICAL' || s === 'HIGH' || s === 'MEDIUM' || s === 'LOW') return s;
|
|
return 'UNKNOWN';
|
|
}
|
|
|
|
function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | null {
|
|
if (vulns.length === 0) return null;
|
|
let highestIdx = -1;
|
|
for (const v of vulns) {
|
|
const idx = SEVERITY_ORDER.indexOf(v.severity);
|
|
if (idx > highestIdx) highestIdx = idx;
|
|
}
|
|
return highestIdx >= 0 ? SEVERITY_ORDER[highestIdx] : null;
|
|
}
|
|
|
|
export function parseTrivyOutput(raw: string): {
|
|
vulnerabilities: TrivyVulnerability[];
|
|
secrets: TrivySecret[];
|
|
misconfigs: TrivyMisconfig[];
|
|
os: string | null;
|
|
} {
|
|
let parsed: TrivyRawOutput;
|
|
try {
|
|
parsed = JSON.parse(raw) as TrivyRawOutput;
|
|
} catch (e) {
|
|
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
|
|
throw new Error('Malformed Trivy output: ' + (e as Error).message);
|
|
}
|
|
const vulnSeen = new Set<string>();
|
|
const vulnerabilities: TrivyVulnerability[] = [];
|
|
const secrets: TrivySecret[] = [];
|
|
const misconfigs: TrivyMisconfig[] = [];
|
|
for (const result of parsed.Results ?? []) {
|
|
const target = result.Target ?? '';
|
|
for (const v of result.Vulnerabilities ?? []) {
|
|
const id = v.VulnerabilityID ?? '';
|
|
const pkg = v.PkgName ?? '';
|
|
if (!id || !pkg) continue;
|
|
const key = `${id}::${pkg}`;
|
|
if (vulnSeen.has(key)) continue;
|
|
vulnSeen.add(key);
|
|
vulnerabilities.push({
|
|
vulnerabilityId: id,
|
|
pkgName: pkg,
|
|
installedVersion: v.InstalledVersion ?? '',
|
|
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
|
|
severity: normalizeSeverity(v.Severity),
|
|
title: v.Title ?? '',
|
|
description: v.Description ?? '',
|
|
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
|
});
|
|
}
|
|
for (const s of result.Secrets ?? []) {
|
|
const ruleId = s.RuleID ?? '';
|
|
if (!ruleId) continue;
|
|
secrets.push({
|
|
ruleId,
|
|
category: s.Category ?? null,
|
|
severity: normalizeSeverity(s.Severity),
|
|
title: s.Title ?? null,
|
|
target,
|
|
startLine: typeof s.StartLine === 'number' ? s.StartLine : null,
|
|
endLine: typeof s.EndLine === 'number' ? s.EndLine : null,
|
|
matchExcerpt: redactSecretMatch(s.Match),
|
|
});
|
|
}
|
|
for (const m of result.Misconfigurations ?? []) {
|
|
const ruleId = m.ID ?? m.AVDID ?? '';
|
|
if (!ruleId) continue;
|
|
misconfigs.push({
|
|
ruleId,
|
|
checkId: m.AVDID ?? null,
|
|
severity: normalizeSeverity(m.Severity),
|
|
title: m.Title ?? null,
|
|
message: m.Message ?? m.Description ?? null,
|
|
resolution: m.Resolution ?? null,
|
|
target,
|
|
primaryUrl: m.PrimaryURL ? m.PrimaryURL : null,
|
|
});
|
|
}
|
|
}
|
|
const osFamily = parsed.Metadata?.OS?.Family;
|
|
const osName = parsed.Metadata?.OS?.Name;
|
|
const osInfo = osFamily
|
|
? osName
|
|
? `${osFamily} ${osName}`
|
|
: osFamily
|
|
: null;
|
|
return { vulnerabilities, secrets, misconfigs, os: osInfo };
|
|
}
|
|
|
|
class TrivyService {
|
|
private static instance: TrivyService;
|
|
private version: string | null = null;
|
|
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) {
|
|
TrivyService.instance = new TrivyService();
|
|
}
|
|
return TrivyService.instance;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
await this.detectTrivy();
|
|
if (this.source === 'none') {
|
|
console.log('[Trivy] Binary not found; vulnerability scanning disabled');
|
|
} else {
|
|
console.log(`[Trivy] Available (version ${this.version}, source ${this.source})`);
|
|
}
|
|
}
|
|
|
|
async detectTrivy(): Promise<{ available: boolean; version: string | null; source: TrivySource }> {
|
|
const started = Date.now();
|
|
const wasAvailable = this.source !== 'none';
|
|
const candidates: Array<{ path: string; source: TrivySource }> = [];
|
|
const managedPath = TrivyInstaller.getInstance().binaryPath();
|
|
try {
|
|
fs.accessSync(managedPath, fs.constants.X_OK);
|
|
candidates.push({ path: managedPath, source: 'managed' });
|
|
} catch {
|
|
/* 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=${isAvailable} source=${this.source} version=${this.version ?? 'null'} tookMs=${
|
|
this.detectionTimestamp - started
|
|
}`,
|
|
);
|
|
if (isAvailable && !wasAvailable) {
|
|
enableCapability('vulnerability-scanning');
|
|
console.log(
|
|
`[Trivy] Binary detected (source=${this.source}); vulnerability scanning enabled (version ${this.version})`,
|
|
);
|
|
} else if (!isAvailable && wasAvailable) {
|
|
disableCapability('vulnerability-scanning');
|
|
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
|
|
}
|
|
return { available: isAvailable, version: this.version, source: this.source };
|
|
}
|
|
|
|
getDetectionTimestamp(): number {
|
|
return this.detectionTimestamp;
|
|
}
|
|
|
|
isTrivyAvailable(): boolean {
|
|
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',
|
|
};
|
|
if (registries.length === 0) {
|
|
return { env: baseEnv, cleanup: () => undefined };
|
|
}
|
|
const { config, warnings } = await RegistryService.getInstance().resolveDockerConfig();
|
|
if (sendWarning) {
|
|
for (const w of warnings) sendWarning(w);
|
|
}
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-trivy-'));
|
|
const configPath = path.join(tmpDir, 'config.json');
|
|
fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 });
|
|
const cleanup = () => {
|
|
try {
|
|
fs.unlinkSync(configPath);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
try {
|
|
fs.rmdirSync(tmpDir);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
};
|
|
return { env: { ...baseEnv, DOCKER_CONFIG: tmpDir }, cleanup };
|
|
}
|
|
|
|
async getImageDigest(imageRef: string, nodeId: number): Promise<string | null> {
|
|
try {
|
|
const docker = DockerController.getInstance(nodeId).getDocker();
|
|
const info = (await docker.getImage(imageRef).inspect()) as {
|
|
RepoDigests?: string[];
|
|
Id?: string;
|
|
};
|
|
if (info.RepoDigests && info.RepoDigests.length > 0) {
|
|
const digest = info.RepoDigests[0].split('@')[1];
|
|
if (digest) return digest;
|
|
}
|
|
return info.Id ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private scanKey(nodeId: number, imageRef: string): string {
|
|
return `${nodeId}:${imageRef}`;
|
|
}
|
|
|
|
isScanning(nodeId: number, imageRef: string): boolean {
|
|
return this.scanningImages.has(this.scanKey(nodeId, imageRef));
|
|
}
|
|
|
|
async scanImage(
|
|
imageRef: string,
|
|
nodeId: number,
|
|
options: {
|
|
useCache?: boolean;
|
|
digest?: string | null;
|
|
scanners?: readonly TrivyScanner[];
|
|
} = {},
|
|
): Promise<TrivyScanResult> {
|
|
const binary = this.binaryPath;
|
|
if (!binary) {
|
|
throw new Error('Trivy is not available on this host');
|
|
}
|
|
const scanners = normalizeScanners(options.scanners);
|
|
const scannersUsed = scanners.join(',');
|
|
const key = this.scanKey(nodeId, imageRef);
|
|
if (this.scanningImages.has(key)) {
|
|
throw new Error('Already scanning this image');
|
|
}
|
|
this.scanningImages.add(key);
|
|
const startedAt = Date.now();
|
|
diag(
|
|
`scanImage: start nodeId=${nodeId} imageRef=${imageRef} scanners=${scannersUsed} useCache=${options.useCache !== false}`,
|
|
);
|
|
|
|
try {
|
|
const digest = options.digest ?? (await this.getImageDigest(imageRef, nodeId));
|
|
diag(`scanImage: digest=${digest ?? 'null'} for ${imageRef}`);
|
|
|
|
if (options.useCache !== false && digest) {
|
|
const cached = DatabaseService.getInstance().getLatestScanByDigest(
|
|
digest,
|
|
scannersUsed,
|
|
);
|
|
if (cached && startedAt - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
|
diag(
|
|
`scanImage: cache hit for digest=${digest} scanId=${cached.id} ageMs=${startedAt - cached.scanned_at}`,
|
|
);
|
|
const db = DatabaseService.getInstance();
|
|
const details = db.getVulnerabilityDetails(cached.id, { limit: 1000 }).items;
|
|
const cachedSecrets = scanners.includes('secret')
|
|
? db.getSecretFindings(cached.id, { limit: 1000 }).items
|
|
: [];
|
|
return {
|
|
imageRef,
|
|
imageDigest: digest,
|
|
scannedAt: cached.scanned_at,
|
|
totalVulnerabilities: cached.total_vulnerabilities,
|
|
criticalCount: cached.critical_count,
|
|
highCount: cached.high_count,
|
|
mediumCount: cached.medium_count,
|
|
lowCount: cached.low_count,
|
|
unknownCount: cached.unknown_count,
|
|
fixableCount: cached.fixable_count,
|
|
secretCount: cached.secret_count,
|
|
scannersUsed: cached.scanners_used,
|
|
highestSeverity: cached.highest_severity,
|
|
vulnerabilities: details.map((d) => ({
|
|
vulnerabilityId: d.vulnerability_id,
|
|
pkgName: d.pkg_name,
|
|
installedVersion: d.installed_version,
|
|
fixedVersion: d.fixed_version,
|
|
severity: d.severity,
|
|
title: d.title ?? '',
|
|
description: d.description ?? '',
|
|
primaryUrl: d.primary_url,
|
|
})),
|
|
secrets: cachedSecrets.map((s) => ({
|
|
ruleId: s.rule_id,
|
|
category: s.category,
|
|
severity: s.severity,
|
|
title: s.title,
|
|
target: s.target,
|
|
startLine: s.start_line,
|
|
endLine: s.end_line,
|
|
matchExcerpt: s.match_excerpt,
|
|
})),
|
|
metadata: {
|
|
os: cached.os_info,
|
|
trivyVersion: cached.trivy_version,
|
|
scanDurationMs: cached.scan_duration_ms ?? 0,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
diag(`scanImage: cache miss; invoking trivy for ${imageRef}`);
|
|
const { env, cleanup } = await this.buildEnv();
|
|
try {
|
|
const args = [
|
|
'image',
|
|
'--format',
|
|
'json',
|
|
'--quiet',
|
|
'--no-progress',
|
|
'--scanners',
|
|
scannersUsed,
|
|
imageRef,
|
|
];
|
|
const execStart = Date.now();
|
|
const { stdout } = await execFileAsync(binary, args, {
|
|
env,
|
|
timeout: SCAN_TIMEOUT_MS,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
diag(
|
|
`scanImage: trivy exited after ${Date.now() - execStart}ms, output=${stdout.length} bytes`,
|
|
);
|
|
const { vulnerabilities, secrets, os: osInfo } = parseTrivyOutput(stdout);
|
|
diag(
|
|
`scanImage: parsed ${vulnerabilities.length} unique vulns, ${secrets.length} secrets (os=${osInfo ?? 'unknown'})`,
|
|
);
|
|
|
|
let critical = 0,
|
|
high = 0,
|
|
medium = 0,
|
|
low = 0,
|
|
unknown = 0,
|
|
fixable = 0;
|
|
for (const v of vulnerabilities) {
|
|
switch (v.severity) {
|
|
case 'CRITICAL':
|
|
critical++;
|
|
break;
|
|
case 'HIGH':
|
|
high++;
|
|
break;
|
|
case 'MEDIUM':
|
|
medium++;
|
|
break;
|
|
case 'LOW':
|
|
low++;
|
|
break;
|
|
default:
|
|
unknown++;
|
|
}
|
|
if (v.fixedVersion) fixable++;
|
|
}
|
|
|
|
return {
|
|
imageRef,
|
|
imageDigest: digest,
|
|
scannedAt: Date.now(),
|
|
totalVulnerabilities: vulnerabilities.length,
|
|
criticalCount: critical,
|
|
highCount: high,
|
|
mediumCount: medium,
|
|
lowCount: low,
|
|
unknownCount: unknown,
|
|
fixableCount: fixable,
|
|
secretCount: secrets.length,
|
|
scannersUsed,
|
|
highestSeverity: computeHighestSeverity(vulnerabilities),
|
|
vulnerabilities,
|
|
secrets,
|
|
metadata: {
|
|
os: osInfo,
|
|
trivyVersion: this.version,
|
|
scanDurationMs: Date.now() - startedAt,
|
|
},
|
|
};
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
} finally {
|
|
this.scanningImages.delete(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an `in_progress` scan row. The returned ID is immediately
|
|
* usable by clients that need a handle to poll; callers must pair
|
|
* this with `finishScan` to move the row to `completed` or `failed`.
|
|
*/
|
|
beginScan(
|
|
imageRef: string,
|
|
nodeId: number,
|
|
triggeredBy: VulnScanTrigger,
|
|
stackContext: string | null = null,
|
|
scanners: readonly TrivyScanner[] = ['vuln'],
|
|
): number {
|
|
const db = DatabaseService.getInstance();
|
|
const scannersUsed = normalizeScanners(scanners).join(',');
|
|
const scanId = db.createVulnerabilityScan({
|
|
node_id: nodeId,
|
|
image_ref: imageRef,
|
|
image_digest: null,
|
|
scanned_at: Date.now(),
|
|
total_vulnerabilities: 0,
|
|
critical_count: 0,
|
|
high_count: 0,
|
|
medium_count: 0,
|
|
low_count: 0,
|
|
unknown_count: 0,
|
|
fixable_count: 0,
|
|
secret_count: 0,
|
|
misconfig_count: 0,
|
|
scanners_used: scannersUsed,
|
|
highest_severity: null,
|
|
os_info: null,
|
|
trivy_version: this.version,
|
|
scan_duration_ms: null,
|
|
triggered_by: triggeredBy,
|
|
status: 'in_progress',
|
|
error: null,
|
|
stack_context: stackContext,
|
|
});
|
|
diag(
|
|
`beginScan: scanId=${scanId} imageRef=${imageRef} nodeId=${nodeId} trigger=${triggeredBy} scanners=${scannersUsed}`,
|
|
);
|
|
return scanId;
|
|
}
|
|
|
|
/**
|
|
* Execute the scan and persist results into a scan row already
|
|
* created by `beginScan`. Always flips the row to `completed` on
|
|
* success or `failed` on error.
|
|
*/
|
|
async finishScan(
|
|
scanId: number,
|
|
imageRef: string,
|
|
nodeId: number,
|
|
opts: { useCache?: boolean; scanners?: readonly TrivyScanner[] } = {},
|
|
): Promise<VulnerabilityScan> {
|
|
const db = DatabaseService.getInstance();
|
|
const startedAt = Date.now();
|
|
try {
|
|
const result = await this.scanImage(imageRef, nodeId, {
|
|
useCache: opts.useCache,
|
|
scanners: opts.scanners,
|
|
});
|
|
db.updateVulnerabilityScan(scanId, {
|
|
image_digest: result.imageDigest,
|
|
scanned_at: result.scannedAt,
|
|
total_vulnerabilities: result.totalVulnerabilities,
|
|
critical_count: result.criticalCount,
|
|
high_count: result.highCount,
|
|
medium_count: result.mediumCount,
|
|
low_count: result.lowCount,
|
|
unknown_count: result.unknownCount,
|
|
fixable_count: result.fixableCount,
|
|
secret_count: result.secretCount,
|
|
scanners_used: result.scannersUsed,
|
|
highest_severity: result.highestSeverity,
|
|
os_info: result.metadata.os,
|
|
trivy_version: result.metadata.trivyVersion,
|
|
scan_duration_ms: result.metadata.scanDurationMs,
|
|
status: 'completed',
|
|
});
|
|
db.insertVulnerabilityDetails(
|
|
scanId,
|
|
result.vulnerabilities.map((v) => ({
|
|
vulnerability_id: v.vulnerabilityId,
|
|
pkg_name: v.pkgName,
|
|
installed_version: v.installedVersion,
|
|
fixed_version: v.fixedVersion,
|
|
severity: v.severity,
|
|
title: v.title || null,
|
|
description: v.description || null,
|
|
primary_url: v.primaryUrl,
|
|
})),
|
|
);
|
|
db.insertSecretFindings(
|
|
scanId,
|
|
result.secrets.map((s) => ({
|
|
rule_id: s.ruleId,
|
|
category: s.category,
|
|
severity: s.severity,
|
|
title: s.title,
|
|
target: s.target,
|
|
start_line: s.startLine,
|
|
end_line: s.endLine,
|
|
match_excerpt: s.matchExcerpt,
|
|
})),
|
|
);
|
|
const stored = db.getVulnerabilityScan(scanId);
|
|
if (!stored) throw new Error('Scan vanished after write');
|
|
diag(
|
|
`finishScan: scanId=${scanId} completed vulns=${result.totalVulnerabilities} secrets=${result.secretCount} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
|
|
);
|
|
return stored;
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'Scan failed');
|
|
db.updateVulnerabilityScan(scanId, {
|
|
status: 'failed',
|
|
error: msg,
|
|
scan_duration_ms: Date.now() - startedAt,
|
|
});
|
|
diag(`finishScan: scanId=${scanId} failed: ${msg}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async runScanAndPersist(
|
|
imageRef: string,
|
|
nodeId: number,
|
|
triggeredBy: VulnScanTrigger,
|
|
stackContext: string | null = null,
|
|
opts: { useCache?: boolean; scanners?: readonly TrivyScanner[] } = {},
|
|
): Promise<VulnerabilityScan> {
|
|
const scanId = this.beginScan(imageRef, nodeId, triggeredBy, stackContext, opts.scanners);
|
|
return this.finishScan(scanId, imageRef, nodeId, opts);
|
|
}
|
|
|
|
/**
|
|
* Scan a compose stack directory for misconfigurations. A new scan
|
|
* row is persisted with image_ref='stack:<name>' so misconfigs share
|
|
* the same history surface as image scans.
|
|
*/
|
|
async scanComposeStack(
|
|
nodeId: number,
|
|
stackName: string,
|
|
triggeredBy: VulnScanTrigger = 'manual',
|
|
): Promise<VulnerabilityScan> {
|
|
const binary = this.binaryPath;
|
|
if (!binary) {
|
|
throw new Error('Trivy is not available on this host');
|
|
}
|
|
const fsvc = FileSystemService.getInstance(nodeId);
|
|
const baseDir = fsvc.getBaseDir();
|
|
const resolvedBase = path.resolve(baseDir);
|
|
const resolved = path.resolve(baseDir, stackName);
|
|
if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
|
|
throw new Error('Invalid stack path');
|
|
}
|
|
if (!(await fsvc.hasComposeFile(resolved))) {
|
|
throw new Error(`No compose file found for stack: ${stackName}`);
|
|
}
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const scanId = db.createVulnerabilityScan({
|
|
node_id: nodeId,
|
|
image_ref: `stack:${stackName}`,
|
|
image_digest: null,
|
|
scanned_at: Date.now(),
|
|
total_vulnerabilities: 0,
|
|
critical_count: 0,
|
|
high_count: 0,
|
|
medium_count: 0,
|
|
low_count: 0,
|
|
unknown_count: 0,
|
|
fixable_count: 0,
|
|
secret_count: 0,
|
|
misconfig_count: 0,
|
|
scanners_used: 'config',
|
|
highest_severity: null,
|
|
os_info: null,
|
|
trivy_version: this.version,
|
|
scan_duration_ms: null,
|
|
triggered_by: triggeredBy,
|
|
status: 'in_progress',
|
|
error: null,
|
|
stack_context: stackName,
|
|
});
|
|
const startedAt = Date.now();
|
|
try {
|
|
const { env, cleanup } = await this.buildEnv();
|
|
try {
|
|
const args = ['config', '--format', 'json', '--quiet', '--no-progress', resolved];
|
|
const { stdout } = await execFileAsync(binary, args, {
|
|
env,
|
|
timeout: SCAN_TIMEOUT_MS,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
const { misconfigs } = parseTrivyOutput(stdout);
|
|
let critical = 0,
|
|
high = 0,
|
|
medium = 0,
|
|
low = 0,
|
|
unknown = 0;
|
|
for (const m of misconfigs) {
|
|
switch (m.severity) {
|
|
case 'CRITICAL':
|
|
critical++;
|
|
break;
|
|
case 'HIGH':
|
|
high++;
|
|
break;
|
|
case 'MEDIUM':
|
|
medium++;
|
|
break;
|
|
case 'LOW':
|
|
low++;
|
|
break;
|
|
default:
|
|
unknown++;
|
|
}
|
|
}
|
|
const highestSeverity: VulnSeverity | null =
|
|
critical > 0 ? 'CRITICAL'
|
|
: high > 0 ? 'HIGH'
|
|
: medium > 0 ? 'MEDIUM'
|
|
: low > 0 ? 'LOW'
|
|
: unknown > 0 ? 'UNKNOWN'
|
|
: null;
|
|
db.updateVulnerabilityScan(scanId, {
|
|
scanned_at: Date.now(),
|
|
critical_count: critical,
|
|
high_count: high,
|
|
medium_count: medium,
|
|
low_count: low,
|
|
unknown_count: unknown,
|
|
misconfig_count: misconfigs.length,
|
|
highest_severity: highestSeverity,
|
|
trivy_version: this.version,
|
|
scan_duration_ms: Date.now() - startedAt,
|
|
status: 'completed',
|
|
});
|
|
db.insertMisconfigFindings(
|
|
scanId,
|
|
misconfigs.map((m) => ({
|
|
rule_id: m.ruleId,
|
|
check_id: m.checkId,
|
|
severity: m.severity,
|
|
title: m.title,
|
|
message: m.message,
|
|
resolution: m.resolution,
|
|
target: m.target,
|
|
primary_url: m.primaryUrl,
|
|
})),
|
|
);
|
|
const stored = db.getVulnerabilityScan(scanId);
|
|
if (!stored) throw new Error('Scan vanished after write');
|
|
return stored;
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'Stack scan failed');
|
|
db.updateVulnerabilityScan(scanId, {
|
|
status: 'failed',
|
|
error: msg,
|
|
scan_duration_ms: Date.now() - startedAt,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async scanAllNodeImages(
|
|
nodeId: number,
|
|
triggeredBy: VulnScanTrigger = 'scheduled',
|
|
): Promise<ScanAllNodeImagesResult> {
|
|
if (this.source === 'none') {
|
|
throw new Error('Trivy is not available on this host');
|
|
}
|
|
const images = await DockerController.getInstance(nodeId).getImages();
|
|
const imageRefs = new Set<string>();
|
|
for (const img of images as Array<{ RepoTags?: string[] }>) {
|
|
for (const tag of img.RepoTags ?? []) {
|
|
if (tag && tag !== '<none>:<none>') imageRefs.add(tag);
|
|
}
|
|
}
|
|
|
|
let scanned = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
const countedDigests = new Set<string>();
|
|
|
|
const addSeverity = (row: VulnerabilityScan | null): void => {
|
|
if (!row) return;
|
|
severity.critical += row.critical_count;
|
|
severity.high += row.high_count;
|
|
severity.medium += row.medium_count;
|
|
severity.low += row.low_count;
|
|
severity.unknown += row.unknown_count;
|
|
};
|
|
|
|
for (const ref of imageRefs) {
|
|
try {
|
|
const digest = await this.getImageDigest(ref, nodeId);
|
|
if (digest) {
|
|
if (countedDigests.has(digest)) continue;
|
|
const cached =
|
|
DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln');
|
|
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
|
skipped++;
|
|
addSeverity(cached);
|
|
countedDigests.add(digest);
|
|
continue;
|
|
}
|
|
}
|
|
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
|
|
addSeverity(fresh);
|
|
scanned++;
|
|
if (digest) countedDigests.add(digest);
|
|
} catch (err) {
|
|
failed++;
|
|
console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error'));
|
|
}
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
}
|
|
return { scanned, skipped, failed, severity };
|
|
}
|
|
|
|
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
|
|
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(
|
|
binary,
|
|
['image', '--format', format, '--quiet', '--no-progress', imageRef],
|
|
{
|
|
env,
|
|
timeout: SBOM_TIMEOUT_MS,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
},
|
|
);
|
|
return stdout;
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}
|
|
}
|
|
|
|
export default TrivyService;
|