/** * Shield API — per-app security status for DashCaddy service tiles. * * DC-Shield: turns the sec-dashboard collector snapshot * (/var/lib/sec-dashboard/snapshot.json, written every 2 min on this host) * into a per-service shield verdict so the frontend can badge every app tile: * * GET /api/v1/security/shield * → { success, generated_utc, snapshot_age_s, * fleet: { verdict: secure|watch|incident, reasons: [...] }, * services: { : { * level: shielded|attacked|degraded, // tile badge color * label: "...", // short text for tooltip/title * probes_24h: N, denied_24h: N, errors_24h: N, * banned_now: N, failed_auth_24h: N * } }, unmapped_hosts: [...] } * * Mapping rules: caddy top_apps hosts are matched against services.json * subdomains (host "plex.sami" → service "plex"). Hosts with no matching * service are returned in unmapped_hosts (still visible, not silently dropped). * * Verdict policy mirrors sec-dashboard renderVerdict(): degraded sources, * snapshot staleness, lvl-10+ alert bursts, disk ≥88%, agents down, drift. */ const express = require('express'); const fs = require('fs'); const { ok, errorResponse } = require('../src/utils/responses'); const SNAPSHOT_PATH = '/var/lib/sec-dashboard/snapshot.json'; const SERVICES_PATH = '/opt/dashcaddy/dashcaddy-api/data/services.json'; const STALE_WARN_S = 600; // >10 min old snapshot = degraded signal const STALE_BAD_S = 1800; // >30 min = incident-class (collector wedged) function loadJson(path) { try { return JSON.parse(fs.readFileSync(path, 'utf8')); } catch (_) { return null; } } /** Map caddy host entries to DashCaddy service ids by subdomain match. */ function hostToServiceId(host, services) { if (!host) return null; const bare = String(host).toLowerCase().split(':')[0]; for (const s of services) { if (!s || !s.url) continue; let url = s.url; try { url = new URL(s.url).hostname; } catch (_) { /* raw hostname */ } const svcHost = String(url).toLowerCase().split(':')[0]; if (!svcHost) continue; if (bare === svcHost) return s.id; // "www." and "sub." prefix tolerance both ways if (bare === 'www.' + svcHost || svcHost === 'www.' + bare) return s.id; } return null; } /** Fleet verdict — same policy as the sec.sami header, computed server-side. */ function fleetVerdict(snap, ageS) { let worst = 'secure'; const reasons = []; const bump = (lvl, why) => { if (lvl === 'incident') { worst = 'incident'; reasons.push(why); } else if (lvl === 'watch' && worst !== 'incident') { if (worst === 'secure') worst = 'watch'; reasons.push(why); } }; const srcs = snap.sources || {}; const degraded = Object.entries(srcs).filter(([, v]) => !v || v.ok !== true); if (degraded.length) bump('watch', `${degraded.length} degraded source(s)`); if (ageS > STALE_BAD_S) bump('incident', `snapshot ${Math.round(ageS / 60)}m stale`); else if (ageS > STALE_WARN_S) bump('watch', `snapshot ${Math.round(ageS / 60)}m stale`); const w = srcs.wazuh; if (w && w.ok) { const hi = (w.data && w.data.level10_total_24h) || 0; if (hi > 20) bump('incident', `${hi} lvl-10+ alerts/24h`); else if (hi > 0) bump('watch', `${hi} lvl-10+ alert(s)/24h`); } const p = srcs.posture; if (p && p.ok && p.data) { const disk = parseFloat(p.data.dns2_disk_used); if (Number.isFinite(disk) && disk >= 88) bump('watch', `dns2 disk ${Math.round(disk)}%`); } const ag = srcs.agents; if (ag && ag.ok && Array.isArray(ag.data)) { const down = ag.data.filter((a) => !String(a.status || '').startsWith('Active')).length; if (down > 0) bump('watch', `${down} SIEM agent(s) down`); } const n2 = srcs.nft_dns2, n3 = srcs.nft_samihost, nd = srcs.nft_dns3; if (n2 && n3 && nd && n2.ok && n3.ok && nd.ok) { const set = new Set([ n2.data && n2.data.blackholed_subnets, n3.data && n3.data.blackholed_subnets, nd.data && nd.data.blackholed_subnets, ]); if (set.size > 1) bump('watch', 'blacklist sync drift'); } return { verdict: worst, reasons: reasons.slice(0, 4) }; } module.exports = function ({ log }) { const router = express.Router(); router.get('/shield', (req, res) => { const snap = loadJson(SNAPSHOT_PATH); if (!snap) { return errorResponse(res, 503, 'security snapshot unavailable', { code: 'SNAPSHOT_UNAVAILABLE' }); } const collectedMs = Date.parse(snap.collected_utc || '') || 0; const ageS = collectedMs ? Math.max(0, Math.round((Date.now() - collectedMs) / 1000)) : null; const services = loadJson(SERVICES_PATH) || []; const svcIds = new Set(services.map((s) => s && s.id).filter(Boolean)); const caddy = (snap.sources && snap.sources.caddy && snap.sources.caddy.ok) ? snap.sources.caddy.data : null; const defenses = (snap.sources && snap.sources.app_defenses && snap.sources.app_defenses.ok) ? snap.sources.app_defenses.data : null; // Normalize possibly-string counters to finite numbers before summing (judge r1). const num = (v) => { const n = typeof v === 'number' ? v : parseFloat(v); return Number.isFinite(n) ? n : 0; }; // Aggregate fail2ban counts (current banned + recent failures) across all jails/hosts. // Counters may arrive as numeric strings ("7") — normalize before summing (judge r1). let bannedNow = 0; let failedAuth = 0; if (defenses) { for (const group of Object.values(defenses)) { if (!group || typeof group !== 'object') continue; for (const jail of Object.values(group)) { if (!jail || typeof jail !== 'object') continue; bannedNow += num(jail.current_banned); failedAuth += num(jail.failed_total); } } } const out = {}; const unmapped = []; const nowS = Math.floor(Date.now() / 1000); const windowH = (caddy && caddy.window_hours) || 24; const LEVEL_RANK = { shielded: 0, degraded: 1, attacked: 2 }; const worstLevel = (a, b) => (LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b); // Aggregate ALL caddy hosts mapping to the same service — a later clean // entry must never erase an earlier attacked/degraded one (judge r1). if (caddy && Array.isArray(caddy.top_apps)) { const agg = new Map(); // serviceId -> {probes, denied, errors, requests, worst} for (const app of caddy.top_apps) { const id = hostToServiceId(app.host, services); const probes = num(app.probes); const denied = num(app.denied); const errors = num(app.errors); const requests = num(app.requests); let level = 'shielded'; const labels = []; if (denied > 0 || probes > 20) { level = 'attacked'; labels.push(`${denied} denied · ${probes} probes (${windowH}h)`); } if (errors > Math.max(requests, 1) * 0.5 && requests >= 10) { // errors are LESS severe than an active attack — keep attacked (judge r2) if (level !== 'attacked') level = 'degraded'; labels.push(`${errors} upstream errors (${windowH}h)`); } if (id) { const cur = agg.get(id) || { probes: 0, denied: 0, errors: 0, requests: 0, worst: 'shielded', labels: [] }; cur.probes += probes; cur.denied += denied; cur.errors += errors; cur.requests += requests; cur.worst = worstLevel(cur.worst, level); labels.forEach((l) => { if (!cur.labels.includes(l)) cur.labels.push(l); }); agg.set(id, cur); } else { const cur = agg.get('__unmapped:' + app.host) || { host: app.host, probes: 0, denied: 0, errors: 0, requests: 0, worst: 'shielded', labels: [] }; cur.probes += probes; cur.denied += denied; cur.errors += errors; cur.requests += requests; cur.worst = worstLevel(cur.worst, level); labels.forEach((l) => { if (!cur.labels.includes(l)) cur.labels.push(l); }); agg.set('__unmapped:' + app.host, cur); } } for (const [key, a] of agg) { // The label MUST match the worst level — never report "clean —" on an // attacked/degraded aggregate (judge r2). Prefer an attacked label over // a degraded one; fall back to the clean summary only when shielded. let label; if (a.worst === 'shielded' || !(a.labels && a.labels.length)) { label = (a.worst === 'shielded') ? `clean — ${a.requests} req/${windowH}h` : (a.labels && a.labels[0]) || `no traffic details (${windowH}h)`; } else { const attackedLbl = a.labels.find((l) => l.includes('denied')); label = attackedLbl || a.labels[a.labels.length - 1]; } const entry = { level: a.worst, label, probes_24h: a.probes, denied_24h: a.denied, errors_24h: a.errors, requests_24h: a.requests, banned_now: bannedNow, failed_auth_24h: failedAuth, }; if (key.startsWith('__unmapped:')) { unmapped.push({ host: key.slice('__unmapped:'.length), level: a.worst, label: entry.label, probes_24h: a.probes, denied_24h: a.denied, errors_24h: a.errors }); } else { out[key] = entry; } } } // Services with no caddy traffic entry still get a (quiet) badge entry so // the frontend can show "no data" rather than nothing. for (const id of svcIds) { if (!out[id]) { out[id] = { level: 'shielded', label: 'no recent edge traffic', probes_24h: 0, denied_24h: 0, errors_24h: 0, requests_24h: 0, banned_now: bannedNow, failed_auth_24h: failedAuth, }; } } const fleet = fleetVerdict(snap, ageS === null ? STALE_BAD_S + 1 : ageS); if (ageS !== null && ageS > STALE_WARN_S) fleet.stale = true; log && log.info && log.info(`[shield] served ${Object.keys(out).length} services, fleet=${fleet.verdict}, unmapped=${unmapped.length}, snapshot age=${ageS}s`); return ok(res, { generated_utc: new Date(nowS * 1000).toISOString(), snapshot_age_s: ageS, window_hours: windowH, fleet, services: out, unmapped_hosts: unmapped, }); }); return router; };