Treat the report as what it is, and cut the setup prose

THE REPORT IS AN UNTRUSTED DOCUMENT. Alert bodies are composed here from counts
and that rule held, but the alert exists to say go and read the report, and the
report is the devbox's own words: file paths, unit names, whatever ssh said. A
path is a filename and a filename may contain an escape sequence.

`dejima-guard report` cats that to a terminal on the machine holding the SSH key
to the box under suspicion. ESC[2K ESC[A erases the line above and writes over
it, so a compromised devbox could name a file such that the evidence of it
scrolls past as an all-clear. Control characters are replaced with "?" as the
text is collected - replaced rather than deleted, since a filename containing an
escape sequence is itself a finding, and sanitised on the way in rather than on
display, so the baseline and every diff against it are clean too. Printable
bytes are untouched, so a legitimate non-ASCII path still reads as itself.

The setup sections lost a third of their words without losing a step: a
paragraph duplicating step 2 in full, the Alpine rationale that is already in
the Dockerfile, trust-host explained twice at length, and a second copy of "sent
is not received" that What it does not do already makes.

The rewrite found the install block never installed dejima-guard-install-key,
which the instructions tell you to run. A test now holds the two together.
This commit is contained in:
2026-08-28 21:39:47 -07:00
parent 5c3f9c7c2e
commit a21be2e4b4
3 changed files with 155 additions and 104 deletions
+51 -104
View File
@@ -45,35 +45,32 @@ is not a service, and a machine of its own would be a poor trade for something
whose whole job is to notice.
```sh
sudo install -m 0755 bin/dejima-guard /usr/local/sbin/dejima-guard
sudo install -m 0755 bin/dejima-guard bin/dejima-guard-install-key /usr/local/sbin/
sudo install -d -m 0750 /etc/dejima-guard /var/lib/dejima-guard
sudo install -m 0640 etc/config.example /etc/dejima-guard/config
sudoedit /etc/dejima-guard/config
```
Then a key of its own, restricted at the devbox end so it can run the two
read-only subcommands and nothing else:
A key of its own, put on the devbox restricted so it can run the two read-only
subcommands and nothing else:
```sh
sudo ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
sudo cat /var/lib/dejima-guard/id_ed25519.pub
sudo dejima-guard-install-key
```
`etc/config.example` has the `authorized_keys` line to pair it with. Then record
the devbox's host key, which nothing else does and every other command needs:
It authenticates to the devbox the ordinary way, so expect a password prompt —
the guard's key is what it installs, not what it uses. It then tries four
commands with the new key and reports whether the restriction holds. It needs
`dejima-guard-shell` and a sudoers rule on the devbox, both from
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access);
if either is missing it says which.
Then the devbox's host key, which nothing else records and every other command
needs:
```sh
sudo dejima-guard trust-host
```
`StrictHostKeyChecking` is left on, deliberately, and batch mode turns its
prompt into a failure — so until this is run, every cycle reports the devbox
unreachable at max priority. Compare the fingerprint it prints against
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the devbox, or pass
`--fingerprint SHA256:...` and have it refuse to write anything that does not
match. Then:
```sh
sudo dejima-guard check # first run records the baseline, alerts nothing
sudo install -m 0644 systemd/dejima-guard.{service,timer} /etc/systemd/system/
sudo sed -i 's|@@BIN_DIR@@|/usr/local/sbin|' /etc/systemd/system/dejima-guard.service
@@ -83,17 +80,29 @@ sudo systemctl daemon-reload && sudo systemctl enable --now dejima-guard.timer
**Take the first baseline on a box you believe is clean.** One taken on a
compromised machine records the compromise as normal and reports clean forever.
`trust-host` prints the fingerprint before writing it — compare it against
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the devbox, or pass
`--fingerprint SHA256:...` and have it refuse anything else. Skipping it is not
a quiet failure: host key checking is left on, batch mode turns its prompt into
an error, and every cycle then reports the devbox unreachable at max priority.
### Or as a container
**1. Copy the example and edit it.** Every variable in it is commented with
what goes in it.
**1. Copy the example and edit it.** Every variable is commented with what goes
in it. Your copy is gitignored, and the example will not start as shipped — its
host is a placeholder the guard refuses, so it cannot become a live config by
being copied and forgotten.
```sh
cp docker-compose.example.yml docker-compose.yml
$EDITOR docker-compose.yml
```
**2. Make a key for the guard and put it on the devbox.**
**A copy, not an override.** `docker compose up` merges
`docker-compose.override.yml` with the file it overrides rather than replacing
it, so an override would leave the example's values in play.
**2. Make a key and put it on the devbox**, as described above.
```sh
mkdir -p secrets
@@ -101,116 +110,54 @@ ssh-keygen -t ed25519 -N '' -f secrets/guard_key -C dejima-guard
bin/dejima-guard-install-key --pub secrets/guard_key.pub you@devbox
```
That connects **as you**, with your own credentials — the guard's key is what
it installs, not what it uses. It then tries four commands with the new key and
tells you whether the restriction holds.
**Not `ssh-copy-id`.** That copies the bare key with no options, and a bare key
**Not `ssh-copy-id`.** It copies the bare key with no options, and a bare key
here is a shell on the devbox for whoever eventually takes *this* machine —
which is the machine holding the evidence about that one. The line needs a
forced command, and `ssh-copy-id` has no way to attach one. Doing it by hand
instead means copy-then-edit, with a window in between where an unrestricted
key is live; this writes the restricted line in a single append.
the one holding the evidence about that one. The line needs a forced command,
which `ssh-copy-id` cannot attach, and doing it by hand means copy-then-edit
with an unrestricted key live in between.
It needs `dejima-guard-shell` on the devbox and a sudoers rule permitting the
two audit commands — both from
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access).
If either is missing the check above says which.
**3. Record the devbox's host key.** In a container this matters more, not
less: the volume starts empty, and without it every cycle alerts that the
devbox is unreachable.
```sh
docker compose run --rm dejima-guard dejima-guard trust-host
```
It prints the fingerprint before writing it. Compare that against
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` run on the devbox itself, or
pass `--fingerprint SHA256:...` so it refuses to write anything else. It will
not replace a key it already has: a host key that changed is either a rebuild
or the thing this program exists to notice, and that is not a helper's call.
**4. Write the secret config.** This is the one file that is not a copy of
anything: create `secrets/config` and put in it whatever must not be in the
environment — on a self-hosted ntfy with access control, that is the token.
**3. Write the secret config.** The one file that is not a copy of anything:
whatever must not sit in the environment, which on a self-hosted ntfy is the
token.
```
ntfy_token = <the token, from your ntfy server>
```
If your ntfy needs no token, the file can be empty, but it still has to exist:
compose will refuse to start with a secret it cannot find.
An ntfy needing no token can have this empty, but it must exist — compose will
not start with a secret it cannot find.
```sh
chmod 600 secrets/guard_key secrets/config
sudo chown 10001:10001 secrets/guard_key secrets/config
```
**The `chown` is not optional, and it is the first thing that goes wrong
without it.** The image runs as uid 10001 rather than root, and compose mounts
a file secret with the host's ownership and mode — so a key that is `0600` and
yours is a key the container cannot open. ssh reports that as
`Permission denied (publickey,password)`, which looks exactly like the devbox
rejecting the key, and sends you to the far end to debug a file on this one.
`dejima-guard` checks the key itself before connecting and says so plainly, but
the `chown` is what fixes it.
**The `chown` is not optional.** The image runs as uid 10001, and compose mounts
a secret with the host's ownership — so a key that is `0600` and yours is one
the container cannot open. 10001 is fixed in the `Dockerfile` and is part of the
interface. Bind-mounting a host directory in place of the named volume needs the
same treatment.
10001 is fixed in the `Dockerfile` and is part of the interface, so it is safe
to write down here.
**5. Start it, then prove the alert path works.**
**4. Record the host key and start it.**
```sh
docker compose run --rm dejima-guard dejima-guard trust-host
docker compose up -d
docker compose exec dejima-guard dejima-guard test-alert
```
**Go and check that alert arrived on your phone.** Nothing else exercises that
path: a clean cycle never publishes, so a wrong token or a wrong topic sits
undiscovered until the first real finding — and then takes it with it. The
guard suppresses its heartbeat when a send fails, so a broken channel
eventually shows up as a dead guard, but the first time you find that out
should be now and not then.
**A copy, not an override.** `docker compose up` reads `docker-compose.yml` and
`docker-compose.override.yml` together and merges them, so overriding the
example leaves its values in play rather than replacing them. Your copy is
gitignored, which is also where your host and your URLs belong.
The example will not start as shipped: its host is a placeholder the guard
refuses, so it cannot become a live config by being copied and forgotten.
The public half of that key goes in the devbox's `authorized_keys`, restricted
as `etc/config.example` shows.
**It never publishes a port, and it never should.** What makes a container an
acceptable home for a program holding an SSH key to the devbox is that nothing
can connect to it. The compose file drops every capability, mounts the root
filesystem read-only, and sets `no-new-privileges`.
path, and a broken one takes the first real finding with it.
**The volume is not a cache.** It holds the baseline, the last report and
`known_hosts`. Losing it is not a clean slate: a fresh baseline compares clean
against everything for the rest of its life, and a fresh `known_hosts` fails
host-key verification on every connection — which `BatchMode` turns into
"could not reach the devbox", at max priority, forever. A false alarm that
never stops is the kind that gets muted.
against everything for the rest of its life, and a fresh `known_hosts` reports
the devbox unreachable forever.
**Config comes from the environment first, then the file.** Compose is where
everything else about a deployment lives, so the environment is the ergonomic
place for a hostname. It is the wrong place for a token: `docker inspect` shows
it, and so does anything that can read `/proc`. Put secrets in the mounted
config file, which is what `DEJIMA_GUARD_CONF` points at.
**If you bind-mount a host directory instead of the named volume**, chown it to
uid `10001` first. The image creates that user and the named volume inherits
its ownership; a bind mount does not, and the guard cannot write its baseline
to a directory owned by root.
The image is Debian rather than Alpine deliberately: the diff that produces the
change list uses `--unchanged-line-format`, which is GNU diffutils and not
busybox. Alpine works once diffutils and coreutils are added back, at which
point the smaller base has been patched towards this one a package at a time.
**It never publishes a port, and never should.** What makes a container an
acceptable home for a program holding an SSH key to the devbox is that nothing
can connect to it. The compose file also drops every capability, mounts the root
filesystem read-only, and sets `no-new-privileges`.
### What goes in each setting
+28
View File
@@ -152,6 +152,27 @@ ssh_devbox() { # ssh_devbox COMMAND...
ssh $opts ${key:+-i "$key"} "$user@$host" "$@"
}
# Everything the devbox said, made safe to store and to print.
#
# THE REPORT IS AN UNTRUSTED DOCUMENT. The alert bodies are composed here from
# counts, and that rule was enforced - but the alert exists to say go and read
# the report, and the report is the devbox's own words: file paths, unit names,
# and whatever ssh had to say. A path is a filename, and a filename may contain
# an escape sequence.
#
# `dejima-guard report` cats that to a terminal, on the machine holding the SSH
# key to the box the report is about. ESC[2K ESC[A erases the line above and
# writes over it, so a compromised devbox could name a file such that the
# evidence of it scrolls past as an all-clear. Every byte of that path came from
# the machine under suspicion.
#
# Control characters become "?" rather than being deleted: a filename with an
# escape sequence in it is itself a finding, and one that vanished silently
# would be the second thing this got wrong. Newlines are kept, being the line
# structure rather than content, and anything printable is left alone so a
# legitimate non-ASCII path still reads as itself.
sanitise() { LC_ALL=C tr '\000-\011\013-\037\177' '?'; }
# The watched host's own name, for alerts that would otherwise say "the devbox".
#
# An operator watching more than one box gets pushes that are indistinguishable
@@ -343,6 +364,7 @@ cmd_check() {
# has any say in, so it goes to the log and the report on this machine and
# never into the push.
if ! state=$(ssh_devbox dejima-devbox-audit drift --state 2>"$ssh_err"); then
sanitise < "$ssh_err" > "$ssh_err.clean" && mv "$ssh_err.clean" "$ssh_err"
alert max "$(target_short): unreachable" \
"dejima-guard could not collect from $(target). It may be down, off the network, or refusing this key." || true
say "FAIL: could not reach $(target). ssh said:"
@@ -373,11 +395,16 @@ cmd_check() {
say "FAIL: empty state"
return 1
fi
# Sanitised after capture, not piped through on the way in: a pipeline would
# put tr's status where ssh's needs to be, and reading the wrong end of a
# pipeline is a mistake this program has already made once.
state=$(printf '%s\n' "$state" | sanitise)
printf '%s\n' "$state" > "$now"
# 2. Containment, which the devbox can answer for itself.
reach_rc=0
reach_out=$(ssh_devbox dejima-devbox-audit reach 2>&1) || reach_rc=$?
reach_out=$(printf '%s\n' "$reach_out" | sanitise)
# 3. Drift, which it cannot: the baseline is here.
if [ ! -f "$BASE" ]; then
@@ -458,6 +485,7 @@ cmd_accept() {
install -d -m 0750 "$STATE_DIR"
state=$(ssh_devbox dejima-devbox-audit drift --state) || die "could not collect from the devbox"
[ -n "$state" ] || die "the devbox returned nothing - not accepting that as a baseline"
state=$(printf '%s\n' "$state" | sanitise)
printf '%s\n' "$state" > "$BASE"
say "Baseline accepted: $(grep -c . "$BASE") entries in $BASE"
say " Take it on a box you believe is clean. One taken on a compromised"
+76
View File
@@ -712,3 +712,79 @@ keyline() { awk '{print $1, $2}' "$BATS_TEST_TMPDIR/hk_$1.pub"; }
2>/dev/null || true)
[ -z "$found" ] || { echo "$found"; false; }
}
# --- the report is an untrusted document -------------------------------------
#
# Alert bodies are composed here from counts, and that rule holds. But the alert
# exists to say go and read the report, and the report is the devbox's own
# words: file paths, unit names, whatever ssh said. A path is a filename, and a
# filename may contain an escape sequence.
@test "escape sequences from the devbox do not reach the report" {
# ESC[2K ESC[A erases the line above and writes over it. `dejima-guard
# report` cats this to a terminal on the machine holding the SSH key to the
# box the report is about, so a compromised devbox could name a file such
# that the evidence of it scrolls past as an all-clear.
stub_ssh "FILE aaa /etc/one" 0
run_watch check # first cycle takes the baseline
stub_ssh "$(printf 'FILE aaa /etc/\033[2K\033[Aall-clear')" 0
run_watch check # second produces the diff, and a report
run_watch report
[[ "$output" != *$'\033'* ]]
[[ "$output" == *"?[2K"* ]]
}
@test "a control character is replaced rather than dropped" {
# A filename with an escape sequence in it is itself a finding. One that
# vanished silently would be the second thing this got wrong.
stub_ssh "FILE aaa /etc/one" 0
run_watch check
stub_ssh "$(printf 'FILE aaa /etc/we\001ird')" 0
run_watch check
run_watch report
[[ "$output" == *"we?ird"* ]]
}
@test "a legitimate non-ASCII path is left as it is" {
# Stripping to ASCII would mangle every accented filename on the box into a
# difference that never goes away.
stub_ssh "FILE aaa /etc/one" 0
run_watch check
stub_ssh 'FILE aaa /etc/café-und-größe' 0
run_watch check
run_watch report
[[ "$output" == *"café-und-größe"* ]]
}
@test "the baseline is stored sanitised, so a diff cannot resurrect one" {
# Sanitising only on display would leave the bytes in the baseline and in
# every diff computed against it.
stub_ssh "$(printf 'FILE aaa /etc/x\033[2Ky')" 0
run_watch check
! grep -q $'\033' "$DEJIMA_GUARD_STATE/baseline"
}
@test "ssh's own words are sanitised too" {
# It is the far end talking, on the path that reports the far end failing.
cat > "$STUB/ssh" <<'SSHEOF'
#!/bin/sh
printf 'Host key \033[2Kverification failed.\n' >&2
exit 255
SSHEOF
chmod +x "$STUB/ssh"
run_watch check
[[ "$output" != *$'\033'* ]]
}
@test "the setup installs every program the README later tells you to run" {
# A rewrite of the setup replaced a manual step with a call to
# dejima-guard-install-key, which the install block did not install. The
# instructions were then correct about what to run and wrong about whether
# it was there.
for prog in $(grep -oE '\bdejima-guard(-[a-z-]+)?\b' "$REPO/README.md" \
| sort -u | grep -v '^dejima-guard$'); do
[ -f "$REPO/bin/$prog" ] || continue # not one of ours
grep -q "bin/$prog" "$REPO/README.md" \
|| { echo "README runs $prog but never installs it"; false; }
done
}