From 32688ce0b1348cdccfa27dfd68159bf13b5fdf1b Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 08:06:05 +0100 Subject: [PATCH 1/6] test(devenv): check the secrets against another local user outside the isolation unit.sh runs under scripts/ci/no-docker.sh, where setuid programs such as sudo do not work, so its U-16 check that the user nobody cannot read a secret only printed INFO, in CI as well. tests/devenv/cross-user.sh now owns that check. The checkout's own bin/devenv generates the secrets under no-docker.sh in a new directory below /tmp; outside the isolation the script checks file type, owner and modes as data and then runs only /bin/ls and /bin/cat as nobody on fixed paths. A control file in a 0755 directory next to the secrets directory must be readable by nobody, so a pass cannot come from an unreachable path (the earlier check ran below a 0700 temporary directory). The script refuses arguments, DEVENV, GUARD, ADAPTED, a run inside no-docker.sh and local changes to the files it runs. Without passwordless sudo it skips with INFO, except in CI, where that fails. --- tests/devenv/cross-user.sh | 142 +++++++++++++++++++++++++++++++++++++ tests/devenv/unit.sh | 9 +-- 2 files changed, 145 insertions(+), 6 deletions(-) create mode 100755 tests/devenv/cross-user.sh diff --git a/tests/devenv/cross-user.sh b/tests/devenv/cross-user.sh new file mode 100755 index 0000000..2bb6d3c --- /dev/null +++ b/tests/devenv/cross-user.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# Cross-user check of the generated secrets (U-16): another local user +# (nobody) can neither list the secrets directory nor read a secret that +# bin/devenv generated. +# +# This suite owns that check; tests/devenv/unit.sh cannot run it, because +# setuid programs such as sudo do not work inside scripts/ci/no-docker.sh. +# It is split in two parts so that no code under test runs with the +# caller's privileges outside the isolation: +# 1. The checkout's own bin/devenv (never a copy named by DEVENV or any +# other input) runs ensure_secrets under scripts/ci/no-docker.sh, with +# its runtime directory in a fresh directory below /tmp and a fake +# docker that reports no volume. +# 2. Outside the isolation, this script checks the result as data (file +# type, owner, modes; never the contents) and then runs only fixed +# commands as nobody on fixed paths: /bin/cat FILE and /bin/ls DIR. +# A control file that this script makes in a 0755 directory next to the +# secrets directory, and that nobody must be able to read, proves that the +# path is reachable for nobody and that the read check detects an exposed +# file. +# +# The check needs passwordless sudo. Without it the suite prints INFO and +# exits 0, except in CI (CI=true), where that is a failure. +# +# It certifies the committed code, so it refuses (exit 64) when DEVENV, +# GUARD, ADAPTED or NO_DOCKER_ACTIVE is set, outside a Git checkout, and +# when one of the files it runs differs from HEAD. It is not a mutation +# harness: run mutated code only through unit.sh and scripts/ci/tests/. +# +# Usage: tests/devenv/cross-user.sh +# Exit codes: 0 passed (or skipped locally), 1 a check failed, +# 2 result tally mismatch, 64 refused + +# shellcheck source-path=SCRIPTDIR + +set -u + +here=$(cd -P "$(dirname "$0")" && pwd -P) || exit 64 +root=$(cd -P "$here/../.." && pwd -P) || exit 64 + +refuse() { + printf 'cross-user: refused: %s\n' "$1" >&2 + exit 64 +} + +[ $# -eq 0 ] || refuse "no arguments are accepted" +for var in DEVENV GUARD ADAPTED NO_DOCKER_ACTIVE; do + [ -z "${!var+set}" ] || refuse "$var is set; this check runs only the checkout's own code, outside the isolation" +done + +# The files this suite runs, outside or inside the isolation. +involved='bin/devenv tests/devenv/cross-user.sh tests/devenv/tally.sh scripts/ci/no-docker.sh scripts/ci/no-docker-probe.py' +# shellcheck disable=SC2086 # the fixed list of paths is split into words +{ + git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1 && + git -C "$root" ls-files --error-unmatch -- $involved >/dev/null 2>&1 +} || refuse "not a Git checkout with $involved tracked" +# shellcheck disable=SC2086 +git -C "$root" -c core.fsmonitor=false diff --no-ext-diff --no-textconv --quiet HEAD -- $involved || + refuse "local modifications to $involved; commit them first (an edited file cannot be told from a mutated one)" + +# shellcheck source=tally.sh +. "$here/tally.sh" + +cd / || exit 64 +if ! sudo -n -u nobody -- /usr/bin/true 2>/dev/null; then + if [ "${CI-}" = true ]; then + printf 'FAIL CU-00 passwordless sudo to the user nobody is required in CI\n' + exit 1 + fi + printf 'INFO CU cross-user read check needs passwordless sudo to the user nobody (runs in CI); skipped\n' + exit 0 +fi + +# Below /tmp, not $TMPDIR: on macOS $TMPDIR is a 0700 per-user directory, so +# nobody could not reach anything below it and the check would pass for the +# wrong reason. The parents of the runtime directory are 0755 for the same +# reason; the control file below proves it. +base=$(mktemp -d /tmp/flowcrm-cross-user.XXXXXX) || exit 2 +base=$(cd -P "$base" && pwd -P) || exit 2 +trap 'rm -rf "$base"' EXIT +tally_init "$base/tally" + +result() { # name ok(0|1) [detail] + if [ "$2" -eq 0 ]; then pass_result "$1"; else fail_result "$1${3:+ $3}"; fi +} + +rt=$base/rt +secrets_dir=$rt/secrets +names='db_password db_owner_password db_app_password app_secret' +mkdir "$base/bin" "$rt" && chmod 0755 "$base" "$rt" || exit 2 +printf '#!/bin/sh\nexit 1\n' >"$base/bin/docker" && chmod 0755 "$base/bin/docker" || exit 2 +# The control: a directory as open as the secrets directory must not be, +# with a 0444 file at the depth of a secret, made by this script. +{ mkdir "$rt/control" && chmod 0755 "$rt/control" && printf 'control\n' >"$rt/control/file" && + chmod 0444 "$rt/control/file"; } || exit 2 + +# --- part 1, isolated: the checkout's bin/devenv generates the secrets +# shellcheck disable=SC2016 # expanded by the isolated shell +"$root/scripts/ci/no-docker.sh" "$BASH" -c ' + set -u + . "$1" || exit 2 + FLOWCRM_STACK=app + init_paths + RUNTIME_DIR=$2 SECRETS_DIR=$2/secrets STATE_FILE=$2/dev.env LOCK_DIR=$2/lock + GATE_DIR=$2/gate GATE_FILE=$2/gate/security-gate + PATH=$3:$PATH + ensure_secrets +' cross-user "$root/bin/devenv" "$rt" "$base/bin" 2>"$base/generate.err" +rc=$? + +# --- part 2, outside the isolation: checks as data, then fixed reads as nobody +mode_of() { # GNU stat first: its -f means file system status + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} +ok=0 +{ [ "$rc" = 0 ] && [ ! -L "$rt" ] && [ -d "$rt" ] && [ ! -L "$secrets_dir" ] && [ -d "$secrets_dir" ] && + [ -O "$secrets_dir" ] && [ "$(mode_of "$secrets_dir")" = 700 ]; } || ok=1 +for name in $names; do + f=$secrets_dir/$name + { [ ! -L "$f" ] && [ -f "$f" ] && [ -s "$f" ] && [ -O "$f" ] && [ "$(mode_of "$f")" = 444 ]; } || ok=1 +done +result "CU-01 the checkout's bin/devenv created the secrets in the isolation (directory 0700, regular files 0444)" $ok \ + "(rc $rc: $(head -c 2000 "$base/generate.err" | tr '\n' ' ' | tr -cd '[:print:]'))" + +as_nobody() { sudo -n -u nobody -- "$@" >/dev/null 2>&1; } + +as_nobody /bin/cat "$rt/control/file" +result "CU-02 control: nobody can read a 0444 file in a 0755 directory next to the secrets directory (the path is reachable and the read check detects an exposed file)" $? + +if as_nobody /bin/ls "$secrets_dir"; then r=1; else r=0; fi +result "CU-03 nobody cannot list the secrets directory" $r +for name in $names; do + if as_nobody /bin/cat "$secrets_dir/$name"; then r=1; else r=0; fi + result "CU-04 nobody cannot read the secret $name" $r +done +# Nothing here changes, follows or executes a path that the isolated code +# could have replaced (such as a symbolic link to a file of the caller): +# the paths are only tested, stat'ed and read by nobody. + +tally_finish "" diff --git a/tests/devenv/unit.sh b/tests/devenv/unit.sh index f14eea5..e4113dd 100755 --- a/tests/devenv/unit.sh +++ b/tests/devenv/unit.sh @@ -803,12 +803,9 @@ chmod 0644 "$SECRETS_DIR/db_password" r=$(secrets_rc exists) [ "$r" = 0 ] && [ "$(mode_of "$SECRETS_DIR")" = 700 ] && [ "$(mode_of "$SECRETS_DIR/db_password")" = 444 ] result "U-16 a too-open secrets directory and secret file are tightened (0700, 0444)" $? "(rc $r)" -if sudo -n -u nobody true 2>/dev/null; then - if sudo -n -u nobody cat "$SECRETS_DIR/db_password" >/dev/null 2>&1; then r=1; else r=0; fi - result "U-16 another local user (nobody) cannot read a secret" $r -else - printf 'INFO U-16 cross-user read check needs passwordless sudo (runs in CI); permissions checked above\n' -fi +# That another local user cannot read a secret needs sudo, which does not +# work in the isolation: tests/devenv/cross-user.sh owns that check (CU-xx). +printf 'INFO U-16 cross-user read check: tests/devenv/cross-user.sh (outside the isolation); permissions checked above\n' rm -f "$SECRETS_DIR/db_password" r=$(secrets_rc exists) [ "$r" = "$EX_SAFETY" ] && [ ! -e "$SECRETS_DIR/db_password" ] && grep -q "db-data was initialised with it" "$work/secrets.err" && From 7f31abca12d5ba3de1cf7984621a331dca7dbd7c Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 08:06:15 +0100 Subject: [PATCH 2/6] ci: run the cross-user secrets check in the Dev environment job tests/devenv/cross-user.sh runs as its own step after the allocator unit tests, on the fresh checkout of the job, and ShellCheck covers it. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5479da..a53abd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: run: >- docker run --rm --network none -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d - -x bin/devenv tests/devenv/unit.sh tests/devenv/integration.sh tests/devenv/tally.sh + -x bin/devenv tests/devenv/unit.sh tests/devenv/cross-user.sh tests/devenv/integration.sh tests/devenv/tally.sh tests/app/lib.sh tests/app/backend.sh tests/app/frontend.sh tests/app/stack.sh tests/app/prodlike.sh tests/app/scan.sh docker/api/entrypoint.sh docker/api/healthcheck.sh docker/db/flowcrm-db-roles docker/db/initdb-roles.sh @@ -112,6 +112,9 @@ jobs: - name: Allocator unit tests run: tests/devenv/unit.sh + - name: Secrets unreadable by another local user (trusted, outside the isolation) + run: tests/devenv/cross-user.sh + - name: Allocator integration tests (real Docker) run: tests/devenv/integration.sh From b4bcda2db68e6bb071042e138c9371626d2e7922 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 08:06:15 +0100 Subject: [PATCH 3/6] docs(dev-environment): describe the cross-user check of the secrets State that tests/devenv/cross-user.sh, not unit.sh, owns the check that another local user cannot read a generated secret, how it keeps code under test out of its privileged part, its control and when it refuses or skips. --- docs/dev-environment.md | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/dev-environment.md b/docs/dev-environment.md index b209bf5..f5fefa0 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -409,6 +409,7 @@ Its regression suite is `scripts/ci/tests/guard-dockerfile-frontend.test.sh`. ```console $ make test-devenv # allocator: unit + integration (fixture) $ tests/devenv/unit.sh # no Docker needed +$ tests/devenv/cross-user.sh # secrets vs. another local user (passwordless sudo) $ DEVENV_TEST_PORT_BASE=18080 tests/devenv/integration.sh $ make test-app # application: the four suites below ``` @@ -449,9 +450,9 @@ command substitution, `-H`, `--context` or an inherited `DOCKER_HOST`, ports 2375 and 2376 and every listening loopback port. If the isolation cannot be applied or proven, the suite does not run (exit 125). - Setuid programs cannot run inside, so `sudo` is unavailable. The U-16 - check that another local user cannot read a secret therefore prints INFO - and is not counted, in CI as well as locally; the U-16 mode checks still - run. + check that another local user cannot read a secret is therefore not part + of `unit.sh` (which prints INFO and counts only the U-16 mode checks); it + belongs to `tests/devenv/cross-user.sh`, described below. - On Linux the probe finds an engine socket owned by the calling user only in the places listed above. @@ -489,6 +490,35 @@ Mutation testing: - BE-18 mutates a copy of the backend inside the api test container, which has no Docker socket. +Cross-user check of the secrets: + +`tests/devenv/cross-user.sh` (CU-xx, its own CI step in the `Dev +environment` job) checks that the user `nobody` can neither list the +secrets directory nor read a secret that `bin/devenv` generated. It needs +`sudo`, so it runs outside the isolation, and is built so that no code under +test runs there: + +- The checkout's own `bin/devenv`, never a copy named by an input, runs + `ensure_secrets` under `no-docker.sh`, with its runtime directory in a new + directory below `/tmp` and a fake `docker` that reports no volume. +- Outside the isolation the script checks the result only as data (file + type, owner, modes `0700`/`0444`), then runs fixed commands as `nobody` + on fixed paths: `/bin/ls` of the directory and `/bin/cat` of each secret. +- A control makes a pass meaningful: `nobody` must be able to read a + `0444` file that the script puts in a `0755` directory next to the + secrets directory. This proves that the path is reachable (below a `0700` + directory such as the macOS `$TMPDIR` every read would fail) and that the + read check detects an exposed file. +- Nothing outside the isolation changes, follows or executes a path that the + isolated code could have replaced; those paths are only tested, `stat`ed + and read by `nobody`. +- It certifies committed code and is not a mutation harness: it refuses + (exit 64) arguments, `DEVENV`, `GUARD`, `ADAPTED`, a run inside + `no-docker.sh`, a directory that is not a Git checkout, and local + changes to `bin/devenv`, itself, `tally.sh`, `no-docker.sh` or the probe. +- Without passwordless sudo to `nobody` it prints INFO and exits 0; in CI + (`CI=true`) that is a failure. + Application suites (`tests/app/`), each in its own Compose projects: - `scan.sh` (SC-xx): Trivy scans of the runtime images (production-like api @@ -596,6 +626,9 @@ run, so several runs from one checkout do not interfere. never a success), the Makefile interface (targets and the commands they run) and checks that the result tally detects failed and lost results. +- `tests/devenv/cross-user.sh` checks that another local user cannot list + or read the generated secrets (CU-01 to CU-04, 7 results; see "Cross-user check of + the secrets" under "Isolation from Docker"). - `tests/devenv/integration.sh` runs scenarios PA-01 to PA-23 and the Makefile targets against real Docker in temporary checkout copies. It uses the 20-port block starting at `DEVENV_TEST_PORT_BASE` (default 8080) and @@ -612,7 +645,7 @@ run, so several runs from one checkout do not interfere. and verifies that the lock is held until they end. PA-23 checks that an invalid Compose file fails with exit 2, keeps the verified state and tries no other port. -- Both suites compare the printed PASS/FAIL records with their counters and +- The suites compare the printed PASS/FAIL records with their counters and exit 2 on a mismatch; a scenario without results or a failed setup step counts as a failure. From fc0cbd606026f2263680e7e0090d1249302d7b77 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 20:51:09 +0100 Subject: [PATCH 4/6] test(devenv): count a cross-user denial only after a passed control The negative checks CU-03 and CU-04 passed whenever the command as nobody failed, including when the secrets were never generated (CU-01 failed) or when sudo itself failed. They now count only if CU-01 and the control CU-02 passed and the command failed with 'Permission denied'; otherwise the result is a failure. Commands as nobody run through /usr/bin/env -i, so no variable kept by sudo's env_keep reaches them and their messages are in the C locale. A failed control reports its error and the mode of every parent directory, the most likely first-run failure on a CI runner. The skip line without passwordless sudo says that CU-01 to CU-04 were not run, and CU-00 in CI includes the error of sudo. --- docs/dev-environment.md | 13 +++++--- tests/devenv/cross-user.sh | 66 +++++++++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/dev-environment.md b/docs/dev-environment.md index f5fefa0..073b950 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -503,12 +503,17 @@ test runs there: directory below `/tmp` and a fake `docker` that reports no volume. - Outside the isolation the script checks the result only as data (file type, owner, modes `0700`/`0444`), then runs fixed commands as `nobody` - on fixed paths: `/bin/ls` of the directory and `/bin/cat` of each secret. + on fixed paths with an empty environment (`/usr/bin/env -i`): `/bin/ls` + of the directory and `/bin/cat` of each secret. - A control makes a pass meaningful: `nobody` must be able to read a `0444` file that the script puts in a `0755` directory next to the secrets directory. This proves that the path is reachable (below a `0700` directory such as the macOS `$TMPDIR` every read would fail) and that the - read check detects an exposed file. + read check detects an exposed file. If the control fails, its result + shows the error and the mode of every parent directory. +- A denial counts only if the secrets were generated (CU-01), the control + passed (CU-02) and the command failed with `Permission denied`; any other + failure, such as `sudo` itself failing, makes the result a failure. - Nothing outside the isolation changes, follows or executes a path that the isolated code could have replaced; those paths are only tested, `stat`ed and read by `nobody`. @@ -516,8 +521,8 @@ test runs there: (exit 64) arguments, `DEVENV`, `GUARD`, `ADAPTED`, a run inside `no-docker.sh`, a directory that is not a Git checkout, and local changes to `bin/devenv`, itself, `tally.sh`, `no-docker.sh` or the probe. -- Without passwordless sudo to `nobody` it prints INFO and exits 0; in CI - (`CI=true`) that is a failure. +- Without passwordless sudo to `nobody` it prints INFO (CU-01 to CU-04 not + run, no PASS) and exits 0; in CI (`CI=true`) that is a failure (CU-00). Application suites (`tests/app/`), each in its own Compose projects: diff --git a/tests/devenv/cross-user.sh b/tests/devenv/cross-user.sh index 2bb6d3c..45d8c93 100755 --- a/tests/devenv/cross-user.sh +++ b/tests/devenv/cross-user.sh @@ -14,14 +14,17 @@ # docker that reports no volume. # 2. Outside the isolation, this script checks the result as data (file # type, owner, modes; never the contents) and then runs only fixed -# commands as nobody on fixed paths: /bin/cat FILE and /bin/ls DIR. +# commands as nobody on fixed paths, with an empty environment: +# /usr/bin/env -i /bin/cat FILE and /usr/bin/env -i /bin/ls DIR. A +# denial counts only as "Permission denied" after a passed control. # A control file that this script makes in a 0755 directory next to the # secrets directory, and that nobody must be able to read, proves that the # path is reachable for nobody and that the read check detects an exposed # file. # -# The check needs passwordless sudo. Without it the suite prints INFO and -# exits 0, except in CI (CI=true), where that is a failure. +# The check needs passwordless sudo. Without it the suite prints INFO (CU-01 +# to CU-04 NOT RUN, no PASS) and exits 0, except in CI (CI=true), where that +# is a failure (CU-00). # # It certifies the committed code, so it refuses (exit 64) when DEVENV, # GUARD, ADAPTED or NO_DOCKER_ACTIVE is set, outside a Git checkout, and @@ -63,13 +66,20 @@ git -C "$root" -c core.fsmonitor=false diff --no-ext-diff --no-textconv --quiet # shellcheck source=tally.sh . "$here/tally.sh" +# Printable text of at most 2000 bytes of standard input, on one line: error +# output goes into the log this way (never a secret, which is never read). +clean() { head -c 2000 | tr '\n' ' ' | tr -cd '[:print:]'; } + cd / || exit 64 -if ! sudo -n -u nobody -- /usr/bin/true 2>/dev/null; then +# Every command as nobody is a fixed absolute binary on fixed arguments, with +# an empty environment (env -i: nothing of the caller passes, and messages +# are in the C locale). +if ! probe_err=$(sudo -n -u nobody -- /usr/bin/env -i /usr/bin/true 2>&1); then if [ "${CI-}" = true ]; then - printf 'FAIL CU-00 passwordless sudo to the user nobody is required in CI\n' + printf 'FAIL CU-00 passwordless sudo to the user nobody is required in CI (%s)\n' "$(printf '%s' "$probe_err" | clean)" exit 1 fi - printf 'INFO CU cross-user read check needs passwordless sudo to the user nobody (runs in CI); skipped\n' + printf 'INFO CU-01 to CU-04 NOT RUN: the cross-user read check needs passwordless sudo to the user nobody (runs in CI)\n' exit 0 fi @@ -122,18 +132,44 @@ for name in $names; do { [ ! -L "$f" ] && [ -f "$f" ] && [ -s "$f" ] && [ -O "$f" ] && [ "$(mode_of "$f")" = 444 ]; } || ok=1 done result "CU-01 the checkout's bin/devenv created the secrets in the isolation (directory 0700, regular files 0444)" $ok \ - "(rc $rc: $(head -c 2000 "$base/generate.err" | tr '\n' ' ' | tr -cd '[:print:]'))" - -as_nobody() { sudo -n -u nobody -- "$@" >/dev/null 2>&1; } + "(rc $rc: $(clean <"$base/generate.err"))" +generated=$ok + +# Stdout is discarded (it would be a secret), stderr kept for the result. +as_nobody() { sudo -n -u nobody -- /usr/bin/env -i "$@" >/dev/null 2>"$base/nobody.err"; } +modes_up() { # path: the mode of it and of every parent, for a failed control + local p=$1 out= + while :; do + out="$out $p=$(mode_of "$p")" + [ "$p" != / ] || break + p=$(dirname "$p") + done + printf '%s' "${out# }" +} -as_nobody /bin/cat "$rt/control/file" -result "CU-02 control: nobody can read a 0444 file in a 0755 directory next to the secrets directory (the path is reachable and the read check detects an exposed file)" $? +reachable=1 +if as_nobody /bin/cat "$rt/control/file"; then reachable=0; fi +result "CU-02 control: nobody can read a 0444 file in a 0755 directory next to the secrets directory (the path is reachable and the read check detects an exposed file)" $reachable \ + "(as nobody: $(clean <"$base/nobody.err"); modes: $(modes_up "$rt/control/file"))" + +# A denial counts only if the secrets were generated (CU-01), the control +# was read (CU-02) and the command failed with "Permission denied": any +# other failure, such as sudo itself failing, is not a denial. +denied() { # name command path + if [ "$generated" != 0 ] || [ "$reachable" != 0 ]; then + fail_result "$1 (not checked: CU-01 or CU-02 failed)" + elif as_nobody "$2" "$3"; then + fail_result "$1 (nobody succeeded)" + elif grep -q 'Permission denied' "$base/nobody.err"; then + pass_result "$1" + else + fail_result "$1 (failed, but not with Permission denied: $(clean <"$base/nobody.err"))" + fi +} -if as_nobody /bin/ls "$secrets_dir"; then r=1; else r=0; fi -result "CU-03 nobody cannot list the secrets directory" $r +denied "CU-03 nobody cannot list the secrets directory" /bin/ls "$secrets_dir" for name in $names; do - if as_nobody /bin/cat "$secrets_dir/$name"; then r=1; else r=0; fi - result "CU-04 nobody cannot read the secret $name" $r + denied "CU-04 nobody cannot read the secret $name" /bin/cat "$secrets_dir/$name" done # Nothing here changes, follows or executes a path that the isolated code # could have replaced (such as a symbolic link to a file of the caller): From 423a46686b2bd50583cffdea8e9cd84a1be22d8e Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 21:59:29 +0100 Subject: [PATCH 5/6] fix(test): keep the trusted cross-user step out of the isolated directory The isolated step runs as the caller and is given the temporary directory, so it can replace anything in it, for example with a symbolic link to a file under $HOME that the macOS sandbox would not let it write. Outside the isolation, the script then appended every result to the tally file in that directory (tee -a), wrote the stderr of the commands run as nobody to a file there, and read the error log of the generation from it. The error output of the generation and of the commands run as nobody is now kept in variables (the generation's last 4000 bytes), and the tally is in a new directory made after the isolated step returns, outside the temporary directory. Nothing outside the isolation writes, creates or reads a file there any more; those paths are only tested and stat'ed, and read by nobody. The control CU-02 now also lists its directory as nobody, so CU-03 cannot pass because /bin/ls cannot run as nobody. The documentation says that CU-04 is denied by the 0700 directory (the files are 0444 by design), and that processes left behind by the isolated step are not ended and could still change a result, race the tally directory or keep the step waiting. --- docs/dev-environment.md | 20 ++++++++++++--- tests/devenv/cross-user.sh | 51 ++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/docs/dev-environment.md b/docs/dev-environment.md index acab92f..469cd92 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -523,16 +523,28 @@ test runs there: of the directory and `/bin/cat` of each secret. - A control makes a pass meaningful: `nobody` must be able to read a `0444` file that the script puts in a `0755` directory next to the - secrets directory. This proves that the path is reachable (below a `0700` + secrets directory, and to list that directory. This proves that the path is reachable (below a `0700` directory such as the macOS `$TMPDIR` every read would fail) and that the read check detects an exposed file. If the control fails, its result shows the error and the mode of every parent directory. - A denial counts only if the secrets were generated (CU-01), the control passed (CU-02) and the command failed with `Permission denied`; any other failure, such as `sudo` itself failing, makes the result a failure. -- Nothing outside the isolation changes, follows or executes a path that the - isolated code could have replaced; those paths are only tested, `stat`ed - and read by `nobody`. +- CU-04 is denied by the `0700` secrets directory, not by the file mode: + the secret files are `0444` by design, so that the containers' + unprivileged users can read the bind-mounted copy. CU-01 checks both + modes as data. +- The isolated code runs as the caller and knows the temporary directory, + so it could replace anything in it, for example with a symbolic link to a + file under `$HOME`. After it returns, nothing outside the isolation + writes, creates, executes or reads a file there: those paths are only + tested and `stat`ed, and read by `nobody`. Error output is kept in + variables, and the result tally is in a new directory made after the + isolated step, outside the temporary directory. +- Neither isolation ends processes that the isolated step leaves behind. + Such a process stays isolated, but it could still change a result, race + the new tally directory below `$TMPDIR`, or keep the step waiting on its + output until the CI job times out. - It certifies committed code and is not a mutation harness: it refuses (exit 64) arguments, `DEVENV`, `GUARD`, `ADAPTED`, a run inside `no-docker.sh`, a directory that is not a Git checkout, and local diff --git a/tests/devenv/cross-user.sh b/tests/devenv/cross-user.sh index 45d8c93..c3424b6 100755 --- a/tests/devenv/cross-user.sh +++ b/tests/devenv/cross-user.sh @@ -17,10 +17,12 @@ # commands as nobody on fixed paths, with an empty environment: # /usr/bin/env -i /bin/cat FILE and /usr/bin/env -i /bin/ls DIR. A # denial counts only as "Permission denied" after a passed control. +# It never writes, creates or reads a file below the directory that +# the isolated code was given (which that code could have replaced). # A control file that this script makes in a 0755 directory next to the -# secrets directory, and that nobody must be able to read, proves that the -# path is reachable for nobody and that the read check detects an exposed -# file. +# secrets directory, and that nobody must be able to read (and the +# directory list), proves that the path is reachable for nobody and that +# both checks detect an exposed file. # # The check needs passwordless sudo. Without it the suite prints INFO (CU-01 # to CU-04 NOT RUN, no PASS) and exits 0, except in CI (CI=true), where that @@ -89,8 +91,8 @@ fi # reason; the control file below proves it. base=$(mktemp -d /tmp/flowcrm-cross-user.XXXXXX) || exit 2 base=$(cd -P "$base" && pwd -P) || exit 2 -trap 'rm -rf "$base"' EXIT -tally_init "$base/tally" +tally_dir= +trap 'rm -rf "$base" ${tally_dir:+"$tally_dir"}' EXIT result() { # name ok(0|1) [detail] if [ "$2" -eq 0 ]; then pass_result "$1"; else fail_result "$1${3:+ $3}"; fi @@ -107,8 +109,14 @@ printf '#!/bin/sh\nexit 1\n' >"$base/bin/docker" && chmod 0755 "$base/bin/docker chmod 0444 "$rt/control/file"; } || exit 2 # --- part 1, isolated: the checkout's bin/devenv generates the secrets +# The isolated code knows $base and runs as the caller, so it can replace +# anything below it, for example with a symbolic link to a file under $HOME +# (which the macOS sandbox would not let it write). The trusted part +# therefore never writes, creates or reads a file below $base after this +# step: error output is kept in variables (the last 4000 bytes) and the +# tally is in a directory made after this step, outside $base. # shellcheck disable=SC2016 # expanded by the isolated shell -"$root/scripts/ci/no-docker.sh" "$BASH" -c ' +gen_err=$({ "$root/scripts/ci/no-docker.sh" "$BASH" -c ' set -u . "$1" || exit 2 FLOWCRM_STACK=app @@ -117,8 +125,11 @@ printf '#!/bin/sh\nexit 1\n' >"$base/bin/docker" && chmod 0755 "$base/bin/docker GATE_DIR=$2/gate GATE_FILE=$2/gate/security-gate PATH=$3:$PATH ensure_secrets -' cross-user "$root/bin/devenv" "$rt" "$base/bin" 2>"$base/generate.err" +' cross-user "$root/bin/devenv" "$rt" "$base/bin" 2>&1 >/dev/null | tail -c 4000 + exit "${PIPESTATUS[0]}"; }) rc=$? +tally_dir=$(mktemp -d "${TMPDIR:-/tmp}/flowcrm-cross-user-tally.XXXXXX") || exit 2 +tally_init "$tally_dir/tally" # --- part 2, outside the isolation: checks as data, then fixed reads as nobody mode_of() { # GNU stat first: its -f means file system status @@ -132,11 +143,11 @@ for name in $names; do { [ ! -L "$f" ] && [ -f "$f" ] && [ -s "$f" ] && [ -O "$f" ] && [ "$(mode_of "$f")" = 444 ]; } || ok=1 done result "CU-01 the checkout's bin/devenv created the secrets in the isolation (directory 0700, regular files 0444)" $ok \ - "(rc $rc: $(clean <"$base/generate.err"))" + "(rc $rc: $(printf '%s' "$gen_err" | tail -c 2000 | clean))" generated=$ok -# Stdout is discarded (it would be a secret), stderr kept for the result. -as_nobody() { sudo -n -u nobody -- /usr/bin/env -i "$@" >/dev/null 2>"$base/nobody.err"; } +# Stdout is discarded (it would be a secret); stderr is kept in nobody_err. +as_nobody() { nobody_err=$(sudo -n -u nobody -- /usr/bin/env -i "$@" 2>&1 >/dev/null); } modes_up() { # path: the mode of it and of every parent, for a failed control local p=$1 out= while :; do @@ -147,10 +158,12 @@ modes_up() { # path: the mode of it and of every parent, for a failed control printf '%s' "${out# }" } +# Both commands of the negative checks must work on the control, so that +# neither can pass because the command itself cannot run as nobody. reachable=1 -if as_nobody /bin/cat "$rt/control/file"; then reachable=0; fi -result "CU-02 control: nobody can read a 0444 file in a 0755 directory next to the secrets directory (the path is reachable and the read check detects an exposed file)" $reachable \ - "(as nobody: $(clean <"$base/nobody.err"); modes: $(modes_up "$rt/control/file"))" +if as_nobody /bin/cat "$rt/control/file" && as_nobody /bin/ls "$rt/control"; then reachable=0; fi +result "CU-02 control: nobody can read a 0444 file in, and list, a 0755 directory next to the secrets directory (the path is reachable and the checks detect an exposed file)" $reachable \ + "(as nobody: $(printf '%s' "$nobody_err" | clean); modes: $(modes_up "$rt/control/file"))" # A denial counts only if the secrets were generated (CU-01), the control # was read (CU-02) and the command failed with "Permission denied": any @@ -160,10 +173,10 @@ denied() { # name command path fail_result "$1 (not checked: CU-01 or CU-02 failed)" elif as_nobody "$2" "$3"; then fail_result "$1 (nobody succeeded)" - elif grep -q 'Permission denied' "$base/nobody.err"; then + elif case $nobody_err in *'Permission denied'*) true ;; *) false ;; esac; then pass_result "$1" else - fail_result "$1 (failed, but not with Permission denied: $(clean <"$base/nobody.err"))" + fail_result "$1 (failed, but not with Permission denied: $(printf '%s' "$nobody_err" | clean))" fi } @@ -171,8 +184,10 @@ denied "CU-03 nobody cannot list the secrets directory" /bin/ls "$secrets_dir" for name in $names; do denied "CU-04 nobody cannot read the secret $name" /bin/cat "$secrets_dir/$name" done -# Nothing here changes, follows or executes a path that the isolated code -# could have replaced (such as a symbolic link to a file of the caller): -# the paths are only tested, stat'ed and read by nobody. +# Nothing here writes, creates, follows into a write or executes a path that +# the isolated code could have replaced (such as a symbolic link to a file +# of the caller): those paths are only tested and stat'ed, and read by +# nobody. CU-04 is denied by the 0700 directory: the secret files are 0444 +# by design (the containers' unprivileged users read the bind mount). tally_finish "" From ca289b31c9b38f3b4b3104886defcb633803279f Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Thu, 29 Oct 2026 22:16:26 +0100 Subject: [PATCH 6/6] fix(test): generate the cross-user secrets below /var/tmp The macOS isolation now denies writes to /tmp, except the here-document files of bash 3.2, and refuses a TMPDIR below it. The isolated step of cross-user.sh generated the secrets below /tmp/flowcrm-cross-user.*, so on macOS it would no longer be able to create them. The macOS $TMPDIR is not an alternative: it is 0700, so nobody could not reach anything below it, and the control CU-02 would fail. The throwaway directory is now made below /var/tmp, which is 1777 on Linux and macOS, and given to the isolated step as its TMPDIR: on macOS the sandbox lets it write only there and in /dev. The control still shares every parent directory with the secrets. The result tally stays in the caller's own TMPDIR, which the macOS sandbox now cannot write, so a process left behind by the isolated step can no longer race it there. --- docs/dev-environment.md | 18 +++++++++++++----- tests/devenv/cross-user.sh | 20 ++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/dev-environment.md b/docs/dev-environment.md index 135f3d7..352b57f 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -545,7 +545,12 @@ test runs there: - The checkout's own `bin/devenv`, never a copy named by an input, runs `ensure_secrets` under `no-docker.sh`, with its runtime directory in a new - directory below `/tmp` and a fake `docker` that reports no volume. + directory below `/var/tmp` and a fake `docker` that reports no volume. + That directory is the isolated step's `TMPDIR`: on macOS the sandbox lets + it write only there and in `/dev`. `/var/tmp` is used because it is + `1777` on Linux and macOS; the macOS `$TMPDIR` is `0700`, so `nobody` + could not reach anything below it, and the macOS isolation denies writes + to `/tmp` and refuses a `TMPDIR` below it. - Outside the isolation the script checks the result only as data (file type, owner, modes `0700`/`0444`), then runs fixed commands as `nobody` on fixed paths with an empty environment (`/usr/bin/env -i`): `/bin/ls` @@ -569,11 +574,14 @@ test runs there: writes, creates, executes or reads a file there: those paths are only tested and `stat`ed, and read by `nobody`. Error output is kept in variables, and the result tally is in a new directory made after the - isolated step, outside the temporary directory. + isolated step in the caller's own `$TMPDIR`, outside the temporary + directory. - Neither isolation ends processes that the isolated step leaves behind. - Such a process stays isolated, but it could still change a result, race - the new tally directory below `$TMPDIR`, or keep the step waiting on its - output until the CI job times out. + Such a process stays isolated, but it could still change a result or keep + the step waiting on its output until the CI job times out. On macOS it + cannot write the tally, which is outside its sandbox; on Linux the + isolation does not restrict file writes, but such a process has no more + file access than the caller. - It certifies committed code and is not a mutation harness: it refuses (exit 64) arguments, `DEVENV`, `GUARD`, `ADAPTED`, a run inside `no-docker.sh`, a directory that is not a Git checkout, and local diff --git a/tests/devenv/cross-user.sh b/tests/devenv/cross-user.sh index c3424b6..7563d7c 100755 --- a/tests/devenv/cross-user.sh +++ b/tests/devenv/cross-user.sh @@ -10,7 +10,7 @@ # caller's privileges outside the isolation: # 1. The checkout's own bin/devenv (never a copy named by DEVENV or any # other input) runs ensure_secrets under scripts/ci/no-docker.sh, with -# its runtime directory in a fresh directory below /tmp and a fake +# its runtime directory in a fresh directory below /var/tmp and a fake # docker that reports no volume. # 2. Outside the isolation, this script checks the result as data (file # type, owner, modes; never the contents) and then runs only fixed @@ -85,11 +85,14 @@ if ! probe_err=$(sudo -n -u nobody -- /usr/bin/env -i /usr/bin/true 2>&1); then exit 0 fi -# Below /tmp, not $TMPDIR: on macOS $TMPDIR is a 0700 per-user directory, so -# nobody could not reach anything below it and the check would pass for the -# wrong reason. The parents of the runtime directory are 0755 for the same -# reason; the control file below proves it. -base=$(mktemp -d /tmp/flowcrm-cross-user.XXXXXX) || exit 2 +# Below /var/tmp (1777 on Linux and macOS), not $TMPDIR: on macOS $TMPDIR is +# a 0700 per-user directory, so nobody could not reach anything below it and +# the check would pass for the wrong reason. Not /tmp either: the macOS +# isolation denies writes there and refuses a TMPDIR below it. The parents +# of the runtime directory are 0755 for the same reason; the control file +# below proves it. The isolated step gets this directory as its TMPDIR, the +# only place (with /dev) where the macOS sandbox lets it write. +base=$(mktemp -d /var/tmp/flowcrm-cross-user.XXXXXX) || exit 2 base=$(cd -P "$base" && pwd -P) || exit 2 tally_dir= trap 'rm -rf "$base" ${tally_dir:+"$tally_dir"}' EXIT @@ -114,9 +117,10 @@ printf '#!/bin/sh\nexit 1\n' >"$base/bin/docker" && chmod 0755 "$base/bin/docker # (which the macOS sandbox would not let it write). The trusted part # therefore never writes, creates or reads a file below $base after this # step: error output is kept in variables (the last 4000 bytes) and the -# tally is in a directory made after this step, outside $base. +# tally is in a directory made after this step, outside $base, in the +# caller's own TMPDIR (which the macOS sandbox does not let it write). # shellcheck disable=SC2016 # expanded by the isolated shell -gen_err=$({ "$root/scripts/ci/no-docker.sh" "$BASH" -c ' +gen_err=$({ TMPDIR=$base "$root/scripts/ci/no-docker.sh" "$BASH" -c ' set -u . "$1" || exit 2 FLOWCRM_STACK=app