diff --git a/contrib/testing/README.md b/contrib/testing/README.md index f1340e1..26d6a89 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -41,6 +41,9 @@ one-off files in `/tmp`: - `manager-lifecycle-check.sh` exercises systemd reload/restart/stop/start, one agent crash, and an unclean manager death which leaves a stale PID file. It requires explicit live-test opt-in and restores an active service on exit. +- `agent-privilege-check.sh` verifies the live manager/agent process tree, + real/effective/saved UID and GID tuples, supplementary groups, and inherited, + permitted, effective, and ambient capabilities. The complete mapping from the original one-off `/tmp` names to maintained scripts is recorded in `tmp-script-map.md`. diff --git a/contrib/testing/agent-privilege-check.sh b/contrib/testing/agent-privilege-check.sh new file mode 100755 index 0000000..ae5c676 --- /dev/null +++ b/contrib/testing/agent-privilege-check.sh @@ -0,0 +1,137 @@ +#!/bin/sh +set -eu + +SERVICE=${BONGO_TEST_SERVICE:-bongo.service} +EXPECTED=${BONGO_TEST_AGENTS:-"bongostore bongoqueue bongoantispam bongoavirus bongorules bongosieve bongocollector bongoworker bongosmtp bongosmtpc bongoimap bongopop3 bongo-web"} + +if [ "${BONGO_ALLOW_LIVE_PRIVILEGE_TEST:-}" != 1 ]; then + echo "set BONGO_ALLOW_LIVE_PRIVILEGE_TEST=1 for the disposable host" >&2 + exit 2 +fi + +manager=$(sudo -n /usr/bin/systemctl show "$SERVICE" -p MainPID --value) +bongo_uid=$(/usr/bin/id -u bongo) +bongo_gid=$(/usr/bin/id -g bongo) + +if [ -z "$manager" ] || [ "$manager" -le 1 ] || + ! sudo -n /usr/bin/systemctl is-active "$SERVICE" >/dev/null; then + echo "$SERVICE is not active" >&2 + exit 1 +fi + +status_field() +{ + field=$1 + pid=$2 + sed -n "s/^${field}:[[:space:]]*//p" "/proc/$pid/status" +} + +assert_zero_caps() +{ + pid=$1 + name=$2 + for field in CapInh CapPrm CapEff CapAmb; do + value=$(status_field "$field" "$pid") + if [ "$value" != 0000000000000000 ]; then + echo "$name ($pid) retained $field=$value" >&2 + exit 1 + fi + done +} + +assert_no_groups() +{ + pid=$1 + name=$2 + groups=$(status_field Groups "$pid") + if [ -n "$groups" ]; then + echo "$name ($pid) retained supplementary groups: $groups" >&2 + exit 1 + fi +} + +assert_permanent_drop() +{ + pid=$1 + name=$2 + set -- $(status_field Uid "$pid") + if [ "$#" -ne 4 ] || [ "$1" != "$bongo_uid" ] || + [ "$2" != "$bongo_uid" ] || [ "$3" != "$bongo_uid" ] || + [ "$4" != "$bongo_uid" ]; then + echo "$name ($pid) did not permanently drop UID: $*" >&2 + exit 1 + fi + set -- $(status_field Gid "$pid") + if [ "$#" -ne 4 ] || [ "$1" != "$bongo_gid" ] || + [ "$2" != "$bongo_gid" ] || [ "$3" != "$bongo_gid" ] || + [ "$4" != "$bongo_gid" ]; then + echo "$name ($pid) did not permanently drop GID: $*" >&2 + exit 1 + fi + assert_no_groups "$pid" "$name" + assert_zero_caps "$pid" "$name" +} + +find_child() +{ + wanted=$1 + for pid in $(/usr/bin/pgrep -P "$manager" 2>/dev/null || true); do + name=$(status_field Name "$pid") + if [ "$name" = "$wanted" ]; then + printf '%s\n' "$pid" + return 0 + fi + done + return 1 +} + +# The supervisor must regain root only while forking a replacement which has +# to bind a privileged listener. It must otherwise run as bongo with no active +# or ambient capability and no supplementary group. +set -- $(status_field Uid "$manager") +if [ "$#" -ne 4 ] || [ "$1" != 0 ] || [ "$2" != "$bongo_uid" ] || + [ "$4" != "$bongo_uid" ]; then + echo "manager has unexpected UID tuple: $*" >&2 + exit 1 +fi +assert_no_groups "$manager" bongo-manager +for field in CapInh CapEff CapAmb; do + value=$(status_field "$field" "$manager") + if [ "$value" != 0000000000000000 ]; then + echo "manager retained active $field=$value" >&2 + exit 1 + fi +done + +for name in $EXPECTED; do + pid=$(find_child "$name") || { + echo "configured agent $name is not a child of manager $manager" >&2 + exit 1 + } + if [ "$name" = bongoworker ]; then + # ACME certificate deployment is the one scheduled operation which + # currently needs a short, explicit seteuid(0) window. The idle worker + # must still have bongo as effective/filesystem UID and no active or + # ambient capability. This exception is removed when deployment is + # split into a dedicated privileged helper. + set -- $(status_field Uid "$pid") + if [ "$#" -ne 4 ] || [ "$1" != 0 ] || + [ "$2" != "$bongo_uid" ] || [ "$4" != "$bongo_uid" ]; then + echo "$name ($pid) has unexpected UID tuple: $*" >&2 + exit 1 + fi + assert_no_groups "$pid" "$name" + for field in CapInh CapEff CapAmb; do + value=$(status_field "$field" "$pid") + if [ "$value" != 0000000000000000 ]; then + echo "$name ($pid) retained active $field=$value" >&2 + exit 1 + fi + done + else + assert_permanent_drop "$pid" "$name" + fi + echo "PASS $name PID $pid privilege state" +done + +echo "PASS all configured agents are manager children with bounded privilege" diff --git a/init/bongo.service.in b/init/bongo.service.in index 9292a8b..fa443b5 100644 --- a/init/bongo.service.in +++ b/init/bongo.service.in @@ -36,6 +36,7 @@ TimeoutStartSec=60s TimeoutStopSec=45s KillMode=control-group KillSignal=SIGTERM +CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_SETGID CAP_SETUID CAP_NET_BIND_SERVICE UMask=0077 LimitNOFILE=65536 TasksMax=4096 diff --git a/src/agents/antispam/antispam.c b/src/agents/antispam/antispam.c index 49db73b..28d7b95 100644 --- a/src/agents/antispam/antispam.c +++ b/src/agents/antispam/antispam.c @@ -241,7 +241,7 @@ XplServiceMain() int ccode; int startupOpts; - if (XplSetEffectiveUser(MsgGetUnprivilegedUser()) < 0) { + if (XplSetRealUser(MsgGetUnprivilegedUser()) < 0) { Log(LOG_ERROR, "Could not drop to unprivileged user '%s'", MsgGetUnprivilegedUser()); return(1); } diff --git a/src/agents/avirus/avirus.c b/src/agents/avirus/avirus.c index dfba255..52dba98 100755 --- a/src/agents/avirus/avirus.c +++ b/src/agents/avirus/avirus.c @@ -493,7 +493,7 @@ XplServiceMain(int argc, char *argv[]) int ccode; int startupOpts; - if (XplSetEffectiveUser(MsgGetUnprivilegedUser()) < 0) { + if (XplSetRealUser(MsgGetUnprivilegedUser()) < 0) { Log(LOG_ERROR, "Could not drop to unprivileged user '%s'", MsgGetUnprivilegedUser()); return(1); }