mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
feat: add an on-demand node-wide security scan with live progress (#1367)
Add a "Scan this node" action on the Security overview that scans, in one pass, any combination of three types: image vulnerabilities, image secrets, and compose misconfigurations. Progress streams live into the deploy-feedback modal. - TrivyService.scanNode runs the selected scanners across the node's images and, for misconfig, every stack's compose file, behind a per-node lock and tolerant of per-item failures. The existing scanAllNodeImages becomes a thin vuln-only wrapper over the shared image loop, so scheduled scans are unchanged. - POST /api/security/scan-node (admin, scanner-gated) streams sanitized progress to the deploy terminal and returns a combined summary. Secret scans stream counts only, never matched values. - Frontend adds a "scan" action verb and a ScanNodeLauncher wired into the overview; the scan stays bound to the node it started on even if the active node changes mid-run.
This commit is contained in:
@@ -18,6 +18,7 @@ import { blockIfReplica } from '../middleware/fleetSyncGuards';
|
||||
import { validateStackPatternForRedos } from './fleet';
|
||||
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
|
||||
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
|
||||
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
// Trivy emits misconfig rule ids in two shapes that Sencho persists verbatim:
|
||||
@@ -329,6 +330,73 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
// On-demand node-wide scan: images for the selected scanners (vuln/secret) and,
|
||||
// when requested, every stack's compose config for misconfigurations. Streams
|
||||
// sanitized progress to the deploy-feedback terminal when the client opened one.
|
||||
securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Response): Promise<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 body = req.body ?? {};
|
||||
if (![body.vulns, body.secrets, body.misconfig].every((v) => v === undefined || typeof v === 'boolean')) {
|
||||
res.status(400).json({ error: 'vulns, secrets and misconfig must be booleans' });
|
||||
return;
|
||||
}
|
||||
const vulns = body.vulns === true;
|
||||
const secrets = body.secrets === true;
|
||||
const misconfig = body.misconfig === true;
|
||||
if (!vulns && !secrets && !misconfig) {
|
||||
res.status(400).json({ error: 'Select at least one scan type' });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId;
|
||||
|
||||
// Stream progress to the deploy-feedback terminal only when the client supplied
|
||||
// a session id: a bare getTerminalWs(undefined) falls back to the most recent
|
||||
// terminal and could cross-stream this scan into an unrelated deploy. scanNode
|
||||
// emits only counts and rule ids, never raw secret values; the wrapper here
|
||||
// additionally strips CR/LF and caps line length before sending.
|
||||
const sessionId = req.get(DEPLOY_SESSION_HEADER);
|
||||
const ws = sessionId ? getTerminalWs(sessionId) : undefined;
|
||||
const onProgress = ws
|
||||
? (line: string): void => {
|
||||
try { ws.send(line.replace(/[\r\n]+/g, ' ').slice(0, 2000) + '\r\n'); }
|
||||
catch { /* socket closed mid-scan; the scan still runs to completion */ }
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
DatabaseService.getInstance().insertAuditLog({
|
||||
timestamp: Date.now(),
|
||||
username: req.user?.username ?? 'unknown',
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status_code: 200,
|
||||
node_id: nodeId,
|
||||
ip_address: req.ip ?? '',
|
||||
summary: `node-wide scan triggered (vulns=${vulns} secrets=${secrets} misconfig=${misconfig})`,
|
||||
});
|
||||
} catch (auditErr) {
|
||||
console.warn('[Security] failed to record node-scan audit log:', getErrorMessage(auditErr, 'unknown'));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await svc.scanNode(nodeId, { vulns, secrets, misconfig }, 'manual', onProgress);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'Failed to run node scan');
|
||||
if (message === 'Already scanning this node') {
|
||||
res.status(409).json({ error: message });
|
||||
return;
|
||||
}
|
||||
console.error('[Security] node-wide scan failed:', sanitizeForLog(message));
|
||||
res.status(500).json({ error: 'Failed to run node scan' });
|
||||
}
|
||||
});
|
||||
|
||||
securityRouter.get('/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
try {
|
||||
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
|
||||
|
||||
Reference in New Issue
Block a user