mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08: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:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Verifies the `scanners_used` dimension of the digest cache key.
|
||||
*
|
||||
* A vuln-only row must not satisfy a lookup for a vuln+secret scan; otherwise
|
||||
* a secret-scan request would silently reuse a stale finding set.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
function seedScan(overrides: Partial<{ scanners_used: string; scanned_at: number }> = {}): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
image_ref: 'alpine:3.19',
|
||||
image_digest: 'sha256:deadbeef',
|
||||
scanned_at: overrides.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: overrides.scanners_used ?? 'vuln',
|
||||
highest_severity: null,
|
||||
os_info: 'alpine 3.19',
|
||||
trivy_version: '0.56.0',
|
||||
scan_duration_ms: 1200,
|
||||
triggered_by: 'manual',
|
||||
status: 'completed',
|
||||
error: null,
|
||||
stack_context: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('getLatestScanByDigest scanners scoping', () => {
|
||||
it('returns null for a vuln+secret lookup when only a vuln row exists', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
seedScan({ scanners_used: 'vuln' });
|
||||
expect(
|
||||
db.getLatestScanByDigest('sha256:deadbeef', 'vuln,secret'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the matching row when scanners_used matches exactly', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const id = seedScan({ scanners_used: 'vuln,secret', scanned_at: Date.now() + 10 });
|
||||
const row = db.getLatestScanByDigest('sha256:deadbeef', 'vuln,secret');
|
||||
expect(row?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('falls back to latest completed row when scannersUsed omitted', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const row = db.getLatestScanByDigest('sha256:deadbeef');
|
||||
expect(row).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for digest with no completed scans', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
expect(db.getLatestScanByDigest('sha256:notreal', 'vuln')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Unit tests for secret and misconfiguration parsing in TrivyService.
|
||||
*
|
||||
* Covers the new code paths introduced alongside `--scanners vuln,secret` and
|
||||
* the `trivy config` stack flow: secret match redaction, misconfig extraction,
|
||||
* and the scanner-canonicalization helper that feeds the digest cache key.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
normalizeScanners,
|
||||
parseTrivyOutput,
|
||||
redactSecretMatch,
|
||||
} from '../services/TrivyService';
|
||||
|
||||
describe('normalizeScanners', () => {
|
||||
it('defaults to vuln when input is empty or undefined', () => {
|
||||
expect(normalizeScanners()).toEqual(['vuln']);
|
||||
expect(normalizeScanners([])).toEqual(['vuln']);
|
||||
});
|
||||
|
||||
it('keeps canonical order regardless of input order', () => {
|
||||
expect(normalizeScanners(['secret', 'vuln'])).toEqual(['vuln', 'secret']);
|
||||
expect(normalizeScanners(['vuln', 'secret'])).toEqual(['vuln', 'secret']);
|
||||
});
|
||||
|
||||
it('deduplicates repeated entries', () => {
|
||||
expect(normalizeScanners(['vuln', 'vuln', 'secret'])).toEqual(['vuln', 'secret']);
|
||||
});
|
||||
|
||||
it('produces a join-stable string so cache keys stay comparable', () => {
|
||||
const a = normalizeScanners(['secret', 'vuln']).join(',');
|
||||
const b = normalizeScanners(['vuln', 'secret']).join(',');
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('vuln,secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactSecretMatch', () => {
|
||||
it('returns null for empty, whitespace, or missing input', () => {
|
||||
expect(redactSecretMatch(null)).toBeNull();
|
||||
expect(redactSecretMatch(undefined)).toBeNull();
|
||||
expect(redactSecretMatch('')).toBeNull();
|
||||
expect(redactSecretMatch(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the full string (trimmed) when 8 chars or fewer', () => {
|
||||
expect(redactSecretMatch('abc')).toBe('abc');
|
||||
expect(redactSecretMatch('abcdefgh')).toBe('abcdefgh');
|
||||
expect(redactSecretMatch(' short ')).toBe('short');
|
||||
});
|
||||
|
||||
it('redacts values longer than 8 chars to first 8 + ellipsis', () => {
|
||||
expect(redactSecretMatch('abcdefghi')).toBe('abcdefgh...');
|
||||
expect(
|
||||
redactSecretMatch('AKIA1234567890ABCDEF'),
|
||||
).toBe('AKIA1234...');
|
||||
});
|
||||
|
||||
it('never leaks the full secret even if the match is a long key-like string', () => {
|
||||
const leaked = 'ghp_abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const result = redactSecretMatch(leaked) ?? '';
|
||||
expect(result.length).toBeLessThanOrEqual(11);
|
||||
expect(result.endsWith('...')).toBe(true);
|
||||
expect(leaked.includes(result.replace('...', ''))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrivyOutput - secrets', () => {
|
||||
it('extracts secret findings with redacted match excerpts', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'app/config.env',
|
||||
Secrets: [
|
||||
{
|
||||
RuleID: 'aws-access-key-id',
|
||||
Category: 'AWS',
|
||||
Severity: 'CRITICAL',
|
||||
Title: 'AWS Access Key ID',
|
||||
StartLine: 12,
|
||||
EndLine: 12,
|
||||
Match: 'AKIAIOSFODNN7EXAMPLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.secrets.length).toBe(1);
|
||||
const s = parsed.secrets[0];
|
||||
expect(s.ruleId).toBe('aws-access-key-id');
|
||||
expect(s.category).toBe('AWS');
|
||||
expect(s.severity).toBe('CRITICAL');
|
||||
expect(s.target).toBe('app/config.env');
|
||||
expect(s.startLine).toBe(12);
|
||||
expect(s.matchExcerpt).toBe('AKIAIOSF...');
|
||||
expect(s.matchExcerpt?.includes('EXAMPLE')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops secret entries missing a rule id', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'a',
|
||||
Secrets: [
|
||||
{ Severity: 'HIGH', Match: 'x' },
|
||||
{ RuleID: 'gh-token', Severity: 'HIGH', Match: 'y' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.secrets.length).toBe(1);
|
||||
expect(parsed.secrets[0].ruleId).toBe('gh-token');
|
||||
});
|
||||
|
||||
it('returns an empty secrets array when no Secrets key is present', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [{ Target: 'a', Vulnerabilities: [] }],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.secrets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrivyOutput - misconfigs', () => {
|
||||
it('extracts misconfigurations with resolution and primary url', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'docker-compose.yml',
|
||||
Misconfigurations: [
|
||||
{
|
||||
ID: 'DS002',
|
||||
AVDID: 'AVD-DS-0002',
|
||||
Severity: 'HIGH',
|
||||
Title: 'Container running as root',
|
||||
Message: 'Specify a non-root user.',
|
||||
Resolution: 'Set `user:` in the service definition.',
|
||||
PrimaryURL: 'https://avd.aquasec.com/misconfig/ds002',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.misconfigs.length).toBe(1);
|
||||
const m = parsed.misconfigs[0];
|
||||
expect(m.ruleId).toBe('DS002');
|
||||
expect(m.checkId).toBe('AVD-DS-0002');
|
||||
expect(m.severity).toBe('HIGH');
|
||||
expect(m.target).toBe('docker-compose.yml');
|
||||
expect(m.resolution).toContain('user:');
|
||||
expect(m.primaryUrl).toBe('https://avd.aquasec.com/misconfig/ds002');
|
||||
});
|
||||
|
||||
it('tolerates missing optional fields', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'compose.yml',
|
||||
Misconfigurations: [{ ID: 'X1', Severity: 'LOW' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.misconfigs.length).toBe(1);
|
||||
expect(parsed.misconfigs[0].ruleId).toBe('X1');
|
||||
expect(parsed.misconfigs[0].severity).toBe('LOW');
|
||||
});
|
||||
|
||||
it('returns vulnerabilities, secrets, and misconfigs together when all present', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Target: 'app',
|
||||
Vulnerabilities: [
|
||||
{
|
||||
VulnerabilityID: 'CVE-2024-1111',
|
||||
PkgName: 'openssl',
|
||||
InstalledVersion: '1.0',
|
||||
Severity: 'HIGH',
|
||||
},
|
||||
],
|
||||
Secrets: [{ RuleID: 'aws', Severity: 'HIGH', Match: 'secretvalue' }],
|
||||
Misconfigurations: [{ ID: 'M1', Severity: 'MEDIUM' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.vulnerabilities.length).toBe(1);
|
||||
expect(parsed.secrets.length).toBe(1);
|
||||
expect(parsed.misconfigs.length).toBe(1);
|
||||
});
|
||||
});
|
||||
+100
-3
@@ -1625,7 +1625,7 @@ async function triggerPostDeployScan(
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest);
|
||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
@@ -1994,6 +1994,19 @@ function validateScanPolicyRow(row: unknown): string | null {
|
||||
}
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
|
||||
// Returns a normalized scanners array, undefined when no input was provided,
|
||||
// or null when the input is present but invalid.
|
||||
function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
const out = new Set<'vuln' | 'secret'>();
|
||||
for (const item of raw) {
|
||||
if (item !== 'vuln' && item !== 'secret') return null;
|
||||
out.add(item);
|
||||
}
|
||||
return Array.from(out) as readonly ('vuln' | 'secret')[];
|
||||
}
|
||||
function validateCveSuppressionRow(row: unknown): string | null {
|
||||
if (!row || typeof row !== 'object') return 'row must be an object';
|
||||
const r = row as Record<string, unknown>;
|
||||
@@ -7475,19 +7488,49 @@ app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): vo
|
||||
const imageRef = rawImageRef;
|
||||
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
|
||||
const force = req.body?.force === true;
|
||||
const scanners = parseScannersInput(req.body?.scanners);
|
||||
if (scanners === null) {
|
||||
res.status(400).json({ error: 'scanners must be an array of "vuln" or "secret"' });
|
||||
return;
|
||||
}
|
||||
if (scanners?.includes('secret') && !requirePaid(req, res)) return;
|
||||
const nodeId = req.nodeId;
|
||||
if (svc.isScanning(nodeId, imageRef)) {
|
||||
res.status(409).json({ error: 'Already scanning this image' });
|
||||
return;
|
||||
}
|
||||
const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext);
|
||||
const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext, scanners);
|
||||
res.status(202).json({ scanId });
|
||||
|
||||
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force }).catch((err) => {
|
||||
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force, scanners }).catch((err) => {
|
||||
console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
|
||||
}
|
||||
const stackName = typeof req.body?.stackName === 'string' ? req.body.stackName.trim() : '';
|
||||
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' }); return;
|
||||
}
|
||||
try {
|
||||
const scan = await svc.scanComposeStack(req.nodeId, stackName, 'manual');
|
||||
res.status(201).json(scan);
|
||||
} catch (error) {
|
||||
const message = (error as Error).message || '';
|
||||
if (message === 'Invalid stack path' || message.startsWith('No compose file found')) {
|
||||
res.status(404).json({ error: message }); return;
|
||||
}
|
||||
console.error('[Security] Stack config scan failed:', error);
|
||||
res.status(500).json({ error: message || 'Failed to scan stack' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
@@ -7546,6 +7589,60 @@ app.get(
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/api/security/scans/:scanId/secrets',
|
||||
authMiddleware,
|
||||
(req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const scanId = Number(req.params.scanId);
|
||||
if (!Number.isFinite(scanId)) {
|
||||
res.status(400).json({ error: 'Invalid scan id' }); return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const scan = db.getVulnerabilityScan(scanId);
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
const severity = typeof req.query.severity === 'string'
|
||||
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
|
||||
: undefined;
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
|
||||
if (severity && !validSeverities.has(severity)) {
|
||||
res.status(400).json({ error: 'Invalid severity filter' }); return;
|
||||
}
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
res.json(db.getSecretFindings(scanId, { severity, limit, offset }));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/api/security/scans/:scanId/misconfigs',
|
||||
authMiddleware,
|
||||
(req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const scanId = Number(req.params.scanId);
|
||||
if (!Number.isFinite(scanId)) {
|
||||
res.status(400).json({ error: 'Invalid scan id' }); return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const scan = db.getVulnerabilityScan(scanId);
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
const severity = typeof req.query.severity === 'string'
|
||||
? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN')
|
||||
: undefined;
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']);
|
||||
if (severity && !validSeverities.has(severity)) {
|
||||
res.status(400).json({ error: 'Invalid severity filter' }); return;
|
||||
}
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
res.json(db.getMisconfigFindings(scanId, { severity, limit, offset }));
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId);
|
||||
|
||||
@@ -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