mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(images): Trivy-powered vulnerability scanning (#635)
* feat(images): Trivy-powered vulnerability scanning Scan container images for known CVEs via Trivy. On-demand scanning and severity badges are available on every tier; scheduled scans, scan policies, SBOM generation, and scan history are gated to Skipper+. - New TrivyService (binary detection, per-image scan, SBOM, digest cache) - Three new tables: vulnerability_scans, vulnerability_details, scan_policies - 12 routes under /api/security (scan, results, summaries, SBOM, policies, compare) - Post-deploy async scans wired into all five deploy paths, with a per-deploy opt-out toggle in the App Store deploy sheet - "scan" action type added to SchedulerService for fleet-wide recurring scans - Frontend: severity badges in Resources Hub with animated cursor detail, scan results drawer with vulnerability table and filters, and a new Security section in Settings for scan policy CRUD - Policy threshold violations dispatch a warning or critical alert based on the policy's block_on_deploy flag; deploys themselves are never blocked * fix(security): compute scan age in useEffect to satisfy react-hooks/purity
This commit is contained in:
+393
-6
@@ -69,6 +69,7 @@ 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 semver from 'semver';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
|
||||
@@ -1578,12 +1579,78 @@ function isSqliteUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE';
|
||||
}
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral.
|
||||
// Tier gate for scheduled tasks: 'update' and 'scan' actions require Skipper+, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePaid(req, res);
|
||||
if (action === 'update' || action === 'scan') return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
async function triggerPostDeployScan(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
): Promise<void> {
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) return;
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const containers = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${stackName}`] },
|
||||
});
|
||||
const imageRefs = new Set<string>();
|
||||
for (const c of containers as Array<{ Image?: string }>) {
|
||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||
}
|
||||
if (imageRefs.size === 0) return;
|
||||
|
||||
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;
|
||||
}
|
||||
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||
|
||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
scan.critical_count > 0 ? 'error' : 'warning',
|
||||
`Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
policy &&
|
||||
severityRank(scan.highest_severity) >= severityRank(policy.max_severity)
|
||||
) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
policy.block_on_deploy ? 'error' : 'warning',
|
||||
`Policy "${policy.name}" triggered for ${imageRef}: ${scan.highest_severity} exceeds ${policy.max_severity}`,
|
||||
stackName,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
|
||||
type PermissionAction =
|
||||
@@ -4474,6 +4541,11 @@ app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Re
|
||||
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
|
||||
}
|
||||
res.json(result);
|
||||
if (result.deployed) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
sendGitSourceError(res, error);
|
||||
}
|
||||
@@ -4641,6 +4713,11 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => {
|
||||
deployed,
|
||||
deployError,
|
||||
});
|
||||
if (deployed) {
|
||||
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (fromGitDiag) {
|
||||
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
|
||||
@@ -4791,6 +4868,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) =>
|
||||
console.log(`[Stacks] Deploy completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||
res.json({ message: 'Deployed successfully' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
@@ -4910,6 +4990,9 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
|
||||
console.log(`[Stacks] Update completed: ${stackName}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
res.json({ status: 'Update completed' });
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Stacks] Update failed: ${stackName}`, error);
|
||||
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
|
||||
@@ -6033,8 +6116,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (!['stack', 'fleet', 'system'].includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
|
||||
}
|
||||
if (!['restart', 'snapshot', 'prune', 'update'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, or update.' }); return;
|
||||
if (!['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return;
|
||||
}
|
||||
// Tier gate based on action type
|
||||
if (!requireScheduledTaskTier(action, req, res)) return;
|
||||
@@ -6051,6 +6134,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
|
||||
if (action === 'prune' && target_type !== 'system') {
|
||||
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
|
||||
}
|
||||
if (action === 'scan' && target_type !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
if (target_type === 'stack' && (!target_id || !node_id)) {
|
||||
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
|
||||
}
|
||||
@@ -6151,7 +6237,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
||||
}
|
||||
if (action && !['restart', 'snapshot', 'prune', 'update'].includes(action)) {
|
||||
if (action && !['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action' }); return;
|
||||
}
|
||||
|
||||
@@ -6169,6 +6255,9 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
|
||||
if (finalAction === 'prune' && finalTargetType !== 'system') {
|
||||
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
|
||||
}
|
||||
if (finalAction === 'scan' && finalTargetType !== 'system') {
|
||||
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
|
||||
}
|
||||
|
||||
// Validate prune targets
|
||||
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
|
||||
@@ -6847,7 +6936,7 @@ app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Res
|
||||
app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { stackName, template, envVars } = req.body;
|
||||
const { stackName, template, envVars, skip_scan } = req.body;
|
||||
|
||||
if (!stackName || !template) {
|
||||
return res.status(400).json({ error: 'stackName and template are required' });
|
||||
@@ -6906,6 +6995,11 @@ app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Resp
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
console.log(`[Templates] Deploy completed: ${stackName}`);
|
||||
res.json({ success: true, message: 'Template deployed successfully' });
|
||||
if (!skip_scan) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
|
||||
);
|
||||
}
|
||||
} catch (deployError: unknown) {
|
||||
const rawError = getErrorMessage(deployError, String(deployError));
|
||||
console.error(`[Templates] Deploy failed: ${stackName} -`, rawError);
|
||||
@@ -7151,6 +7245,299 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
// =========================
|
||||
// Vulnerability Scanning Routes
|
||||
// =========================
|
||||
|
||||
app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => {
|
||||
const svc = TrivyService.getInstance();
|
||||
res.json({ available: svc.isTrivyAvailable(), version: svc.getVersion() });
|
||||
});
|
||||
|
||||
app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
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) {
|
||||
res.status(400).json({ error: 'imageRef is required' });
|
||||
return;
|
||||
}
|
||||
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
|
||||
const force = req.body?.force === true;
|
||||
const nodeId = req.nodeId;
|
||||
if (svc.isScanning(nodeId, imageRef)) {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, {
|
||||
imageRef,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to list scans:', error);
|
||||
res.status(500).json({ error: 'Failed to list scans' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Response): void => {
|
||||
const scanId = Number(req.params.scanId);
|
||||
if (!Number.isFinite(scanId)) {
|
||||
res.status(400).json({ error: 'Invalid scan id' }); return;
|
||||
}
|
||||
const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId);
|
||||
if (!scan || scan.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'Scan not found' }); return;
|
||||
}
|
||||
res.json(scan);
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/security/scans/:scanId/vulnerabilities',
|
||||
authMiddleware,
|
||||
(req: Request, res: Response): void => {
|
||||
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;
|
||||
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId);
|
||||
res.json(summaries);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to fetch image summaries:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch image summaries' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
res.status(503).json({ error: 'Trivy is not available on this host' }); return;
|
||||
}
|
||||
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
|
||||
const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json';
|
||||
if (!imageRef) {
|
||||
res.status(400).json({ error: 'imageRef is required' }); return;
|
||||
}
|
||||
if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') {
|
||||
res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return;
|
||||
}
|
||||
try {
|
||||
const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat);
|
||||
const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json';
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`);
|
||||
res.send(sbom);
|
||||
} catch (error) {
|
||||
console.error('[Security] SBOM generation failed:', error);
|
||||
res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
res.json(DatabaseService.getInstance().getScanPolicies());
|
||||
});
|
||||
|
||||
app.post('/api/security/policies', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Policy name is required' }); return;
|
||||
}
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
||||
if (!validSeverities.has(max_severity)) {
|
||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||
}
|
||||
try {
|
||||
const policy = DatabaseService.getInstance().createScanPolicy({
|
||||
name: name.trim(),
|
||||
node_id: node_id != null ? Number(node_id) : null,
|
||||
stack_pattern: stack_pattern ? String(stack_pattern) : null,
|
||||
max_severity,
|
||||
block_on_deploy: block_on_deploy ? 1 : 0,
|
||||
enabled: enabled === false ? 0 : 1,
|
||||
});
|
||||
res.status(201).json(policy);
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to create policy:', error);
|
||||
res.status(500).json({ error: 'Failed to create policy' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid policy id' }); return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) updates.name = String(body.name).trim();
|
||||
if (body.node_id !== undefined) updates.node_id = body.node_id != null ? Number(body.node_id) : null;
|
||||
if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null;
|
||||
if (body.max_severity !== undefined) {
|
||||
const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
|
||||
if (!validSeverities.has(body.max_severity)) {
|
||||
res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return;
|
||||
}
|
||||
updates.max_severity = body.max_severity;
|
||||
}
|
||||
if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0;
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0;
|
||||
const policy = DatabaseService.getInstance().updateScanPolicy(id, updates);
|
||||
if (!policy) {
|
||||
res.status(404).json({ error: 'Policy not found' }); return;
|
||||
}
|
||||
res.json(policy);
|
||||
});
|
||||
|
||||
app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isFinite(id)) {
|
||||
res.status(400).json({ error: 'Invalid policy id' }); return;
|
||||
}
|
||||
DatabaseService.getInstance().deleteScanPolicy(id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const scanId1 = Number(req.query.scanId1);
|
||||
const scanId2 = Number(req.query.scanId2);
|
||||
if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) {
|
||||
res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const a = db.getVulnerabilityScan(scanId1);
|
||||
const b = db.getVulnerabilityScan(scanId2);
|
||||
if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) {
|
||||
res.status(404).json({ error: 'One or both scans not found' }); return;
|
||||
}
|
||||
const aVulns = db.getVulnerabilityDetails(scanId1, { limit: 1000 }).items;
|
||||
const bVulns = db.getVulnerabilityDetails(scanId2, { limit: 1000 }).items;
|
||||
const keyOf = (v: { vulnerability_id: string; pkg_name: string }) =>
|
||||
`${v.vulnerability_id}::${v.pkg_name}`;
|
||||
const aMap = new Map(aVulns.map((v) => [keyOf(v), v]));
|
||||
const bMap = new Map(bVulns.map((v) => [keyOf(v), v]));
|
||||
const added = bVulns.filter((v) => !aMap.has(keyOf(v)));
|
||||
const removed = aVulns.filter((v) => !bMap.has(keyOf(v)));
|
||||
const unchanged = aVulns.filter((v) => bMap.has(keyOf(v)));
|
||||
res.json({
|
||||
scanA: { id: a.id, scanned_at: a.scanned_at, image_ref: a.image_ref },
|
||||
scanB: { id: b.id, scanned_at: b.scanned_at, image_ref: b.image_ref },
|
||||
added,
|
||||
removed,
|
||||
unchanged: unchanged.map((v) => ({ vulnerability_id: v.vulnerability_id, pkg_name: v.pkg_name, severity: v.severity })),
|
||||
});
|
||||
});
|
||||
|
||||
// =========================
|
||||
// Node Management API
|
||||
// =========================
|
||||
|
||||
Reference in New Issue
Block a user