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