From a95bf1ff333e301125ef8a2b6442f0c74f30c089 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 17 Apr 2026 07:57:08 -0400 Subject: [PATCH] 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 --- .../src/__tests__/database-scan-cache.test.ts | 76 +++ .../__tests__/trivy-secret-misconfig.test.ts | 195 +++++++ backend/src/index.ts | 103 +++- backend/src/services/DatabaseService.ts | 220 ++++++- backend/src/services/TrivyService.ts | 346 ++++++++++- docs/features/vulnerability-scanning.mdx | 68 ++- frontend/src/components/EditorLayout.tsx | 59 +- frontend/src/components/ResourcesView.tsx | 55 +- .../src/components/VulnerabilityScanSheet.tsx | 550 +++++++++++++----- frontend/src/types/security.ts | 29 + 10 files changed, 1513 insertions(+), 188 deletions(-) create mode 100644 backend/src/__tests__/database-scan-cache.test.ts create mode 100644 backend/src/__tests__/trivy-secret-misconfig.test.ts diff --git a/backend/src/__tests__/database-scan-cache.test.ts b/backend/src/__tests__/database-scan-cache.test.ts new file mode 100644 index 00000000..54b003bf --- /dev/null +++ b/backend/src/__tests__/database-scan-cache.test.ts @@ -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(); + }); +}); diff --git a/backend/src/__tests__/trivy-secret-misconfig.test.ts b/backend/src/__tests__/trivy-secret-misconfig.test.ts new file mode 100644 index 00000000..fff28506 --- /dev/null +++ b/backend/src/__tests__/trivy-secret-misconfig.test.ts @@ -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); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index df0f51b2..9c1b241f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; @@ -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 => { + 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); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 4791f192..fd1b62b8 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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>, + ): 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>, + ): 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 { const rows = this.db .prepare( diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 32de876a..bba9c50f 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -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(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(); + const vulnSeen = new Set(); 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 { 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 { 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 { - 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:' so misconfigs share + * the same history surface as image scans. + */ + async scanComposeStack( + nodeId: number, + stackName: string, + triggeredBy: VulnScanTrigger = 'manual', + ): Promise { + 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; diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 943e60ba..3bac169c 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -25,13 +25,17 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Scan policies (warning and critical alerts) | | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | | Scan history and comparison | | ✓ | ✓ | +| Secret detection in image filesystems | | ✓ | ✓ | +| Compose file misconfiguration scanning | | ✓ | ✓ | ## On-demand scanning Navigate to the **Resources** tab and open the **Images** panel. When Trivy is available, every image row shows a shield icon alongside the delete action. -1. Click the shield icon on any image row to start a scan. -2. The row shows a loading spinner while Trivy runs. Most scans finish in 10 to 60 seconds depending on image size and whether the Trivy database is already cached. +1. Click the shield icon on any image row. A menu appears with two options: + - **Scan (vulnerabilities)**: the default, fastest path. Trivy inspects package metadata only. + - **Full scan (vulnerabilities + secrets)**: additionally walks the image filesystem for hardcoded credentials, tokens, and keys. This takes noticeably longer. +2. The row shows a loading spinner while Trivy runs. Most vulnerability scans finish in 10 to 60 seconds depending on image size and whether the Trivy database is already cached. Full scans add the time needed to read the filesystem. 3. When the scan completes, a severity badge appears next to the image status (e.g. `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `CLEAN`). 4. Click the badge to open the scan results drawer. @@ -55,15 +59,16 @@ Scan results are cached by image digest. If the same digest is scanned again wit ## The scan results drawer -The drawer shows a full breakdown of the most recent scan for an image: +The drawer shows a full breakdown of the most recent scan for an image and groups findings across three tabs: **Vulnerabilities**, **Secrets**, and **Misconfigs**. The summary header shows counts across all three so you can see the full risk picture at a glance. - **Summary**: counts per severity (critical, high, medium, low), total vulnerabilities, how many have a fix available, the Trivy version used, and when the scan ran. -- **Filter tabs**: narrow the table to a specific severity. -- **Vulnerability table**: paginated list of every CVE found, including: +- **Vulnerabilities tab**: severity filter pills narrow the table, paginated list of every CVE found, including: - **CVE ID** (linked to the upstream advisory) - **Package** name and installed version - **Severity** badge - **Fixed version** with a green indicator if a fix is available +- **Secrets tab**: hardcoded credentials or keys detected in the image filesystem, with severity, rule, title, and the file/line location. Secret values are redacted: only the first eight characters of the match are stored. +- **Misconfigs tab**: misconfiguration findings with severity, check ID, title, target file, and a suggested resolution. For image scans this tab is empty; for stack config scans (see below) it is the primary view. ### Actions @@ -163,6 +168,47 @@ From the scan results drawer, click **Download SBOM** and choose a format: The download starts immediately and uses the image's digest (when available) in the filename. +## Secret detection + + + Secret detection requires a **Skipper** or **Admiral** license. + + +Full scans ask Trivy to walk the image filesystem for patterns that look like hardcoded credentials, API tokens, cloud access keys, or private keys. Detection rules cover common providers (AWS, GCP, GitHub, Slack, Stripe) plus generic high-entropy strings. + +To run a full scan, click the shield icon in the Resources Hub and pick **Full scan (vulnerabilities + secrets)**. Findings appear on the **Secrets** tab of the scan drawer: + +| Column | Description | +|--------|-------------| +| **Severity** | Trivy-assigned severity for the rule that matched. | +| **Rule** | The detection rule identifier (e.g. `aws-access-key-id`). | +| **Title** | A short description of what was detected. The second line shows a redacted excerpt of the match. | +| **Target** | The file path inside the image filesystem, including the line number range when available. | + +Only the first eight characters of any matched secret are stored, followed by an ellipsis. The full value is never written to the database, so exporting a scan drawer or comparing scans cannot leak the underlying credential. + +Full scans take longer than vulnerability-only scans because Trivy reads every file in the image. If runtime is a concern, schedule full scans overnight and keep deploy-time scans on the default vulnerability-only setting. + +## Compose misconfiguration scanning + + + Compose misconfiguration scanning requires a **Skipper** or **Admiral** license. + + +Beyond package CVEs, Sencho can run `trivy config` against a stack's Compose file to flag insecure defaults before you deploy. Typical checks include containers running as root, missing resource limits, privileged mode, host network, and mounted Docker sockets. + +From any stack page, click **Scan config** next to the Deploy controls. Sencho runs the scanner against the stack's working directory and opens the scan drawer on the **Misconfigs** tab: + +| Column | Description | +|--------|-------------| +| **Severity** | Rule severity (CRITICAL/HIGH/MEDIUM/LOW). | +| **Check** | Rule ID or AVD identifier for the violated check. | +| **Title** | Short summary, linked to the upstream advisory when available. The second line shows Trivy's detailed message. | +| **Target** | The file that triggered the finding. | +| **Fix** | The recommended resolution. | + +Config scans are stored in the same history as image scans with an `image_ref` of `stack:`, so they appear on the Scan history page and can be exported as CSV. + ## Scan history Every scan Sencho runs is stored with its full vulnerability detail. Scan records are automatically pruned after 90 days to keep the database compact. The history is used to power: @@ -241,3 +287,15 @@ Scan policies are managed from the control Sencho instance and replicate to ever ### I suppressed a CVE but the scan badge count is unchanged Badge counts reflect the raw findings so alerting and policy evaluation stay accurate. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces. + +### The Secrets tab is empty on an image I expect to contain credentials + +Secret detection matches against Trivy's built-in rule set, which focuses on well-known provider patterns. Plain text passwords, custom token formats, or values that do not match any published rule will not appear. Ensure you picked **Full scan (vulnerabilities + secrets)** from the shield-icon menu; a plain vulnerability scan does not walk the filesystem. + +### Scan config button is disabled on a stack + +The button is only shown when Trivy is available on the stack's node, the current user is an admin, and the license is Skipper or Admiral. If all three conditions are met but the button stays disabled, another stack action (deploy, update, rollback) is still in progress; wait for it to finish. + +### Compose misconfiguration scan returns 404 + +The scanner needs to locate a Compose file in the stack directory. If the stack was created outside Sencho and the working directory does not contain a file named `compose.yml`, `compose.yaml`, `docker-compose.yml`, or `docker-compose.yaml`, the scan returns 404. Name the file accordingly or keep the stack under Sencho's managed compose directory. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7f711a61..964ef636 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2 } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; @@ -63,6 +63,8 @@ import { useNodes } from '@/context/NodeContext'; import type { Node } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; +import { useTrivyStatus } from '@/hooks/useTrivyStatus'; +import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; interface ContainerInfo { Id: string; @@ -101,6 +103,9 @@ const formatBytes = (bytes: number) => { export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); + const { status: trivy } = useTrivyStatus(); + const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); + const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -1037,6 +1042,34 @@ export default function EditorLayout() { setIsEditing(true); }; + const scanStackConfig = async () => { + if (!selectedFile || stackMisconfigScanning) return; + const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); + setStackMisconfigScanning(true); + const loadingId = toast.loading(`Scanning ${stackName} configuration...`); + try { + const res = await apiFetch('/security/scan/stack', { + method: 'POST', + body: JSON.stringify({ stackName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); + if (data.status === 'failed') { + throw new Error(data.error || 'Scan failed'); + } + toast.success( + `Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`, + ); + setStackMisconfigScanId(data.id as number); + } catch (error) { + const err = error as { message?: string; error?: string; data?: { error?: string } }; + toast.error(err?.message || err?.error || err?.data?.error || 'Config scan failed'); + } finally { + toast.dismiss(loadingId); + setStackMisconfigScanning(false); + } + }; + const deployStack = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -2415,6 +2448,24 @@ export default function EditorLayout() { {loadingAction === 'update' ? 'Updating...' : 'Update'} + {trivy.available && isAdmin && isPaid && ( + + )} {isPaid && backupInfo.exists && ( @@ -2883,6 +2934,12 @@ export default function EditorLayout() { onSourceChanged={refreshGitSourcePending} /> )} + + {/* Stack config misconfig scan results */} + setStackMisconfigScanId(null)} + /> ); } diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index df0e1d12..65c66e8e 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -590,13 +590,17 @@ export default function ResourcesView() { } }; - const handleScanImage = async (imageRef: string, force = false) => { + const handleScanImage = async ( + imageRef: string, + options: { force?: boolean; scanners?: ('vuln' | 'secret')[] } = {}, + ) => { + const { force = false, scanners } = options; setScanningImageRef(imageRef); const loadingId = toast.loading(`Scanning ${imageRef}...`); try { const res = await apiFetch('/security/scan', { method: 'POST', - body: JSON.stringify({ imageRef, force }), + body: JSON.stringify({ imageRef, force, scanners }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); @@ -885,20 +889,37 @@ export default function ResourcesView() {
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( - + + + + + + handleScanImage(img.RepoTags![0], { scanners: ['vuln'] })} + > + Scan (vulnerabilities) + + {isPaid && ( + handleScanImage(img.RepoTags![0], { scanners: ['vuln', 'secret'] })} + > + Full scan (vulnerabilities + secrets) + + )} + + )} {isAdmin &&
- {/* Severity filter tabs */} -
- {(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => ( - - ))} - {needsPagination && ( -
- - - {safePage + 1} / {totalPages} - - + setTab(v as FindingTab)} + className="flex flex-col flex-1 min-h-0" + > +
+ + + + Vulnerabilities + + ({totalDetails}) + + + + + Secrets + + ({scan.secret_count ?? secrets.length}) + + + + + Misconfigs + + ({scan.misconfig_count ?? misconfigs.length}) + + + +
+ + +
+ {(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => ( + + ))} + {needsPagination && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )}
- )} -
- {totalDetails > details.length && ( -
- Showing first {details.length} of {totalDetails}. Export CSV for the complete list. -
- )} - - -
- {pageItems.length === 0 ? ( -
- {details.length === 0 - ? 'No vulnerabilities found.' - : 'No vulnerabilities match the selected filter.'} + {totalDetails > details.length && ( +
+ Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
- ) : ( - - - - CVE - Package - Severity - Installed - Fixed - {canManageSuppressions && } - - - - {pageItems.map((d) => ( - - - - {d.suppressed && ( - - )} - {d.primary_url ? ( - - {d.vulnerability_id} - - - ) : ( - d.vulnerability_id - )} - - - - {d.pkg_name} - - - - - {d.installed_version} - - {d.fixed_version ? ( - - - {d.fixed_version} - - ) : ( - - - )} - - {canManageSuppressions && ( - - {!d.suppressed && ( - - )} - - )} - - ))} - -
)} -
- + + +
+ {pageItems.length === 0 ? ( +
+ {details.length === 0 + ? 'No vulnerabilities found.' + : 'No vulnerabilities match the selected filter.'} +
+ ) : ( + + + + CVE + Package + Severity + Installed + Fixed + {canManageSuppressions && } + + + + {pageItems.map((d) => ( + + + + {d.suppressed && ( + + )} + {d.primary_url ? ( + + {d.vulnerability_id} + + + ) : ( + d.vulnerability_id + )} + + + + {d.pkg_name} + + + + + {d.installed_version} + + {d.fixed_version ? ( + + + {d.fixed_version} + + ) : ( + - + )} + + {canManageSuppressions && ( + + {!d.suppressed && ( + + )} + + )} + + ))} + +
+ )} +
+
+ + + + {secretsNeedsPagination && ( +
+
+ + + {secretsSafePage + 1} / {secretsTotalPages} + + +
+
+ )} + +
+ {secrets.length === 0 ? ( +
+ No secrets detected. +
+ ) : ( + + + + Severity + Rule + Title + Target + + + + {secretsPageItems.map((s) => ( + + + + + + {s.rule_id} + + +
+ {s.title || -} +
+ {s.match_excerpt && ( +
+ {s.match_excerpt} +
+ )} +
+ + {s.target} + {s.start_line != null && ( + + :{s.start_line} + {s.end_line != null && s.end_line !== s.start_line + ? `-${s.end_line}` + : ''} + + )} + +
+ ))} +
+
+ )} +
+
+
+ + + {misconfigsNeedsPagination && ( +
+
+ + + {misconfigsSafePage + 1} / {misconfigsTotalPages} + + +
+
+ )} + +
+ {misconfigs.length === 0 ? ( +
+ No misconfigurations detected. +
+ ) : ( + + + + Severity + Check + Title + Target + Fix + + + + {misconfigsPageItems.map((m) => ( + + + + + + {m.check_id || m.rule_id} + + +
+ {m.primary_url ? ( + + {m.title || m.rule_id} + + + ) : ( + m.title || m.rule_id + )} +
+ {m.message && ( +
+ {m.message} +
+ )} +
+ + {m.target} + + + {m.resolution || -} + +
+ ))} +
+
+ )} +
+
+
+
)} diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index d1a05bbb..685fd298 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -32,6 +32,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; @@ -42,6 +45,32 @@ export interface VulnerabilityScan { stack_context: 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 VulnerabilityDetail { id: number; scan_id: number;