const http = require("node:http"); const os = require("node:os"); const { execSync } = require("node:child_process"); const { readFileSync, readdirSync, existsSync } = require("node:fs"); const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); const PORT = parseInt(process.env.PORT || "3001", 10); const TOKEN = process.env.HEALTH_TOKEN; const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports"; const AGENTS_MAP_PATH = process.env.DEFENSEURS_AGENTS_MAP_PATH || "/data/defenseurs/agents-map.json"; const LOGTO_HEALTH_URL = process.env.LOGTO_HEALTH_URL || "https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration"; const LOGTO_TIMEOUT_MS = 3000; const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 }; const VALID_SEVERITIES = Object.keys(SEVERITY_RANK); const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"]; // Severity filter. Asymmetric rule (issue #3): // - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO) // - threshold "INFO" -> INFO only (explicit opt-in) // - any other -> everything at or above threshold, EXCEPT INFO // INFO is therefore reachable only via explicit ?severity=INFO. function allowedSeverities(threshold) { if (!threshold) return ["CRITICAL", "HIGH", "MEDIUM"]; if (threshold === "INFO") return ["INFO"]; const min = SEVERITY_RANK[threshold]; return VALID_SEVERITIES.filter( (s) => s !== "INFO" && SEVERITY_RANK[s] >= min, ); } if (!TOKEN) { console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); } function readProcStat() { try { const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim(); const parts = line.split(/\s+/).slice(1).map(Number); const idle = parts[3] + parts[4]; const total = parts.reduce((a, b) => a + b, 0); return { idle, total }; } catch { return null; } } async function getCpuPercent() { const t1 = readProcStat(); if (!t1) return 0; const { idle: idle1, total: total1 } = t1; // Sample over 500ms without blocking the event loop, so other async work // (e.g. the Logto healthcheck) can run concurrently. await delay(500); const t2 = readProcStat(); if (!t2) return 0; const dIdle = t2.idle - idle1; const dTotal = t2.total - total1; if (dTotal === 0) return 0; return Math.round((1 - dIdle / dTotal) * 100); } async function getLogtoHealth() { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS); const start = performance.now(); try { const res = await fetch(LOGTO_HEALTH_URL, { signal: ac.signal }); const responseTimeMs = Math.round(performance.now() - start); if (res.ok) return { status: "up", responseTimeMs }; return { status: "down", responseTimeMs, error: `HTTP ${res.status}` }; } catch (err) { const responseTimeMs = Math.round(performance.now() - start); const error = err.name === "AbortError" ? "timeout" : err.message || "network error"; return { status: "down", responseTimeMs, error }; } finally { clearTimeout(timer); } } function getDisk() { try { // Alpine df doesn't support --output, use standard POSIX format const out = execSync("df -k /", { encoding: "utf-8" }); const parts = out.trim().split("\n")[1].trim().split(/\s+/); // df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1); const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1); const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1); const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0; return { totalGB, usedGB, freeGB, usagePercent }; } catch { return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 }; } } // Reproduce the isScanReport guard from defenseurs/src/report.ts. The // defenseur-auto run report has shape { actions[], skipped[] } with no // findings[] — it must be filtered out so it never reaches the auto pipeline // (which expects scan-shaped reports only). function isScanReport(value) { return ( typeof value === "object" && value !== null && Array.isArray(value.findings) && typeof value.agent === "string" && typeof value.timestamp === "string" ); } // Read all `defenseur-_*.json` files under `dir` matching the // given UTC date. Returns parsed scan reports keyed by filename so the caller // can dedupe across REPORTS_DIR + REPORTS_DIR/archive. function collectScanReportsFromDir(dir, date) { const collected = new Map(); if (!existsSync(dir)) return collected; const files = readdirSync(dir).filter( (f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"), ); for (const file of files) { try { const raw = readFileSync(path.join(dir, file), "utf-8"); const parsed = JSON.parse(raw); if (!isScanReport(parsed)) continue; if (!parsed.timestamp.startsWith(date)) continue; collected.set(file, parsed); } catch (err) { console.error(`[reports/scans] failed to parse ${file}:`, err.message); } } return collected; } // Read all `defenseur-_*.json` files under REPORTS_DIR for the // given UTC date. The scan reports use an ISO timestamp with `:` and `.` // rewritten as `-` in the filename (e.g. defenseur-booking_2026-05-06T05-30-11-249Z.json). // We match `_` then re-confirm via parsed.timestamp.startsWith(date). // // The Sergent rotates fresh reports out of REPORTS_DIR into REPORTS_DIR/archive // at 07:30 UTC daily (cf. defenseurs/src/sergent.ts renameSync). For ~22h/day // the only copy lives in archive/ — so we scan both and concatenate. Top-level // files take precedence on filename collision (more recent by definition). function readScanReportsForDate(date) { const topLevel = collectScanReportsFromDir(REPORTS_DIR, date); const archive = collectScanReportsFromDir(path.join(REPORTS_DIR, "archive"), date); // Merge with top-level priority — only insert archive entries whose filename // is not already present at the top level. for (const [file, report] of archive) { if (!topLevel.has(file)) topLevel.set(file, report); } // Stable sort by timestamp asc — same convention as readReports() in // defenseurs/src/report.ts. return [...topLevel.values()].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); } // Find the latest scan report for a given agent across REPORTS_DIR and // REPORTS_DIR/archive. Files are named `defenseur-_.json`. // The trailing underscore in the prefix prevents collisions on agent names // that share a prefix (e.g. "booking" vs "booking-staging"). function findLatestReportForAgent(agent) { // `agent` is the full identifier as written by the Sergent into // agents-map.json (e.g. "defenseur-booking") — matches both the JSON // `agent` field and the filename prefix `_.json`. const prefix = `${agent}_`; const dirs = [REPORTS_DIR, path.join(REPORTS_DIR, "archive")]; let latest = null; for (const dir of dirs) { if (!existsSync(dir)) continue; const files = readdirSync(dir).filter( (f) => f.startsWith(prefix) && f.endsWith(".json"), ); for (const file of files) { try { const raw = readFileSync(path.join(dir, file), "utf-8"); const parsed = JSON.parse(raw); if (!isScanReport(parsed)) continue; if (parsed.agent !== agent) continue; const ts = new Date(parsed.timestamp).getTime(); if (Number.isNaN(ts)) continue; if (!latest || ts > latest._ts) { latest = parsed; latest._ts = ts; } } catch (err) { console.error(`[defenseurs/findings] failed to parse ${file}:`, err.message); } } } if (latest) delete latest._ts; return latest; } async function getHealth() { const cpus = os.cpus(); const totalMem = os.totalmem(); const freeMem = os.freemem(); const usedMem = totalMem - freeMem; const [cpuUsagePercent, logto] = await Promise.all([ getCpuPercent(), getLogtoHealth(), ]); return { timestamp: new Date().toISOString(), hostname: os.hostname(), uptime: Math.floor(os.uptime()), cpu: { model: cpus[0]?.model?.trim() || "unknown", cores: cpus.length, loadAvg: os.loadavg().map((l) => +l.toFixed(2)), usagePercent: cpuUsagePercent, }, memory: { totalGB: +(totalMem / 1e9).toFixed(1), usedGB: +(usedMem / 1e9).toFixed(1), freeGB: +(freeMem / 1e9).toFixed(1), usagePercent: Math.round((usedMem / totalMem) * 100), }, disk: getDisk(), logto, }; } async function handler(req, res) { res.setHeader("Content-Type", "application/json"); // Parse the URL so /reports/scans can carry a `?date=` query string. The // placeholder host is required because URL() needs an absolute URL. const parsedUrl = new URL(req.url, "http://localhost"); const pathname = parsedUrl.pathname; const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"]; if (req.method !== "GET" || !validRoutes.includes(pathname)) { res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); return; } if (!TOKEN) { res.writeHead(401); res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" })); return; } const auth = req.headers["authorization"]; if (auth !== `Bearer ${TOKEN}`) { res.writeHead(401); res.end(JSON.stringify({ error: "Unauthorized" })); return; } if (pathname === "/defenseurs") { const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"; try { const status = readFileSync(statusPath, "utf-8"); res.writeHead(200); res.end(status); } catch { res.writeHead(200); res.end(JSON.stringify({ status: "no_data" })); } return; } if (pathname === "/defenseurs/findings") { const project = parsedUrl.searchParams.get("project"); const category = parsedUrl.searchParams.get("category"); const severity = parsedUrl.searchParams.get("severity"); if (!project) { res.writeHead(400); res.end(JSON.stringify({ error: "Bad request: project= required" })); return; } if (category && !VALID_CATEGORIES.includes(category)) { res.writeHead(400); res.end(JSON.stringify({ error: `Bad request: category must be one of ${VALID_CATEGORIES.join(",")}` })); return; } if (severity && !VALID_SEVERITIES.includes(severity)) { res.writeHead(400); res.end(JSON.stringify({ error: `Bad request: severity must be one of ${VALID_SEVERITIES.join(",")}` })); return; } let agentsMap; try { agentsMap = JSON.parse(readFileSync(AGENTS_MAP_PATH, "utf-8")); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: "Internal error", message: err.message })); return; } const agent = agentsMap[project]; if (!agent) { res.writeHead(404); res.end(JSON.stringify({ error: `Unknown project: ${project}` })); return; } try { const report = findLatestReportForAgent(agent); if (!report) { res.writeHead(200); res.end(JSON.stringify({ findings: [], status: "no_data" })); return; } const allowed = new Set(allowedSeverities(severity)); const findings = report.findings.filter( (f) => allowed.has(f.severity) && (!category || f.category === category), ); res.writeHead(200); res.end(JSON.stringify({ agent, project, timestamp: report.timestamp, findings, })); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: "Internal error", message: err.message })); } return; } if (pathname === "/reports/scans") { const date = parsedUrl.searchParams.get("date"); // Regex short-circuit before any filesystem access — blocks path // traversal (`../../etc/passwd` -> 400) and bogus inputs. if (!date || !SCAN_DATE_RE.test(date)) { res.writeHead(400); res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" })); return; } try { const reports = readScanReportsForDate(date); res.writeHead(200); res.end(JSON.stringify({ date, count: reports.length, reports })); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: "Internal error", message: err.message })); } return; } try { const data = await getHealth(); res.writeHead(200); res.end(JSON.stringify(data)); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: "Internal error", message: err.message })); } } if (require.main === module) { const server = http.createServer(handler); server.listen(PORT, () => { console.log(`vps-health-api listening on :${PORT}`); }); } module.exports = { handler, allowedSeverities, findLatestReportForAgent };