Non-blocking points of the second review of PR #47: refusing 65535, and
dropping the end anchor of the pattern, both left the suite green.
- Ports 1 and 65535 must be accepted. Given to both variables at once,
so the run stops on the "must differ" refusal, which comes after the
range check, and nothing listens on a port fixed in advance.
- "80abc" must be refused, on each variable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Non-blocking points of the review of PR #47.
- TOKEN and INGEST_TOKEN are no longer read from the environment, like
the URLs: two inherited values that happened to be equal broke the
compartmentalisation cases. Pinned in the proxy case, which now runs
with one inherited value for both.
- The refusal cases run each bad value (not a number, 0, above 65535)
on each of the two variables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-curl.sh booted its servers on 3099 and 3098, hardcoded, and
ci.test.js runs it whole on every `npm test`: two runs side by side got
in each other's way, at least one of them failing every time.
- TEST_CURL_PORT and TEST_CURL_NOINGEST_PORT choose the ports, 3099 and
3098 staying the defaults. Names of their own rather than PORT, which
a dev shell often exports already.
- BASE_URL and NOINGEST_URL are derived from the ports and no longer
read from the environment: overridden alone, they sent every curl away
from the servers the script had started.
- A value outside 1-65535 and two identical ports are refused up front,
with the variable named.
- ci.test.js gives every case free ports of its own (freePorts() keeps
its probes open together so they are distinct), and no case listens on
3099 or 3098 any more. New cases: two passes at once, the four
refusals, source pins on the defaults and on the derived URLs, and a
first-try connection check that pins the `wait` of the trap, which the
fixed ports used to pin by accident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of PR #45, non-blocking: two behaviours were right by hand
and held by no test.
- a listener on 3099 that closes at once, then one that accepts and stalls:
the port rule is "anything but connection refused", not "an HTTP answer"
- a server that stays alive and never listens: the wait ends on its
deadline, with a message and exit 1
Also moves the port check above the "Fixtures" comment it had split from
its mkdir lines, and counts four protections in CLAUDE.md, not three.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #45: wait_for did not keep a neighbour out. With a listener
already on 3099, the server started by the script only dies of EADDRINUSE
after the wait loop has reached the neighbour, and the cases then ran
against it.
- require_free checks both ports before anything starts: free means
"connection refused" (curl exit 7), as in scripts/boot-from-copy.sh
- wait_for runs on a clock deadline, and checks the process again after a
successful probe
- the EXIT trap waits for the servers it killed, so a run started right
after finds the two ports free
ci.test.js adds a real neighbour on each port, and bounds the two
failed-start cases to 3 s: a message that does not stop the run is now red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Behind a proxy from the environment, test-curl.sh sent its 18 curl lines to
the proxy instead of the servers it boots: with a dead proxy it exited 7
after 11 s without a line of output.
- export NO_PROXY and no_proxy, as scripts/boot-from-copy.sh does
- unset FORCE_COLOR: node colours a number even into a pipe, and 8 cases
compared "3" against a coloured "3"
- wait_for checks the server process before the port and stops the run with
a message when a server died or never answered
- the EXIT trap guards each kill: under set -e, a failed kill on the first
server ended the trap and left the second one running on 3098
ci.test.js runs the whole script three times: behind a fake proxy that
answers everything, and with a fake node that fails for each port in turn.
Fixes#41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A node:fs function handed as-is to an asynchronous mechanism
(process.nextTick(writeFileSync, ...), a bound listener, a .then) runs with
no runtime frame on its stack, so the stack filter let the write through on
a path the suite does exercise.
The spies now record every call. The test's own fixture edits go through
the real functions captured before any spy exists. The recursive rmSync of
the reports directory becomes a rename: rmSync recurses through the public
fs.rmdirSync, which is the spy by then.
Fixes#40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two promises of sweepStaleTempFiles() were held by no test:
- lstat, not stat: a symlink named like a temp file is neither followed
nor removed. The link is aged too, so the isFile() guard is what keeps it.
- the ^ anchor of TEMP_FILE_RE: a temp name behind a prefix is never touched.
Each new case goes red under its mutation (statSync, anchor removed,
isFile() dropped); index.js itself is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No runtime file breaks the "require at column 0" pin, so nothing
exercised its detector: on a03e4d4, bringing either regex back to its
form on main, or deleting the require or the import clause, left the 19
cases green, and the two holes this PR closes could reopen unnoticed.
The rule moves into loadsLate(line), which the pin on index.js and
metrics.js now calls, and six rows check it directly: an indented
require and a dynamic import at any column are flagged, each with and
without a space before the parenthesis; a top-level require and a
comment line quoting require are let through.
Measured on the new tree: either regex back to its main form turns 1
row red, removing the require or the import clause turns 2 rows red,
removing the column condition turns 3 red (the two real files and the
top-level row), removing the comment skip turns 1 red.
ci.test.js 19 -> 25 cases, vitest 347 -> 353.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example of a key that still passes the continue-on-error pin had
lost its u002d escape sequence on the way into ci.test.js and CLAUDE.md:
both read as the plain quoted key, which the pin catches (the "a quoted
key" self-check row holds that very text). CLAUDE.md thus said the
detector was checked on the quoted key, then that the same key passed.
The example now carries the escape, written through a backslash built
at runtime so that no tool decodes it: `grep -c u002d` gives 1 per
file, PyYAML reads the key as continue-on-error, the pin misses it.
The list of known holes of the column-0 require pin now opens with
"among others" ("entre autres" in CLAUDE.md): a line break or a comment
between require and its parenthesis, and require?.(, pass as well, as
the review of 92bfb42 measured.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three findings of the review, each first measured green on the previous
tree (18/18), then red on this one.
The proxy test dropped the inherited no_proxy, so a script exporting
NO_PROXY alone stayed green, although curl reads the lowercase variable
first and an inherited no_proxy would win. The test now sets no_proxy
to a list that leaves the loopback out. Red when the script exports
NO_PROXY alone, and on the script from main.
The column-0 require pin missed a space before the parenthesis:
an indented `require (` and an `import (` both passed. The regexes
now accept whitespace there. Red on each.
The continue-on-error detector required a colon after the word, so a
YAML anchor reused as a key (`&coe continue-on-error`, then
`*coe : true`) passed, while PyYAML reads it as the key. The detector
is now the bare word, with a self-check row for the anchor; red when
the colon is required again, three rows red with the original anchored
regex. An escaped key still passes, since the pin reads text and not
YAML: the comment and CLAUDE.md now claim any literal spelling, no
longer any spelling, and list the indirections the require pin cannot
follow.
ci.test.js 18 -> 19 cases, vitest 346 -> 347.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR #34 review listed two forms that pass the "require at column 0"
heuristic: a function written whole on a column-0 line, and a require(
wrapped onto a column-0 line inside a function. Both stay open: neither
fits the style of the repository, and the second has the very shape of
the legitimate `} = require("node:fs");` that closes index.js's
multi-line import, so only a parser could tell them apart. They are now
written next to the pin and in CLAUDE.md, rather than only in a closed
PR and its follow-up issue.
CLAUDE.md also records the proxy bypass of boot-from-copy.sh, the wider
continue-on-error pin and its self-check, and the new counts (ci.test.js
12 -> 18 cases, vitest 340 -> 346).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs promised that writes.test.js pins every write. They now say what
it covers: node:fs writes and shell commands, on the paths it exercises.
They also name what keeps the second layer up: the Defenseurs content is
group-writable, and only the owning group (defenseur, gid 1001, not the
gid 1000 of node and ubuntu) keeps the container out, so a chgrp ubuntu
would drop that layer silently.
Fixes#36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Defenseurs bind mounts are `:ro` since 2026-09-30, set as a suffix
on the Coolify destination path; the reports Directory Mount was recreated
as a Volume Mount with a Source Path to take it. CLAUDE.md, README.md and
.env.example now describe the two layers (mount mode and Unix
permissions), the three checks to run after a redeploy, and the Coolify
gotchas met on the way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the review of the workstation monitoring stack (#18-#23):
- loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The
admin card joins them, so a shorter array rendered a partial line and a
longer one was silently truncated; both are now a 400, and a stored
snapshot off that format degrades to neverSeen on read.
- sweepStaleTempFiles() runs at startup, before listen, and removes the
`.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind,
once older than an hour. Exact name format only, regular files only
(lstat), best effort: a missing HOSTS_DIR stays silent, other failures
are logged and never stop the server.
- Document that cleanString keeps control characters on purpose (safety
rests on the consumer escaping at render time), and that frozenRequest
does not freeze `new Date()`, which stamps receivedAt on POST.
Fixes#25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Forbid `continue-on-error` in ci.yml: that mutation left every test green,
and a failing step would turn the commit status green in silence.
- boot-from-copy.sh refuses a port already held by something else (any curl
exit other than 7). Without it, the wait loop could reach the neighbour and
assert on a server it never started — the new test reproduces exactly that
when the check is removed.
- Pin every require of the COPY'd runtime modules at column 0, with no
dynamic import: the boot check only calls /health, so it only proves the
modules loaded at startup.
Fixes#33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing verified this repository until now. The workflow runs on every pull
request (no branches filter, so intermediate stack links get a status) and on
pushes to main, in node:22-alpine, the Dockerfile's own base: npm ci, npm test,
the socket-level pass, then scripts/boot-from-copy.sh.
The script stands in for a docker build, which would need the Docker socket in
the instance-scoped runner and so root on the production VPS for every
workflow. It starts the CMD from the COPY sources alone and fails on the
MODULE_NOT_FOUND that #11 had to fix by hand. __tests__/ci.test.js pins the
workflow and what the script assumes of the Dockerfile.
Refs #24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review of #30: `startsWith("Bearer")` followed by slice(7) would
accept any byte as the separator (`Bearer\t<token>`, `BearerX<token>`) and
the suite stayed green. Two MALFORMED cases now reject both; the mutation
turns them red.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #30 found four mutations that left the suite green: an early
success return for the read kind, a reversed `want === header` comparison,
and tokenMatches() reduced to `===` or to raw strings behind a length check
(the GHSA-376v-xgjx-7mfr shape).
Behavioural guard: a spy on crypto.timingSafeEqual, set before a fresh
require of index.js, requires every request (6 read routes, short and
same-length wrong tokens, the right token, the ingest route) to compare two
32-byte digests. Source pins now also allowlist every equality operator in
checkAuth() (both operand orders), require a single `return null`, and check
that tokenMatches() hashes both sides without a length test.
All seven mutations (the reviewer's four, the original three) now turn
13-15 tests red. CLAUDE.md no longer claims the pin covers the helper.
Refs #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The read path still compared the bearer with `header === \`Bearer ${token}\``,
which stops at the first differing byte and leaks the token's length and
prefix through response time. checkAuth() now has a single comparison for
both token kinds: `Bearer ` scheme check, then tokenMatches() (SHA-256
digests, then timingSafeEqual, so lengths are always equal). Only the
expected token varies.
Semantics are unchanged: exact match, case-sensitive scheme. Every existing
auth test passes untouched.
Tests: near-miss tokens (prefix, suffix, double space) on all six read
routes, an empty-token assertion on the helper, and a source pin that
forbids direct equality against a Bearer string and requires exactly one
tokenMatches() call in checkAuth(). A timing test would be flaky, so the pin
is the regression guard: three mutations back to direct comparison each
turn it red.
Fixes#17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four cases on top of the 41 from #26: the file served byte for byte
(non-canonical fixture, compared with cmp so the trailing newline counts),
no_data when absent, 401 without a token and 401 with the ingest token.
Refs #27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sergent now persists per-finding state (firstSeen, unverified, 30-day
history) in findings-state.json next to status.json. The admin dashboard
needs it for the drill-down, since it is the only source carrying a
finding's age.
- New read route GET /defenseurs/state, path from
DEFENSEURS_FINDINGS_STATE_PATH (default /data/defenseurs/findings-state.json),
already covered by the /data/defenseurs mount.
- Extract serveJsonFile() so /defenseurs and /defenseurs/state share one
contract: raw content, or 200 {status:"no_data"} on any read error.
- Tests: new state.test.js (byte-identical body, no_data, path isolation,
401 x3, 404 on POST) and the route added to the auth.test.js matrix.
- Docs: README and CLAUDE.md endpoint, config and mount sections; the README
mount list now matches the real /data/defenseurs directory mount.
Fixes#27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test-curl.sh predates vitest and still claimed to be the authoritative suite
for one endpoint. It is now the socket-level pass, and its header says so:
vitest covers the logic, this script covers what only a real client and a
real TCP connection can show.
Twenty-four cases for POST /hosts/<id> and GET /hosts, chosen for what they
prove rather than for coverage: the 413 answered before the connection is
cut, the two tokens refusing each other's routes in both directions, 401
landing ahead of 403 so an unauthenticated caller cannot probe the allowlist,
the route regex turning a malformed id and a traversal attempt into 404
before any handler runs, and the ingest-persist-read round trip closing on a
GET that shows the pushed hostname online.
Two cases needed care to be worth anything:
The hostile payload splices "__proto__" into the JSON as text. Written as
__proto__: in an object literal it would set the prototype and JSON.stringify
would emit nothing, leaving the case asserting against a payload that never
carried the key. It now checks the response and the persisted file, whose key
set must be exactly the whitelist.
The 413 case runs under set -e while the server destroys the socket right
after flushing the response, so curl can exit 55/56 having already read the
status line. Every probe goes through an http_code helper that absorbs that;
a curl which truly got nothing reports 000, which fails the case rather than
passing it quietly. -H "Expect:" also suppresses the 100-continue handshake,
which otherwise changes which side notices the reset first.
The fail-closed 503 needs a server booted without HOSTS_INGEST_TOKEN, so the
script now runs a second instance on 3098 for it, and confirms reads still
answer 200 there — the two tokens are independent, including in absence.
The EXIT trap dereferenced an unset SERVER_PID under set -u, which made it
error out instead of cleaning up when anything failed before the boot. Both
PIDs are initialised and the kills guarded.
CLAUDE.md: the vitest count was stale (244 -> 251, auth.test.js 19 -> 26) and
test-curl.sh was undocumented.
Refs #16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.
run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.
50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.
agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.
Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.
Resolves#15
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.
Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:
resolveRoute() -> no match means an immediate 404, deny by default
checkAuth() -> the single gate; only the expected token varies per
descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
for ingestion)
dispatch
The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.
Security controls on the new write path:
- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
and encoded traversals fail the match, so every hostile id lands on 404.
403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
ingestion path only; realigning HEALTH_TOKEN touches every read route in
production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
a client can lie in the header and chunked encoding omits it. Past the cap,
413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
numbers, strings capped at 128 chars, everything else dropped — because the
file is read back by GET /hosts and ends up in the admin React tree. The
read path re-runs the same whitelist: the bind-mount is writable, so the
file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
values are filtered to printable ASCII so a crafted header cannot forge
extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.
Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.
Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.
Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.
Resolves#13
Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.
Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
Verified by mutation: serializing the two calls fails the latency test
at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.
Resolves#11
Persistent Storages replace the documented (never-existing) per-file
bind-mounts; agents-map.json check path corrected to /data/defenseurs/;
app has no Forgejo Source so post-merge deploy is a manual API trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drill-down endpoint exposing detailed findings per project. Resolves the
HTTP gap for the Vercel admin dashboard, which cannot SSH/Tailscale to
the VPS, plus a future portable /analyse-vulnerabilite skill.
- Project -> agent lookup via /data/defenseurs/agents-map.json (Sergent snapshot)
- findLatestReportForAgent scans REPORTS_DIR + REPORTS_DIR/archive (post-07:30 UTC rotation)
- Filters: category exact match, severity threshold inclusive upward
- Asymmetric severity rule: default hides LOW+INFO; ?severity=LOW returns
LOW+MEDIUM+HIGH+CRITICAL but still hides INFO; INFO opt-in via explicit param
- Distinguishes "report present + scan clean" (no status field) from
"no report at all" ({findings:[], status:"no_data"})
- Bootstraps vitest (devDep; runtime stays 0-dep), 14 tests covering auth,
validation, filters, asymmetry, mtime selection, error paths
- Refactor: export handler so tests can spin up ephemeral servers; server.listen
guarded by require.main === module
Closes#3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the SSH/rsync canal between Max's workstation cron and the VPS
for fetching defenseur scan reports. The defenseur-auto orchestrator now
pulls reports/defenseur-X_<date>*.json over HTTPS, reusing HEALTH_TOKEN.
The handler mirrors the style of index.js (HTTP native, no framework),
includes the same isScanReport guard as defenseurs/src/report.ts (filters
out defenseur-auto_*.json run reports), and validates the date param
against /^\d{4}-\d{2}-\d{2}$/ to short-circuit path traversal before any
filesystem access.
Validated by test-curl.sh — 11 cases covering auth, validation, date
filter, isScanReport filter, sort order, GET-only and 404 paths.
Spike: ~/claude-code/.spikes/archived/endpoint-reports-sur-vps-health-api-pour/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add inline warning in .env.example and CLAUDE.md Auth section:
HEALTH_TOKEN is read at runtime only — passing it as Coolify build ARG
leaks the secret in clear in application_deployment_queues.logs.
Refs #4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1.
- New `logto: {status, responseTimeMs, error?}` field in /health response
- Configurable via LOGTO_HEALTH_URL env (default: auth.lacompagniemaximus.com
OIDC discovery endpoint)
- 3s timeout via AbortController; /health stays HTTP 200 even if Logto is down
- getCpuPercent converted to async (setTimeout-based delay) so the 500ms CPU
sample and the Logto fetch run concurrently via Promise.all; total latency
stays max(500ms, <=3000ms) instead of the sum
- Commit project CLAUDE.md (previously untracked) with the new field documented
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>