Merge pull request 'feat(defenseurs): GET /defenseurs/state serves findings-state.json' (#28) from issue-27-defenseurs-state into main

This commit is contained in:
maximus 2026-09-25 01:41:35 +00:00
commit 8eef50d517
6 changed files with 221 additions and 13 deletions

View file

@ -6,6 +6,7 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
- `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 /defenseurs/state` — contenu brut de `findings-state.json` (etat par finding du Sergent : `firstSeen`, `unverified`, historique 30 jours). Meme contrat que `/defenseurs` via le helper partage `serveJsonFile()` : 200 contenu brut, 200 `{status:"no_data"}` si absent ou illisible. Ni parse ni controle de fraicheur ici (le Sergent ecrit en atomique et isole un etat corrompu en `.corrupt-*` ; la peremption est jugee cote dashboard). Consommateur : dashboard admin Vercel (drill-down, en parallele de `/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"`. - `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"`.
@ -33,13 +34,15 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
- 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` et `/defenseurs/findings` (default `/data/defenseurs/reports`) - `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
- `DEFENSEURS_STATUS_PATH` : fichier servi par `/defenseurs` (default `/data/defenseurs/status.json`)
- `DEFENSEURS_FINDINGS_STATE_PATH` : fichier servi par `/defenseurs/state` (default `/data/defenseurs/findings-state.json`)
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`) - `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`) - `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee - `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503) - `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900) - `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) : - Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API. - `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `findings-state.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API.
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API. - `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API.
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500. - `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
@ -56,14 +59,15 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## Tests
- `npm test` (vitest) — 251 cas - `npm test` (vitest) — 263 cas
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence) - `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (26 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**) - `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (29 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture) - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture)
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**) - `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
- Runtime reste 0-dep ; vitest en devDep uniquement - Runtime reste 0-dep ; vitest en devDep uniquement
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (41 cas). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis. - `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (45 cas). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu **avant** que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par `/defenseurs/state`, et le 503 d'une instance demarree sans `HOSTS_INGEST_TOKEN` (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis.
## Gotchas ## Gotchas

View file

@ -13,6 +13,7 @@ push its snapshot without gaining read access to the Defenseurs reports.
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability | | GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) | | GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur | | GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
| GET | `/defenseurs/state` | `HEALTH_TOKEN` | Sergent's per-finding state (findings-state.json) |
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date | | GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation | | GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot | | POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
@ -67,6 +68,24 @@ unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
false, neverSeen: true }` instead of failing the response, so the dashboard can false, neverSeen: true }` instead of failing the response, so the dashboard can
say "agent not installed" during commissioning. say "agent not installed" during commissioning.
### `GET /defenseurs/state`
Serves the Sergent's `findings-state.json` verbatim — per-finding `firstSeen`,
`unverified` status and 30-day history. It is the only source that carries a
finding's age, which is why the admin dashboard's drill-down reads it alongside
`/defenseurs`. The file is not parsed or checked for freshness here: the Sergent
writes it atomically and quarantines unreadable state itself, and staleness is
judged by the reader.
- `200` — raw file content
- `200 { status: "no_data" }` — file absent or unreadable (same contract as `/defenseurs`)
- `401` — missing or invalid token
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
"https://health.lacompagniemaximus.com/defenseurs/state"
```
### `GET /defenseurs/findings` ### `GET /defenseurs/findings`
Query params: Query params:
@ -101,6 +120,8 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
| `PORT` | `3001` | HTTP port | | `PORT` | `3001` | HTTP port |
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) | | `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir | | `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
| `DEFENSEURS_STATUS_PATH` | `/data/defenseurs/status.json` | Served by `/defenseurs` |
| `DEFENSEURS_FINDINGS_STATE_PATH` | `/data/defenseurs/findings-state.json` | Served by `/defenseurs/state` |
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot | | `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL | | `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** | | `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** |
@ -112,9 +133,11 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
Read-only for the API — written by the Defenseurs Sergent: Read-only for the API — written by the Defenseurs Sergent:
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json` - `/data/defenseurs/` -> `/data/defenseurs/` — directory mount holding
`status.json`, `findings-state.json` and `agents-map.json`, all written there
directly by the Sergent. (`/home/defenseur/defenseurs/status.json` is an
obsolete decoy nobody reads.)
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/` - `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
Read-write — the API is the writer: Read-write — the API is the writer:

View file

@ -3,7 +3,7 @@ const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
// Authentication safety net for the four read routes. // Authentication safety net for the read routes.
// //
// findings.test.js and health.test.js cover what each route *returns*; this // findings.test.js and health.test.js cover what each route *returns*; this
// file covers the gate in front of all of them. The small overlap on // file covers the gate in front of all of them. The small overlap on
@ -19,7 +19,14 @@ const TOKEN = "test-token";
// Every GET route reachable by the handler. Any new route must be added here. // Every GET route reachable by the handler. Any new route must be added here.
// POST /hosts/<id> is not a GET, so it gets its own describe block below — // POST /hosts/<id> is not a GET, so it gets its own describe block below —
// but it is covered, and any future route must be too. // but it is covered, and any future route must be too.
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts"]; const ROUTES = [
"/health",
"/defenseurs",
"/defenseurs/findings",
"/defenseurs/state",
"/reports/scans",
"/hosts",
];
// Ingest token, distinct from the read token on purpose: a read token must not // Ingest token, distinct from the read token on purpose: a read token must not
// open the write path, and a write token must not open the read routes. // open the write path, and a write token must not open the read routes.
@ -73,6 +80,7 @@ beforeEach(async () => {
process.env.REPORTS_DIR = path.join(tmpDir, "reports"); process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json"); process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json"); process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(tmpDir, "findings-state.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts"); process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad"; process.env.HOSTS_ALLOWED_IDS = "thinkpad";
// Configured on purpose: an unset ingest token answers 503 before the header // Configured on purpose: an unset ingest token answers 503 before the header
@ -110,7 +118,7 @@ describe("auth gate — invalid token", () => {
describe("auth gate — malformed Authorization header", () => { describe("auth gate — malformed Authorization header", () => {
// The header is compared byte for byte against `Bearer <token>`, so these // The header is compared byte for byte against `Bearer <token>`, so these
// near-misses are all rejected. /defenseurs stands in for the four routes: // near-misses are all rejected. /defenseurs stands in for the read routes:
// it serves the parc-wide Defenseurs report and is the costliest to leak. // it serves the parc-wide Defenseurs report and is the costliest to leak.
const MALFORMED = [ const MALFORMED = [
["raw token without the Bearer scheme", TOKEN], ["raw token without the Bearer scheme", TOKEN],

133
__tests__/state.test.js Normal file
View file

@ -0,0 +1,133 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// GET /defenseurs/state serves the Sergent's findings-state.json, and shares
// serveJsonFile() with GET /defenseurs. Both routes are covered here: the
// helper extraction must leave /defenseurs byte-identical to what it served.
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
// Deliberately non-canonical JSON (odd spacing, key order, trailing newline):
// a handler that parsed and re-serialized the file would not reproduce it.
const STATE_RAW =
'{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }\n';
const STATUS_RAW = '{"timestamp":"2026-09-24T00:00:00.000Z", "agents": [] }\n';
let tmpDir;
let statePath;
let statusPath;
let server;
let baseUrl;
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function request(route, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${route}`, { method, headers });
return { status: res.status, text: await res.text() };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-state-test-"));
statePath = path.join(tmpDir, "findings-state.json");
statusPath = path.join(tmpDir, "status.json");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.DEFENSEURS_FINDINGS_STATE_PATH = statePath;
process.env.DEFENSEURS_STATUS_PATH = statusPath;
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("GET /defenseurs/state", () => {
test("200 with the file content byte for byte when present", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(text).toBe(STATE_RAW);
});
test("200 no_data when the file is absent", async () => {
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
test("serves findings-state.json, never status.json", async () => {
fs.writeFileSync(statusPath, STATUS_RAW);
const { status, text } = await request("/defenseurs/state");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
test("401 with no Authorization header", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", { auth: null });
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("401 with a wrong bearer token", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", { auth: "Bearer wrong" });
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("401 with the ingest token", async () => {
fs.writeFileSync(statePath, STATE_RAW);
const { status, text } = await request("/defenseurs/state", {
auth: `Bearer ${INGEST_TOKEN}`,
});
expect(status).toBe(401);
expect(text).not.toContain("schemaVersion");
});
test("404 (not 401) on POST without Authorization", async () => {
const { status } = await request("/defenseurs/state", { method: "POST", auth: null });
expect(status).toBe(404);
});
});
describe("GET /defenseurs — unchanged by the serveJsonFile extraction", () => {
test("200 with status.json byte for byte when present", async () => {
fs.writeFileSync(statusPath, STATUS_RAW);
const { status, text } = await request("/defenseurs");
expect(status).toBe(200);
expect(text).toBe(STATUS_RAW);
});
test("200 no_data when status.json is absent", async () => {
const { status, text } = await request("/defenseurs");
expect(status).toBe(200);
expect(JSON.parse(text)).toEqual({ status: "no_data" });
});
});

View file

@ -456,18 +456,31 @@ async function handleHealth(req, res) {
} }
} }
function handleDefenseurs(req, res) { // Serves a file written by the Sergent as-is, without re-parsing it: the
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"; // Sergent writes atomically and quarantines unreadable state itself. Any read
// error (absent, EACCES, EISDIR) degrades to a 200 no_data the reader expects.
function serveJsonFile(res, filePath) {
try { try {
const status = readFileSync(statusPath, "utf-8"); const content = readFileSync(filePath, "utf-8");
res.writeHead(200); res.writeHead(200);
res.end(status); res.end(content);
} catch { } catch {
res.writeHead(200); res.writeHead(200);
res.end(JSON.stringify({ status: "no_data" })); res.end(JSON.stringify({ status: "no_data" }));
} }
} }
function handleDefenseurs(req, res) {
serveJsonFile(res, process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json");
}
function handleDefenseursState(req, res) {
serveJsonFile(
res,
process.env.DEFENSEURS_FINDINGS_STATE_PATH || "/data/defenseurs/findings-state.json",
);
}
function handleFindings(req, res, ctx) { function handleFindings(req, res, ctx) {
const project = ctx.parsedUrl.searchParams.get("project"); const project = ctx.parsedUrl.searchParams.get("project");
const category = ctx.parsedUrl.searchParams.get("category"); const category = ctx.parsedUrl.searchParams.get("category");
@ -643,6 +656,7 @@ const ROUTES = [
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth }, { method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs }, { method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
{ method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings }, { method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings },
{ method: "GET", path: "/defenseurs/state", tokenKind: "read", handle: handleDefenseursState },
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans }, { method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList }, { method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest }, { method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },

View file

@ -126,11 +126,18 @@ JSON
# be writable — the same requirement the /data/hosts mount carries in prod. # be writable — the same requirement the /data/hosts mount carries in prod.
mkdir -p "$TMP_DIR/hosts" mkdir -p "$TMP_DIR/hosts"
# Sergent findings state, served verbatim by GET /defenseurs/state. The spacing
# is deliberately non-canonical: a server that parsed and re-serialised the file
# would not reproduce it byte for byte.
printf '%s\n' '{ "schemaVersion":1, "findings": {"a-deps-1": {"firstSeen":"2026-09-01T00:00:00.000Z"}},"history":[] }' \
> "$TMP_DIR/findings-state.json"
# Boot the server with the temp REPORTS_DIR and HOSTS_DIR. # Boot the server with the temp REPORTS_DIR and HOSTS_DIR.
PORT=3099 \ PORT=3099 \
HEALTH_TOKEN="$TOKEN" \ HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \ REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts" \ HOSTS_DIR="$TMP_DIR/hosts" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \
HOSTS_ALLOWED_IDS="thinkpad,workbench" \ HOSTS_ALLOWED_IDS="thinkpad,workbench" \
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \ HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
@ -145,6 +152,7 @@ PORT=3098 \
HEALTH_TOKEN="$TOKEN" \ HEALTH_TOKEN="$TOKEN" \
REPORTS_DIR="$TMP_DIR/reports" \ REPORTS_DIR="$TMP_DIR/reports" \
HOSTS_DIR="$TMP_DIR/hosts-noingest" \ HOSTS_DIR="$TMP_DIR/hosts-noingest" \
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \
HOSTS_ALLOWED_IDS="thinkpad" \ HOSTS_ALLOWED_IDS="thinkpad" \
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
node "$(dirname "$0")/index.js" >/dev/null 2>&1 & node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
@ -444,6 +452,24 @@ code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Ty
code=$(http_code -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/hosts") code=$(http_code -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/hosts")
[[ "$code" == "200" ]] && pass "no HOSTS_INGEST_TOKEN -> GET /hosts still 200" || fail "read path on fail-closed instance -> got $code" [[ "$code" == "200" ]] && pass "no HOSTS_INGEST_TOKEN -> GET /hosts still 200" || fail "read path on fail-closed instance -> got $code"
# Case 32 : GET /defenseurs/state serves findings-state.json byte for byte.
# Compared through files so the trailing newline is part of the comparison
# (command substitution would strip it).
http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/defenseurs/state" > "$TMP_DIR/state-served.json"
cmp -s "$TMP_DIR/state-served.json" "$TMP_DIR/findings-state.json" \
&& pass "GET /defenseurs/state -> file served byte for byte" \
|| fail "GET /defenseurs/state -> body differs from the file"
# Absent file -> 200 no_data, the same contract as /defenseurs.
body=$(http_body -H "Authorization: Bearer $TOKEN" "$NOINGEST_URL/defenseurs/state")
[[ "$body" == '{"status":"no_data"}' ]] \
&& pass "GET /defenseurs/state, file absent -> no_data" \
|| fail "GET /defenseurs/state absent -> $body"
code=$(http_code "$BASE_URL/defenseurs/state")
[[ "$code" == "401" ]] && pass "GET /defenseurs/state no-auth -> 401" || fail "GET /defenseurs/state no-auth -> got $code"
# A read route: the ingest token must not open it.
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/defenseurs/state")
[[ "$code" == "401" ]] && pass "GET /defenseurs/state with ingest token -> 401" || fail "GET /defenseurs/state ingest token -> got $code"
echo echo
echo "=== Results: $PASS passed, $FAIL failed ===" echo "=== Results: $PASS passed, $FAIL failed ==="
[[ "$FAIL" == "0" ]] || exit 1 [[ "$FAIL" == "0" ]] || exit 1