diff --git a/.env.example b/.env.example index 6de3e3d..fa768f6 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,18 @@ PORT=3001 # Buildtime ARG leaks the secret in clear in application_deployment_queues.logs. HEALTH_TOKEN=change-me-to-a-strong-secret LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration -# Directory served by GET /reports/scans. Bind-mount target on Coolify — -# parent /data/defenseurs/ is already mounted (status.json sits next to it). +# Directory read by GET /reports/scans and GET /defenseurs/findings. On Coolify +# it is its own read-only mount (/home/defenseur/defenseurs/reports -> +# /data/defenseurs/reports:ro), nested inside the read-only /data/defenseurs +# mount that holds status.json. 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. +# Unlike the two /data/defenseurs mounts (read-only `:ro`; content owned by +# defenseur:defenseur, uid/gid 1001, group-writable but not by gid 1000), 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. diff --git a/CLAUDE.md b/CLAUDE.md index 135bd59..a1b0c01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime. - `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"`. - `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"`. +- `POST /hosts/` — **seule surface d'ecriture de l'API** (figee par `__tests__/writes.test.js`, sur les chemins nominaux et degrades qu'il exerce). 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"`. ## Routage et auth @@ -42,9 +42,11 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404 - `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` + `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/defenseurs` (host) -> `/data/defenseurs:ro` : `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. **Monte en lecture seule** depuis le 2026-09-30 (issue #36). + - `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports:ro` : rapports de scan (+ sous-dir `archive/`), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le `:ro` (#36). **Monte en lecture seule**. - `/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. + - Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : `:ro` est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere (`'/data/defenseurs:/data/defenseurs:ro'`). (2) Les droits Unix : le contenu appartient a `defenseur:defenseur` (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (`node`, gid 1000, sans groupe supplementaire), donc `test -w` y echoue meme sans `:ro`. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de `node` dans l'image, et de `ubuntu` sur l'hote). Un `chgrp ubuntu` ou un `chmod o+w` la ferait tomber sans bruit. Le `:ro` ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant. + - Verification apres tout redeploy, par trois vues : `docker inspect --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}'` (`RW=false` sur les deux montages Defenseurs, `RW=true` sur `/data/hosts`) ; `/proc/mounts` dans le conteneur (vue du noyau) ; et le **contenu** des routes de lecture, pas seulement leur code, car un montage manquant repond 200 `no_data`. ## Deploy @@ -60,7 +62,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests - **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 329 cas +- `npm test` (vitest) — 340 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) @@ -68,6 +70,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (171 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree) - `__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__/ci.test.js` — epingles du workflow (12 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`** (une etape rouge passerait au vert en silence), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`) + - `__tests__/writes.test.js` — seule surface d'ecriture : `HOSTS_DIR` (11 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms) - Runtime reste 0-dep ; vitest en devDep uniquement - `sh scripts/boot-from-copy.sh` — demarre le `CMD` depuis les seules sources du `COPY`, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu de `docker build` en CI : un vrai build demanderait le socket Docker dans le runner d'instance, donc root sur le VPS de prod pour tout workflow de tout depot. Il n'accepte qu'une ligne `COPY` en forme shell sans option, et un `CMD` en forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (`BOOT_PORT` pour en changer) ; il refuse de demarrer si le port est deja pris (tout code de sortie curl autre que 7, « connexion refusee »), sans quoi ses assertions pourraient interroger un voisin. Il n'appelle que `/health` : il ne prouve que les modules charges au demarrage, d'ou l'epingle « `require` en colonne 0 » dans `ci.test.js`. - `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. @@ -78,10 +81,10 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - 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 `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais. - `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env. - `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`. -- 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'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, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR`, et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas. - 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. Un processus tue entre les deux laisse un `....tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. - 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. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui. - 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`. +- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Lecture seule : suffixer la destination par `:ro`, que Coolify recopie tel quel dans le compose (verifie sur beta.463, #36). Un Directory Mount ne se modifie pas dans l'UI : sauvegarder le dossier hote, supprimer le montage **sans cocher l'effacement du dossier sur le serveur**, puis le recreer en Volume Mount **avec Source Path** (sans Source Path, c'est un volume nomme qui ne lit pas le dossier hote). Sous un parent en `:ro`, le point de montage imbrique doit deja exister cote hote : `/data/defenseurs/reports`, dossier vide root en 755, est a garder, car Docker ne peut pas le creer dans un parent en lecture seule et le conteneur ne demarrerait pas. Ne jamais monter les fichiers Defenseurs un par un : le Sergent ecrit par `rename` atomique, un bind de fichier resterait accroche a l'ancien inode. 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 94a9e6d..0e7b3f3 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,27 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \ ## Bind-mounts (Coolify) -Read-only for the API — written by the Defenseurs Sergent: +Mounted read-only (`:ro`) — written on the host by the Defenseurs Sergent: -- `/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/` +- `/data/defenseurs` -> `/data/defenseurs:ro` — holds `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:ro` — its + own mount, nested inside the previous one. + +Two layers keep the API out of these files: the `:ro` mode (a suffix on the +Coolify destination path, copied as-is into the generated compose), and Unix +permissions (content owned by `defenseur:defenseur`, uid and gid 1001, +directories 755/775, files 664, while the container runs as uid 1000 with no +supplementary group). The group bits grant write, so the second layer holds +only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on +the host): a `chgrp ubuntu` would drop it silently. + +`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but +`HOSTS_DIR`, and that the only shell commands are the two read-only ones in +`metrics.js`, on every path it exercises: boot, nominal reads and ingest, +rejections, invalid input, a corrupt file, missing files. Read-write — the API is the writer: diff --git a/__tests__/writes.test.js b/__tests__/writes.test.js new file mode 100644 index 0000000..a72f026 --- /dev/null +++ b/__tests__/writes.test.js @@ -0,0 +1,449 @@ +const http = require("node:http"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const child_process = require("node:child_process"); +const { fileURLToPath } = require("node:url"); + +// The API's only write surface is HOSTS_DIR (issue #36). +// +// Since 2026-09-30 the two Defenseurs mounts are read-only in the container +// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR +// or /data/defenseurs would not fail in the other test files: their fixtures +// live in a writable temp dir, and CI runs as root, so a chmod-555 fixture +// would prove nothing either. It would fail in production, as a 500 (EROFS) +// on the route that tried it. This file moves that failure to CI, for the +// paths it exercises: boot, the nominal reads and ingest, and the degraded +// ones (rejections, invalid input, a corrupt file, missing files). +// +// Every node:fs function that can write is spied BEFORE a fresh require, +// because the runtime modules destructure their fs functions at load time +// (same technique as the timingSafeEqual spy in auth.test.js). A call is +// recorded when its stack goes through a runtime file of the repo, then passed +// through to the real function. A shell command can write too, out of the fs +// spies' sight, so child_process is spied the same way and the set of commands +// is pinned to the two read-only ones metrics.js runs. +// +// Limits: a path no case takes stays out of sight, and so does a write +// deferred past settle() (a timer longer than 20 ms). +// +// Boot is exercised by calling the exported sweepStaleTempFiles(), which is +// what the `require.main === module` block runs before listen. That block +// never runs in-process, so its content is pinned at the end of this file. + +const REPO_ROOT = path.resolve(__dirname, ".."); +const TOKEN = "test-token"; +const INGEST_TOKEN = "test-ingest-token"; +const ORPHAN = ".thinkpad.4242.1700000000000.tmp"; + +// Runtime file = under the repo root, outside node_modules and __tests__. +// Used both to attribute a call to the API and to purge the module cache. +function isRuntimeFile(file) { + const rel = path.relative(REPO_ROOT, file); + if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false; + const top = rel.split(path.sep)[0]; + return top !== "node_modules" && top !== "__tests__"; +} + +function fromRuntimeFile(stack) { + return stack + .split("\n") + .slice(1) + .some((frame) => { + const match = /\(?(\/[^()]+?):\d+:\d+\)?$/.exec(frame.trim()); + return match !== null && isRuntimeFile(match[1]); + }); +} + +function captureStack() { + const limit = Error.stackTraceLimit; + Error.stackTraceLimit = 50; + const { stack } = new Error(); + Error.stackTraceLimit = limit; + return stack; +} + +// Write APIs of node:fs, by the argument(s) naming what gets written. Each is +// spied in its callback, Sync and fs.promises forms, when that form exists. +const O_WRITE = + fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND; + +function opensForWriting(flags) { + if (flags === undefined || flags === null || typeof flags === "function") return false; // "r" + if (typeof flags === "number") return (flags & O_WRITE) !== 0; + return /[wa+]/.test(String(flags)); +} + +// mkdtemp's argument is a prefix: `/x` creates `/xXXXXXX` below +// , but `` alone creates the sibling `XXXXXX`. Checking the +// prefix plus one character tells the two apart. +function mkdtempTarget(prefix) { + if (prefix instanceof URL) return `${fileURLToPath(prefix)}X`; + if (typeof prefix === "string" || Buffer.isBuffer(prefix)) return `${prefix.toString()}X`; + return prefix; +} + +const at = (...indexes) => (args) => indexes.map((i) => args[i]); +const WRITE_APIS = [ + // The destination is the first argument. + ...[ + "writeFile", "appendFile", "unlink", "mkdir", "rm", "rmdir", "truncate", + "chmod", "lchmod", "chown", "lchown", "utimes", "lutimes", "createWriteStream", + ].map((name) => [name, at(0)]), + ["mkdtemp", (args) => [mkdtempTarget(args[0])]], + // Only the destination (second argument) is written. + ...["copyFile", "cp", "link", "symlink"].map((name) => [name, at(1)]), + // Both ends of a rename change. + ["rename", at(0, 1)], + // Metadata changes through a file descriptor: the target cannot be proven, + // so the fd itself is recorded and fails the path rule. + ...["fchmod", "fchown", "futimes"].map((name) => [name, at(0)]), + // An open is a write only when its flags say so. + ["open", (args) => (opensForWriting(args[1]) ? [args[0]] : null)], +]; + +// The two commands metrics.js runs, to read /proc/stat and the root disk. +const ALLOWED_COMMANDS = ["df -k /", "head -1 /proc/stat"]; +const CHILD_PROCESS_APIS = ["exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "fork"]; + +// `exec("cmd")` and `execFile("file", ["arg"])` both come out as one string. +function commandOf(args) { + return (Array.isArray(args[1]) ? [args[0], ...args[1]] : [args[0]]).map(String).join(" "); +} + +let log = []; +let commandLog = []; + +function drain() { + const out = log; + log = []; + return out; +} + +function foreignCommands() { + return commandLog.filter((command) => !ALLOWED_COMMANDS.includes(command)); +} + +function installWriteSpies() { + for (const [name, targetsOf] of WRITE_APIS) { + for (const [owner, key] of [ + [fs, name], + [fs, `${name}Sync`], + [fs.promises, name], + ]) { + // lchmod is an own property holding undefined on Linux. + if (typeof owner[key] !== "function") continue; + const original = owner[key]; + vi.spyOn(owner, key).mockImplementation(function (...args) { + if (fromRuntimeFile(captureStack())) { + const targets = targetsOf(args); + if (targets) log.push({ fn: key, targets }); + } + return original.apply(this, args); + }); + } + } +} + +function installCommandSpies() { + for (const key of CHILD_PROCESS_APIS) { + if (typeof child_process[key] !== "function") continue; + const original = child_process[key]; + vi.spyOn(child_process, key).mockImplementation(function (...args) { + if (fromRuntimeFile(captureStack())) commandLog.push(commandOf(args)); + return original.apply(this, args); + }); + } +} + +// Every runtime module, not only index.js: a module left in the cache would +// keep the functions it destructured under a previous test's spies, which +// vi.restoreAllMocks() has turned back into silent pass-throughs. +function purgeRuntimeModules() { + for (const file of Object.keys(require.cache)) { + if (isRuntimeFile(file)) delete require.cache[file]; + } +} + +// Equal to HOSTS_DIR or below it. path.relative rather than startsWith, so a +// sibling such as `/hosts-evil` does not pass. A file descriptor, or any +// argument that is not a path, cannot be proven and fails. +function toPath(arg) { + if (typeof arg === "string") return path.resolve(arg); + if (Buffer.isBuffer(arg)) return path.resolve(arg.toString()); + if (arg instanceof URL) return path.resolve(fileURLToPath(arg)); + return null; +} + +function violations(writes) { + const hostsDir = path.resolve(process.env.HOSTS_DIR); + return writes.flatMap(({ fn, targets }) => + targets + .filter((arg) => { + const target = toPath(arg); + if (target === null) return true; + const rel = path.relative(hostsDir, target); + return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel); + }) + .map((arg) => `${fn}(${typeof arg === "string" ? arg : typeof arg})`), + ); +} + +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 writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +} + +// One macrotask more, so a write deferred past res.end() is caught too. +function settle() { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +let tmpDir; +let server; +let baseUrl; +let bootWrites; + +// A transport failure (a connection cut before a 413 is read) comes back as +// "transport-error", so it fails an assertion instead of passing for a code. +async function send(route, { method = "GET", auth = `Bearer ${TOKEN}`, body } = {}) { + const headers = auth ? { Authorization: auth } : {}; + if (body !== undefined) headers["Content-Type"] = "application/json"; + try { + const res = await fetch(`${baseUrl}${route}`, { method, headers, body }); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // Not JSON: the status alone is asserted. + } + return { status: res.status, body: json }; + } catch { + return { status: "transport-error", body: null }; + } +} + +// Created before the spies: nothing here is attributed to the API. +function writeFixtures() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-")); + const defenseurs = path.join(tmpDir, "defenseurs"); + const reports = path.join(defenseurs, "reports"); + const hosts = path.join(tmpDir, "hosts"); + const report = (timestamp) => ({ + agent: "defenseur-proj", + timestamp, + findings: [{ severity: "HIGH", category: "deps", title: "vulnerable dependency" }], + }); + + writeJson(path.join(defenseurs, "status.json"), { + schemaVersion: 1, + timestamp: "2026-09-30T07:30:00.000Z", + agents: [{ name: "defenseur-proj", status: "ok" }], + }); + writeJson(path.join(defenseurs, "findings-state.json"), { + schemaVersion: 1, + updatedAt: "2026-09-30T07:30:00.000Z", + findings: { "proj:deps:1": { firstSeen: "2026-09-29T07:30:00.000Z" } }, + }); + writeJson(path.join(defenseurs, "agents-map.json"), { proj: "defenseur-proj" }); + writeJson(path.join(reports, "defenseur-proj_2026-09-30T07-30-00-000Z.json"), report("2026-09-30T07:30:00.000Z")); + writeJson(path.join(reports, "archive", "defenseur-proj_2026-09-29T07-30-00-000Z.json"), report("2026-09-29T07:30:00.000Z")); + // A stored snapshot, so GET /hosts runs its real read path, not neverSeen. + writeJson(path.join(hosts, "thinkpad.json"), { ...validSnapshot(), receivedAt: new Date().toISOString() }); + // An orphan temp file older than an hour: the boot sweep must remove it, + // which gives every test a write it can see (proof its spies are live). + const orphan = path.join(hosts, ORPHAN); + fs.writeFileSync(orphan, "{}"); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(orphan, twoHoursAgo, twoHoursAgo); + + process.env.HEALTH_TOKEN = TOKEN; + process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; + process.env.REPORTS_DIR = reports; + process.env.DEFENSEURS_STATUS_PATH = path.join(defenseurs, "status.json"); + process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(defenseurs, "findings-state.json"); + process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json"); + process.env.HOSTS_DIR = hosts; + process.env.HOSTS_ALLOWED_IDS = "thinkpad"; + // Closed port: /health must never reach the real IdP. + process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; +} + +beforeEach(async () => { + writeFixtures(); + log = []; + commandLog = []; + installWriteSpies(); + installCommandSpies(); + purgeRuntimeModules(); + const { handler, sweepStaleTempFiles } = require("../index.js"); + sweepStaleTempFiles(); + server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; + bootWrites = drain(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => { + test("boot: module load and the temp-file sweep write only under HOSTS_DIR", () => { + expect(bootWrites.map(({ fn }) => fn)).toContain("unlinkSync"); + expect(fs.existsSync(path.join(process.env.HOSTS_DIR, ORPHAN))).toBe(false); + expect(violations(bootWrites)).toEqual([]); + expect(foreignCommands()).toEqual([]); + }); + + // Each route with the content that proves its fixture was really read: a + // missing mount answers 200 no_data, which must not pass for a clean read. + const READ_ROUTES = [ + ["/health", (body) => expect(body.cpu).toBeDefined()], + ["/defenseurs", (body) => expect(body.agents).toHaveLength(1)], + ["/defenseurs/state", (body) => expect(Object.keys(body.findings)).toHaveLength(1)], + [ + "/defenseurs/findings?project=proj", + (body) => { + expect(body.agent).toBe("defenseur-proj"); + expect(body.findings).toHaveLength(1); + }, + ], + ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(1)], + [ + "/hosts", + (body) => { + const host = body.hosts.find(({ id }) => id === "thinkpad"); + expect(host.neverSeen).toBeUndefined(); + expect(host.receivedAt).toEqual(expect.any(String)); + }, + ], + ]; + + test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => { + // This test's own spies are live: they saw the boot sweep. + expect(bootWrites).not.toHaveLength(0); + + const { status, body } = await send(route); + expect(status).toBe(200); + expect(body.status).not.toBe("no_data"); + expectContent(body); + + await settle(); + expect(drain()).toEqual([]); + expect(foreignCommands()).toEqual([]); + }); + + test("POST /hosts/ writes, and only under HOSTS_DIR", async () => { + const { status } = await send("/hosts/thinkpad", { + method: "POST", + auth: `Bearer ${INGEST_TOKEN}`, + body: JSON.stringify(validSnapshot()), + }); + expect(status).toBe(204); + + await settle(); + const writes = drain(); + expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"])); + expect(violations(writes)).toEqual([]); + expect(foreignCommands()).toEqual([]); + }); + + // The branches the nominal cases never take, and where the most plausible + // future writes would land: quarantining a corrupt file, recreating a + // missing one, logging a rejection or a refused payload to disk. + test("rejections, invalid input, corrupt and missing files write nothing outside HOSTS_DIR", async () => { + expect(bootWrites).not.toHaveLength(0); + const ingest = `Bearer ${INGEST_TOKEN}`; + const rejected = [ + [401, "/defenseurs", { auth: null }], + [401, "/defenseurs", { auth: "Bearer wrong" }], + [401, "/hosts", { auth: ingest }], + [404, "/nope", {}], + [400, "/defenseurs/findings", {}], + [400, "/defenseurs/findings?project=proj&severity=BOGUS", {}], + [404, "/defenseurs/findings?project=unknown", {}], + [400, "/reports/scans?date=../../etc", {}], + [401, "/hosts/thinkpad", { method: "POST", auth: "Bearer wrong", body: "{}" }], + [403, "/hosts/laptop", { method: "POST", auth: ingest, body: JSON.stringify(validSnapshot()) }], + [400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "{not json" }], + [400, "/hosts/thinkpad", { method: "POST", auth: ingest, body: JSON.stringify({ hostname: "x" }) }], + [413, "/hosts/thinkpad", { method: "POST", auth: ingest, body: "x".repeat(5000) }], + ]; + for (const [expected, route, options] of rejected) { + const { status } = await send(route, options); + expect([options.method || "GET", route, status]).toEqual([options.method || "GET", route, expected]); + } + + // A corrupt agents-map.json: where a quarantine rename would go. + fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, "{corrupt"); + expect((await send("/defenseurs/findings?project=proj")).status).toBe(500); + + // Missing Defenseurs files and a corrupt snapshot: where a "recreate it" + // write would go. Each route must degrade, with the content that says so. + fs.writeFileSync(process.env.DEFENSEURS_AGENTS_MAP_PATH, JSON.stringify({ proj: "defenseur-proj" })); + fs.rmSync(process.env.DEFENSEURS_STATUS_PATH); + fs.rmSync(process.env.DEFENSEURS_FINDINGS_STATE_PATH); + fs.rmSync(process.env.REPORTS_DIR, { recursive: true }); + fs.writeFileSync(path.join(process.env.HOSTS_DIR, "thinkpad.json"), "{corrupt"); + const degraded = [ + ["/defenseurs", (body) => expect(body.status).toBe("no_data")], + ["/defenseurs/state", (body) => expect(body.status).toBe("no_data")], + ["/defenseurs/findings?project=proj", (body) => expect(body).toEqual({ findings: [], status: "no_data" })], + ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(0)], + ["/hosts", (body) => expect(body.hosts.find(({ id }) => id === "thinkpad").neverSeen).toBe(true)], + ]; + for (const [route, expectContent] of degraded) { + const { status, body } = await send(route); + expect([route, status]).toEqual([route, 200]); + expectContent(body); + } + + await settle(); + expect(violations(drain())).toEqual([]); + expect(foreignCommands()).toEqual([]); + }); +}); + +describe("shell commands — only the two read-only metrics ones (#36)", () => { + // Positive control for the child_process spies: /health must show exactly + // the two commands. The other cases only check that nothing else ran. + test("GET /health runs exactly `head -1 /proc/stat` and `df -k /`", async () => { + const { status } = await send("/health"); + expect(status).toBe(200); + expect([...new Set(commandLog)].sort()).toEqual([...ALLOWED_COMMANDS].sort()); + }); +}); + +describe("fs writes — the entrypoint adds no write of its own", () => { + // The block below never runs in-process: the tests above stand in for it by + // calling the exported sweep. This pin keeps that stand-in honest. A new call + // in the block (a PID file, a marker) must be looked at, then allowed here. + // The comparison is an exact set, so a block that cannot be found or comes + // back empty fails instead of passing vacuously. + test("the main block only sweeps, creates the server and listens", () => { + const source = fs.readFileSync(path.join(REPO_ROOT, "index.js"), "utf-8"); + const start = source.indexOf("if (require.main === module) {"); + expect(start).toBeGreaterThan(-1); + const block = source.slice(start, source.indexOf("\n}\n", start) + 2); + const calls = [...block.matchAll(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)] + .map((match) => match[1]) + .filter((name) => name !== "if"); + expect([...new Set(calls)].sort()).toEqual( + ["console.log", "http.createServer", "server.listen", "sweepStaleTempFiles"].sort(), + ); + }); +});