mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
feat(security): secret and misconfiguration scanning (#651)
Extends Trivy scans with secret detection in image filesystems and misconfiguration scanning for Compose stacks. Adds tabs to the scan drawer for vulnerabilities, secrets, and misconfigs. Secret matches are redacted server-side (first 8 chars + ellipsis) before storage. - TrivyService: --scanners vuln,secret for images; trivy config for stacks - DB: scanners_used/secret_count/misconfig_count cols; secret_findings, misconfig_findings tables; cache key scoped by scanners - Routes: POST /security/scan accepts scanners array (requirePaid when secret requested); POST /security/scan/stack; GET .../secrets and .../misconfigs (paid-tier reads) - UI: tabs in VulnerabilityScanSheet; scan-options dropdown on images; Scan config button on stack header
This commit is contained in:
@@ -281,6 +281,9 @@ export interface VulnerabilityScan {
|
||||
low_count: number;
|
||||
unknown_count: number;
|
||||
fixable_count: number;
|
||||
secret_count: number;
|
||||
misconfig_count: number;
|
||||
scanners_used: string;
|
||||
highest_severity: VulnSeverity | null;
|
||||
os_info: string | null;
|
||||
trivy_version: string | null;
|
||||
@@ -304,6 +307,32 @@ export interface VulnerabilityDetail {
|
||||
primary_url: string | null;
|
||||
}
|
||||
|
||||
export interface SecretFinding {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
rule_id: string;
|
||||
category: string | null;
|
||||
severity: VulnSeverity;
|
||||
title: string | null;
|
||||
target: string;
|
||||
start_line: number | null;
|
||||
end_line: number | null;
|
||||
match_excerpt: string | null;
|
||||
}
|
||||
|
||||
export interface MisconfigFinding {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
rule_id: string;
|
||||
check_id: string | null;
|
||||
severity: VulnSeverity;
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
resolution: string | null;
|
||||
target: string;
|
||||
primary_url: string | null;
|
||||
}
|
||||
|
||||
export interface ScanPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -375,6 +404,7 @@ export class DatabaseService {
|
||||
this.migrateRoleAssignments();
|
||||
this.migrateNotificationRoutes();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
this.migrateSecretMisconfigColumns();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -592,6 +622,9 @@ export class DatabaseService {
|
||||
low_count INTEGER NOT NULL DEFAULT 0,
|
||||
unknown_count INTEGER NOT NULL DEFAULT 0,
|
||||
fixable_count INTEGER NOT NULL DEFAULT 0,
|
||||
secret_count INTEGER NOT NULL DEFAULT 0,
|
||||
misconfig_count INTEGER NOT NULL DEFAULT 0,
|
||||
scanners_used TEXT NOT NULL DEFAULT 'vuln',
|
||||
highest_severity TEXT,
|
||||
os_info TEXT,
|
||||
trivy_version TEXT,
|
||||
@@ -624,6 +657,36 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_details_scan ON vulnerability_details(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_details_severity ON vulnerability_details(severity);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret_findings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
category TEXT,
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT,
|
||||
target TEXT NOT NULL,
|
||||
start_line INTEGER,
|
||||
end_line INTEGER,
|
||||
match_excerpt TEXT,
|
||||
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_secret_findings_scan ON secret_findings(scan_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS misconfig_findings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER NOT NULL,
|
||||
rule_id TEXT NOT NULL,
|
||||
check_id TEXT,
|
||||
severity TEXT NOT NULL,
|
||||
title TEXT,
|
||||
message TEXT,
|
||||
resolution TEXT,
|
||||
target TEXT NOT NULL,
|
||||
primary_url TEXT,
|
||||
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_misconfig_findings_scan ON misconfig_findings(scan_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_policies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -939,6 +1002,19 @@ export class DatabaseService {
|
||||
tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
private migrateSecretMisconfigColumns(): void {
|
||||
const tryAddColumn = (table: string, col: string, def: string) => {
|
||||
try {
|
||||
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
|
||||
} catch {
|
||||
/* column already present */
|
||||
}
|
||||
};
|
||||
tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||
tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||
tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(): Agent[] {
|
||||
@@ -2097,10 +2173,11 @@ export class DatabaseService {
|
||||
`INSERT INTO vulnerability_scans (
|
||||
node_id, image_ref, image_digest, scanned_at,
|
||||
total_vulnerabilities, critical_count, high_count, medium_count,
|
||||
low_count, unknown_count, fixable_count, highest_severity,
|
||||
os_info, trivy_version, scan_duration_ms, triggered_by, status,
|
||||
error, stack_context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
low_count, unknown_count, fixable_count,
|
||||
secret_count, misconfig_count, scanners_used,
|
||||
highest_severity, os_info, trivy_version, scan_duration_ms,
|
||||
triggered_by, status, error, stack_context
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const result = stmt.run(
|
||||
scan.node_id,
|
||||
@@ -2114,6 +2191,9 @@ export class DatabaseService {
|
||||
scan.low_count,
|
||||
scan.unknown_count,
|
||||
scan.fixable_count,
|
||||
scan.secret_count,
|
||||
scan.misconfig_count,
|
||||
scan.scanners_used,
|
||||
scan.highest_severity,
|
||||
scan.os_info,
|
||||
scan.trivy_version,
|
||||
@@ -2134,6 +2214,7 @@ export class DatabaseService {
|
||||
'node_id', 'image_ref', 'image_digest', 'scanned_at',
|
||||
'total_vulnerabilities', 'critical_count', 'high_count',
|
||||
'medium_count', 'low_count', 'unknown_count', 'fixable_count',
|
||||
'secret_count', 'misconfig_count', 'scanners_used',
|
||||
'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
|
||||
'triggered_by', 'status', 'error', 'stack_context',
|
||||
]);
|
||||
@@ -2198,8 +2279,17 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
public getLatestScanByDigest(digest: string): VulnerabilityScan | null {
|
||||
public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
|
||||
if (!digest) return null;
|
||||
if (scannersUsed) {
|
||||
return (
|
||||
(this.db
|
||||
.prepare(
|
||||
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND scanners_used = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
|
||||
)
|
||||
.get(digest, scannersUsed) as VulnerabilityScan | undefined) ?? null
|
||||
);
|
||||
}
|
||||
return (
|
||||
(this.db
|
||||
.prepare(
|
||||
@@ -2301,6 +2391,126 @@ export class DatabaseService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
public insertSecretFindings(
|
||||
scanId: number,
|
||||
findings: Array<Omit<SecretFinding, 'id' | 'scan_id'>>,
|
||||
): void {
|
||||
if (findings.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO secret_findings (
|
||||
scan_id, rule_id, category, severity, title, target, start_line, end_line, match_excerpt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof findings) => {
|
||||
for (const f of rows) {
|
||||
stmt.run(
|
||||
scanId,
|
||||
f.rule_id,
|
||||
f.category,
|
||||
f.severity,
|
||||
f.title,
|
||||
f.target,
|
||||
f.start_line,
|
||||
f.end_line,
|
||||
f.match_excerpt,
|
||||
);
|
||||
}
|
||||
});
|
||||
txn(findings);
|
||||
}
|
||||
|
||||
public getSecretFindings(
|
||||
scanId: number,
|
||||
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
|
||||
): { items: SecretFinding[]; total: number } {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['scan_id = ?'];
|
||||
const params: unknown[] = [scanId];
|
||||
if (opts.severity) {
|
||||
where.push('severity = ?');
|
||||
params.push(opts.severity);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM secret_findings WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
).cnt;
|
||||
const severityOrder = `CASE severity
|
||||
WHEN 'CRITICAL' THEN 0
|
||||
WHEN 'HIGH' THEN 1
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 3
|
||||
ELSE 4 END`;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM secret_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as SecretFinding[];
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
public insertMisconfigFindings(
|
||||
scanId: number,
|
||||
findings: Array<Omit<MisconfigFinding, 'id' | 'scan_id'>>,
|
||||
): void {
|
||||
if (findings.length === 0) return;
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO misconfig_findings (
|
||||
scan_id, rule_id, check_id, severity, title, message, resolution, target, primary_url
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
const txn = this.db.transaction((rows: typeof findings) => {
|
||||
for (const f of rows) {
|
||||
stmt.run(
|
||||
scanId,
|
||||
f.rule_id,
|
||||
f.check_id,
|
||||
f.severity,
|
||||
f.title,
|
||||
f.message,
|
||||
f.resolution,
|
||||
f.target,
|
||||
f.primary_url,
|
||||
);
|
||||
}
|
||||
});
|
||||
txn(findings);
|
||||
}
|
||||
|
||||
public getMisconfigFindings(
|
||||
scanId: number,
|
||||
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
|
||||
): { items: MisconfigFinding[]; total: number } {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['scan_id = ?'];
|
||||
const params: unknown[] = [scanId];
|
||||
if (opts.severity) {
|
||||
where.push('severity = ?');
|
||||
params.push(opts.severity);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const total = (
|
||||
this.db
|
||||
.prepare(`SELECT COUNT(*) as cnt FROM misconfig_findings WHERE ${whereSql}`)
|
||||
.get(...(params as never[])) as { cnt: number }
|
||||
).cnt;
|
||||
const severityOrder = `CASE severity
|
||||
WHEN 'CRITICAL' THEN 0
|
||||
WHEN 'HIGH' THEN 1
|
||||
WHEN 'MEDIUM' THEN 2
|
||||
WHEN 'LOW' THEN 3
|
||||
ELSE 4 END`;
|
||||
const items = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM misconfig_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as MisconfigFinding[];
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
public getImageScanSummaries(nodeId: number): Record<string, ScanSummary> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
VulnScanTrigger,
|
||||
VulnerabilityScan,
|
||||
} from './DatabaseService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { disableCapability, enableCapability } from './CapabilityRegistry';
|
||||
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
|
||||
@@ -38,9 +39,33 @@ interface TrivyRawVulnerability {
|
||||
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 {
|
||||
@@ -63,6 +88,30 @@ export interface TrivyVulnerability {
|
||||
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;
|
||||
@@ -74,8 +123,11 @@ export interface TrivyScanResult {
|
||||
lowCount: number;
|
||||
unknownCount: number;
|
||||
fixableCount: number;
|
||||
secretCount: number;
|
||||
scannersUsed: string;
|
||||
highestSeverity: VulnSeverity | null;
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
secrets: TrivySecret[];
|
||||
metadata: {
|
||||
os: string | null;
|
||||
trivyVersion: string | null;
|
||||
@@ -83,8 +135,41 @@ export interface TrivyScanResult {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -103,6 +188,8 @@ function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | nul
|
||||
|
||||
export function parseTrivyOutput(raw: string): {
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
secrets: TrivySecret[];
|
||||
misconfigs: TrivyMisconfig[];
|
||||
os: string | null;
|
||||
} {
|
||||
let parsed: TrivyRawOutput;
|
||||
@@ -112,16 +199,19 @@ export function parseTrivyOutput(raw: string): {
|
||||
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 seen = new Set<string>();
|
||||
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 (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (vulnSeen.has(key)) continue;
|
||||
vulnSeen.add(key);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
@@ -133,6 +223,34 @@ export function parseTrivyOutput(raw: string): {
|
||||
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;
|
||||
@@ -141,7 +259,7 @@ export function parseTrivyOutput(raw: string): {
|
||||
? `${osFamily} ${osName}`
|
||||
: osFamily
|
||||
: null;
|
||||
return { vulnerabilities, os: osInfo };
|
||||
return { vulnerabilities, secrets, misconfigs, os: osInfo };
|
||||
}
|
||||
|
||||
class TrivyService {
|
||||
@@ -318,34 +436,46 @@ class TrivyService {
|
||||
async scanImage(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
options: { useCache?: boolean; digest?: string | null } = {},
|
||||
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} useCache=${options.useCache !== false}`);
|
||||
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);
|
||||
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 details =
|
||||
DatabaseService.getInstance().getVulnerabilityDetails(cached.id, {
|
||||
limit: 1000,
|
||||
}).items;
|
||||
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,
|
||||
@@ -357,6 +487,8 @@ class TrivyService {
|
||||
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,
|
||||
@@ -368,6 +500,16 @@ class TrivyService {
|
||||
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,
|
||||
@@ -387,7 +529,7 @@ class TrivyService {
|
||||
'--quiet',
|
||||
'--no-progress',
|
||||
'--scanners',
|
||||
'vuln',
|
||||
scannersUsed,
|
||||
imageRef,
|
||||
];
|
||||
const execStart = Date.now();
|
||||
@@ -399,9 +541,9 @@ class TrivyService {
|
||||
diag(
|
||||
`scanImage: trivy exited after ${Date.now() - execStart}ms, output=${stdout.length} bytes`,
|
||||
);
|
||||
const { vulnerabilities, os: osInfo } = parseTrivyOutput(stdout);
|
||||
const { vulnerabilities, secrets, os: osInfo } = parseTrivyOutput(stdout);
|
||||
diag(
|
||||
`scanImage: parsed ${vulnerabilities.length} unique vulns (os=${osInfo ?? 'unknown'})`,
|
||||
`scanImage: parsed ${vulnerabilities.length} unique vulns, ${secrets.length} secrets (os=${osInfo ?? 'unknown'})`,
|
||||
);
|
||||
|
||||
let critical = 0,
|
||||
@@ -441,8 +583,11 @@ class TrivyService {
|
||||
lowCount: low,
|
||||
unknownCount: unknown,
|
||||
fixableCount: fixable,
|
||||
secretCount: secrets.length,
|
||||
scannersUsed,
|
||||
highestSeverity: computeHighestSeverity(vulnerabilities),
|
||||
vulnerabilities,
|
||||
secrets,
|
||||
metadata: {
|
||||
os: osInfo,
|
||||
trivyVersion: this.version,
|
||||
@@ -467,8 +612,10 @@ class TrivyService {
|
||||
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,
|
||||
@@ -481,6 +628,9 @@ class TrivyService {
|
||||
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,
|
||||
@@ -490,7 +640,9 @@ class TrivyService {
|
||||
error: null,
|
||||
stack_context: stackContext,
|
||||
});
|
||||
diag(`beginScan: scanId=${scanId} imageRef=${imageRef} nodeId=${nodeId} trigger=${triggeredBy}`);
|
||||
diag(
|
||||
`beginScan: scanId=${scanId} imageRef=${imageRef} nodeId=${nodeId} trigger=${triggeredBy} scanners=${scannersUsed}`,
|
||||
);
|
||||
return scanId;
|
||||
}
|
||||
|
||||
@@ -503,12 +655,15 @@ class TrivyService {
|
||||
scanId: number,
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
opts: { useCache?: boolean } = {},
|
||||
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 });
|
||||
const result = await this.scanImage(imageRef, nodeId, {
|
||||
useCache: opts.useCache,
|
||||
scanners: opts.scanners,
|
||||
});
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
image_digest: result.imageDigest,
|
||||
scanned_at: result.scannedAt,
|
||||
@@ -519,6 +674,8 @@ class TrivyService {
|
||||
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,
|
||||
@@ -538,10 +695,23 @@ class TrivyService {
|
||||
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 total=${result.totalVulnerabilities} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
|
||||
`finishScan: scanId=${scanId} completed vulns=${result.totalVulnerabilities} secrets=${result.secretCount} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
|
||||
);
|
||||
return stored;
|
||||
} catch (error) {
|
||||
@@ -561,12 +731,146 @@ class TrivyService {
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger,
|
||||
stackContext: string | null = null,
|
||||
opts: { useCache?: boolean } = {},
|
||||
opts: { useCache?: boolean; scanners?: readonly TrivyScanner[] } = {},
|
||||
): Promise<VulnerabilityScan> {
|
||||
const scanId = this.beginScan(imageRef, nodeId, triggeredBy, stackContext);
|
||||
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',
|
||||
@@ -590,7 +894,7 @@ class TrivyService {
|
||||
const digest = await this.getImageDigest(ref, nodeId);
|
||||
if (digest) {
|
||||
const cached =
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest);
|
||||
DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
|
||||
skipped++;
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user