Tell the kinds of change apart, instead of counting them together

Every finding arrived as one "unexplained changes" at high: a checksum that
moved because logrotate ran, a port that was not open yesterday, and the audit
binary itself being replaced were the same push.

A change under a watched path is its own alert now, at max. The audit's own
binary is the sharp one - if it changed, everything this guard has been told
since is suspect, including the report saying so, and it used to be
indistinguishable from a config edit among a hundred others. The forced command,
the sudoers rule and authorized_keys are the same argument one layer out: they
are what makes this access read-only, so a change to them changes what a stolen
key is worth. Configurable, with a default naming the machinery this design
rests on.

A newly present PORT or UNIT is max as well. Both are the shape of a thing
arranging to run again or to be reached, which is not a config edit. A port that
went away is not: removal is drift worth reporting and is not this shape.

Still one alert per cycle, ordered by what the operator has to do about it. And
still no path in an alert body: the devbox chose those strings, and a count plus
a category is enough to say go and read the report.

LINK lines are counted, since the devbox now collects symlinks - hashing cannot
see one, so a substitution would otherwise be reported as nothing at all.
This commit is contained in:
2026-08-29 15:13:57 -07:00
parent 551e814f81
commit e41cc06ecc
4 changed files with 173 additions and 2 deletions
+1
View File
@@ -235,6 +235,7 @@ other end of the connection, not the machine the guard runs on.
| `ssh_key` | The private key **on this machine**. Its own, not a general-purpose administrative one |
| `ssh_known_hosts` | Where the devbox's host key is remembered. Must persist: in a container, on the state volume. Populated by `dejima-guard trust-host`, never automatically |
| `ssh_timeout` | Seconds to wait. The line between a slow devbox and a gone one |
| `critical_paths` | Paths whose change is its own max-priority alert, one per line. Defaults to the audit binary, the forced command, the sudoers rule, `sshd_config` and `authorized_keys` |
**Where alerts go.**
+57 -2
View File
@@ -366,16 +366,54 @@ LAST="$STATE_DIR/last-report"
# the watched machine chose. A count of changed unit files is safe to send; the
# name of one is not.
summarise() { # summarise DIFF_FILE
local f=$1 n_file n_unit n_port
local f=$1 n_file n_unit n_port n_link
n_file=$(grep -c '^[+-] FILE ' "$f" || true)
n_unit=$(grep -c '^[+-] UNIT ' "$f" || true)
n_port=$(grep -c '^[+-] PORT ' "$f" || true)
n_link=$(grep -c '^[+-] LINK ' "$f" || true)
[ "$n_file" -gt 0 ] && printf '%s file(s)\n' "$n_file"
[ "$n_link" -gt 0 ] && printf '%s symlink(s)\n' "$n_link"
[ "$n_unit" -gt 0 ] && printf '%s systemd unit(s)\n' "$n_unit"
[ "$n_port" -gt 0 ] && printf '%s listening port(s)\n' "$n_port"
return 0
}
# Paths whose change is a different kind of event from a config edit.
#
# The audit's own binary is the sharp one. If it changed, everything this guard
# has been told since is suspect, including the report saying so - and until now
# that arrived as "1 file(s) changed", indistinguishable from a logrotate tweak
# among a hundred others. The forced command, the sudoers rule and the
# authorized_keys line are the same argument one layer out: they are what makes
# the guard's access read-only, so a change to them is a change to what a stolen
# key is worth.
#
# Matched as a substring against the whole diff line, which is a path in every
# line kind this can appear in. The default list names the machinery this design
# rests on; anything else an operator cares about goes in the config.
CRITICAL_DEFAULT='/usr/local/sbin/dejima-devbox-audit
/usr/local/sbin/dejima-guard-shell
/etc/sudoers.d/dejima-guard
/etc/ssh/sshd_config
/.ssh/authorized_keys'
critical_hits() { # critical_hits DIFF_FILE -> matching lines
local f=$1 pat
while IFS= read -r pat; do
[ -n "$pat" ] || continue
grep -F -- "$pat" "$f" || true
done <<< "$(cfg critical_paths "$CRITICAL_DEFAULT")"
}
# A listener or an enabled unit that was not there before is not a config edit.
#
# It is the shape of a thing arranging to run again, or to be reached. Both were
# folded into one "unexplained changes" alert at high, beside a checksum that
# moved because logrotate ran.
new_persistence() { # new_persistence DIFF_FILE
grep -cE '^\+ (PORT|UNIT) ' "$1" || true
}
# Checked here rather than at the point of use.
#
# ssh_devbox runs inside a command substitution, and `exit` in there ends the
@@ -549,10 +587,27 @@ cmd_check() {
#
# Not one per finding: an apt upgrade would otherwise arrive as forty
# pushes, and the next real one lands in a topic already muted.
if [ "$changed" = 1 ] && [ "$reach_rc" -ne 0 ]; then
# Ordered by what the operator has to do about it, most serious first, and
# still one alert per cycle: an apt upgrade would otherwise arrive as forty
# pushes and the next real one would land in a muted topic.
local crit_n=0 newp=0
crit_n=$(critical_hits "$now.diff" | grep -c . || true)
newp=$(new_persistence "$now.diff")
if [ "$changed" = 1 ] && [ "$crit_n" -gt 0 ]; then
# No path names in the body. The devbox chose those strings, and the
# count plus the category is enough to say go and read the report.
alert max "$(target_short): watched path changed" \
"$crit_n line(s) changed under paths this guard treats as its own footing on $(target), including possibly the audit it collects with. Treat what it has reported since as unverified." || true
rc=1
elif [ "$changed" = 1 ] && [ "$reach_rc" -ne 0 ]; then
alert max "$(target_short): changes and containment" \
"On $(target): $body changed, and the containment check did not pass." || true
rc=1
elif [ "$newp" -gt 0 ]; then
alert max "$(target_short): new listener or unit" \
"On $(target): $body changed, and $newp of them is something newly arranged to run or to be reached." || true
rc=1
elif [ "$changed" = 1 ]; then
alert high "$(target_short): unexplained changes" \
"On $(target): $body changed since the last accepted state." || true
+14
View File
@@ -55,6 +55,20 @@ ssh_key = /var/lib/dejima-guard/id_ed25519
# guard that cannot connect at all.
ssh_known_hosts = /var/lib/dejima-guard/known_hosts
# Paths whose change is a different kind of event from a config edit.
#
# The audit's own binary is the sharp one: if it changed, everything this guard
# has been told since is suspect, including the report saying so. It used to
# arrive as "1 file(s) changed", indistinguishable from a logrotate tweak among
# a hundred others. The forced command, the sudoers rule and authorized_keys are
# the same argument one layer out - they are what makes this access read-only,
# so a change to them changes what a stolen key is worth.
#
# One per line, matched as a substring of the path. The default covers the
# machinery this design rests on; add anything else you would want woken for.
# critical_paths = /usr/local/sbin/dejima-devbox-audit
# /etc/sudoers.d/dejima-guard
# Seconds to wait for the connection. A devbox that is slow is not a devbox
# that is gone, and this is the line between those two readings.
ssh_timeout = 10
+101
View File
@@ -1146,3 +1146,104 @@ SSHEOF
grep -q '^heartbeat_url' "$REPO/etc/config.example"
grep -q 'heartbeat_url' "$REPO/README.md"
}
# --- what kind of change it was ------------------------------------------------
#
# Every finding used to arrive as one "unexplained changes" at high. A checksum
# that moved because logrotate ran, a new listening port, and the audit binary
# itself being replaced were the same push.
@test "a change to the audit's own binary is its own alert, at max" {
# The sharpest one. If the collector changed, everything this guard has been
# told since is suspect, including the report saying so.
stub_ssh "FILE aaa /usr/local/sbin/dejima-devbox-audit" 0
run_watch accept
stub_ssh "FILE bbb /usr/local/sbin/dejima-devbox-audit" 0
run_watch check
[[ "$(curl_log)" == *"Priority: max"* ]]
[[ "$(curl_log)" == *"watched path changed"* ]]
[[ "$(curl_log)" == *"unverified"* ]]
}
@test "the forced command and the sudoers rule are watched the same way" {
# They are what makes the guard's access read-only, so a change to them is a
# change in what a stolen key is worth.
for path in /usr/local/sbin/dejima-guard-shell /etc/sudoers.d/dejima-guard; do
rm -rf "$DEJIMA_GUARD_STATE"; mkdir -p "$DEJIMA_GUARD_STATE"
stub_ssh "FILE aaa $path" 0
run_watch accept
stub_ssh "FILE bbb $path" 0
run_watch check
[[ "$(curl_log)" == *"watched path changed"* ]] || { echo "missed $path"; false; }
done
}
@test "an authorized_keys change is watched whichever home it is in" {
# Matched on the tail of the path, since the account name is the operator's
# and root's copy is a different directory again.
stub_ssh "FILE aaa /home/someone/.ssh/authorized_keys" 0
run_watch accept
stub_ssh "FILE bbb /home/someone/.ssh/authorized_keys" 0
run_watch check
[[ "$(curl_log)" == *"watched path changed"* ]]
}
@test "a new listening port alerts at max, not as an ordinary change" {
# A listener is a thing arranging to be reached. It is not a config edit.
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE aaa /etc/one
PORT tcp 0.0.0.0:4444" 0
run_watch check
[[ "$(curl_log)" == *"Priority: max"* ]]
[[ "$(curl_log)" == *"newly arranged to run or to be reached"* ]]
}
@test "a newly enabled unit alerts at max too" {
stub_ssh "FILE aaa /etc/one" 0
run_watch accept
stub_ssh "FILE aaa /etc/one
UNIT backdoor.service" 0
run_watch check
[[ "$(curl_log)" == *"Priority: max"* ]]
}
@test "a port that went away is not a new listener" {
# Removal is drift worth reporting and is not the shape this escalates for.
stub_ssh "FILE aaa /etc/one
PORT tcp 0.0.0.0:4444" 0
run_watch accept
stub_ssh "FILE aaa /etc/one" 0
run_watch check
[[ "$(curl_log)" == *"unexplained changes"* ]]
[[ "$(curl_log)" != *"newly arranged"* ]]
}
@test "an ordinary file change still reports as ordinary drift" {
# The escalations are worth nothing if everything escalates.
stub_ssh "FILE aaa /etc/logrotate.conf" 0
run_watch accept
stub_ssh "FILE bbb /etc/logrotate.conf" 0
run_watch check
[[ "$(curl_log)" == *"unexplained changes"* ]]
[[ "$(curl_log)" != *"Priority: max"* ]]
}
@test "symlink changes are counted and named" {
# The devbox collects them now because hashing cannot see one. A count that
# ignored them would report a substitution as nothing at all.
stub_ssh "LINK /usr/local/bin/x -> /bin/sh" 0
run_watch accept
stub_ssh "LINK /usr/local/bin/x -> /tmp/evil" 0
run_watch check
[[ "$(curl_log)" == *"symlink"* ]]
}
@test "the watched paths are configurable, and the default is not empty" {
export DEJIMA_GUARD_CRITICAL_PATHS='/etc/mine'
stub_ssh "FILE aaa /etc/mine" 0
run_watch accept
stub_ssh "FILE bbb /etc/mine" 0
run_watch check
[[ "$(curl_log)" == *"watched path changed"* ]]
}