Harden the host path, and describe the risk this actually carries

From a security review by the agent working on dejima, prompted by the operator
asking whether the three gaps this page listed were really the risks, given the
guard usually runs on an ordinary VM with none of the devbox's hardening. They
were not. Five findings, all confirmed by reading the code.

THE UNIT RAN AS ROOT. The container path has always run unprivileged, on the
argument that root would be granting root to a program holding an SSH key to
another machine, for no reason at all. The unit disagreed with its own project:
no User=, and NoNewPrivileges does not drop capabilities for root. So a bug here
yielded root on a machine nothing in this repository hardens. SECURITY.md listed
"runs as a non-root uid" among the stronger properties, which was true of the
container and read as a property of the whole. Now an account of its own,
StateDirectory, an empty capability bounding set, and the rest of what a unit
that opens two connections and writes one directory can give up.

COLLECTION WAS UNBOUNDED AND THE DEVBOX CHOSE THE SIZE. The answer went into a
variable, then a file, then both sides of a diff. That is memory pressure and a
full /var/lib on a host running other things, which is the clearest thing the
watched machine can do to the watcher's. Capped now, with an over-long answer
treated as a finding rather than truncated: truncation would hand the devbox a
way to manufacture a diff, or to cut the state off just before what it added.
pipefail is off across that pipeline on purpose, since head closing the pipe
SIGPIPEs ssh and would report a capped read as a failed connection.

THE SSH CLIENT INHERITED A CONFIG THIS PROJECT DOES NOT OWN. ForwardAgent,
PermitLocalCommand, ClearAllForwardings and ControlMaster are named explicitly
now, on a connection to a host the design assumes may be hostile. Not -F
/dev/null, which would discard a ProxyJump an operator may need to reach the
devbox at all.

THE FIRST BASELINE WAS TAKEN UNATTENDED. accept exists because that is a
decision, and the timer's first fire bypassed it five minutes after boot, with
the warning going to a journal nobody reads yet. check refuses without one now
and sends no heartbeat, so a guard that has not been told what normal looks like
reports as down, which is what it is.

THE CONFIG FILE IS EXECUTABLE-EQUIVALENT, since alert_command runs from it.
Refused if writable by group or others, the way ssh treats a private key.

And the framing, which is what prompted the review: the page led with three
comparisons against dejima. Those are accurate and none of them is what a reader
most needs. It now leads with the two things that bound this program - the host
it happens to run on, and a collector that runs on the machine under suspicion -
and keeps the dejima comparison below them, where it belongs.
This commit is contained in:
2026-08-29 13:07:47 -07:00
parent 1012abe6fa
commit 26d29e92f2
5 changed files with 383 additions and 56 deletions
+22 -8
View File
@@ -77,18 +77,27 @@ whose whole job is to notice.
### On the host, with a systemd timer
```sh
sudo useradd --system --home-dir /var/lib/dejima-guard \
--shell /usr/sbin/nologin 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
sudo install -d -m 0750 -o dejima-guard -g dejima-guard \
/etc/dejima-guard /var/lib/dejima-guard
sudo install -m 0640 -o root -g dejima-guard etc/config.example /etc/dejima-guard/config
sudoedit /etc/dejima-guard/config
```
**An account of its own, because the unit runs as it.** This holds an SSH key to
another machine, and a bug in it should yield that account rather than root on
whatever host you put it on. The config stays root-owned and group-readable: it
names a command the guard runs, so write access to it is code execution as the
guard, and `dejima-guard` refuses to start if it is writable by group or others.
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 dejima-guard-install-key
sudo -u dejima-guard ssh-keygen -t ed25519 -N '' -f /var/lib/dejima-guard/id_ed25519
sudo -u dejima-guard dejima-guard-install-key
```
It authenticates to the devbox the ordinary way, so expect a password prompt:
@@ -102,15 +111,19 @@ Then the devbox's host key, which nothing else records and every other command
needs:
```sh
sudo dejima-guard trust-host
sudo dejima-guard check # first run records the baseline, alerts nothing
sudo -u dejima-guard dejima-guard trust-host
sudo -u dejima-guard dejima-guard accept # the baseline, taken deliberately
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
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.
**Take the baseline on a box you believe is clean.** One taken on a compromised
machine records the compromise as normal and reports clean forever. `check` will
not take one for you: under the timer that would happen five minutes after boot,
unattended, and everything the guard says afterwards rests on that file. Until
you run `accept` it reports as down, which is what a guard that has not been
told what normal looks like is.
`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
@@ -174,6 +187,7 @@ same treatment.
```sh
docker compose run --build --rm dejima-guard dejima-guard trust-host
docker compose run --build --rm dejima-guard dejima-guard accept # the baseline
docker compose up --build -d
docker compose exec dejima-guard dejima-guard test-alert
```
+43 -21
View File
@@ -34,32 +34,54 @@ half that matters: see
["Let the guard in"](https://gitbox.branderson.io/dejima/dejima-devbox-hardener/src/branch/main/docs/building-a-devbox.md#guard-access).
`dejima-guard-install-key` proves the restriction rather than assuming it.
## Where this is weaker than dejima, and why
## What actually bounds this
dejima runs untrusted code and is built accordingly. This runs one shell script
against one host. Two of its controls are still missing here, and both are worth
knowing about rather than discovering.
Two things characterise the exposure, and both matter more than any comparison
with the projects around it.
**No egress bound.** dejima's central claim is a deny-by-default allowlist
covering DNS as well as IP, which bounds where a compromised dependency can send
things. This has none: the container reaches anything the host can. Its actual
needs are three destinations: the devbox on 22, ntfy, and the heartbeat, so an
allowlist is cheap, but it belongs to your host or your container network and
this project neither ships nor requires one. Adding it inside the container is
possible and is the wrong trade: it would mean granting `NET_ADMIN` to a
container that currently drops every capability.
**It runs on whatever you run it on.** The guard holds a key to the watched box
and parses that box's output, and its exposure is bounded by the hardening of a
machine this project does not describe and asks nothing of. A NAS, a home
server, an existing VM: whatever it is, it is not hardened by anything here. The
container path answers this for itself, running unprivileged with every
capability dropped and the root filesystem read-only. The host path is a systemd
unit, and what that unit asks for is the whole of the answer: its own account,
an empty capability bounding set, and memory and task limits.
**The credential is in the container.** dejima keeps the model credential out
entirely, behind a proxy, so the container can use it without holding it. Here
the private key is mounted. An `SSH_AUTH_SOCK` from the host would give the same
use-without-possession and is not currently documented.
What the guard does to protect that host: it never runs as root, it caps how
much the watched machine may send it, it names the ssh settings a local
`ssh_config` could otherwise widen, and it refuses a config file that is
writable by group or others, because `alert_command` makes that file
executable-equivalent.
**No request logging.** dejima logs egress verdicts. This logs its findings and
its failures, not its connections.
**The collector runs on the watched host.** `dejima-devbox-audit` executes on
the devbox, so root there can return whatever it likes, including last week's
state verbatim. This is the detection ceiling and no amount of care on this side
raises it. The heartbeat proves the guard ran, not that it saw the truth. What
the design does buy is that lying has to be deliberate and continuous: the
baseline is here, out of reach, so a payload that did not think to look is
caught, and one that stops the collection entirely is caught by its silence.
Where it is stronger: it drops every capability, mounts the root filesystem
read-only, sets `no-new-privileges`, runs as a non-root uid, publishes no port,
and accepts no inbound connection at all.
## Where it sits next to the other projects
Not a fourth enforcement layer. It enforces nothing, and it is the only one of
the four that adds a machine holding credentials rather than taking some away.
Three of dejima's controls have no equivalent here, and are worth knowing rather
than discovering:
- **No egress bound.** dejima's deny-by-default allowlist covers DNS as well as
IP. This has none: the container reaches whatever the host can. Its needs are
three destinations, so an allowlist is cheap, but it belongs to your host or
your container network. Doing it inside the container would mean granting
`NET_ADMIN` to something that currently drops every capability, which is a
worse trade than the gap.
- **The credential is in the container.** dejima keeps the model credential out
entirely, behind a proxy. Here the private key is mounted. An `SSH_AUTH_SOCK`
from the host would give the same use-without-possession and is not documented
yet.
- **No request logging.** dejima logs egress verdicts. This logs findings and
failures, not connections.
## What it deliberately does not do
+123 -22
View File
@@ -53,6 +53,27 @@ die() { say "$PROG: $*"; exit 2; }
# `docker inspect` and to anything that can read /proc, which a token in a
# 0640 file is not - so the token is the one value worth mounting even when
# everything else comes from compose.
# The config names a command to run (alert_command), so write access to it is
# code execution as whoever this runs as. That makes its mode part of the
# security of the thing rather than tidiness, and worth refusing rather than
# noting - ssh takes the same line about a private key, for the same reason.
#
# Writable is what is checked, not readable: the container path mounts this file
# with the host's ownership, and group-readable is normal there.
check_conf_perms() {
local mode
[ -f "$CONF" ] || return 0
mode=$(stat -c %a "$CONF" 2>/dev/null) || return 0
# Masked arithmetic, not a pattern on the digits. Matching the last
# character tests other-write and misses group-write entirely, which is the
# half a shared machine is most likely to have.
if [ $(( 8#$mode & 0022 )) -ne 0 ]; then
die "$CONF is writable by group or others (mode $mode).
It names a command this program runs, so that is code execution as $(id -un).
chmod 0640 $CONF"
fi
}
cfg() { # cfg KEY [DEFAULT]
local v="" env_name
env_name="DEJIMA_GUARD_$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')"
@@ -165,6 +186,19 @@ ssh_devbox() { # ssh_devbox COMMAND...
# Found on a real devbox: the installer's own check reported the forced
# command not in force, because it had the same bug.
[ -n "$key" ] && opts="$opts -o IdentitiesOnly=yes"
# Named rather than inherited. ssh reads /etc/ssh/ssh_config and the running
# account's ~/.ssh/config, neither of which this project owns, on a machine
# whose hardening it describes nothing about - and this is a connection to a
# host the whole design assumes may be hostile.
#
# These four are the ones that let the local config or the far end widen
# what the connection can do: an agent to borrow, a local command to run, a
# forwarding to open, a multiplexed socket to ride in on. Set here rather
# than by -F /dev/null, which would also discard a legitimate ProxyJump the
# operator needs to reach the devbox at all.
opts="$opts -o ForwardAgent=no -o ClearAllForwardings=yes"
opts="$opts -o PermitLocalCommand=no -o ControlMaster=no -o ControlPath=none"
# shellcheck disable=SC2086
ssh $opts ${key:+-i "$key"} "$user@$host" "$@"
}
@@ -190,6 +224,40 @@ ssh_devbox() { # ssh_devbox COMMAND...
# legitimate non-ASCII path still reads as itself.
sanitise() { LC_ALL=C tr '\000-\011\013-\037\177' '?'; }
# Collection, with a ceiling on how much the watched host may send.
#
# The devbox chose the size and nothing capped it: the answer went into a shell
# variable, then a file, then both sides of a diff. A compromised devbox could
# return as much as it liked, which is memory pressure and a full /var/lib on a
# machine that is running other things. It is the clearest thing the watched
# host can do to the guard's host.
#
# It never defeated the watching - a guard that dies stops sending the heartbeat
# and the dead-man's switch says so - so this is about blast radius on the host
# rather than about detection.
#
# An over-long answer is a FINDING, not data to truncate and diff. Truncating
# would produce a state that differs from the baseline in a way the devbox
# controls, which is a way to manufacture alerts or, worse, to cut the state off
# just before whatever was added.
#
# pipefail is off for the pipeline on purpose. `head` closing the pipe sends
# SIGPIPE to ssh, so with pipefail a capped read reports as a failed connection
# - the one reading that sends an operator to the wrong end. The size is checked
# first and ssh's own status is read from PIPESTATUS.
collect() { # collect DEST_FILE ERR_FILE; 0 ok, 1 too big, 2 ssh failed
local cap rc
cap=$(cfg max_state_bytes 8388608)
set +o pipefail
ssh_devbox dejima-devbox-audit drift --state 2>"$2" \
| head -c "$((cap + 1))" > "$1"
rc=${PIPESTATUS[0]}
set -o pipefail
[ "$(wc -c < "$1")" -le "$cap" ] || return 1
[ "$rc" -eq 0 ] || return 2
return 0
}
# 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
@@ -315,6 +383,7 @@ summarise() { # summarise DIFF_FILE
# result and the cycle carried on as though the box had answered with nothing.
require_config() {
local h
check_conf_perms
h=$(cfg devbox_host)
[ -n "$h" ] || die "no devbox_host in $CONF - nothing to watch"
# No default for the account, deliberately.
@@ -372,7 +441,7 @@ require_config() {
}
cmd_check() {
local rc=0 state now reach_rc reach_out changed=0 body=""
local rc=0 now reach_rc reach_out changed=0 body=""
require_config
install -d -m 0750 "$STATE_DIR"
now=$(mktemp) || die "no temp space"
@@ -393,7 +462,19 @@ cmd_check() {
# LOCALLY ONLY. This text is the one thing near an alert that the far end
# 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
local craw crc=0
craw="$now.raw"
collect "$craw" "$ssh_err" || crc=$?
if [ "$crc" -eq 1 ]; then
alert max "$(target_short): oversize response" \
"The audit on $(target) returned more than this guard will accept. That is not a state; it is a machine sending as much as it can." || true
say "FAIL: $(target) returned more than $(cfg max_state_bytes 8388608) bytes."
say " Nothing was compared. A real state is a few hundred kilobytes."
return 1
fi
if [ "$crc" -ne 0 ]; 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
@@ -415,21 +496,20 @@ cmd_check() {
return 1
fi
# Tested on the string, not on the file. `printf '%s\n' ""` writes a
# newline, so a file holding nothing useful is still one byte long and
# passes -s - which had an audit that returned nothing read as a valid
# state, and on a first run recorded it as the baseline.
if [ -z "$state" ]; then
# Sanitised on the way from the capped capture into the file the diff reads,
# so the baseline and every diff against it are clean too.
sanitise < "$craw" > "$now"
# Tested for a non-empty line rather than a non-empty file. `printf '%s\n' ""`
# writes a newline, so a file holding nothing useful is still one byte long
# and passes -s, which had an audit that returned nothing read as a valid
# state and, on a first run, recorded it as the baseline.
if ! grep -q . "$now"; then
alert max "$(target_short): audit returned nothing" \
"The audit on $(target) ran and produced no state. That is not a clean box; it is a check that did not run." || true
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
@@ -437,12 +517,24 @@ cmd_check() {
reach_out=$(printf '%s\n' "$reach_out" | sanitise)
# 3. Drift, which it cannot: the baseline is here.
# NOT TAKEN HERE, and this is the point of having a separate accept.
#
# It used to record one and print a warning. Under the timer that happens
# five minutes after boot, unattended, quite possibly before anybody has
# looked at the machine, and the warning goes to a journal nobody is reading
# yet. Everything this program says afterwards rests on that file being an
# honest picture of a clean box.
#
# No heartbeat either. A guard that has not been told what normal looks like
# is not watching, and the dead-man's switch is the channel that says so.
if [ ! -f "$BASE" ]; then
cp "$now" "$BASE"
say "First run. Baseline recorded from the devbox as it is now."
say " $(grep -c . "$BASE") entries. Take it on a box you believe is clean."
heartbeat
return 0
say "No baseline. Nothing to compare against, so nothing was compared."
say ""
say " Take one deliberately, on a box you believe is clean:"
say " $PROG accept"
say ""
say " Until then this reports as down, which is what it is."
return 1
fi
diff --unchanged-line-format= --old-line-format='- %L' --new-line-format='+ %L' \
@@ -510,13 +602,22 @@ cmd_check() {
# Accepting the current state is a decision, so it is a separate command and
# never something a check does on its own.
cmd_accept() {
local state
local raw err crc=0
require_config
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"
raw=$(mktemp) || die "no temp space"
err=$(mktemp) || die "no temp space"
# shellcheck disable=SC2064
trap "rm -f '$raw' '$err'" EXIT
# Through the same capped collector as a check. A baseline is the one file
# here that everything else is compared against, so it is the last place to
# accept an unbounded answer from the machine being baselined.
collect "$raw" "$err" || crc=$?
[ "$crc" -ne 1 ] || die "$(target) returned more than $(cfg max_state_bytes 8388608) bytes - not accepting that as a baseline"
[ "$crc" -eq 0 ] || die "could not collect from the devbox"
grep -q . "$raw" || die "the devbox returned nothing - not accepting that as a baseline"
sanitise < "$raw" > "$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"
say " machine records the compromise as normal and reports clean forever."
+33 -1
View File
@@ -8,13 +8,45 @@ Wants=network-online.target
Type=oneshot
ExecStart=@@BIN_DIR@@/dejima-guard check
# NOT ROOT, and this is the setting that matters most here.
#
# The container path has always run as its own uid, on the argument that running
# as root grants root to a program holding an SSH key to another machine, for no
# reason at all. That argument does not stop being true on a host, and this unit
# used to disagree with it: no User=, so a bug in the guard yielded root on a
# machine this project describes no hardening for, rather than an unprivileged
# account in a capability-dropped container.
#
# StateDirectory creates /var/lib/dejima-guard and chowns it to this user, so it
# replaces the ReadWritePaths line rather than joining it.
User=dejima-guard
Group=dejima-guard
StateDirectory=dejima-guard
# NoNewPrivileges does not drop capabilities for root, so on the old unit it was
# doing less than it looked like it was. Emptied outright: this opens two
# outbound connections and writes one directory.
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
PrivateDevices=yes
ProtectKernelModules=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
# It listens on nothing and needs almost nothing. Whatever this machine is, it
# is not this program's to put at risk.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/dejima-guard
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
# The watched host chooses how much it sends back. The collector caps that
# itself and treats an over-long answer as a finding; these are the backstop for
# everything the cap does not cover, on a machine that is running other things.
MemoryMax=256M
TasksMax=64
+162 -4
View File
@@ -69,18 +69,43 @@ heartbeats() { curl_log | grep -c 'kuma.example.invalid' || true; }
# --- the first run ----------------------------------------------------------
@test "the first run records a baseline and does not alert" {
# Everything is new on a first run. Alerting on it would train the operator
# to dismiss the first alert they ever get from this.
@test "a first run refuses rather than baselining whatever it finds" {
# It used to take one and print a warning. Under the timer that is five
# minutes after boot, unattended, quite possibly before anyone has looked at
# the machine, with the warning going to a journal nobody is reading yet -
# and everything this program says afterwards rests on that file being an
# honest picture of a clean box.
stub_ssh "FILE aaa /etc/one" 0
run_watch check
[ "$status" -ne 0 ]
[ ! -f "$DEJIMA_GUARD_STATE/baseline" ]
[[ "$output" == *"accept"* ]]
}
@test "a guard with no baseline reports as down, rather than as healthy" {
# It is not watching. The dead-man's switch is the one channel that can say
# so, and a heartbeat here would tell it all is well.
stub_ssh "FILE aaa /etc/one" 0
run_watch check
[ "$(heartbeats)" = 0 ]
[ "$(alerts)" = 0 ]
}
@test "accept takes the baseline, and a check against it alerts nothing" {
# Everything is new the first time. Alerting on it would train the operator
# to dismiss the first alert they ever get from this.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
[ "$status" -eq 0 ]
[ -f "$DEJIMA_GUARD_STATE/baseline" ]
run_watch check
[ "$status" -eq 0 ]
[ "$(alerts)" = 0 ]
}
@test "an unchanged box alerts nothing and sends the heartbeat" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
run_watch check
[ "$status" -eq 0 ]
@@ -131,6 +156,7 @@ heartbeats() { curl_log | grep -c 'kuma.example.invalid' || true; }
# An apt upgrade otherwise arrives as forty pushes, and the next real alert
# lands in a topic already muted.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
curl_reset
stub_ssh "FILE aaa /etc/one
@@ -147,6 +173,7 @@ UNIT backdoor.service" 0
# push hands it a channel to the operator's phone - the thing dejima's own
# notification guarantee exists to prevent, defeated one layer out.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
curl_reset
stub_ssh "FILE aaa /etc/one
@@ -158,6 +185,7 @@ FILE bbb /etc/evil-http-backdoor" 0
@test "the detail is kept locally, where the alert deliberately does not put it" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
stub_ssh "FILE aaa /etc/one
FILE bbb /etc/evil-http-backdoor" 0
@@ -167,6 +195,7 @@ FILE bbb /etc/evil-http-backdoor" 0
@test "containment failing alerts even when nothing changed" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
curl_reset
stub_ssh "FILE aaa /etc/one" 1
@@ -177,6 +206,7 @@ FILE bbb /etc/evil-http-backdoor" 0
@test "both wrong at once is one alert, at max" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
curl_reset
stub_ssh "FILE bbb /etc/two" 1
@@ -191,6 +221,7 @@ FILE bbb /etc/evil-http-backdoor" 0
# Every call site used to end in `|| true`, so a wrong token produced
# exactly nothing: drift found, ALERTED printed, no alert in existence.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
printf '#!/bin/sh\nexit 22\n' > "$STUB/curl"; chmod +x "$STUB/curl"
stub_ssh "FILE bbb /etc/two" 0
@@ -222,6 +253,7 @@ EOF
@test "the detail is still written when the alert could not be sent" {
# It is the only surviving record of a finding nobody was told about.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
printf '#!/bin/sh\nexit 22\n' > "$STUB/curl"; chmod +x "$STUB/curl"
stub_ssh "FILE bbb /etc/evil" 0
@@ -232,6 +264,7 @@ EOF
@test "no alerting configured at all is a failure, not a quiet success" {
stub_ssh "FILE aaa /etc/one" 0
printf 'devbox_host = devbox.example.invalid\ndevbox_user = tester\n' > "$DEJIMA_GUARD_CONF"
run_watch accept # the baseline is a decision now
run_watch check
stub_ssh "FILE bbb /etc/two" 0
run_watch check
@@ -289,7 +322,8 @@ EOF
@test "the environment overrides the config file" {
stub_ssh "FILE aaa /etc/one" 0
printf 'devbox_host = wrong.example.invalid\ndevbox_user = tester\n' > "$DEJIMA_GUARD_CONF"
DEJIMA_GUARD_DEVBOX_HOST=devbox.example.invalid run_watch check
chmod 0640 "$DEJIMA_GUARD_CONF"
DEJIMA_GUARD_DEVBOX_HOST=devbox.example.invalid run_watch accept
[ "$status" -eq 0 ]
[ -f "$DEJIMA_GUARD_STATE/baseline" ]
}
@@ -298,6 +332,7 @@ EOF
# The container path must not become the only path. A systemd host mounts
# nothing and sets nothing.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept # the baseline is a decision now
run_watch check
[ "$status" -eq 0 ]
}
@@ -464,6 +499,7 @@ devbox_user = tester\nntfy_url = ntfy.example.invalid/x\n' > "$DEJIMA_GUARD_CONF
# A fully qualified name in a title pushes the finding off the end of the
# line, leaving a push that says only which machine, never what.
stub_ssh "FILE /etc/passwd aaaa" 0
run_watch accept # the baseline is a decision now
run_watch check # first run takes the baseline
stub_ssh "FILE /etc/passwd bbbb" 0
run_watch check
@@ -734,6 +770,7 @@ keyline() { awk '{print $1, $2}' "$BATS_TEST_TMPDIR/hk_$1.pub"; }
# 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 accept # the baseline is a decision now
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
@@ -746,6 +783,7 @@ keyline() { awk '{print $1, $2}' "$BATS_TEST_TMPDIR/hk_$1.pub"; }
# 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 accept # the baseline is a decision now
run_watch check
stub_ssh "$(printf 'FILE aaa /etc/we\001ird')" 0
run_watch check
@@ -757,6 +795,7 @@ keyline() { awk '{print $1, $2}' "$BATS_TEST_TMPDIR/hk_$1.pub"; }
# 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 accept # the baseline is a decision now
run_watch check
stub_ssh 'FILE aaa /etc/café-und-größe' 0
run_watch check
@@ -935,6 +974,7 @@ SSHEOF
# An Uptime Kuma push URL carries its token in the path, so the URL is the
# credential rather than merely the destination.
stub_ssh "FILE /etc/passwd aaaa" 0
run_watch accept # the baseline is a decision now
run_watch check
[[ "$(curl_log)" == *kuma.example.invalid* ]]
[[ "$(curl_argv)" != *kuma.example.invalid* ]]
@@ -973,3 +1013,121 @@ url = "http://evil.example.invalid/'
found=$(cd "$REPO" && git ls-files | xargs grep -nF "$dash" 2>/dev/null || true)
[ -z "$found" ] || { echo "$found"; false; }
}
# --- the guard's own host ------------------------------------------------------
#
# The container path has always run unprivileged in a capability-dropped
# sandbox. The host path runs on whatever the operator has, and this project
# describes no hardening for that machine, so what the unit asks for is the
# whole of what bounds a bug here.
@test "the systemd unit does not run as root" {
# The Dockerfile argues that running as root grants root to a program
# holding an SSH key to another machine, for no reason at all. That argument
# does not stop being true on a host, and the unit used to disagree with it.
grep -q '^User=' "$REPO/systemd/dejima-guard.service"
grep -q '^StateDirectory=' "$REPO/systemd/dejima-guard.service"
}
@test "the unit empties the capability bounding set" {
# NoNewPrivileges does not drop capabilities for root, so on the old unit it
# was doing less than it looked like it was doing.
grep -q '^CapabilityBoundingSet=$' "$REPO/systemd/dejima-guard.service"
}
@test "the unit bounds memory and tasks" {
# The watched host chooses how much it sends. The collector caps that, and
# these are the backstop for what the cap does not cover.
grep -q '^MemoryMax=' "$REPO/systemd/dejima-guard.service"
grep -q '^TasksMax=' "$REPO/systemd/dejima-guard.service"
}
@test "SECURITY.md does not claim a non-root uid the host path lacks" {
# It listed "runs as a non-root uid" among the stronger properties, which
# was true of the container and read as a property of the project.
if grep -q 'non-root uid' "$REPO/SECURITY.md"; then
grep -q '^User=' "$REPO/systemd/dejima-guard.service"
fi
}
# --- how much the watched host may send ---------------------------------------
@test "an oversize response is a finding, not something to truncate and diff" {
# Truncating would hand the devbox a way to manufacture a diff, or to cut
# the state off just before whatever it added.
export DEJIMA_GUARD_MAX_STATE_BYTES=2048
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
big=$(head -c 9000 /dev/zero | tr '\0' 'x')
stub_ssh "$big" 0
run_watch check
[ "$status" -ne 0 ]
[[ "$(curl_log)" == *"oversize"* ]]
}
@test "an oversize response does not become the baseline" {
export DEJIMA_GUARD_MAX_STATE_BYTES=2048
big=$(head -c 9000 /dev/zero | tr '\0' 'x')
stub_ssh "$big" 0
run_watch accept
[ "$status" -ne 0 ]
[ ! -f "$DEJIMA_GUARD_STATE/baseline" ]
}
@test "a capped read is not reported as the devbox being unreachable" {
# head closing the pipe sends SIGPIPE to ssh, so with pipefail on, a capped
# read looks like a failed connection - the one reading that sends an
# operator to the wrong end of the problem.
export DEJIMA_GUARD_MAX_STATE_BYTES=2048
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
big=$(head -c 9000 /dev/zero | tr '\0' 'x')
stub_ssh "$big" 0
run_watch check
[[ "$(curl_log)" != *unreachable* ]]
}
@test "a normal-sized state still collects" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
[ -f "$DEJIMA_GUARD_STATE/baseline" ]
grep -q '/etc/one' "$DEJIMA_GUARD_STATE/baseline"
}
# --- what the ssh client is allowed to inherit --------------------------------
@test "the connection names the settings a local ssh_config could widen" {
# ssh reads /etc/ssh/ssh_config and the account's ~/.ssh/config, neither of
# which this project owns, on a connection to a host the design assumes may
# be hostile.
stub_ssh "FILE aaa /etc/one" 0
cat > "$STUB/ssh" <<'SSHEOF'
#!/bin/sh
printf '%s\n' "$*" >> "$DEJIMA_GUARD_STATE/ssh.log"
case "$*" in *"drift --state"*) echo "FILE aaa /etc/one" ;; esac
SSHEOF
chmod +x "$STUB/ssh"
run_watch accept
for o in ForwardAgent=no ClearAllForwardings=yes PermitLocalCommand=no ControlMaster=no; do
grep -q "$o" "$DEJIMA_GUARD_STATE/ssh.log" || { echo "missing $o"; false; }
done
}
# --- the config file is executable-equivalent ---------------------------------
@test "a group-writable config is refused, since it names a command to run" {
# alert_command means write access to this file is code execution as
# whoever the guard runs as. ssh takes the same line about a private key.
chmod 0660 "$DEJIMA_GUARD_CONF"
stub_ssh "FILE aaa /etc/one" 0
run_watch check
[ "$status" -ne 0 ]
[[ "$output" == *"writable by group"* ]]
}
@test "a correctly permissioned config is accepted" {
chmod 0640 "$DEJIMA_GUARD_CONF"
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
[ "$status" -eq 0 ]
}