feat(defenseurs): add GET /defenseurs/findings?project=X route #9

Open
maximus wants to merge 1 commit from issue-3-defenseurs-findings into main
7 changed files with 1853 additions and 10 deletions
Showing only changes of commit e88a044711 - Show all commits

View file

@ -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 /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 /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
@ -18,14 +19,25 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
- Port : `3001` (env `PORT`)
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
- `REPORTS_DIR` : dossier lu par `/reports/scans` (default `/data/defenseurs/reports`)
- Bind-mount : `/data/defenseurs/` (status.json + reports/) read-only
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
- `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
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
- 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
View 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
View 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
View file

@ -8,12 +8,32 @@ 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).");
}
@ -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() {
const cpus = os.cpus();
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");
// 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 pathname = parsedUrl.pathname;
const validRoutes = ["/health", "/defenseurs", "/reports/scans"];
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" }));
@ -220,6 +279,68 @@ const server = http.createServer(async (req, res) => {
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
@ -249,8 +370,13 @@ const server = http.createServer(async (req, res) => {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
});
}
server.listen(PORT, () => {
console.log(`vps-health-api listening on :${PORT}`);
});
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 };

1428
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,10 @@
"description": "Lightweight VPS health monitoring endpoint",
"main": "index.js",
"scripts": {
"start": "node index.js"
"start": "node index.js",
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.8"
}
}

7
vitest.config.js Normal file
View file

@ -0,0 +1,7 @@
module.exports = {
test: {
environment: "node",
globals: true,
include: ["__tests__/**/*.test.js"],
},
};