Commit graph

42 commits

Author SHA1 Message Date
le king fu
a03e4d4296 docs: keep the escaped-key example escaped, bound the holes list (#35)
All checks were successful
CI / verification (pull_request) Successful in 21s
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>
2026-10-01 16:13:55 -04:00
le king fu
92bfb426ca test(ci): close the gaps found in the review of PR #42 (#35)
All checks were successful
CI / verification (pull_request) Successful in 20s
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>
2026-10-01 15:56:32 -04:00
le king fu
ae27c7d504 docs: record the known holes of the column-0 require pin (#35)
All checks were successful
CI / verification (pull_request) Successful in 21s
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>
2026-09-30 20:54:06 -04:00
le king fu
32db0f81d5 test(ci): catch continue-on-error in any YAML spelling (#35)
The pin was anchored at the start of the line, so a step written as a
flow mapping, `- { name: x, run: false, continue-on-error: true }`,
passed it: 12/12 green with that step appended to ci.yml. A quoted key,
`"continue-on-error": true`, escaped it the same way.

The detector is now unanchored and accepts a closing quote before the
colon. It still runs on `ciCode`, so full comment lines may quote the
key; anything else that quotes it (a run: script, a step name, a
trailing comment) now fails the pin, loudly, by design.

The detector is checked on its own: through the same comment stripping
as `ciCode`, it must catch a block key, a list item, a flow mapping and
a quoted key, and ignore a full comment line. Proven by mutation: the
flow-style and quoted-key steps appended to ci.yml each turn the pin
red; the old anchored regex turns the flow-mapping and quoted-key rows
red; dropping the quote class turns the quoted-key row red; disabling
the comment stripping turns the comment row red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:53:13 -04:00
le king fu
fef9f69fc0 fix(ci): keep every curl of the boot check off environment proxies (#35)
Only the port pre-check of scripts/boot-from-copy.sh carried --noproxy.
The wait loop and the two assertions still honoured http_proxy, so on a
workstation with a proxy configured the script talked to the proxy
instead of the server it had just started, and failed loudly.

Export NO_PROXY='*' and no_proxy='*' at the top of the script, which
covers every curl call, and drop the now-redundant --noproxy.

The new test boots the script for real behind a fake proxy that answers
everything and records what reaches it. It first proves the proxy is
live (a bare curl with the same environment reaches it), then requires
that nothing of the boot check reaches it and that the script passes.
Red on the previous script (the wait loop and the first assertion went
through the proxy), red again when the export is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:51:57 -04:00
le king fu
48c9a408f6 docs: bound the write guard's claims and name the owning group (#36)
All checks were successful
CI / verification (pull_request) Successful in 22s
CI / verification (push) Successful in 20s
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>
2026-09-30 19:59:44 -04:00
le king fu
7523b397f0 test: cover degraded paths and shell commands in the write guard (#36)
Review of PR #39: the spies only watched authenticated requests on
complete fixtures, so a write on an error path passed the whole suite
(reviewer mutations R1, R2, R3 and R11: quarantining a corrupt
agents-map.json, recreating a missing status.json, logging a 401/403 or a
refused payload to disk).

A new case walks rejections (401/403/404), invalid input (400/413), a
corrupt agents-map.json, missing Defenseurs files and a corrupt snapshot,
requires the degraded content (no_data, neverSeen), and requires no write
outside HOSTS_DIR. child_process is now spied the same way as node:fs and
the set of shell commands is pinned to the two read-only ones metrics.js
runs, with GET /health as positive control.

R1, R2, R3 and R11 went from 0 to 1 red each; R4 (touch through
execFileSync) is now caught by this file. A write deferred by a timer
longer than 20 ms (R8b) stays out of sight, as the header says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:59:44 -04:00
le king fu
6a2f7fec04 docs: record the read-only Defenseurs mounts (#36)
All checks were successful
CI / verification (pull_request) Successful in 20s
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>
2026-09-29 22:06:39 -04:00
le king fu
aeb941b2c0 test: pin that the API writes only under HOSTS_DIR (#36)
The two Defenseurs mounts are now read-only in the container, so a write
added one day under REPORTS_DIR or /data/defenseurs would fail in
production as a 500 (EROFS) while every other test stays green: their
fixtures are writable temp dirs, and CI runs as root.

writes.test.js spies every node:fs write API (callback, Sync and
fs.promises forms) before a fresh require, after purging all runtime
modules from the require cache, and records the calls whose stack goes
through a runtime file. It covers boot (the temp-file sweep), the six GET
routes (no write at all, one macrotask after the response included) and
POST /hosts/<id> (writes only under HOSTS_DIR). Each case proves its own
spies are live, and the require.main block is pinned since it never runs
in-process.

Mutation proofs: a write at module load, in the scan collector, through an
fs function not imported today, in the ingest rename or in metrics.js, a
dead spy, and a new call in the main block each turn the expected case
red. With the cache purge narrowed to index.js, the metrics.js write stays
green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:06:39 -04:00
le king fu
523d90bf7c fix(hosts): require 3 loadAvg values and sweep orphan temp files (#25)
All checks were successful
CI / verification (pull_request) Successful in 21s
CI / verification (push) Successful in 20s
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>
2026-09-29 20:30:07 -04:00
le king fu
156ca818ca ci: pin the review follow-ups of the CI workflow (#33)
All checks were successful
CI / verification (pull_request) Successful in 19s
CI / verification (push) Successful in 20s
- 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>
2026-09-27 20:54:47 -04:00
le king fu
4e7879da6c ci: add a Forgejo Actions workflow with a boot-from-COPY check (#24)
All checks were successful
CI / verification (pull_request) Successful in 21s
CI / verification (push) Successful in 19s
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>
2026-09-27 20:22:48 -04:00
2b35b78a0b Merge pull request 'fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches' (#30) from issue-17-timing-safe-health-token into main 2026-09-27 23:27:44 +00:00
le king fu
f006a1c05e test(auth): pin the single space after the Bearer scheme
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>
2026-09-27 19:23:50 -04:00
le king fu
5074f95d0a test(auth): guard tokenMatches itself and close the pin's blind spots
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>
2026-09-27 16:13:16 -04:00
le king fu
f70a7e928f fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches
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>
2026-09-27 16:06:04 -04:00
8eef50d517 Merge pull request 'feat(defenseurs): GET /defenseurs/state serves findings-state.json' (#28) from issue-27-defenseurs-state into main 2026-09-25 01:41:35 +00:00
le king fu
7c14e581cb test(defenseurs): cover GET /defenseurs/state in the socket-level pass
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>
2026-09-24 20:42:56 -04:00
le king fu
a5d1bcdedb feat(defenseurs): serve the Sergent's findings state at GET /defenseurs/state
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>
2026-09-24 20:42:20 -04:00
00e1743c9d Merge pull request 'test(hosts): extend the socket-level pass to the two workstation routes' (#26) from issue-16-hosts-monitoring-commissioning into main 2026-09-25 00:42:08 +00:00
le king fu
50a829beb3 test(hosts): extend the socket-level pass to the two workstation routes
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>
2026-08-18 20:18:53 -04:00
le king fu
e181a9691c fix(review): close the three gaps found in stack review
Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of
which changed behaviour, all of which weakened a guarantee the stack was
supposed to provide.

1. auth.test.js carried "any new route must be added here" but /hosts was
   never added when #13 introduced it, so no test asserted GET /hosts -> 401
   on a missing header. The drift class the file exists to catch slipped on
   its first outing. ROUTES now covers /hosts, and a dedicated block pins
   both directions of the token separation: a read token cannot write, an
   ingest token cannot read.

2. ingestOversized() folded any transport error into 413, so the four
   oversized-body tests would have stayed green if the server had stopped
   writing the status and merely killed the socket - on the one path where
   "a status, not a dead socket" is the whole client contract. Transport
   errors are now surfaced instead of swallowed.

3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by
   run-push.sh, so setting it in the documented env file did nothing. Now
   exported and documented.

Also drops a claim from agent/README.md that the review proved false: the
ingest/read token split buys no containment on the ThinkPad, which already
stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop
compromises both, so they rotate together.
2026-08-16 14:24:43 -04:00
le king fu
e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
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
2026-08-16 12:21:59 -04:00
le king fu
56ee580167 test(hosts): grow the workstation API smoke pass into a full matrix
The suite that shipped with the implementation pinned the decisions that
were expensive to get wrong. This turns it into the exhaustive matrix the
surface deserves: POST /hosts/<id> is the only publicly writable endpoint
on the service, so a mistake there is an intrusion rather than an outage.

__tests__/hosts.test.js goes from 22 to 155 cases (suite 61 -> 194):

- auth: nine near-miss Authorization headers, the read token refused on
  the write route, the ingest token refused on all five read routes, and
  the two tokens proven independent — either one unset leaves the other
  path working
- rejection ordering: 404 (routing) beats 503 (ingest token unset) beats
  403 (outside the allowlist), and a malformed id answers 404 whatever
  token it carries, so route existence stays unenumerable
- routing: eleven more malformed ids, every one of them a 404. Widening
  HOST_ROUTE_RE to `^/hosts/(.+)$` turns sixteen of them red, which is
  what makes the narrow pattern a traversal control rather than a comment
- payload: twenty-two field mutations, eight bodies that are not a JSON
  object, an Infinity only a raw body can express, and the 4 KiB ceiling
  pinned from both sides — 4096 accepted, 4097 and 8 KiB refused
- persistence: 0600 mode, whitelist rebuild, 128-character cap, loadAvg
  sliced to three, the route id winning over an id in the body, an
  overwrite leaving no temp debris, and the 500 path when HOSTS_DIR
  cannot be created
- freshness: 899/900/901 exact against a frozen Date.now() instead of the
  wall clock, plus a custom HOSTS_STALE_SECONDS and its fallbacks
- listing: eight ways a snapshot file can be corrupt, each degrading its
  own entry while a healthy neighbour keeps its data

The 4 KiB hostname of the acceptance criteria is covered twice, because
the body ceiling fires before the sanitiser ever sees it: 4096 characters
are refused with 413, and 3500 characters (which fit) are truncated to
128 with the long value absent from the file.

index.js is untouched. No defect surfaced, and the two mutations used to
prove the net bites were reverted.

Resolves #14
2026-08-16 12:05:31 -04:00
le king fu
fedb1c81dc feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts)
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
2026-08-16 11:49:36 -04:00
le king fu
babfd1f4b9 test(auth): cover the 401 gate on all four read routes
The 14 existing tests all hit /defenseurs/findings — /health, /defenseurs
and /reports/scans had no authentication coverage at all. This is the
safety net for the routing/auth refactor that comes next: a miswiring
could expose the parc-wide Defenseurs reports publicly without turning a
single test red.

Adds __tests__/auth.test.js (19 tests), following the findings.test.js
pattern (real http server + temp dir):
- 401 on all four routes with no Authorization header
- 401 on all four routes with a wrong bearer token
- 401 on malformed headers (no scheme, lowercase scheme, scheme only)
- 401 fail-closed when HEALTH_TOKEN is unset
- 404 on unknown routes and on POST against existing routes

These describe current behaviour: index.js is untouched. Two tests
document that route/method validation runs before authentication, so an
unauthenticated caller gets 404 rather than 401 on those paths.

Resolves #12
2026-08-16 11:36:05 -04:00
le king fu
79cb813767 refactor: extract CPU/RAM/disk collection into metrics.js
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
2026-08-16 11:30:03 -04:00
le king fu
39a55fad93 docs: sync CLAUDE.md with real Coolify mounts and manual deploy trigger
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>
2026-07-15 20:47:17 -04:00
17f071b1f7 Merge pull request 'feat(defenseurs): add GET /defenseurs/findings?project=X route' (#9) from issue-3-defenseurs-findings into main 2026-07-16 00:45:09 +00:00
le king fu
e88a044711 feat(defenseurs): add GET /defenseurs/findings?project=X route
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>
2026-05-12 20:56:56 -04:00
ac21fd7f4b Merge pull request 'feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window' (#8) from feat/reports-scans-archive-fallback into main 2026-05-11 00:44:25 +00:00
le king fu
2e756557ff feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window
Sergent renameSync() rotates reports/ -> reports/archive/ at 07:30 UTC daily,
so for ~22h per day the only copy of a fresh scan lives in archive/. The
handler now scans both directories and concatenates with top-level priority
on filename collision. archive/ missing is a silent skip.

Tests : 17/17 in test-curl.sh (11 existing + 6 new for archive coverage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:53:14 -04:00
09a4ddeb34 Merge PR #6: feat(reports): add GET /reports/scans endpoint for defenseur-auto 2026-05-08 01:04:41 +00:00
le king fu
6eda076a25 feat(reports): add GET /reports/scans endpoint for defenseur-auto
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>
2026-05-07 20:50:29 -04:00
f88f44e347 Merge PR #5: docs: warn HEALTH_TOKEN must be runtime-only on Coolify 2026-05-03 20:12:24 +00:00
le king fu
9510e96231 docs: warn HEALTH_TOKEN must be runtime-only on Coolify
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>
2026-05-03 15:54:07 -04:00
fc3c3a9268 Merge pull request 'feat: add Logto healthcheck to /health endpoint' (#2) from issue-1-logto-healthcheck into main 2026-04-22 01:56:22 +00:00
le king fu
28dd759f98 feat: add Logto healthcheck to /health endpoint
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>
2026-04-21 21:38:20 -04:00
le king fu
9a4c5c7775 Add /defenseurs endpoint to serve security status
Reads status.json written by the sergent (Escouade Défenseur)
from a Docker volume mount path. Used by the admin dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:13:10 -05:00
le king fu
69fea95320 fix: fail-closed auth when HEALTH_TOKEN is not set
Reject all requests if HEALTH_TOKEN env var is undefined instead of
allowing unauthenticated access (fail-open → fail-closed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:06:16 -05:00
le king fu
0e168d5323 fix: use POSIX df for Alpine compatibility
Alpine's df doesn't support --output flag, use df -k instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:52:51 -05:00
le king fu
d6eb06302c feat: initial vps-health-api service
Zero-dependency Node.js health endpoint exposing CPU, RAM, disk and
uptime metrics. Bearer token auth, Docker-ready (node:22-alpine).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:48:09 -05:00