Compare commits
4 commits
64e4669619
...
7c14e581cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c14e581cb | ||
|
|
a5d1bcdedb | ||
| 00e1743c9d | |||
|
|
50a829beb3 |
6 changed files with 446 additions and 21 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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 /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 /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`)
|
||||
- `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`)
|
||||
- `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`)
|
||||
- `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_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)
|
||||
- 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.
|
||||
- `/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,13 +59,15 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
|
||||
## Tests
|
||||
|
||||
- `npm test` (vitest) — 244 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__/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__/auth.test.js` — filet de securite auth sur les routes de lecture (19 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__/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
|
||||
- `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
|
||||
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -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 | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
|
||||
| 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 | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
|
||||
| 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
|
||||
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`
|
||||
|
||||
Query params:
|
||||
|
|
@ -101,6 +120,8 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
|||
| `PORT` | `3001` | HTTP port |
|
||||
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
|
||||
| `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 |
|
||||
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
|
||||
| `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:
|
||||
|
||||
- `/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/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
||||
|
||||
Read-write — the API is the writer:
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const fs = require("node:fs");
|
|||
const os = require("node:os");
|
||||
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
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
// 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.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.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_ALLOWED_IDS = "thinkpad";
|
||||
// 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", () => {
|
||||
// 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.
|
||||
const MALFORMED = [
|
||||
["raw token without the Bearer scheme", TOKEN],
|
||||
|
|
|
|||
133
__tests__/state.test.js
Normal file
133
__tests__/state.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
22
index.js
22
index.js
|
|
@ -456,18 +456,31 @@ async function handleHealth(req, res) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleDefenseurs(req, res) {
|
||||
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
||||
// Serves a file written by the Sergent as-is, without re-parsing it: the
|
||||
// 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 {
|
||||
const status = readFileSync(statusPath, "utf-8");
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
res.writeHead(200);
|
||||
res.end(status);
|
||||
res.end(content);
|
||||
} catch {
|
||||
res.writeHead(200);
|
||||
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) {
|
||||
const project = ctx.parsedUrl.searchParams.get("project");
|
||||
const category = ctx.parsedUrl.searchParams.get("category");
|
||||
|
|
@ -643,6 +656,7 @@ const ROUTES = [
|
|||
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
|
||||
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
|
||||
{ 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: "/hosts", tokenKind: "read", handle: handleHostsList },
|
||||
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
|
||||
|
|
|
|||
260
test-curl.sh
260
test-curl.sh
|
|
@ -1,23 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
# test-curl.sh — manual smoke test for vps-health-api endpoints.
|
||||
#
|
||||
# Spins up the server against a temporary REPORTS_DIR populated with
|
||||
# scan/run-report fixtures, then runs curl against each endpoint and
|
||||
# checks status codes + payload shape. No test runner installed — this
|
||||
# script is the authoritative regression suite for the GET /reports/scans
|
||||
# endpoint until vitest/jest is added.
|
||||
# Spins up the server against a temporary REPORTS_DIR and HOSTS_DIR populated
|
||||
# with fixtures, then runs curl against each endpoint and checks status codes +
|
||||
# payload shape.
|
||||
#
|
||||
# This is the socket-level pass, not a substitute for `npm test`: vitest covers
|
||||
# the logic, this script covers what only a real client and a real TCP socket
|
||||
# can show — a 413 answered before the connection is cut, the two tokens
|
||||
# refusing each other's routes, and the 503 a server booted without
|
||||
# HOSTS_INGEST_TOKEN gives on the ingestion path.
|
||||
#
|
||||
# Usage :
|
||||
# bash test-curl.sh
|
||||
#
|
||||
# Exit 0 if all cases pass, exit 1 on first failure (fail-fast).
|
||||
# Exit 0 if all cases pass, exit 1 otherwise.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:3099}"
|
||||
# Second instance, booted WITHOUT HOSTS_INGEST_TOKEN, for the fail-closed case.
|
||||
NOINGEST_URL="${NOINGEST_URL:-http://localhost:3098}"
|
||||
TOKEN="${TOKEN:-test-token-123}"
|
||||
# Deliberately different from TOKEN — every compartmentalisation case below is
|
||||
# only meaningful because the two values cannot be confused.
|
||||
INGEST_TOKEN="${INGEST_TOKEN:-test-ingest-token-456}"
|
||||
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
||||
trap 'rm -rf "$TMP_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT
|
||||
SERVER_PID=""
|
||||
NOINGEST_PID=""
|
||||
trap 'rm -rf "$TMP_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; [ -n "$NOINGEST_PID" ] && kill "$NOINGEST_PID" 2>/dev/null; true' EXIT
|
||||
|
||||
# Fixtures :
|
||||
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
|
||||
|
|
@ -111,19 +122,51 @@ cat > "$TMP_DIR/reports/archive/defenseur-maximus_2026-05-07T05-00-12-100Z.json"
|
|||
}
|
||||
JSON
|
||||
|
||||
# Boot the server with the temp REPORTS_DIR.
|
||||
# HOSTS_DIR is created by the server on first write; the parent must exist and
|
||||
# be writable — the same requirement the /data/hosts mount carries in prod.
|
||||
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.
|
||||
PORT=3099 \
|
||||
HEALTH_TOKEN="$TOKEN" \
|
||||
REPORTS_DIR="$TMP_DIR/reports" \
|
||||
HOSTS_DIR="$TMP_DIR/hosts" \
|
||||
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/findings-state.json" \
|
||||
HOSTS_ALLOWED_IDS="thinkpad,workbench" \
|
||||
HOSTS_INGEST_TOKEN="$INGEST_TOKEN" \
|
||||
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
||||
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for the server to be ready.
|
||||
# Second instance with HOSTS_INGEST_TOKEN unset, to observe the fail-closed 503
|
||||
# on the ingestion path. Its HOSTS_DIR is separate so a stray write cannot
|
||||
# pollute the fixtures of the main instance.
|
||||
mkdir -p "$TMP_DIR/hosts-noingest"
|
||||
PORT=3098 \
|
||||
HEALTH_TOKEN="$TOKEN" \
|
||||
REPORTS_DIR="$TMP_DIR/reports" \
|
||||
HOSTS_DIR="$TMP_DIR/hosts-noingest" \
|
||||
DEFENSEURS_FINDINGS_STATE_PATH="$TMP_DIR/absent-findings-state.json" \
|
||||
HOSTS_ALLOWED_IDS="thinkpad" \
|
||||
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
||||
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
||||
NOINGEST_PID=$!
|
||||
|
||||
# Wait for both servers to be ready.
|
||||
for _ in {1..50}; do
|
||||
if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
for _ in {1..50}; do
|
||||
if curl -s -o /dev/null "$NOINGEST_URL/health" 2>/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
|
@ -136,6 +179,18 @@ pass() {
|
|||
PASS=$((PASS+1))
|
||||
}
|
||||
|
||||
# `set -e` is on and the 413 case ends with the server calling req.destroy()
|
||||
# once the response is flushed — curl can therefore read the status line and
|
||||
# still exit non-zero on the reset that follows. Wrapping every probe keeps a
|
||||
# transport-level hiccup a FAIL line instead of a silent early exit; a curl
|
||||
# that truly failed reports "000", which no case accepts.
|
||||
http_code() {
|
||||
curl -s -o /dev/null -w '%{http_code}' "$@" || true
|
||||
}
|
||||
http_body() {
|
||||
curl -s "$@" || true
|
||||
}
|
||||
|
||||
# Case 1 : no auth -> 401
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07")
|
||||
[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code"
|
||||
|
|
@ -228,6 +283,193 @@ body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
||||
[[ "$count" == "0" ]] && pass "missing archive/ + archive-only date -> count=0" || fail "missing archive/ archive-only -> count=$count"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workstation snapshots — POST /hosts/<id> and GET /hosts (issue #16)
|
||||
#
|
||||
# Allowlist for this run is "thinkpad,workbench"; `laptop` is the well-formed
|
||||
# id that is deliberately NOT on it, and `ThinkPad` the malformed one.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Fixtures for the ingestion cases.
|
||||
cat > "$TMP_DIR/snapshot.json" <<'JSON'
|
||||
{
|
||||
"hostname": "thinkpad-x1",
|
||||
"uptime": 123456,
|
||||
"cpu": { "model": "Intel Core i7", "cores": 8, "loadAvg": [0.4, 0.5, 0.6], "usagePercent": 12.5 },
|
||||
"memory": { "totalGB": 16, "usedGB": 7.5, "freeGB": 8.5, "usagePercent": 46.9 },
|
||||
"disk": { "totalGB": 512, "usedGB": 210, "freeGB": 302, "usagePercent": 41 }
|
||||
}
|
||||
JSON
|
||||
|
||||
# Same shape, plus an unknown key and a __proto__ that must never reach the
|
||||
# persisted file (which GET /hosts feeds straight into the admin's React tree),
|
||||
# and a hostname well past the 128-character cap.
|
||||
node -e '
|
||||
const payload = {
|
||||
hostname: "h".repeat(300),
|
||||
uptime: 42,
|
||||
cpu: { model: "M", cores: 4, loadAvg: [0, 0, 0], usagePercent: 1 },
|
||||
memory: { totalGB: 8, usedGB: 4, freeGB: 4, usagePercent: 50 },
|
||||
disk: { totalGB: 100, usedGB: 50, freeGB: 50, usagePercent: 50 },
|
||||
injected: "should not survive",
|
||||
};
|
||||
// Spliced in as text on purpose: written as `__proto__:` in an object literal
|
||||
// it would SET the prototype and JSON.stringify would emit nothing, leaving
|
||||
// this case asserting against a payload that never carried the key.
|
||||
const hostile = "{\"__proto__\":{\"polluted\":true}," + JSON.stringify(payload).slice(1);
|
||||
require("fs").writeFileSync(process.argv[1], hostile);
|
||||
' "$TMP_DIR/hostile.json"
|
||||
|
||||
# Body over the 4 KiB ceiling — padding lives in a legitimate field so the
|
||||
# request is rejected on size alone, not on shape.
|
||||
node -e '
|
||||
const payload = {
|
||||
hostname: "x".repeat(5000),
|
||||
uptime: 1,
|
||||
cpu: { model: "M", cores: 1, loadAvg: [0, 0, 0], usagePercent: 0 },
|
||||
memory: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
|
||||
disk: { totalGB: 1, usedGB: 0, freeGB: 1, usagePercent: 0 },
|
||||
};
|
||||
require("fs").writeFileSync(process.argv[1], JSON.stringify(payload));
|
||||
' "$TMP_DIR/oversize.json"
|
||||
|
||||
# Case 14 : GET /hosts without auth -> 401
|
||||
code=$(http_code "$BASE_URL/hosts")
|
||||
[[ "$code" == "401" ]] && pass "GET /hosts no-auth -> 401" || fail "GET /hosts no-auth -> got $code"
|
||||
|
||||
# Case 15 : GET /hosts with a wrong token -> 401
|
||||
code=$(http_code -H "Authorization: Bearer wrong" "$BASE_URL/hosts")
|
||||
[[ "$code" == "401" ]] && pass "GET /hosts wrong-token -> 401" || fail "GET /hosts wrong-token -> got $code"
|
||||
|
||||
# Case 16 : the ingest token must NOT open a read route.
|
||||
code=$(http_code -H "Authorization: Bearer $INGEST_TOKEN" "$BASE_URL/hosts")
|
||||
[[ "$code" == "401" ]] && pass "GET /hosts with ingest token -> 401" || fail "GET /hosts with ingest token -> got $code"
|
||||
|
||||
# Case 17 : before any push, every allowlisted id is present and neverSeen.
|
||||
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
||||
ids=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.map(h=>h.id).join(","));});')
|
||||
never=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.every(h=>h.neverSeen===true&&h.online===false)?"yes":"no");});')
|
||||
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).staleAfterSeconds);});')
|
||||
[[ "$ids" == "thinkpad,workbench" ]] && pass "GET /hosts lists the allowlist sorted" || fail "GET /hosts ids -> $ids"
|
||||
[[ "$never" == "yes" ]] && pass "unpushed hosts degrade to neverSeen + offline" || fail "neverSeen degradation -> $never"
|
||||
[[ "$stale" == "900" ]] && pass "staleAfterSeconds defaults to 900" || fail "staleAfterSeconds -> $stale"
|
||||
|
||||
# Case 18 : POST /hosts/<id> without auth -> 401
|
||||
code=$(http_code -X POST -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad no-auth -> 401" || fail "POST /hosts/thinkpad no-auth -> got $code"
|
||||
|
||||
# Case 19 : the read token must NOT open the ingestion route.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "401" ]] && pass "POST /hosts/thinkpad with read token -> 401" || fail "POST with read token -> got $code"
|
||||
|
||||
# Case 20 : 401 comes before 403 — an unauthenticated caller cannot probe the
|
||||
# allowlist by watching the status code change.
|
||||
code=$(http_code -X POST -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
|
||||
[[ "$code" == "401" ]] && pass "POST unknown id no-auth -> 401 (not 403)" || fail "POST unknown id no-auth -> got $code"
|
||||
|
||||
# Case 21 : well-formed id, valid token, not on the allowlist -> 403
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/laptop")
|
||||
[[ "$code" == "403" ]] && pass "POST /hosts/laptop authenticated -> 403" || fail "POST /hosts/laptop -> got $code"
|
||||
|
||||
# Case 22 : malformed id never reaches the handler — the route regex is the
|
||||
# first control, so it answers 404 rather than 403.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/ThinkPad")
|
||||
[[ "$code" == "404" ]] && pass "POST malformed id -> 404" || fail "POST malformed id -> got $code"
|
||||
|
||||
# Case 23 : traversal attempt collapses to a path the router does not know.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/../../etc/passwd")
|
||||
[[ "$code" == "404" ]] && pass "POST traversal -> 404" || fail "POST traversal -> got $code"
|
||||
|
||||
# Case 24 : the ingestion path is POST-only.
|
||||
code=$(http_code -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "404" ]] && pass "GET /hosts/thinkpad -> 404 (POST-only)" || fail "GET /hosts/thinkpad -> got $code"
|
||||
|
||||
# Case 25 : unreadable JSON -> 400
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
-d 'not json at all' "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "400" ]] && pass "POST unreadable JSON -> 400" || fail "POST unreadable JSON -> got $code"
|
||||
|
||||
# Case 26 : valid JSON, missing field -> 400
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"hostname":"x"}' "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "400" ]] && pass "POST missing field -> 400" || fail "POST missing field -> got $code"
|
||||
|
||||
# Case 27 : body over 4 KiB -> 413. The server answers, THEN destroys the
|
||||
# socket, so curl can exit 55/56 on the reset after having read the status line;
|
||||
# http_code absorbs that. `-H "Expect:"` suppresses the 100-continue handshake
|
||||
# curl adds on larger uploads (it changes WHEN the body is sent, and with it
|
||||
# which side notices the reset first), and --max-time keeps a half-closed
|
||||
# socket from hanging the run. A curl that gets nothing at all reports 000,
|
||||
# which fails the case rather than passing it silently.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
-H "Expect:" --http1.1 --max-time 10 \
|
||||
--data-binary @"$TMP_DIR/oversize.json" "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "413" ]] && pass "POST oversize body -> 413" || fail "POST oversize body -> got $code"
|
||||
|
||||
# Case 28 : the happy path -> 204 with no body.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$BASE_URL/hosts/thinkpad")
|
||||
[[ "$code" == "204" ]] && pass "POST valid snapshot -> 204" || fail "POST valid snapshot -> got $code"
|
||||
|
||||
# Case 29 : the snapshot is now readable, fresh, and stamped by the server.
|
||||
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
||||
host=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="thinkpad");console.log([h.hostname,h.online,h.ageSeconds<60,h.neverSeen===undefined].join("|"));});')
|
||||
[[ "$host" == "thinkpad-x1|true|true|true" ]] \
|
||||
&& pass "GET /hosts reflects the push (online, fresh, no neverSeen)" \
|
||||
|| fail "GET /hosts after push -> $host"
|
||||
# The other allowlisted id is untouched — one file per host, no cross-talk.
|
||||
other=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).hosts.find(x=>x.id==="workbench").neverSeen===true?"yes":"no");});')
|
||||
[[ "$other" == "yes" ]] && pass "a push for one id leaves the others neverSeen" || fail "cross-talk between ids -> $other"
|
||||
|
||||
# Case 30 : hostile payload is rebuilt from the whitelist — the unknown key and
|
||||
# the __proto__ are dropped, the long hostname is cut to 128 characters.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/hostile.json" "$BASE_URL/hosts/workbench")
|
||||
[[ "$code" == "204" ]] && pass "POST hostile payload -> 204 (accepted, sanitised)" || fail "POST hostile payload -> got $code"
|
||||
body=$(http_body -H "Authorization: Bearer $TOKEN" "$BASE_URL/hosts")
|
||||
clean=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const h=JSON.parse(s).hosts.find(x=>x.id==="workbench");const own=Object.keys(h);console.log([h.hostname.length,own.includes("injected"),own.includes("polluted")].join("|"));});')
|
||||
[[ "$clean" == "128|false|false" ]] \
|
||||
&& pass "hostile payload sanitised (128-char cap, unknown keys dropped)" \
|
||||
|| fail "sanitisation -> $clean"
|
||||
# And on disk: the persisted file carries the whitelist and nothing else.
|
||||
keys=$(node -e 'const r=require("fs").readFileSync(process.argv[1],"utf8");console.log(Object.keys(JSON.parse(r)).sort().join(","));' "$TMP_DIR/hosts/workbench.json")
|
||||
[[ "$keys" == "cpu,disk,hostname,id,memory,receivedAt,uptime" ]] \
|
||||
&& pass "persisted file holds the whitelist only" \
|
||||
|| fail "persisted keys -> $keys"
|
||||
|
||||
# Case 31 : fail-closed. On an instance booted without HOSTS_INGEST_TOKEN the
|
||||
# ingestion path answers 503 — never 204, never a silently dropped snapshot.
|
||||
code=$(http_code -X POST -H "Authorization: Bearer $INGEST_TOKEN" -H "Content-Type: application/json" \
|
||||
--data-binary @"$TMP_DIR/snapshot.json" "$NOINGEST_URL/hosts/thinkpad")
|
||||
[[ "$code" == "503" ]] && pass "no HOSTS_INGEST_TOKEN -> POST answers 503" || fail "fail-closed ingest -> got $code"
|
||||
# Reads keep working on that instance: the two tokens are independent.
|
||||
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"
|
||||
|
||||
# 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 "=== Results: $PASS passed, $FAIL failed ==="
|
||||
[[ "$FAIL" == "0" ]] || exit 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue