vps-health-api/index.js
le king fu e88a044711 feat(defenseurs): add GET /defenseurs/findings?project=X route
Drill-down endpoint exposing detailed findings per project. Resolves the
HTTP gap for the Vercel admin dashboard, which cannot SSH/Tailscale to
the VPS, plus a future portable /analyse-vulnerabilite skill.

- Project -> agent lookup via /data/defenseurs/agents-map.json (Sergent snapshot)
- findLatestReportForAgent scans REPORTS_DIR + REPORTS_DIR/archive (post-07:30 UTC rotation)
- Filters: category exact match, severity threshold inclusive upward
- Asymmetric severity rule: default hides LOW+INFO; ?severity=LOW returns
  LOW+MEDIUM+HIGH+CRITICAL but still hides INFO; INFO opt-in via explicit param
- Distinguishes "report present + scan clean" (no status field) from
  "no report at all" ({findings:[], status:"no_data"})
- Bootstraps vitest (devDep; runtime stays 0-dep), 14 tests covering auth,
  validation, filters, asymmetry, mtime selection, error paths
- Refactor: export handler so tests can spin up ephemeral servers; server.listen
  guarded by require.main === module

Closes #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:56:56 -04:00

382 lines
13 KiB
JavaScript

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-<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() {
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=<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 };