Watch the persistence that does not need root
tests / runner-env (pull_request_target) Successful in 37s
tests / tests (pull_request_target) Successful in 1m16s

Everything drift watched needed root to write: /etc, /usr/local/{bin,sbin} and
the two .ssh directories. A dejima session runs as the ordinary account, so the
cheapest persistence available to it was outside every watched path - a user
systemd unit, a crontab in the spool rather than /etc, a shell rc file, or a
binary in ~/.local/bin, which is on that account's PATH and is where dejima
installs its own CLIs.

Named files under the home directory rather than the directory itself, which
holds the work this box exists for and would be enormous and drift on every
save.

Two more gaps were in how the state was collected rather than where.

SYMLINKS WERE INVISIBLE. `find -type f` returns nothing for one, so replacing a
watched binary with a link to another removed a FILE line and added nothing, and
a removal reads as tidying rather than as substitution. Recorded as LINK lines
with their targets, which also catches user-unit enablement, since that writes a
symlink into a .wants directory.

UDP LISTENERS WERE INVISIBLE. -ltn is TCP only, so a beacon binding a datagram
socket produced no line at all. Adding -u also ADDS A COLUMN: the local address
moves from field 4 to field 5 and field 4 becomes the send queue, so a flag
added without moving the field would have recorded a queue depth as a port on
every line. The netid is printed too, since a UDP and a TCP listener on the same
number are different facts.

v6, guest only and live-safe, because the PORT format change means a baseline
taken before this differs from every state after it. The note says to re-accept
on the guard and to read what it is about to record first: those paths have
never been looked at, so the first baseline over them is the one that decides
what "normal" means there.

None of this widens what the guard's key may run. The audit already ran as root
under the same sudoers rule and already returned this shape of text; it was
looking in fewer places.
This commit is contained in:
2026-08-29 15:14:37 -07:00
parent a81eba16c0
commit 8a183f070a
4 changed files with 189 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
5
6
+35 -2
View File
@@ -616,10 +616,29 @@ BASELINE="$BASELINE_DIR/baseline"
# report its own changes forever.
drift_paths() {
printf '%s\n' /etc /usr/local/bin /usr/local/sbin
local home
local home f
home=$(devbox_home)
[ -d "$home/.ssh" ] && printf '%s\n' "$home/.ssh"
[ -d /root/.ssh ] && printf '%s\n' /root/.ssh
# Persistence that does not need root, and so does not need the container
# escape either. A dejima session runs as this account: it can write a user
# unit, a crontab, a shell rc file, or a binary on its own PATH, and none of
# those touched anything watched above.
#
# A crontab is not in /etc. `crontab -e` writes to the spool, which is the
# whole point of it being a separate command.
[ -d "$home/.config/systemd/user" ] && printf '%s\n' "$home/.config/systemd/user"
[ -d "$home/.local/bin" ] && printf '%s\n' "$home/.local/bin"
[ -d /var/spool/cron/crontabs ] && printf '%s\n' /var/spool/cron/crontabs
# Named individually rather than by taking the home directory, which holds
# the work this box exists for and would be both enormous and drift on every
# save. find treats a file argument as a one-entry tree, so these arrive as
# FILE lines like everything else.
for f in .bashrc .bash_profile .bash_aliases .profile .zshrc .zshenv; do
[ -f "$home/$f" ] && printf '%s\n' "$home/$f"
done
return 0
}
@@ -639,14 +658,28 @@ drift_state() {
find "$p" -type f -print0 2>>"$DRIFT_ERRS" \
| xargs -0 -r sha256sum 2>>"$DRIFT_ERRS" \
| sed 's/^\\\{0,1\}\([0-9a-f]\{64\}\) /FILE \1 /' || true
# SYMLINKS, which -type f does not see at all. Replacing a watched
# binary with a link to another one changed no FILE line and no hash:
# the file is simply gone from the listing, and a removal reads as
# tidying rather than as substitution. It is also how a user unit gets
# enabled, since that writes a symlink into a .wants directory.
find "$p" -type l -printf 'LINK %p -> %l\n' 2>>"$DRIFT_ERRS" || true
done < <(drift_paths)
if command -v systemctl >/dev/null 2>&1; then
systemctl list-unit-files --state=enabled --no-legend --no-pager 2>/dev/null \
| awk 'NF {print "UNIT " $1}'
fi
# UDP as well as TCP. -ltn is TCP only, so a UDP listener was invisible: a
# beacon or a backdoor binding a datagram socket produced no line at all.
#
# The netid is printed because adding -u ADDS A COLUMN. With -t alone the
# local address is field 4; with both it is field 5, and field 4 is the
# send queue. A flag added without moving the field would have recorded a
# queue depth as a port, quietly, on every line.
if command -v ss >/dev/null 2>&1; then
ss -H -ltn 2>/dev/null | awk 'NF {print "PORT " $4}'
ss -H -ltun 2>/dev/null | awk 'NF {print "PORT " $1 " " $5}'
fi
return 0
}
+75
View File
@@ -238,3 +238,78 @@ stub_state() {
run main all
[ "$status" -ne 0 ]
}
# --- what the collector can see ----------------------------------------------
#
# Three things it could not, each a way to persist that left every hash it took
# unchanged.
@test "a symlink is recorded, since hashing cannot see one" {
# `find -type f` returns nothing for a symlink, so replacing a watched
# binary with a link to another one removed a FILE line and added nothing.
# A removal reads as tidying rather than as substitution.
scratch="$BATS_TEST_TMPDIR/links"
mkdir -p "$scratch"
echo real > "$scratch/binary"
ln -s /bin/sh "$scratch/swapped"
drift_paths() { printf '%s\n' "$scratch"; }
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
run drift_state
[[ "$output" == *"LINK $scratch/swapped -> /bin/sh"* ]]
}
@test "a udp listener is recorded, not only tcp" {
# -ltn is TCP only. A beacon binding a datagram socket produced no line.
scratch="$BATS_TEST_TMPDIR/empty"; mkdir -p "$scratch"
drift_paths() { printf '%s\n' "$scratch"; }
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
ss() { printf 'udp UNCONN 0 0 0.0.0.0:1194 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n'; }
export -f ss 2>/dev/null || true
run drift_state
[[ "$output" == *"PORT udp 0.0.0.0:1194"* ]]
[[ "$output" == *"PORT tcp 0.0.0.0:22"* ]]
}
@test "the port line reads the address, not the column that -u shifted" {
# Adding -u adds the netid column, so the local address moves from field 4
# to field 5 and field 4 becomes the send queue. A flag added without moving
# the field would record a queue depth as a port, on every line, quietly.
scratch="$BATS_TEST_TMPDIR/empty2"; mkdir -p "$scratch"
drift_paths() { printf '%s\n' "$scratch"; }
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
ss() { printf 'tcp LISTEN 0 128 192.0.2.5:8080 0.0.0.0:*\n'; }
export -f ss 2>/dev/null || true
run drift_state
[[ "$output" == *"PORT tcp 192.0.2.5:8080"* ]]
[[ "$output" != *"PORT tcp 128"* ]]
}
@test "the paths cover persistence that needs no root" {
# A dejima session runs as this account. It can write a user unit, a
# crontab, a shell rc file or a binary on its own PATH without ever needing
# the container escape, and none of those was watched.
# H, not "home": drift_paths declares `local home` before it calls
# devbox_home, so a stub reading $home sees that empty local under bash's
# dynamic scoping and trips set -u.
H="$BATS_TEST_TMPDIR/home"
mkdir -p "$H/.config/systemd/user" "$H/.local/bin" "$H/.ssh"
echo x > "$H/.bashrc"
devbox_home() { printf '%s\n' "$H"; }
run drift_paths
[ "$status" -eq 0 ]
[[ "$output" == *"$H/.config/systemd/user"* ]]
[[ "$output" == *"$H/.local/bin"* ]]
[[ "$output" == *"$H/.bashrc"* ]]
}
@test "the home directory itself is not watched, only named files under it" {
# It holds the work this box exists for. Hashing it would be enormous and
# would drift on every save, which is the shape of alarm that gets muted.
H="$BATS_TEST_TMPDIR/home2"
mkdir -p "$H/.ssh"
echo x > "$H/.bashrc"
devbox_home() { printf '%s\n' "$H"; }
run drift_paths
[ "$(printf '%s\n' "$output" | grep -cx "$H")" -eq 0 ]
}
+78
View File
@@ -0,0 +1,78 @@
---
version: 6
title: Watch the persistence that does not need root
layers: guest
snapshot: none
downtime: none
---
# v6: watch the persistence that does not need root
Guest only, live-safe, no snapshot. **If you run dejima-guard, this needs a
fresh baseline on the guard**, which is the whole of the work.
## Why
`drift` watched `/etc`, `/usr/local/bin`, `/usr/local/sbin` and the two `.ssh`
directories. Everything in that list needs root to write. A dejima session runs
as the ordinary account, so the cheapest persistence available to it was outside
every watched path:
- a **user systemd unit** in `~/.config/systemd/user`, enabled without sudo;
- a **crontab**, which `crontab -e` writes to `/var/spool/cron/crontabs` and not
to `/etc`;
- a **shell rc file**, run on every login;
- a **binary in `~/.local/bin`**, which is on that account's PATH and is where
dejima installs its own CLIs.
Two more gaps were in how the state was collected rather than where:
- **Symlinks were invisible.** `find -type f` returns nothing for one, so
replacing a watched binary with a link to another removed a `FILE` line and
added nothing. A removal reads as tidying rather than as substitution.
- **UDP listeners were invisible.** `ss -ltn` is TCP only, so a beacon or a
backdoor binding a datagram socket produced no line at all.
None of this widens what the guard's key may run. The audit already ran as root
under the same sudoers rule and already returned this shape of text; it was
looking in fewer places.
## 1. Guest
```sh
cd ~/dejima-devbox-hardener
git pull
sudo ./bin/dejima-devbox-bootstrap
sudo ./bin/dejima-devbox-upgrade mark-guest 6
```
Live-safe. Bootstrap installs only what is missing and does not restart Docker,
so sessions in flight are not disturbed.
## 2. Re-accept the baseline, on the guard
**Do this or every cycle alerts.** `PORT` lines now carry the protocol, so a
baseline taken before this differs from every state taken after it on every
port, and the new paths appear as additions.
On the machine running dejima-guard:
```sh
dejima-guard accept
dejima-guard check # should be clean
```
**Read what `accept` is about to record before you run it.** The state is
larger now and covers places nobody was watching before, so this is the first
time anything in those paths has been looked at. Take it on a box you believe is
clean; one taken later records whatever is there as normal.
If you do not run a guard, `sudo dejima-devbox-audit drift --accept` does the
same thing on the box itself, with the caveat that repository has always
carried: a baseline root can rewrite bounds an intruder who did not think to
look, and not one who did.
## What this does not change
Nothing about the firewall, the node, or what the guard is permitted to run. No
new command, no new sudoers entry, no new key. The same call returns more.