diff --git a/.env.example b/.env.example index dc4aaa2..6de3e3d 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,19 @@ LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-con # Directory served by GET /reports/scans. Bind-mount target on Coolify — # parent /data/defenseurs/ is already mounted (status.json sits next to it). REPORTS_DIR=/data/defenseurs/reports + +# --- Workstation snapshots (POST /hosts/, GET /hosts) -------------------- +# Directory the API WRITES workstation snapshots into, one .json per host. +# Unlike the /data/defenseurs mounts this one must be writable by uid 1000 +# (the `node` user the container runs as) or every ingest answers 500. +HOSTS_DIR=/data/hosts +# Comma-separated allowlist of host ids. Each entry must match +# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup. +HOSTS_ALLOWED_IDS=thinkpad +# Bearer token for POST /hosts/. Separate from HEALTH_TOKEN on purpose: a +# workstation agent should be able to write its own snapshot without gaining +# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN — +# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/ answers 503. +HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret +# Age (seconds) past which GET /hosts reports a host as offline. Default 900. +HOSTS_STALE_SECONDS=900 diff --git a/CLAUDE.md b/CLAUDE.md index 73aad93..71c5954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # VPS Health API -API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif. +API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime. ## Endpoints @@ -9,11 +9,24 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif. - `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-_*.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=` (obligatoire, lookup via `agents-map.json`), `category=` (optionnel, exact match), `severity=` (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 +- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel. +- `POST /hosts/` — **seule surface d'ecriture de l'API**. Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `` bien forme mais hors allowlist / 404 `` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`. -- Bearer token via env `HEALTH_TOKEN` -- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees -- **Coolify** : `HEALTH_TOKEN` doit etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime". +## Routage et auth + +Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` : + +1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token. +2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee. +3. Dispatch vers le handler. + +Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord. + +- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion +- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production +- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist +- Chaque rejet 401/403 est journalise (`[auth] ip= id= reason=`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log +- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime". ## Config @@ -21,9 +34,14 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif. - `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_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`) +- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/`, un `.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. - - `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). + - `/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. + - `/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. ## Deploy @@ -38,9 +56,11 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests -- `npm test` (vitest) — 20 cas +- `npm test` (vitest) — 61 cas - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__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__/hosts.test.js` — smoke des endpoints postes (22 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, entree corrompue degradee). La matrice exhaustive est l'issue #14. - Runtime reste 0-dep ; vitest en devDep uniquement ## Gotchas @@ -48,7 +68,10 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - Pas d'Express — HTTP natif Node.js uniquement - Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. - `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`. -- Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only) +- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages `/data/defenseurs`. +- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. +- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload. +- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code). - `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500. - Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Details dans `la-compagnie-maximus/docs/coolify-ops.md`. - 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). diff --git a/README.md b/README.md index 5ce9ecd..90a6149 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,68 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim ## Endpoints -All endpoints require `Authorization: Bearer $HEALTH_TOKEN`. +Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. The single write +endpoint uses its own token, `$HOSTS_INGEST_TOKEN`, so a workstation agent can +push its snapshot without gaining read access to the Defenseurs reports. -| 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 | +| Method | Path | Token | Description | +|--------|------|-------|-------------| +| 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 | `/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/` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot | + +Routing resolves before authentication: an unknown path or a wrong method +answers `404`, never `401`. + +### `POST /hosts/` + +Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`, +`uptime`, `cpu{model,cores,loadAvg,usagePercent}`, +`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`. + +`` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in +`HOSTS_ALLOWED_IDS`. That regex is the path-traversal control — anything that +fails it never reaches a handler. + +| Code | Case | +|------|------| +| 204 | Snapshot accepted and written | +| 400 | Unparsable JSON, missing field, or wrong type | +| 401 | Ingest token missing or wrong | +| 403 | `` well-formed but not in the allowlist | +| 404 | `` outside the format (never reaches the handler) | +| 413 | Body past 4 KiB — connection cut | +| 503 | `HOSTS_INGEST_TOKEN` not configured (fail-closed) | + +The stored object is rebuilt field by field from a whitelist (numbers must be +finite, strings are capped at 128 chars, everything else dropped) and written +atomically via a temp file + `rename`. `receivedAt` is stamped by the server on +arrival; a client-supplied timestamp is ignored. + +Example: + +``` +curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \ + -H "Content-Type: application/json" --data @snapshot.json \ + "https://health.lacompagniemaximus.com/hosts/thinkpad" +``` + +### `GET /hosts` + +``` +{ "staleAfterSeconds": 900, + "hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk", + "receivedAt", "ageSeconds", "online" } ] } +``` + +One entry per allowlisted id. `online = ageSeconds <= staleAfterSeconds`, +computed server-side. A host that never checked in — or whose snapshot file is +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/findings` @@ -45,17 +99,28 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \ | Env var | Default | Purpose | |---------|---------|---------| | `PORT` | `3001` | HTTP port | -| `HEALTH_TOKEN` | — | Bearer token (fail-closed if missing) | +| `HEALTH_TOKEN` | — | Bearer token for the read routes (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 | +| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** | +| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids | +| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/` (503 if missing) | +| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline | -## Bind-mounts (Coolify, read-only) +## Bind-mounts (Coolify) + +Read-only for the API — written by the Defenseurs Sergent: - `/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` +Read-write — the API is the writer: + +- `/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid + 1000, the `node` user the container runs as, otherwise every ingest 500s. + ## Tests ``` diff --git a/__tests__/hosts.test.js b/__tests__/hosts.test.js new file mode 100644 index 0000000..e0486f6 --- /dev/null +++ b/__tests__/hosts.test.js @@ -0,0 +1,277 @@ +const http = require("node:http"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +// Smoke coverage for the workstation snapshot surface (issue #13). +// +// This is deliberately NOT the exhaustive matrix — that is issue #14. What is +// pinned here is the handful of decisions that would be expensive to get wrong +// and silent to regress: the routing-before-auth ordering, the narrow host id +// regex as the traversal control, 401 winning over 403, the whitelist rebuild, +// and the server-stamped freshness. + +const TOKEN = "test-token"; +const INGEST_TOKEN = "test-ingest-token"; + +let tmpDir; +let hostsDir; +let server; +let baseUrl; +let handler; + +function validSnapshot() { + return { + hostname: "thinkpad-x1", + uptime: 4242, + cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 }, + memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 }, + disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 }, + }; +} + +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 request(route, { method = "GET", auth, body } = {}) { + const headers = {}; + if (auth) headers.Authorization = auth; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); + const parsed = await res.json().catch(() => ({})); + return { status: res.status, body: parsed }; +} + +function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) { + return request(route, { method: "POST", auth, body }); +} + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-hosts-test-")); + hostsDir = path.join(tmpDir, "hosts"); + + process.env.HEALTH_TOKEN = TOKEN; + process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; + process.env.HOSTS_DIR = hostsDir; + process.env.HOSTS_ALLOWED_IDS = "thinkpad"; + delete process.env.HOSTS_STALE_SECONDS; + 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"); + + await startServer(); +}); + +afterEach(async () => { + await stopServer(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete process.env.HOSTS_INGEST_TOKEN; + delete process.env.HOSTS_DIR; + delete process.env.HOSTS_ALLOWED_IDS; + delete process.env.HOSTS_STALE_SECONDS; +}); + +describe("POST /hosts/ — routing is the traversal control", () => { + // Every one of these must be 404, not 400/403: the request never reaches a + // handler. URL() normalises the traversal away, the rest fail the regex. + const REJECTED = [ + ["dot-dot traversal", "/hosts/../../etc/passwd"], + ["encoded traversal", "/hosts/..%2f..%2fetc%2fpasswd"], + ["uppercase id", "/hosts/THINKPAD"], + ["id with a slash", "/hosts/thinkpad/extra"], + ["empty id", "/hosts/"], + ]; + + test.each(REJECTED)("404 on POST %s", async (_label, route) => { + const { status, body } = await ingest(route); + expect(status).toBe(404); + expect(body.error).toBe("Not found"); + }); + + test("404 on GET /hosts/thinkpad — the id route is POST-only", async () => { + const { status } = await request("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` }); + expect(status).toBe(404); + }); +}); + +describe("POST /hosts/ — auth gate", () => { + test("401 with no Authorization header", async () => { + const { status, body } = await ingest("/hosts/thinkpad", { auth: null }); + expect(status).toBe(401); + expect(body.error).toBe("Unauthorized"); + }); + + test("401 when presenting the read token instead of the ingest token", async () => { + const { status } = await ingest("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` }); + expect(status).toBe(401); + }); + + // 401 must win over 403 so an unauthenticated caller cannot probe the + // allowlist for valid workstation ids. + test("401 (not 403) on a non-allowlisted id without a token", async () => { + const { status } = await ingest("/hosts/popos", { auth: null }); + expect(status).toBe(401); + }); + + test("403 on a well-formed id outside the allowlist", async () => { + const { status, body } = await ingest("/hosts/popos"); + expect(status).toBe(403); + expect(body.error).toBe("Forbidden"); + }); + + test("503 when HOSTS_INGEST_TOKEN is unset (fail-closed)", async () => { + delete process.env.HOSTS_INGEST_TOKEN; + await stopServer(); + await startServer(); + const { status, body } = await ingest("/hosts/thinkpad"); + expect(status).toBe(503); + expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured"); + }); +}); + +describe("POST /hosts/ — body handling", () => { + test("204 and an atomically written file on a valid snapshot", async () => { + const { status } = await ingest("/hosts/thinkpad"); + expect(status).toBe(204); + const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8")); + expect(written.hostname).toBe("thinkpad-x1"); + expect(typeof written.receivedAt).toBe("string"); + // No leftover temp file next to the snapshot. + expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]); + }); + + test("the persisted object is rebuilt from the whitelist, not stored verbatim", async () => { + const raw = `{"__proto__":{"polluted":true},"extra":"nope","receivedAt":"1999-01-01T00:00:00.000Z",${JSON.stringify( + { ...validSnapshot(), hostname: "h".repeat(300) }, + ).slice(1)}`; + const { status } = await ingest("/hosts/thinkpad", { body: raw }); + expect(status).toBe(204); + + const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8")); + expect(Object.keys(written)).toEqual([ + "id", + "hostname", + "uptime", + "cpu", + "memory", + "disk", + "receivedAt", + ]); + expect(written.extra).toBeUndefined(); + expect(written.polluted).toBeUndefined(); + expect({}.polluted).toBeUndefined(); + // Strings capped at 128 chars, timestamp stamped by the server. + expect(written.hostname.length).toBe(128); + expect(written.receivedAt).not.toBe("1999-01-01T00:00:00.000Z"); + expect(Date.now() - new Date(written.receivedAt).getTime()).toBeLessThan(10000); + }); + + test("400 on unparsable JSON", async () => { + const { status } = await ingest("/hosts/thinkpad", { body: "{not json" }); + expect(status).toBe(400); + }); + + test("400 on a missing or mistyped field", async () => { + const payload = validSnapshot(); + delete payload.memory; + const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }); + expect(status).toBe(400); + expect(body.error).toMatch(/memory/); + expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false); + }); + + test("413 past 4 KiB, and nothing is written", async () => { + const payload = { ...validSnapshot(), padding: "x".repeat(5000) }; + let status; + try { + ({ status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) })); + } catch { + // The socket is destroyed right after the 413 flushes; a client that + // loses the race sees a transport error instead. Either way the write + // must not have happened, which is what the assertion below pins. + status = 413; + } + expect(status).toBe(413); + expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false); + }); +}); + +describe("GET /hosts", () => { + test("401 when presenting the ingest token instead of the read token", async () => { + const { status } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` }); + expect(status).toBe(401); + }); + + test("reports an allowlisted host that never checked in", async () => { + const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); + expect(status).toBe(200); + expect(body.staleAfterSeconds).toBe(900); + expect(body.hosts).toHaveLength(1); + expect(body.hosts[0]).toMatchObject({ id: "thinkpad", neverSeen: true, online: false }); + }); + + test("serves the snapshot back with a server-computed freshness", async () => { + await ingest("/hosts/thinkpad"); + const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); + expect(status).toBe(200); + const host = body.hosts[0]; + expect(host.neverSeen).toBeUndefined(); + expect(host.hostname).toBe("thinkpad-x1"); + expect(host.ageSeconds).toBeLessThan(5); + expect(host.online).toBe(true); + expect(Object.keys(host.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); + }); + + test("online flips to false past HOSTS_STALE_SECONDS", async () => { + fs.mkdirSync(hostsDir, { recursive: true }); + const stale = { + id: "thinkpad", + ...validSnapshot(), + receivedAt: new Date(Date.now() - 3600 * 1000).toISOString(), + }; + fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), JSON.stringify(stale)); + + const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); + expect(body.hosts[0].online).toBe(false); + expect(body.hosts[0].ageSeconds).toBeGreaterThan(3000); + }); + + test("degrades a corrupted snapshot instead of failing the whole response", async () => { + fs.mkdirSync(hostsDir, { recursive: true }); + fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), "{ truncated"); + + const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); + expect(status).toBe(200); + expect(body.hosts).toHaveLength(1); + expect(body.hosts[0].neverSeen).toBe(true); + }); +}); + +describe("HOSTS_ALLOWED_IDS validation", () => { + test("entries that are not valid host ids are dropped at startup", async () => { + process.env.HOSTS_ALLOWED_IDS = "thinkpad, ../defenseurs/status ,POPOS,,popos"; + await stopServer(); + await startServer(); + + const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` }); + expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]); + }); +}); diff --git a/index.js b/index.js index a009697..563b95d 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,16 @@ const http = require("node:http"); const os = require("node:os"); -const { readFileSync, readdirSync, existsSync } = require("node:fs"); +const { + readFileSync, + readdirSync, + existsSync, + mkdirSync, + writeFileSync, + renameSync, + unlinkSync, +} = require("node:fs"); const path = require("node:path"); +const { createHash, timingSafeEqual } = require("node:crypto"); const { collectMetrics } = require("./metrics.js"); const PORT = parseInt(process.env.PORT || "3001", 10); @@ -19,6 +28,52 @@ 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"]; +// --- Workstation snapshots (POST /hosts/, GET /hosts) ------------------- + +const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts"; +const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN; +const MAX_BODY_BYTES = 4096; +const MAX_STRING_LENGTH = 128; + +// Single source of truth for what a host id may look like. The allowlist check +// and the route matcher are both built from it, so they can never drift apart. +// +// This narrow pattern IS the path-traversal control. Verified in Node: URL() +// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and +// `THINKPAD` simply fail the match — every hostile id therefore lands on 404, +// before any handler runs. 403 is reserved for well-formed ids that are not in +// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass. +const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}"; +const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`); +const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`); + +const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10); +const HOSTS_STALE_SECONDS = + Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900; + +// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid +// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or +// an entry like `../defenseurs/status` must never survive into a file path. +// Rejections are logged rather than silently swallowed: a typo in the env var +// would otherwise look exactly like a workstation that never checked in. +function parseAllowedIds(raw) { + const allowed = new Set(); + for (const entry of String(raw).split(",")) { + const id = entry.trim(); + if (!id) continue; + if (!HOST_ID_RE.test(id)) { + console.warn( + `WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`, + ); + continue; + } + allowed.add(id); + } + return allowed; +} + +const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad"); + // Severity filter. Asymmetric rule (issue #3): // - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO) // - threshold "INFO" -> INFO only (explicit opt-in) @@ -37,6 +92,10 @@ if (!TOKEN) { console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); } +// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path +// fail-closes to 503 and logs the reason on the request that hits it, which is +// the moment an operator actually needs to see it. + async function getLogtoHealth() { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS); @@ -178,130 +237,215 @@ async function getHealth() { }; } -async function handler(req, res) { - res.setHeader("Content-Type", "application/json"); +// --- Hosts helpers ---------------------------------------------------------- - // Parse the URL so /reports/scans can carry a `?date=` query string. The - // placeholder host is required because URL() needs an absolute URL. - const parsedUrl = new URL(req.url, "http://localhost"); - const pathname = parsedUrl.pathname; +// Printable-ASCII-only, length-capped rendering for anything attacker-supplied +// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted +// value from forging extra log records. +function safeLogValue(value, max = 64) { + return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max); +} - const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"]; - if (req.method !== "GET" || !validRoutes.includes(pathname)) { - res.writeHead(404); - res.end(JSON.stringify({ error: "Not found" })); - return; - } +// Log every 401/403. This is the first publicly writable surface on the API: +// without a trace, a token-guessing campaign would leave nothing behind. +function logRejection(req, status, reason, id) { + const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown"); + const method = safeLogValue(req.method, 8); + const url = safeLogValue(req.url, 120); + console.warn( + `[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`, + ); +} - if (!TOKEN) { - res.writeHead(401); - res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" })); - return; - } +// Constant-time bearer comparison for the ingestion path. Both sides are hashed +// first, so the buffers are always 32 bytes: timingSafeEqual never throws on +// mismatched lengths, and the length of the real token cannot leak. +// +// Deliberately scoped to the new write path. Realigning the existing +// HEALTH_TOKEN comparison touches every read route in production and is tracked +// separately (issue #17). +function tokenMatches(provided, expected) { + if (typeof provided !== "string" || typeof expected !== "string") return false; + const a = createHash("sha256").update(provided).digest(); + const b = createHash("sha256").update(expected).digest(); + return timingSafeEqual(a, b); +} - const auth = req.headers["authorization"]; - if (auth !== `Bearer ${TOKEN}`) { - res.writeHead(401); - res.end(JSON.stringify({ error: "Unauthorized" })); - return; - } +// Read the request body with a hard 4 KiB ceiling. The counter runs on the +// bytes actually received, never on Content-Length: a client can lie in the +// header, and chunked transfer-encoding omits it altogether. +// +// Resolves to the raw string, or to null when the response has already been +// written (413) or the socket died — in which case the caller must not answer. +function readBody(req, res) { + return new Promise((resolve) => { + const chunks = []; + let size = 0; + let settled = false; - if (pathname === "/defenseurs") { - const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"; - try { - const status = readFileSync(statusPath, "utf-8"); - res.writeHead(200); - res.end(status); - } catch { - res.writeHead(200); - res.end(JSON.stringify({ status: "no_data" })); - } - return; - } - - if (pathname === "/defenseurs/findings") { - const project = parsedUrl.searchParams.get("project"); - const category = parsedUrl.searchParams.get("category"); - const severity = parsedUrl.searchParams.get("severity"); - - if (!project) { - res.writeHead(400); - res.end(JSON.stringify({ error: "Bad request: project= 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" })); + req.on("data", (chunk) => { + if (settled) return; + size += chunk.length; + if (size > MAX_BODY_BYTES) { + settled = true; + req.pause(); + res.writeHead(413); + // Destroy only once the 413 has been flushed: req.destroy() tears down + // the socket, so cutting first would leave the client with a network + // error instead of a status code. + res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy()); + resolve(null); 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; + chunks.push(chunk); + }); + + req.on("end", () => { + if (settled) return; + settled = true; + resolve(Buffer.concat(chunks).toString("utf-8")); + }); + + req.on("error", () => { + if (settled) return; + settled = true; + resolve(null); + }); + }); +} + +function cleanString(value) { + if (typeof value !== "string") return null; + return value.slice(0, MAX_STRING_LENGTH); +} + +function cleanNumber(value) { + return Number.isFinite(value) ? value : null; +} + +// { totalGB, usedGB, freeGB, usagePercent } — the shape shared by memory and disk. +function sanitizeUsage(value, label) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { error: `${label} must be an object` }; + } + const out = {}; + for (const key of ["totalGB", "usedGB", "freeGB", "usagePercent"]) { + const num = cleanNumber(value[key]); + if (num === null) return { error: `${label}.${key} must be a finite number` }; + out[key] = num; + } + return { value: out }; +} + +function sanitizeCpu(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { error: "cpu must be an object" }; + } + const model = cleanString(value.model); + if (model === null) return { error: "cpu.model must be a string" }; + const cores = cleanNumber(value.cores); + if (cores === null) return { error: "cpu.cores must be a finite number" }; + const usagePercent = cleanNumber(value.usagePercent); + if (usagePercent === null) return { error: "cpu.usagePercent must be a finite number" }; + if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" }; + const loadAvg = []; + for (const entry of value.loadAvg.slice(0, 3)) { + const num = cleanNumber(entry); + if (num === null) return { error: "cpu.loadAvg must contain finite numbers" }; + loadAvg.push(num); + } + return { value: { model, cores, loadAvg, usagePercent } }; +} + +// Rebuild the persisted object field by field from a whitelist — never store +// the payload verbatim. The file is read back by GET /hosts and ends up in the +// admin dashboard's React tree, so an unknown key (`__proto__` among them) or a +// 4 KiB hostname must never make it that far. +function sanitizeSnapshot(payload) { + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return { error: "body must be a JSON object" }; } - if (pathname === "/reports/scans") { - const date = parsedUrl.searchParams.get("date"); - // Regex short-circuit before any filesystem access — blocks path - // traversal (`../../etc/passwd` -> 400) and bogus inputs. - if (!date || !SCAN_DATE_RE.test(date)) { - res.writeHead(400); - res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" })); - return; - } + const hostname = cleanString(payload.hostname); + if (hostname === null) return { error: "hostname must be a string" }; + const uptime = cleanNumber(payload.uptime); + if (uptime === null) return { error: "uptime must be a finite number" }; + + const cpu = sanitizeCpu(payload.cpu); + if (cpu.error) return cpu; + const memory = sanitizeUsage(payload.memory, "memory"); + if (memory.error) return memory; + const disk = sanitizeUsage(payload.disk, "disk"); + if (disk.error) return disk; + + return { + snapshot: { + hostname, + uptime, + cpu: cpu.value, + memory: memory.value, + disk: disk.value, + }, + }; +} + +// Atomic write: temp file in the SAME directory, then renameSync. rename is +// atomic within a filesystem, so a concurrent GET /hosts either sees the old +// snapshot or the new one — never a half-written JSON. +function writeHostSnapshot(id, record) { + mkdirSync(HOSTS_DIR, { recursive: true }); + const finalPath = path.join(HOSTS_DIR, `${id}.json`); + const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`); + try { + writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 }); + renameSync(tmpPath, finalPath); + } catch (err) { try { - const reports = readScanReportsForDate(date); - res.writeHead(200); - res.end(JSON.stringify({ date, count: reports.length, reports })); - } catch (err) { - res.writeHead(500); - res.end(JSON.stringify({ error: "Internal error", message: err.message })); + unlinkSync(tmpPath); + } catch { + // Best effort — the temp file may never have been created. } - return; + throw err; + } +} + +// Read one persisted snapshot. Anything missing, unparsable, off-shape or +// without a usable server timestamp degrades to null: GET /hosts then reports +// that entry as neverSeen instead of failing the whole response. +// +// The file was written by this service, but it lives on a writable bind-mount: +// it is re-run through the same whitelist on the way out rather than trusted. +function readHostSnapshot(id) { + const file = path.join(HOSTS_DIR, `${id}.json`); + let parsed; + try { + parsed = JSON.parse(readFileSync(file, "utf-8")); + } catch (err) { + if (err.code !== "ENOENT") { + console.error(`[hosts] failed to read snapshot for ${id}:`, err.message); + } + return null; } + const result = sanitizeSnapshot(parsed); + if (result.error) { + console.error(`[hosts] snapshot for ${id} rejected on read: ${result.error}`); + return null; + } + + const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null; + if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) { + console.error(`[hosts] snapshot for ${id} has no usable receivedAt`); + return null; + } + + return { ...result.snapshot, receivedAt }; +} + +// --- Route handlers --------------------------------------------------------- + +async function handleHealth(req, res) { try { const data = await getHealth(); res.writeHead(200); @@ -312,6 +456,277 @@ async function handler(req, res) { } } +function handleDefenseurs(req, res) { + const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"; + try { + const status = readFileSync(statusPath, "utf-8"); + res.writeHead(200); + res.end(status); + } catch { + res.writeHead(200); + res.end(JSON.stringify({ status: "no_data" })); + } +} + +function handleFindings(req, res, ctx) { + const project = ctx.parsedUrl.searchParams.get("project"); + const category = ctx.parsedUrl.searchParams.get("category"); + const severity = ctx.parsedUrl.searchParams.get("severity"); + + if (!project) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Bad request: project= 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 })); + } +} + +function handleReportsScans(req, res, ctx) { + const date = ctx.parsedUrl.searchParams.get("date"); + // Regex short-circuit before any filesystem access — blocks path + // traversal (`../../etc/passwd` -> 400) and bogus inputs. + if (!date || !SCAN_DATE_RE.test(date)) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" })); + return; + } + + try { + const reports = readScanReportsForDate(date); + res.writeHead(200); + res.end(JSON.stringify({ date, count: reports.length, reports })); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error", message: err.message })); + } +} + +// Serve one entry per allowlisted workstation, whether or not it ever reported. +// Keeping the neverSeen entries visible is what turns the commissioning window +// (deployed, no heartbeat yet) into "agent not installed" on the dashboard +// instead of a silently missing card. +function handleHostsList(req, res) { + const now = Date.now(); + const hosts = []; + + for (const id of [...HOSTS_ALLOWED_IDS].sort()) { + const record = readHostSnapshot(id); + if (!record) { + hosts.push({ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }); + continue; + } + // Freshness is computed server-side from the server-stamped receivedAt — + // the client never gets a say in whether it looks online. + const ageSeconds = Math.max( + 0, + Math.floor((now - new Date(record.receivedAt).getTime()) / 1000), + ); + hosts.push({ + id, + hostname: record.hostname, + uptime: record.uptime, + cpu: record.cpu, + memory: record.memory, + disk: record.disk, + receivedAt: record.receivedAt, + ageSeconds, + online: ageSeconds <= HOSTS_STALE_SECONDS, + }); + } + + res.writeHead(200); + res.end(JSON.stringify({ staleAfterSeconds: HOSTS_STALE_SECONDS, hosts })); +} + +async function handleHostIngest(req, res, ctx) { + const id = ctx.hostId; + + // The id already matched HOST_ROUTE_RE, so 403 here means "well-formed but + // not one of ours" — the only case that reaches this branch. + if (!HOSTS_ALLOWED_IDS.has(id)) { + logRejection(req, 403, "host id not in HOSTS_ALLOWED_IDS", id); + res.writeHead(403); + res.end(JSON.stringify({ error: "Forbidden" })); + return; + } + + const raw = await readBody(req, res); + if (raw === null) return; // 413 already answered, or the socket is gone. + + let payload; + try { + payload = JSON.parse(raw); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" })); + return; + } + + const result = sanitizeSnapshot(payload); + if (result.error) { + res.writeHead(400); + res.end(JSON.stringify({ error: `Bad request: ${result.error}` })); + return; + } + + // receivedAt is stamped by the SERVER on arrival. A client-supplied timestamp + // would let a stale (or hostile) agent claim to be fresher than it is. + const record = { id, ...result.snapshot, receivedAt: new Date().toISOString() }; + + try { + writeHostSnapshot(id, record); + res.removeHeader("Content-Type"); + res.writeHead(204); + res.end(); + } catch (err) { + console.error(`[hosts] failed to persist snapshot for ${id}:`, err.message); + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error" })); + } +} + +// --- Routing ---------------------------------------------------------------- + +// Route descriptors. `tokenKind` is the ONLY thing that varies between the read +// routes and the ingestion route: authentication itself happens once, in the +// single gate below. Adding a route here therefore cannot accidentally ship +// unauthenticated — which is exactly what a per-branch check invites. +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: "/reports/scans", tokenKind: "read", handle: handleReportsScans }, + { method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList }, + { method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest }, +]; + +// Deny by default: no descriptor match -> null -> 404. +function resolveRoute(method, pathname) { + for (const route of ROUTES) { + if (route.method !== method) continue; + if (route.path !== undefined) { + if (route.path === pathname) return { route, hostId: null }; + continue; + } + const match = route.pattern.exec(pathname); + if (match) return { route, hostId: match[1] }; + } + return null; +} + +// THE single authentication checkpoint. Returns null when the caller may +// proceed, otherwise the rejection to write. Only the expected token varies +// per descriptor. +function checkAuth(req, route) { + const isIngest = route.tokenKind === "ingest"; + const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN; + + if (!expected) { + return isIngest + ? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" } + : { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" }; + } + + const header = req.headers["authorization"]; + const ok = isIngest + ? typeof header === "string" && + header.startsWith("Bearer ") && + tokenMatches(header.slice("Bearer ".length), expected) + : header === `Bearer ${expected}`; + + if (!ok) { + return { status: 401, body: { error: "Unauthorized" }, reason: "missing or invalid bearer token" }; + } + return null; +} + +async function handler(req, res) { + res.setHeader("Content-Type", "application/json"); + + // Parse the URL so /reports/scans can carry a `?date=` query string. The + // placeholder host is required because URL() needs an absolute URL. + const parsedUrl = new URL(req.url, "http://localhost"); + const pathname = parsedUrl.pathname; + + // ORDER IS PART OF THE CONTRACT: routing resolves FIRST, so an unknown path + // or a wrong method answers 404 — not 401 — exactly as before this route + // table existed. Two tests in __tests__/auth.test.js pin that ordering. + const resolved = resolveRoute(req.method, pathname); + if (!resolved) { + res.writeHead(404); + res.end(JSON.stringify({ error: "Not found" })); + return; + } + + const rejection = checkAuth(req, resolved.route); + if (rejection) { + logRejection(req, rejection.status, rejection.reason, resolved.hostId); + res.writeHead(rejection.status); + res.end(JSON.stringify(rejection.body)); + return; + } + + try { + await resolved.route.handle(req, res, { parsedUrl, hostId: resolved.hostId }); + } catch (err) { + // Safety net: every handler already owns its error paths, this only catches + // what none of them anticipated. + console.error(`[handler] unhandled error on ${safeLogValue(pathname, 120)}:`, err.message); + if (!res.headersSent) { + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error" })); + } + } +} + if (require.main === module) { const server = http.createServer(handler); server.listen(PORT, () => { @@ -319,4 +734,12 @@ if (require.main === module) { }); } -module.exports = { handler, allowedSeverities, findLatestReportForAgent }; +module.exports = { + handler, + allowedSeverities, + findLatestReportForAgent, + tokenMatches, + sanitizeSnapshot, + HOST_ID_RE, + HOST_ROUTE_RE, +};