Unify /health: scanner inventory + EDR agents in one route

This commit is contained in:
BlackSnufkin
2026-05-04 02:11:31 -07:00
parent c7a705ee30
commit 964aa71ad7
6 changed files with 94 additions and 89 deletions
+3 -3
View File
@@ -55,9 +55,9 @@ class SystemMixin:
def get_scanners_status(self) -> Dict: def get_scanners_status(self) -> Dict:
"""Inventory of configured analyzers and whether their binaries """Inventory of configured analyzers and whether their binaries
exist on disk (drives the dashboard scanner panel).""" exist on disk. Returns the `scanners` field of the unified /health
response = self._make_request("GET", "/api/system/scanners") response: `{rows: [...], counts: {...}}`."""
return response.json() return (self.check_health() or {}).get("scanners", {"rows": [], "counts": {}})
# ---- destructive --------------------------------------------------- # ---- destructive ---------------------------------------------------
-47
View File
@@ -111,53 +111,6 @@ def api_edr_profiles():
return jsonify({'profiles': deps.edr_registry.list_profiles()}) return jsonify({'profiles': deps.edr_registry.list_profiles()})
@api_bp.route('/api/system/scanners', methods=['GET'])
@error_handler
def api_system_scanners():
"""Inventory of configured analyzers and whether their binaries exist.
Walks the static + dynamic + holygrail sections of analysis config and
reports per-scanner: enabled flag, configured tool path, whether the
file is present on disk. Used by the dashboard to flag missing tools."""
cfg = current_app.config.get('analysis', {}) or {}
def _row(group, name, scanner_cfg):
tool_path = (scanner_cfg or {}).get('tool_path', '').strip()
enabled = bool((scanner_cfg or {}).get('enabled', False))
exists = bool(tool_path) and os.path.isfile(tool_path)
return {
'group': group,
'name': name,
'enabled': enabled,
'tool_path': tool_path,
'exists': exists,
'status': (
'ok' if enabled and exists else
'missing' if enabled and not exists else
'disabled'
),
}
rows = []
for group_key in ('static', 'dynamic'):
group_cfg = cfg.get(group_key) or {}
for scanner_name, scanner_cfg in group_cfg.items():
if isinstance(scanner_cfg, dict):
rows.append(_row(group_key, scanner_name, scanner_cfg))
holygrail = cfg.get('holygrail')
if isinstance(holygrail, dict):
rows.append(_row('holygrail', 'holygrail', holygrail))
counts = {
'total': len(rows),
'ok': sum(1 for r in rows if r['status'] == 'ok'),
'missing': sum(1 for r in rows if r['status'] == 'missing'),
'disabled': sum(1 for r in rows if r['status'] == 'disabled'),
}
return jsonify({'scanners': rows, 'counts': counts})
@api_bp.route('/api/edr/fibratus/<profile>/alerts/since', methods=['GET']) @api_bp.route('/api/edr/fibratus/<profile>/alerts/since', methods=['GET'])
@error_handler @error_handler
def api_fibratus_alerts_passthrough(profile): def api_fibratus_alerts_passthrough(profile):
+27 -15
View File
@@ -8,7 +8,11 @@ from datetime import datetime
from flask import Blueprint, current_app, jsonify from flask import Blueprint, current_app, jsonify
from ..services.error_handling import error_handler from ..services.error_handling import error_handler
from ..services.tool_check import check_analysis_tool, check_holygrail_tool from ..services.tool_check import (
check_analysis_tool,
check_holygrail_tool,
scanner_inventory,
)
from ..utils import path_manager from ..utils import path_manager
management_bp = Blueprint('management', __name__) management_bp = Blueprint('management', __name__)
@@ -100,6 +104,16 @@ def cleanup():
@management_bp.route('/health', methods=['GET']) @management_bp.route('/health', methods=['GET'])
@error_handler @error_handler
def health_check(): def health_check():
"""Unified sandbox health: boot validation + scanner inventory + EDR agents.
Replaces the previous narrow scanner-config-only response. Three sections:
- `sandbox` — upload folder + boot-config issues
- `scanners` — per-analyzer inventory (was `/api/system/scanners`)
- `edr_agents` — live agent + backend reachability
(delegates to the same TTL-cached probe `/api/edr/agents/status` uses)
"""
from ..services import edr_health
app = current_app app = current_app
app.logger.debug("Starting health check.") app.logger.debug("Starting health check.")
config = app.config config = app.config
@@ -127,15 +141,11 @@ def health_check():
check_holygrail_tool(holygrail_section, issues, app.logger) check_holygrail_tool(holygrail_section, issues, app.logger)
static_tools = { scanner_rows, scanner_counts = scanner_inventory(analysis_config)
tool: static_section.get(tool, {}).get('enabled', False)
for tool in static_section.keys() deps = current_app.extensions['litterbox']
} profiles = list(deps.edr_registry._PROFILES.values())
dynamic_tools = { edr_snapshot = edr_health.get_status_snapshot(profiles)
tool: dynamic_section.get(tool, {}).get('enabled', False)
for tool in dynamic_section.keys()
}
holygrail_status = holygrail_section.get('enabled', False)
status = 'ok' if not issues else 'degraded' status = 'ok' if not issues else 'degraded'
app.logger.debug(f"Health check completed. Status: {status}") app.logger.debug(f"Health check completed. Status: {status}")
@@ -143,13 +153,15 @@ def health_check():
return jsonify({ return jsonify({
'status': status, 'status': status,
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'upload_folder_accessible': os.path.isdir(upload_folder) if upload_folder else False,
'issues': issues, 'issues': issues,
'configuration': { 'sandbox': {
'static_analysis': static_tools, 'upload_folder_accessible': os.path.isdir(upload_folder) if upload_folder else False,
'dynamic_analysis': dynamic_tools,
'holygrail_analysis': holygrail_status,
}, },
'scanners': {
'rows': scanner_rows,
'counts': scanner_counts,
},
'edr_agents': edr_snapshot,
}), 200 if status == 'ok' else 503 }), 200 if status == 'ok' else 503
+2 -2
View File
@@ -11,8 +11,8 @@ upload_bp = Blueprint('upload', __name__)
@upload_bp.route('/') @upload_bp.route('/')
def index(): def index():
"""System health dashboard: agents + scanner availability. """System health dashboard: agents + scanner availability.
Live data is fetched async by the page's JS via /api/edr/agents/status Live data is fetched async by the page's JS via /health (single
and /api/system/scanners.""" request returns scanner inventory + EDR agent reachability)."""
deps = current_app.extensions['litterbox'] deps = current_app.extensions['litterbox']
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
+46 -1
View File
@@ -1,8 +1,53 @@
# app/services/tool_check.py # app/services/tool_check.py
"""Tool/path validation helpers for the /health endpoint.""" """Tool/path validation + inventory helpers for the /health endpoint."""
import os import os
def scanner_inventory(analysis_cfg):
"""Per-scanner inventory: enabled flag, configured path, exists-on-disk.
Walks the static + dynamic + holygrail sections of analysis config and
returns `(rows, counts)` for the unified /health response. Same shape as
the (now-removed) /api/system/scanners endpoint.
"""
def _row(group, name, scanner_cfg):
tool_path = (scanner_cfg or {}).get('tool_path', '').strip()
enabled = bool((scanner_cfg or {}).get('enabled', False))
exists = bool(tool_path) and os.path.isfile(tool_path)
return {
'group': group,
'name': name,
'enabled': enabled,
'tool_path': tool_path,
'exists': exists,
'status': (
'ok' if enabled and exists else
'missing' if enabled and not exists else
'disabled'
),
}
cfg = analysis_cfg or {}
rows = []
for group_key in ('static', 'dynamic'):
group_cfg = cfg.get(group_key) or {}
for scanner_name, scanner_cfg in group_cfg.items():
if isinstance(scanner_cfg, dict):
rows.append(_row(group_key, scanner_name, scanner_cfg))
holygrail = cfg.get('holygrail')
if isinstance(holygrail, dict):
rows.append(_row('holygrail', 'holygrail', holygrail))
counts = {
'total': len(rows),
'ok': sum(1 for r in rows if r['status'] == 'ok'),
'missing': sum(1 for r in rows if r['status'] == 'missing'),
'disabled': sum(1 for r in rows if r['status'] == 'disabled'),
}
return rows, counts
def check_analysis_tool(section, tool_name, issues, logger): def check_analysis_tool(section, tool_name, issues, logger):
"""Append issues for a static or dynamic analysis tool.""" """Append issues for a static or dynamic analysis tool."""
tool_config = section.get(tool_name, {}) tool_config = section.get(tool_name, {})
+16 -21
View File
@@ -1,9 +1,8 @@
// app/static/js/dashboard/core.js // app/static/js/dashboard/core.js
// //
// Drives the index dashboard. Polls /api/system/scanners for analyzer // Drives the index dashboard. Single /health fetch returns scanner
// availability and /api/edr/agents/status for live agent + Elastic state. // inventory + live EDR agent reachability in one shot. Auto-refreshes
// Auto-refreshes every 60s; the manual Refresh button forces an immediate // every 60s; the manual Refresh button forces an immediate poll.
// poll. Both fetches are kicked off in parallel.
const REFRESH_MS = 60000; const REFRESH_MS = 60000;
let _refreshTimer = null; let _refreshTimer = null;
@@ -27,7 +26,7 @@ function statusTagClass(status) {
function renderScanners(payload) { function renderScanners(payload) {
const host = document.getElementById('scannersTable'); const host = document.getElementById('scannersTable');
if (!host) return; if (!host) return;
const scanners = (payload && payload.scanners) || []; const scanners = (payload && payload.rows) || [];
const counts = (payload && payload.counts) || {}; const counts = (payload && payload.counts) || {};
setText('scannersCount', setText('scannersCount',
`${counts.ok ?? 0} ok · ${counts.missing ?? 0} missing · ${counts.disabled ?? 0} disabled`); `${counts.ok ?? 0} ok · ${counts.missing ?? 0} missing · ${counts.disabled ?? 0} disabled`);
@@ -119,23 +118,20 @@ async function refreshDashboard() {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
const [scannersResp, agentsResp] = await Promise.all([ // Single /health fetch returns both scanner inventory and EDR
fetch('/api/system/scanners', { cache: 'no-store' }), // reachability. /health responds 200 (ok) or 503 (degraded) — both
fetch('/api/edr/agents/status', { cache: 'no-store' }), // carry a usable payload, so we read JSON either way.
]); const resp = await fetch('/health', { cache: 'no-store' });
if (resp.status !== 200 && resp.status !== 503) {
if (scannersResp.ok) {
renderScanners(await scannersResp.json());
} else {
const host = document.getElementById('scannersTable'); const host = document.getElementById('scannersTable');
if (host) host.innerHTML = if (host) host.innerHTML =
`<div class="lb-muted">Failed to load scanners (HTTP ${scannersResp.status}).</div>`; `<div class="lb-muted">Failed to load health (HTTP ${resp.status}).</div>`;
return;
} }
if (agentsResp.ok) { const data = await resp.json();
const data = await agentsResp.json(); renderScanners(data.scanners || {});
for (const agent of (data.agents || [])) applyAgentRow(agent); for (const agent of ((data.edr_agents || {}).agents || [])) applyAgentRow(agent);
}
} catch (err) { } catch (err) {
console.error('[dashboard] refresh failed:', err); console.error('[dashboard] refresh failed:', err);
} finally { } finally {
@@ -175,9 +171,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Pause the auto-refresh while the tab is hidden — no point pulling // Pause the auto-refresh while the tab is hidden — no point pulling
// /api/system/scanners + /api/edr/agents/status every minute when nobody's // /health every minute when nobody's looking. Resume on visible AND fire
// looking. Resume on visible AND fire one immediate refresh so the user // one immediate refresh so the user sees fresh data the moment they come back.
// sees fresh data the moment they come back.
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden') {
stopTimer(); stopTimer();