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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"* ]]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user