Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.
Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
Verified by mutation: serializing the two calls fails the latency test
at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.
Resolves #11
322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
const http = require("node:http");
|
|
const os = require("node:os");
|
|
const { readFileSync, readdirSync, existsSync } = require("node:fs");
|
|
const path = require("node:path");
|
|
const { collectMetrics } = require("./metrics.js");
|
|
|
|
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).");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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-<agent>_<date>*.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-<agent>_<date>*.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 `_<date>` 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-<agent>_<iso>.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 `<agent>_<iso>.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() {
|
|
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
|
|
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
|
|
// other would push the p99 of /health to ~3.5s.
|
|
const [metrics, logto] = await Promise.all([
|
|
collectMetrics(),
|
|
getLogtoHealth(),
|
|
]);
|
|
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
hostname: os.hostname(),
|
|
uptime: Math.floor(os.uptime()),
|
|
...metrics,
|
|
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=<name> 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 };
|