mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(security): harden Trivy scan lifecycle, logging, and docs (#639)
* fix(security): harden Trivy scan lifecycle, logging, and docs - Call TrivyService.initialize() at startup so capability state is accurate before first request; add periodic re-detect to the scheduler so newly installed Trivy binaries light up without a restart. - Add markStaleScansAsFailed sweep (+ idx_vuln_scans_status index) to recover any scan row left in_progress after a crash or timeout; sweep runs before the paid-tier gate so every tier self-heals. - Split scanImage persistence into beginScan/finishScan so the manual scan route owns a single code path and can return a scanId synchronously while work continues asynchronously. - Validate image refs on /api/security/scan and /sbom via new utility; defense-in-depth against shell-metacharacter payloads. - Dispatch a warning-level alert when a post-deploy scan fails so the operator has a user-visible path to the failure instead of a silent log. - Share DIGEST_CACHE_TTL_MS and severity ordering across service and route layers; remove dead invalidateDetection(). - Add [Trivy:diag] logging gated behind developer_mode for support diagnostics; production logs unchanged. - Frontend: defensive toast fallback chain, sr-only SheetDescription, and a truncation badge when the 500-item detail fetch is capped. - Tests: extend trivy-service and vulnerability-db suites; add image-ref and severity unit tests. - Docs: expand vulnerability-scanning troubleshooting with recovery, re-detect, and diagnostic-log guidance; link Dockerfile comment to trivy-setup. * fix(security): drop unnecessary escape in image-ref forbidden-char regex
This commit is contained in:
@@ -91,6 +91,11 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
|
||||
# Stage 4: Production runtime
|
||||
# Runs on the TARGET platform - no compilation happens here.
|
||||
#
|
||||
# Vulnerability scanning uses the external `trivy` CLI. It is not installed
|
||||
# in this image; operators who want the feature install Trivy on the host
|
||||
# and mount the binary into the container, or run a sidecar. See
|
||||
# docs/operations/trivy-setup.mdx for the supported integration paths.
|
||||
FROM node:22-alpine
|
||||
|
||||
# Pin Docker CLI and Compose versions.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
|
||||
describe('validateImageRef', () => {
|
||||
it('accepts canonical image references', () => {
|
||||
const valid = [
|
||||
'alpine',
|
||||
'alpine:3.19',
|
||||
'nginx:latest',
|
||||
'library/postgres:16',
|
||||
'ghcr.io/owner/project:v1.2.3',
|
||||
'registry.example.com:5000/team/image:tag',
|
||||
'docker.io/library/redis@sha256:abcdef1234567890',
|
||||
'node:20-alpine',
|
||||
'my-image_v2.final',
|
||||
];
|
||||
for (const ref of valid) {
|
||||
expect(validateImageRef(ref), `expected ${ref} to be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects shell-injection payloads', () => {
|
||||
const invalid = [
|
||||
'; rm -rf /',
|
||||
'$(whoami)',
|
||||
'`id`',
|
||||
'alpine && curl evil.com',
|
||||
'image | tee file',
|
||||
'image; ls',
|
||||
'image$VAR',
|
||||
'image`cmd`',
|
||||
'image\nother',
|
||||
'image"name"',
|
||||
"image'name'",
|
||||
];
|
||||
for (const ref of invalid) {
|
||||
expect(validateImageRef(ref), `expected ${ref} to be invalid`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty and whitespace-only strings', () => {
|
||||
expect(validateImageRef('')).toBe(false);
|
||||
expect(validateImageRef(' ')).toBe(false);
|
||||
expect(validateImageRef('\t\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-string inputs', () => {
|
||||
expect(validateImageRef(null)).toBe(false);
|
||||
expect(validateImageRef(undefined)).toBe(false);
|
||||
expect(validateImageRef(42)).toBe(false);
|
||||
expect(validateImageRef({})).toBe(false);
|
||||
expect(validateImageRef([])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects path traversal attempts', () => {
|
||||
expect(validateImageRef('../../etc/passwd')).toBe(false);
|
||||
expect(validateImageRef('image..name')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects references exceeding 255 characters', () => {
|
||||
const longRef = 'a'.repeat(256);
|
||||
expect(validateImageRef(longRef)).toBe(false);
|
||||
const atLimit = 'a'.repeat(255);
|
||||
expect(validateImageRef(atLimit)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { severityRank, isSeverityAtLeast, SEVERITY_ORDER } from '../utils/severity';
|
||||
|
||||
describe('severityRank', () => {
|
||||
it('orders severities CRITICAL > HIGH > MEDIUM > LOW > UNKNOWN', () => {
|
||||
expect(severityRank('CRITICAL')).toBeGreaterThan(severityRank('HIGH'));
|
||||
expect(severityRank('HIGH')).toBeGreaterThan(severityRank('MEDIUM'));
|
||||
expect(severityRank('MEDIUM')).toBeGreaterThan(severityRank('LOW'));
|
||||
expect(severityRank('LOW')).toBeGreaterThan(severityRank('UNKNOWN'));
|
||||
});
|
||||
|
||||
it('returns -1 for null/undefined so missing severities sort below UNKNOWN', () => {
|
||||
expect(severityRank(null)).toBe(-1);
|
||||
expect(severityRank(undefined)).toBe(-1);
|
||||
expect(severityRank(null)).toBeLessThan(severityRank('UNKNOWN'));
|
||||
});
|
||||
|
||||
it('exports a SEVERITY_ORDER array that covers every known severity', () => {
|
||||
expect(SEVERITY_ORDER).toEqual(['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSeverityAtLeast', () => {
|
||||
it('returns true when actual meets or exceeds the threshold', () => {
|
||||
expect(isSeverityAtLeast('CRITICAL', 'HIGH')).toBe(true);
|
||||
expect(isSeverityAtLeast('HIGH', 'HIGH')).toBe(true);
|
||||
expect(isSeverityAtLeast('MEDIUM', 'LOW')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when actual is below the threshold', () => {
|
||||
expect(isSeverityAtLeast('LOW', 'HIGH')).toBe(false);
|
||||
expect(isSeverityAtLeast('MEDIUM', 'CRITICAL')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for missing severities regardless of threshold', () => {
|
||||
expect(isSeverityAtLeast(null, 'LOW')).toBe(false);
|
||||
expect(isSeverityAtLeast(undefined, 'UNKNOWN')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* when the binary is not available.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import TrivyService from '../services/TrivyService';
|
||||
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
|
||||
|
||||
describe('TrivyService', () => {
|
||||
let svc: TrivyService;
|
||||
@@ -31,6 +31,12 @@ describe('TrivyService', () => {
|
||||
expect(result).toHaveProperty('version');
|
||||
expect(typeof result.available).toBe('boolean');
|
||||
});
|
||||
|
||||
it('records a detection timestamp after running', async () => {
|
||||
const before = Date.now();
|
||||
await svc.detectTrivy();
|
||||
expect(svc.getDetectionTimestamp()).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanImage', () => {
|
||||
@@ -51,4 +57,99 @@ describe('TrivyService', () => {
|
||||
expect(svc.isScanning(1, 'nginx:latest')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrivyOutput', () => {
|
||||
it('extracts OS metadata and deduplicates vulnerabilities across targets', () => {
|
||||
const raw = JSON.stringify({
|
||||
Metadata: { OS: { Family: 'alpine', Name: '3.19.0' } },
|
||||
Results: [
|
||||
{
|
||||
Target: 'alpine:3.19 (alpine)',
|
||||
Vulnerabilities: [
|
||||
{
|
||||
VulnerabilityID: 'CVE-2024-0001',
|
||||
PkgName: 'openssl',
|
||||
InstalledVersion: '3.0.0',
|
||||
FixedVersion: '3.0.1',
|
||||
Severity: 'HIGH',
|
||||
},
|
||||
{
|
||||
VulnerabilityID: 'CVE-2024-0002',
|
||||
PkgName: 'curl',
|
||||
InstalledVersion: '8.0',
|
||||
Severity: 'CRITICAL',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Target: 'other-target',
|
||||
Vulnerabilities: [
|
||||
{
|
||||
VulnerabilityID: 'CVE-2024-0001',
|
||||
PkgName: 'openssl',
|
||||
InstalledVersion: '3.0.0',
|
||||
Severity: 'HIGH',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.os).toBe('alpine 3.19.0');
|
||||
expect(parsed.vulnerabilities.length).toBe(2);
|
||||
const ids = parsed.vulnerabilities.map((v) => v.vulnerabilityId);
|
||||
expect(ids).toContain('CVE-2024-0001');
|
||||
expect(ids).toContain('CVE-2024-0002');
|
||||
});
|
||||
|
||||
it('normalizes unknown severities to UNKNOWN', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Vulnerabilities: [
|
||||
{
|
||||
VulnerabilityID: 'CVE-X',
|
||||
PkgName: 'libx',
|
||||
InstalledVersion: '1',
|
||||
Severity: 'NEGLIGIBLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.vulnerabilities[0].severity).toBe('UNKNOWN');
|
||||
});
|
||||
|
||||
it('drops entries missing VulnerabilityID or PkgName', () => {
|
||||
const raw = JSON.stringify({
|
||||
Results: [
|
||||
{
|
||||
Vulnerabilities: [
|
||||
{ PkgName: 'x', Severity: 'HIGH' },
|
||||
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH' },
|
||||
{ VulnerabilityID: 'CVE-2', PkgName: 'y', Severity: 'LOW' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const parsed = parseTrivyOutput(raw);
|
||||
expect(parsed.vulnerabilities.length).toBe(1);
|
||||
expect(parsed.vulnerabilities[0].vulnerabilityId).toBe('CVE-2');
|
||||
});
|
||||
|
||||
it('tolerates missing Metadata and empty Results', () => {
|
||||
const parsed = parseTrivyOutput(JSON.stringify({ Results: [] }));
|
||||
expect(parsed.os).toBeNull();
|
||||
expect(parsed.vulnerabilities).toEqual([]);
|
||||
|
||||
const parsedEmpty = parseTrivyOutput(JSON.stringify({}));
|
||||
expect(parsedEmpty.os).toBeNull();
|
||||
expect(parsedEmpty.vulnerabilities).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws a helpful error on malformed JSON', () => {
|
||||
expect(() => parseTrivyOutput('{not-json')).toThrow(/Malformed/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,6 +296,68 @@ describe('Vulnerability scan storage (in-memory SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── markStaleScansAsFailed ───────────────────────────────────────
|
||||
|
||||
describe('markStaleScansAsFailed', () => {
|
||||
function markStale(olderThanMs: number): number {
|
||||
const cutoff = Date.now() - olderThanMs;
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE vulnerability_scans
|
||||
SET status = 'failed',
|
||||
error = 'Scan did not complete within expected time',
|
||||
scan_duration_ms = ? - scanned_at
|
||||
WHERE status = 'in_progress' AND scanned_at < ?`,
|
||||
)
|
||||
.run(Date.now(), cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
it('flips in_progress scans older than the cutoff to failed', () => {
|
||||
const now = Date.now();
|
||||
const stale = insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
|
||||
const fresh = insertScan({ status: 'in_progress', scanned_at: now - 1_000 });
|
||||
|
||||
const changed = markStale(15 * 60 * 1000);
|
||||
expect(changed).toBe(1);
|
||||
|
||||
const staleRow = db.prepare('SELECT status, error FROM vulnerability_scans WHERE id = ?').get(stale) as {
|
||||
status: string;
|
||||
error: string;
|
||||
};
|
||||
const freshRow = db.prepare('SELECT status FROM vulnerability_scans WHERE id = ?').get(fresh) as {
|
||||
status: string;
|
||||
};
|
||||
expect(staleRow.status).toBe('failed');
|
||||
expect(staleRow.error).toMatch(/did not complete/i);
|
||||
expect(freshRow.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('leaves completed and failed rows untouched', () => {
|
||||
const now = Date.now();
|
||||
const completed = insertScan({ status: 'completed', scanned_at: now - 30 * 60 * 1000 });
|
||||
const failed = insertScan({ status: 'failed', scanned_at: now - 30 * 60 * 1000 });
|
||||
|
||||
const changed = markStale(15 * 60 * 1000);
|
||||
expect(changed).toBe(0);
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT id, status FROM vulnerability_scans WHERE id IN (?, ?)')
|
||||
.all(completed, failed) as Array<{ id: number; status: string }>;
|
||||
expect(rows.find((r) => r.id === completed)?.status).toBe('completed');
|
||||
expect(rows.find((r) => r.id === failed)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('is idempotent when called repeatedly', () => {
|
||||
const now = Date.now();
|
||||
insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
|
||||
|
||||
expect(markStale(15 * 60 * 1000)).toBe(1);
|
||||
expect(markStale(15 * 60 * 1000)).toBe(0);
|
||||
expect(markStale(15 * 60 * 1000)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── vulnerability_details cascade ────────────────────────────────
|
||||
|
||||
describe('vulnerability_details cascade', () => {
|
||||
|
||||
+30
-79
@@ -69,7 +69,9 @@ import { getErrorMessage } from './utils/errors';
|
||||
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
|
||||
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
|
||||
import SelfUpdateService from './services/SelfUpdateService';
|
||||
import TrivyService, { SbomFormat } from './services/TrivyService';
|
||||
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
|
||||
import { severityRank } from './utils/severity';
|
||||
import { validateImageRef } from './utils/image-ref';
|
||||
import semver from 'semver';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
|
||||
@@ -1605,22 +1607,13 @@ async function triggerPostDeployScan(
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName);
|
||||
const severityRank = (s: string | null | undefined): number => {
|
||||
switch ((s ?? '').toUpperCase()) {
|
||||
case 'CRITICAL': return 4;
|
||||
case 'HIGH': return 3;
|
||||
case 'MEDIUM': return 2;
|
||||
case 'LOW': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||
if (digest) {
|
||||
const cached = db.getLatestScanByDigest(digest);
|
||||
if (cached && Date.now() - cached.scanned_at < 24 * 60 * 60 * 1000) continue;
|
||||
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
@@ -1643,7 +1636,13 @@ async function triggerPostDeployScan(
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, (err as Error).message);
|
||||
const message = (err as Error).message;
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -7261,11 +7260,16 @@ app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): vo
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' });
|
||||
return;
|
||||
}
|
||||
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
|
||||
if (!imageRef) {
|
||||
const rawImageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
|
||||
if (!rawImageRef) {
|
||||
res.status(400).json({ error: 'imageRef is required' });
|
||||
return;
|
||||
}
|
||||
if (!validateImageRef(rawImageRef)) {
|
||||
res.status(400).json({ error: 'Invalid imageRef format' });
|
||||
return;
|
||||
}
|
||||
const imageRef = rawImageRef;
|
||||
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
|
||||
const force = req.body?.force === true;
|
||||
const nodeId = req.nodeId;
|
||||
@@ -7273,73 +7277,12 @@ app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): vo
|
||||
res.status(409).json({ error: 'Already scanning this image' });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const scanId = db.createVulnerabilityScan({
|
||||
node_id: nodeId,
|
||||
image_ref: imageRef,
|
||||
image_digest: null,
|
||||
scanned_at: Date.now(),
|
||||
total_vulnerabilities: 0,
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
medium_count: 0,
|
||||
low_count: 0,
|
||||
unknown_count: 0,
|
||||
fixable_count: 0,
|
||||
highest_severity: null,
|
||||
os_info: null,
|
||||
trivy_version: svc.getVersion(),
|
||||
scan_duration_ms: null,
|
||||
triggered_by: 'manual',
|
||||
status: 'in_progress',
|
||||
error: null,
|
||||
stack_context: stackContext,
|
||||
});
|
||||
const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext);
|
||||
res.status(202).json({ scanId });
|
||||
|
||||
const startedAt = Date.now();
|
||||
(async () => {
|
||||
try {
|
||||
const result = await svc.scanImage(imageRef, nodeId, { useCache: !force });
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
image_digest: result.imageDigest,
|
||||
scanned_at: result.scannedAt,
|
||||
total_vulnerabilities: result.totalVulnerabilities,
|
||||
critical_count: result.criticalCount,
|
||||
high_count: result.highCount,
|
||||
medium_count: result.mediumCount,
|
||||
low_count: result.lowCount,
|
||||
unknown_count: result.unknownCount,
|
||||
fixable_count: result.fixableCount,
|
||||
highest_severity: result.highestSeverity,
|
||||
os_info: result.metadata.os,
|
||||
trivy_version: result.metadata.trivyVersion,
|
||||
scan_duration_ms: result.metadata.scanDurationMs,
|
||||
status: 'completed',
|
||||
});
|
||||
db.insertVulnerabilityDetails(
|
||||
scanId,
|
||||
result.vulnerabilities.map((v) => ({
|
||||
vulnerability_id: v.vulnerabilityId,
|
||||
pkg_name: v.pkgName,
|
||||
installed_version: v.installedVersion,
|
||||
fixed_version: v.fixedVersion,
|
||||
severity: v.severity,
|
||||
title: v.title || null,
|
||||
description: v.description || null,
|
||||
primary_url: v.primaryUrl,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || 'Scan failed';
|
||||
console.error(`[Security] Scan failed for ${imageRef}:`, msg);
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
status: 'failed',
|
||||
error: msg,
|
||||
scan_duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
}
|
||||
})();
|
||||
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force }).catch((err) => {
|
||||
console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
@@ -7420,6 +7363,9 @@ app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Respons
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'imageRef is required' }); return;
|
||||
}
|
||||
if (!validateImageRef(imageRef)) {
|
||||
res.status(400).json({ error: 'Invalid imageRef format' }); return;
|
||||
}
|
||||
if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') {
|
||||
res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return;
|
||||
}
|
||||
@@ -7839,6 +7785,11 @@ async function startServer() {
|
||||
// Start Docker Event Stream (causal crash/OOM/health detection per local node)
|
||||
await DockerEventManager.getInstance().start();
|
||||
|
||||
// Detect Trivy binary so the vulnerability-scanning capability reflects
|
||||
// reality before any request hits and so the first scan does not pay
|
||||
// detection latency.
|
||||
await TrivyService.getInstance().initialize();
|
||||
|
||||
// Start Background Image Update Checker
|
||||
ImageUpdateService.getInstance().start();
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ export function disableCapability(c: Capability): void {
|
||||
disabledCapabilities.add(c);
|
||||
}
|
||||
|
||||
export function enableCapability(c: Capability): void {
|
||||
disabledCapabilities.delete(c);
|
||||
}
|
||||
|
||||
/** Returns capabilities this instance actually supports at runtime. */
|
||||
export function getActiveCapabilities(): readonly string[] {
|
||||
if (disabledCapabilities.size === 0) return CAPABILITIES;
|
||||
|
||||
@@ -582,6 +582,7 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_node_image ON vulnerability_scans(node_id, image_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_digest ON vulnerability_scans(image_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_scanned_at ON vulnerability_scans(scanned_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vuln_scans_status ON vulnerability_scans(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_details (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -2153,6 +2154,20 @@ export class DatabaseService {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
public markStaleScansAsFailed(olderThanMs: number): number {
|
||||
const cutoff = Date.now() - olderThanMs;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE vulnerability_scans
|
||||
SET status = 'failed',
|
||||
error = 'Scan did not complete within expected time',
|
||||
scan_duration_ms = ? - scanned_at
|
||||
WHERE status = 'in_progress' AND scanned_at < ?`,
|
||||
)
|
||||
.run(Date.now(), cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
public isImageBeingScanned(nodeId: number, imageRef: string): boolean {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -14,11 +14,15 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import TrivyService from './TrivyService';
|
||||
|
||||
const TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000;
|
||||
|
||||
export class SchedulerService {
|
||||
private static instance: SchedulerService;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private isProcessing = false;
|
||||
private runningTasks = new Set<number>();
|
||||
private lastTrivyRedetect = 0;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -56,6 +60,19 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async maybeRedetectTrivy(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTrivyRedetect < TRIVY_REDETECT_INTERVAL_MS) return;
|
||||
this.lastTrivyRedetect = now;
|
||||
try {
|
||||
await TrivyService.getInstance().detectTrivy();
|
||||
} catch (error) {
|
||||
if (isDebugEnabled()) {
|
||||
console.warn('[SchedulerService:debug] Trivy re-detect failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public calculateNextRun(cronExpression: string): number {
|
||||
const expr = CronExpressionParser.parse(cronExpression);
|
||||
return expr.next().toDate().getTime();
|
||||
@@ -68,12 +85,27 @@ export class SchedulerService {
|
||||
}
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Vulnerability scanning is available on every tier, so the stale-scan sweep
|
||||
// and Trivy re-detect run before the paid-tier gate below.
|
||||
try {
|
||||
const staleScans = db.markStaleScansAsFailed(STALE_SCAN_THRESHOLD_MS);
|
||||
if (staleScans > 0) {
|
||||
console.log(
|
||||
`[SchedulerService] Marked ${staleScans} stale vulnerability scan(s) as failed`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SchedulerService] Stale scan sweep failed:', error);
|
||||
}
|
||||
await this.maybeRedetectTrivy();
|
||||
|
||||
const ls = LicenseService.getInstance();
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
|
||||
if (!isPaid) return;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
const dueTasks = db.getDueScheduledTasks(now);
|
||||
|
||||
|
||||
@@ -11,14 +11,19 @@ import {
|
||||
VulnerabilityScan,
|
||||
} from './DatabaseService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { disableCapability } from './CapabilityRegistry';
|
||||
import { disableCapability, enableCapability } from './CapabilityRegistry';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { SEVERITY_ORDER } from '../utils/severity';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const SEVERITY_ORDER: VulnSeverity[] = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||||
const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function diag(msg: string, ...args: unknown[]): void {
|
||||
if (isDebugEnabled()) console.log(`[Trivy:diag] ${msg}`, ...args);
|
||||
}
|
||||
|
||||
interface TrivyRawVulnerability {
|
||||
VulnerabilityID?: string;
|
||||
@@ -94,6 +99,49 @@ function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | nul
|
||||
return highestIdx >= 0 ? SEVERITY_ORDER[highestIdx] : null;
|
||||
}
|
||||
|
||||
export function parseTrivyOutput(raw: string): {
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
os: string | null;
|
||||
} {
|
||||
let parsed: TrivyRawOutput;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as TrivyRawOutput;
|
||||
} catch (e) {
|
||||
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
|
||||
throw new Error('Malformed Trivy output: ' + (e as Error).message);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vulnerabilities: TrivyVulnerability[] = [];
|
||||
for (const result of parsed.Results ?? []) {
|
||||
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);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
installedVersion: v.InstalledVersion ?? '',
|
||||
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
|
||||
severity: normalizeSeverity(v.Severity),
|
||||
title: v.Title ?? '',
|
||||
description: v.Description ?? '',
|
||||
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const osFamily = parsed.Metadata?.OS?.Family;
|
||||
const osName = parsed.Metadata?.OS?.Name;
|
||||
const osInfo = osFamily
|
||||
? osName
|
||||
? `${osFamily} ${osName}`
|
||||
: osFamily
|
||||
: null;
|
||||
return { vulnerabilities, os: osInfo };
|
||||
}
|
||||
|
||||
class TrivyService {
|
||||
private static instance: TrivyService;
|
||||
private available = false;
|
||||
@@ -119,6 +167,8 @@ class TrivyService {
|
||||
}
|
||||
|
||||
async detectTrivy(): Promise<{ available: boolean; version: string | null }> {
|
||||
const started = Date.now();
|
||||
const wasAvailable = this.available;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 });
|
||||
const match = stdout.match(/Version:\s*([^\s\n]+)/i);
|
||||
@@ -129,9 +179,27 @@ class TrivyService {
|
||||
this.version = null;
|
||||
}
|
||||
this.detectionTimestamp = Date.now();
|
||||
diag(
|
||||
`detectTrivy: available=${this.available} version=${this.version ?? 'null'} tookMs=${
|
||||
this.detectionTimestamp - started
|
||||
}`,
|
||||
);
|
||||
if (this.available && !wasAvailable) {
|
||||
enableCapability('vulnerability-scanning');
|
||||
console.log(
|
||||
`[Trivy] Binary detected on PATH; vulnerability scanning enabled (version ${this.version})`,
|
||||
);
|
||||
} else if (!this.available && wasAvailable) {
|
||||
disableCapability('vulnerability-scanning');
|
||||
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
|
||||
}
|
||||
return { available: this.available, version: this.version };
|
||||
}
|
||||
|
||||
getDetectionTimestamp(): number {
|
||||
return this.detectionTimestamp;
|
||||
}
|
||||
|
||||
isTrivyAvailable(): boolean {
|
||||
return this.available;
|
||||
}
|
||||
@@ -140,10 +208,6 @@ class TrivyService {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
invalidateDetection(): void {
|
||||
this.detectionTimestamp = 0;
|
||||
}
|
||||
|
||||
private async buildEnv(
|
||||
sendWarning?: (msg: string) => void,
|
||||
): Promise<{ env: Record<string, string | undefined>; cleanup: () => void }> {
|
||||
@@ -204,49 +268,6 @@ class TrivyService {
|
||||
return this.scanningImages.has(this.scanKey(nodeId, imageRef));
|
||||
}
|
||||
|
||||
private parseTrivyOutput(raw: string): {
|
||||
vulnerabilities: TrivyVulnerability[];
|
||||
os: string | null;
|
||||
} {
|
||||
let parsed: TrivyRawOutput;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as TrivyRawOutput;
|
||||
} catch (e) {
|
||||
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
|
||||
throw new Error('Malformed Trivy output: ' + (e as Error).message);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const vulnerabilities: TrivyVulnerability[] = [];
|
||||
for (const result of parsed.Results ?? []) {
|
||||
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);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
installedVersion: v.InstalledVersion ?? '',
|
||||
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
|
||||
severity: normalizeSeverity(v.Severity),
|
||||
title: v.Title ?? '',
|
||||
description: v.Description ?? '',
|
||||
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const osFamily = parsed.Metadata?.OS?.Family;
|
||||
const osName = parsed.Metadata?.OS?.Name;
|
||||
const osInfo = osFamily
|
||||
? osName
|
||||
? `${osFamily} ${osName}`
|
||||
: osFamily
|
||||
: null;
|
||||
return { vulnerabilities, os: osInfo };
|
||||
}
|
||||
|
||||
async scanImage(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
@@ -261,13 +282,18 @@ class TrivyService {
|
||||
}
|
||||
this.scanningImages.add(key);
|
||||
const startedAt = Date.now();
|
||||
diag(`scanImage: start nodeId=${nodeId} imageRef=${imageRef} 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);
|
||||
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,
|
||||
@@ -303,6 +329,7 @@ class TrivyService {
|
||||
}
|
||||
}
|
||||
|
||||
diag(`scanImage: cache miss; invoking trivy for ${imageRef}`);
|
||||
const { env, cleanup } = await this.buildEnv();
|
||||
try {
|
||||
const args = [
|
||||
@@ -315,12 +342,19 @@ class TrivyService {
|
||||
'vuln',
|
||||
imageRef,
|
||||
];
|
||||
const execStart = Date.now();
|
||||
const { stdout } = await execFileAsync('trivy', args, {
|
||||
env,
|
||||
timeout: SCAN_TIMEOUT_MS,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
const { vulnerabilities, os: osInfo } = this.parseTrivyOutput(stdout);
|
||||
diag(
|
||||
`scanImage: trivy exited after ${Date.now() - execStart}ms, output=${stdout.length} bytes`,
|
||||
);
|
||||
const { vulnerabilities, os: osInfo } = parseTrivyOutput(stdout);
|
||||
diag(
|
||||
`scanImage: parsed ${vulnerabilities.length} unique vulns (os=${osInfo ?? 'unknown'})`,
|
||||
);
|
||||
|
||||
let critical = 0,
|
||||
high = 0,
|
||||
@@ -375,14 +409,18 @@ class TrivyService {
|
||||
}
|
||||
}
|
||||
|
||||
async runScanAndPersist(
|
||||
/**
|
||||
* Create an `in_progress` scan row. The returned ID is immediately
|
||||
* usable by clients that need a handle to poll; callers must pair
|
||||
* this with `finishScan` to move the row to `completed` or `failed`.
|
||||
*/
|
||||
beginScan(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger,
|
||||
stackContext: string | null = null,
|
||||
): Promise<VulnerabilityScan> {
|
||||
): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
const startedAt = Date.now();
|
||||
const scanId = db.createVulnerabilityScan({
|
||||
node_id: nodeId,
|
||||
image_ref: imageRef,
|
||||
@@ -404,9 +442,25 @@ class TrivyService {
|
||||
error: null,
|
||||
stack_context: stackContext,
|
||||
});
|
||||
diag(`beginScan: scanId=${scanId} imageRef=${imageRef} nodeId=${nodeId} trigger=${triggeredBy}`);
|
||||
return scanId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the scan and persist results into a scan row already
|
||||
* created by `beginScan`. Always flips the row to `completed` on
|
||||
* success or `failed` on error.
|
||||
*/
|
||||
async finishScan(
|
||||
scanId: number,
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
opts: { useCache?: boolean } = {},
|
||||
): Promise<VulnerabilityScan> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const result = await this.scanImage(imageRef, nodeId);
|
||||
const result = await this.scanImage(imageRef, nodeId, { useCache: opts.useCache });
|
||||
db.updateVulnerabilityScan(scanId, {
|
||||
image_digest: result.imageDigest,
|
||||
scanned_at: result.scannedAt,
|
||||
@@ -438,6 +492,9 @@ class TrivyService {
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
return stored;
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message || 'Scan failed';
|
||||
@@ -446,10 +503,22 @@ class TrivyService {
|
||||
error: msg,
|
||||
scan_duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
diag(`finishScan: scanId=${scanId} failed: ${msg}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async runScanAndPersist(
|
||||
imageRef: string,
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger,
|
||||
stackContext: string | null = null,
|
||||
opts: { useCache?: boolean } = {},
|
||||
): Promise<VulnerabilityScan> {
|
||||
const scanId = this.beginScan(imageRef, nodeId, triggeredBy, stackContext);
|
||||
return this.finishScan(scanId, imageRef, nodeId, opts);
|
||||
}
|
||||
|
||||
async scanAllNodeImages(
|
||||
nodeId: number,
|
||||
triggeredBy: VulnScanTrigger = 'scheduled',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const MAX_LENGTH = 255;
|
||||
|
||||
const IMAGE_REF_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9._\-/:@+]*[a-zA-Z0-9])?$/;
|
||||
|
||||
const FORBIDDEN_CHARS = /[\s;|&`$(){}[\]<>'"\\!*?#~]/;
|
||||
|
||||
export function validateImageRef(ref: unknown): ref is string {
|
||||
if (typeof ref !== 'string') return false;
|
||||
const trimmed = ref.trim();
|
||||
if (trimmed.length === 0 || trimmed.length > MAX_LENGTH) return false;
|
||||
if (FORBIDDEN_CHARS.test(trimmed)) return false;
|
||||
if (trimmed.includes('..')) return false;
|
||||
if (!IMAGE_REF_PATTERN.test(trimmed)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assertImageRef(ref: unknown): string {
|
||||
if (!validateImageRef(ref)) {
|
||||
throw new Error('Invalid image reference');
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { VulnSeverity } from '../services/DatabaseService';
|
||||
|
||||
export const SEVERITY_ORDER: VulnSeverity[] = [
|
||||
'UNKNOWN',
|
||||
'LOW',
|
||||
'MEDIUM',
|
||||
'HIGH',
|
||||
'CRITICAL',
|
||||
];
|
||||
|
||||
export function severityRank(severity: VulnSeverity | null | undefined): number {
|
||||
if (!severity) return -1;
|
||||
return SEVERITY_ORDER.indexOf(severity);
|
||||
}
|
||||
|
||||
export function isSeverityAtLeast(
|
||||
actual: VulnSeverity | null | undefined,
|
||||
threshold: VulnSeverity,
|
||||
): boolean {
|
||||
return severityRank(actual) >= severityRank(threshold);
|
||||
}
|
||||
@@ -186,3 +186,19 @@ Sencho forwards the same registry credentials configured under **Settings → Re
|
||||
### Badge is out of date after an image update
|
||||
|
||||
Post-deploy scanning only runs on deploy actions. For long-running images that aren't redeployed, schedule a recurring scan (Skipper+) or click the shield icon in the Resources Hub to re-scan on demand.
|
||||
|
||||
### A scan shows "in progress" for a long time
|
||||
|
||||
Scans have a 5-minute internal timeout. The scheduler also sweeps every tick and marks any scan that has been `in progress` for more than 15 minutes as failed, so the UI always recovers on its own. Wait for the sweep to run, then click the shield icon again to start a fresh scan.
|
||||
|
||||
### Trivy is not detected after installing it
|
||||
|
||||
Sencho re-checks for the Trivy binary every ten minutes from the scheduler. Install Trivy on the host, wait for the next window, and the scanning UI lights up automatically.
|
||||
|
||||
### Collecting diagnostic logs for support
|
||||
|
||||
Enable **Developer Mode** under **Settings → Developer** and trigger the failing scan again. The backend logs verbose `[Trivy:diag]` entries covering detection timing, cache hits, Trivy invocation, and parse statistics. Attach these lines when filing a support issue. Turn Developer Mode off once you have captured the output.
|
||||
|
||||
### Post-deploy scan failure notifications
|
||||
|
||||
When a post-deploy scan fails for a specific image (for example because Trivy could not resolve a private registry pull), Sencho dispatches a warning-level alert through your configured notification channels. The deploy itself is never blocked by a scan failure.
|
||||
|
||||
@@ -150,6 +150,10 @@ After restarting Sencho with the mount in place:
|
||||
|
||||
If the shield icon is missing, see the troubleshooting section below.
|
||||
|
||||
### Runtime detection
|
||||
|
||||
Sencho re-runs the `trivy --version` check every ten minutes from the background scheduler. You can install Trivy on a running host and scanning will light up on its own within that window. If the binary becomes unavailable at runtime, the UI hides itself in the same way.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sencho does not detect Trivy
|
||||
|
||||
@@ -623,8 +623,8 @@ export default function ResourcesView() {
|
||||
}
|
||||
throw new Error('Scan timed out');
|
||||
} catch (error) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(err?.message || 'Scan failed');
|
||||
const err = error as { message?: string; error?: string; data?: { error?: string } };
|
||||
toast.error(err?.message || err?.error || err?.data?.error || 'Scan failed');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setScanningImageRef(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -75,18 +75,21 @@ export function VulnerabilityScanSheet({
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
||||
const [totalDetails, setTotalDetails] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
|
||||
const [page, setPage] = useState(0);
|
||||
const [downloadingSbom, setDownloadingSbom] = useState(false);
|
||||
|
||||
const DETAIL_FETCH_LIMIT = 500;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (scanId == null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [scanRes, detailsRes] = await Promise.all([
|
||||
apiFetch(`/security/scans/${scanId}`),
|
||||
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`),
|
||||
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`),
|
||||
]);
|
||||
if (!scanRes.ok) throw new Error('Failed to fetch scan');
|
||||
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
|
||||
@@ -94,6 +97,7 @@ export function VulnerabilityScanSheet({
|
||||
const detailsData = await detailsRes.json();
|
||||
setScan(scanData);
|
||||
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
|
||||
setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0);
|
||||
setPage(0);
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to load scan');
|
||||
@@ -107,6 +111,7 @@ export function VulnerabilityScanSheet({
|
||||
else {
|
||||
setScan(null);
|
||||
setDetails([]);
|
||||
setTotalDetails(0);
|
||||
setSeverityFilter('ALL');
|
||||
setPage(0);
|
||||
}
|
||||
@@ -191,6 +196,11 @@ export function VulnerabilityScanSheet({
|
||||
{scan?.image_ref ?? 'Loading...'}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{scan
|
||||
? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.`
|
||||
: 'Vulnerability scan details.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !scan && (
|
||||
@@ -336,6 +346,12 @@ export function VulnerabilityScanSheet({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalDetails > details.length && (
|
||||
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
|
||||
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{pageItems.length === 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user