feat(defenseurs): add GET /defenseurs/findings?project=X route #9
7 changed files with 1853 additions and 10 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -7,6 +7,7 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
||||||
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
||||||
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
|
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
|
||||||
|
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
|
|
@ -18,14 +19,25 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
|
|
||||||
- Port : `3001` (env `PORT`)
|
- Port : `3001` (env `PORT`)
|
||||||
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
||||||
- `REPORTS_DIR` : dossier lu par `/reports/scans` (default `/data/defenseurs/reports`)
|
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
|
||||||
- Bind-mount : `/data/defenseurs/` (status.json + reports/) read-only
|
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
|
||||||
|
- Bind-mounts read-only sur Coolify :
|
||||||
|
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json`
|
||||||
|
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
||||||
|
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
|
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `npm test` (vitest) — couvre `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
||||||
|
- Runtime reste 0-dep ; vitest en devDep uniquement
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Pas d'Express — HTTP natif Node.js uniquement
|
- Pas d'Express — HTTP natif Node.js uniquement
|
||||||
- Le `status.json` est ecrit par le Sergent defenseurs, pas par cette API (read-only)
|
- Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only)
|
||||||
|
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /home/defenseur/defenseurs/agents-map.json'`. Sinon `/defenseurs/findings` retourne 500.
|
||||||
|
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).
|
||||||
|
|
|
||||||
64
README.md
Normal file
64
README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# vps-health-api
|
||||||
|
|
||||||
|
Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtime deps.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
All endpoints require `Authorization: Bearer $HEALTH_TOKEN`.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/health` | CPU, memory, disk, uptime, Logto reachability |
|
||||||
|
| GET | `/defenseurs` | Defenseurs executive status (status.json) |
|
||||||
|
| GET | `/defenseurs/findings?project=X` | Detailed findings for a project's Defenseur |
|
||||||
|
| GET | `/reports/scans?date=YYYY-MM-DD` | Aggregated scan reports for a UTC date |
|
||||||
|
|
||||||
|
### `GET /defenseurs/findings`
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
- `project` (required) — project name, looked up in `agents-map.json` (e.g. `la-suite-booking`)
|
||||||
|
- `category` (optional) — exact match, one of `deps|secrets|code|acces|infra`
|
||||||
|
- `severity` (optional) — threshold, one of `CRITICAL|HIGH|MEDIUM|LOW|INFO`
|
||||||
|
- default (no param): `MEDIUM`, `HIGH`, `CRITICAL`
|
||||||
|
- `LOW` returns `LOW`+`MEDIUM`+`HIGH`+`CRITICAL` but still hides `INFO`
|
||||||
|
- `INFO` returns `INFO` only (explicit opt-in)
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200 { agent, project, timestamp, findings: Finding[] }` — report present (empty `findings` if clean scan; no `status` field)
|
||||||
|
- `200 { findings: [], status: "no_data" }` — no report on file for the agent
|
||||||
|
- `400` — missing `project` or invalid `category` / `severity`
|
||||||
|
- `401` — missing or invalid token
|
||||||
|
- `404` — unknown project
|
||||||
|
- `500` — `agents-map.json` unreadable or corrupted
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
||||||
|
"https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
| Env var | Default | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `PORT` | `3001` | HTTP port |
|
||||||
|
| `HEALTH_TOKEN` | — | Bearer token (fail-closed if missing) |
|
||||||
|
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
|
||||||
|
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
|
||||||
|
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
|
||||||
|
|
||||||
|
## Bind-mounts (Coolify, read-only)
|
||||||
|
|
||||||
|
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json`
|
||||||
|
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
||||||
|
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
```
|
||||||
202
__tests__/findings.test.js
Normal file
202
__tests__/findings.test.js
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const TOKEN = "test-token";
|
||||||
|
|
||||||
|
function makeFinding(id, severity, category) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
severity,
|
||||||
|
category,
|
||||||
|
title: `${id} title`,
|
||||||
|
description: `${id} desc`,
|
||||||
|
location: `${id}/loc`,
|
||||||
|
recommendation: `${id} reco`,
|
||||||
|
firstSeen: "2026-05-01T00:00:00.000Z",
|
||||||
|
status: "open",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_FINDINGS = [
|
||||||
|
makeFinding("c1", "CRITICAL", "deps"),
|
||||||
|
makeFinding("h1", "HIGH", "secrets"),
|
||||||
|
makeFinding("m1", "MEDIUM", "deps"),
|
||||||
|
makeFinding("l1", "LOW", "code"),
|
||||||
|
makeFinding("i1", "INFO", "infra"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let reportsDir;
|
||||||
|
let agentsMapPath;
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
let handler;
|
||||||
|
|
||||||
|
function writeReport(agent, timestamp, findings, subdir = "") {
|
||||||
|
const dir = subdir ? path.join(reportsDir, subdir) : reportsDir;
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const safeTs = timestamp.replace(/[:.]/g, "-");
|
||||||
|
const file = path.join(dir, `defenseur-${agent}_${safeTs}.json`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
file,
|
||||||
|
JSON.stringify({ agent: `defenseur-${agent}`, timestamp, findings }),
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAgentsMap(map) {
|
||||||
|
fs.writeFileSync(agentsMapPath, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRawAgentsMap(raw) {
|
||||||
|
fs.writeFileSync(agentsMapPath, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
// Fresh import so module-level constants pick up the stubbed env.
|
||||||
|
delete require.cache[require.resolve("../index.js")];
|
||||||
|
({ handler } = require("../index.js"));
|
||||||
|
server = http.createServer(handler);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!server) return resolve();
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
|
||||||
|
const headers = {};
|
||||||
|
if (auth) headers.Authorization = auth;
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, { headers });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
return { status: res.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-test-"));
|
||||||
|
reportsDir = path.join(tmpDir, "reports");
|
||||||
|
fs.mkdirSync(reportsDir, { recursive: true });
|
||||||
|
agentsMapPath = path.join(tmpDir, "agents-map.json");
|
||||||
|
|
||||||
|
process.env.HEALTH_TOKEN = TOKEN;
|
||||||
|
process.env.REPORTS_DIR = reportsDir;
|
||||||
|
process.env.DEFENSEURS_AGENTS_MAP_PATH = agentsMapPath;
|
||||||
|
|
||||||
|
writeAgentsMap({ "la-suite-booking": "defenseur-booking" });
|
||||||
|
await startServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await stopServer();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /defenseurs/findings", () => {
|
||||||
|
test("401 when no Authorization header", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: null });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("401 on invalid token", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: "Bearer wrong" });
|
||||||
|
expect(status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("400 when project param is missing", async () => {
|
||||||
|
const { status, body } = await get("/defenseurs/findings");
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body.error).toMatch(/project/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("404 on unknown project", async () => {
|
||||||
|
const { status } = await get("/defenseurs/findings?project=unknown-x");
|
||||||
|
expect(status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 default (no severity) returns MEDIUM+HIGH+CRITICAL, hides LOW+INFO", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.agent).toBe("defenseur-booking");
|
||||||
|
expect(body.project).toBe("la-suite-booking");
|
||||||
|
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
|
||||||
|
expect(body.findings).toHaveLength(3);
|
||||||
|
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "MEDIUM"]);
|
||||||
|
expect(body.status).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 ?category=deps filters by category exact match", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.findings.map((f) => f.id).sort()).toEqual(["c1", "m1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 ?severity=HIGH returns HIGH+CRITICAL only", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
|
||||||
|
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 ?severity=LOW returns LOW+MEDIUM+HIGH+CRITICAL but hides INFO", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=LOW");
|
||||||
|
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "LOW", "MEDIUM"]);
|
||||||
|
expect(body.findings.some((f) => f.severity === "INFO")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 ?severity=INFO returns INFO only", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=INFO");
|
||||||
|
expect(body.findings.map((f) => f.severity)).toEqual(["INFO"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 ?category=deps&severity=CRITICAL returns just c1", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
||||||
|
const { body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps&severity=CRITICAL");
|
||||||
|
expect(body.findings.map((f) => f.id)).toEqual(["c1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 picks latest report (mixed top-level + archive)", async () => {
|
||||||
|
writeReport("booking", "2026-05-10T00:00:00.000Z", [makeFinding("old", "HIGH", "deps")], "archive");
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", [makeFinding("new", "HIGH", "deps")]);
|
||||||
|
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
|
||||||
|
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
|
||||||
|
expect(body.findings.map((f) => f.id)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 report present + findings:[] (scan clean) returns no status field", async () => {
|
||||||
|
writeReport("booking", "2026-05-12T00:00:00.000Z", []);
|
||||||
|
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.findings).toEqual([]);
|
||||||
|
expect(body.status).toBeUndefined();
|
||||||
|
expect(body.agent).toBe("defenseur-booking");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("200 report absent returns {findings:[], status:'no_data'}", async () => {
|
||||||
|
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({ findings: [], status: "no_data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("500 on corrupt agents-map.json", async () => {
|
||||||
|
writeRawAgentsMap("{not valid json");
|
||||||
|
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
||||||
|
expect(status).toBe(500);
|
||||||
|
expect(body.error).toBe("Internal error");
|
||||||
|
});
|
||||||
|
});
|
||||||
138
index.js
138
index.js
|
|
@ -8,12 +8,32 @@ const { setTimeout: delay } = require("node:timers/promises");
|
||||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
const TOKEN = process.env.HEALTH_TOKEN;
|
const TOKEN = process.env.HEALTH_TOKEN;
|
||||||
const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports";
|
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 =
|
const LOGTO_HEALTH_URL =
|
||||||
process.env.LOGTO_HEALTH_URL ||
|
process.env.LOGTO_HEALTH_URL ||
|
||||||
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
||||||
const LOGTO_TIMEOUT_MS = 3000;
|
const LOGTO_TIMEOUT_MS = 3000;
|
||||||
const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
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) {
|
if (!TOKEN) {
|
||||||
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +167,45 @@ function readScanReportsForDate(date) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
async function getHealth() {
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
const totalMem = os.totalmem();
|
const totalMem = os.totalmem();
|
||||||
|
|
@ -179,7 +238,7 @@ async function getHealth() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
async function handler(req, res) {
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
// Parse the URL so /reports/scans can carry a `?date=` query string. The
|
// Parse the URL so /reports/scans can carry a `?date=` query string. The
|
||||||
|
|
@ -187,7 +246,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
const parsedUrl = new URL(req.url, "http://localhost");
|
const parsedUrl = new URL(req.url, "http://localhost");
|
||||||
const pathname = parsedUrl.pathname;
|
const pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
const validRoutes = ["/health", "/defenseurs", "/reports/scans"];
|
const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
|
||||||
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end(JSON.stringify({ error: "Not found" }));
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
|
|
@ -220,6 +279,68 @@ const server = http.createServer(async (req, res) => {
|
||||||
return;
|
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") {
|
if (pathname === "/reports/scans") {
|
||||||
const date = parsedUrl.searchParams.get("date");
|
const date = parsedUrl.searchParams.get("date");
|
||||||
// Regex short-circuit before any filesystem access — blocks path
|
// Regex short-circuit before any filesystem access — blocks path
|
||||||
|
|
@ -249,8 +370,13 @@ const server = http.createServer(async (req, res) => {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
if (require.main === module) {
|
||||||
console.log(`vps-health-api listening on :${PORT}`);
|
const server = http.createServer(handler);
|
||||||
});
|
server.listen(PORT, () => {
|
||||||
|
console.log(`vps-health-api listening on :${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handler, allowedSeverities, findLatestReportForAgent };
|
||||||
|
|
|
||||||
1428
package-lock.json
generated
Normal file
1428
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,10 @@
|
||||||
"description": "Lightweight VPS health monitoring endpoint",
|
"description": "Lightweight VPS health monitoring endpoint",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js"
|
"start": "node index.js",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
vitest.config.js
Normal file
7
vitest.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = {
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["__tests__/**/*.test.js"],
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue