Skip to content

test(devenv): check secrets against another local user outside the isolation - #45

Draft
knittingcode wants to merge 9 commits into
test/fcrm-qa009-mutation-containmentfrom
test/fcrm-u16-trusted-cross-user
Draft

knittingcode wants to merge 9 commits into
test/fcrm-qa009-mutation-containmentfrom
test/fcrm-u16-trusted-cross-user

Conversation

@knittingcode

Copy link
Copy Markdown
Owner

Summary

Restores the U-16 check that another local user cannot read a generated secret. It now lives in a separate script, tests/devenv/cross-user.sh, which runs as its own step in the Dev environment job. #43's Docker isolation of tests/devenv/unit.sh is unchanged.

Stacked on #43 (base test/fcrm-qa009-mutation-containment).

Motivation

#43 runs unit.sh under scripts/ci/no-docker.sh, where setuid programs such as sudo do not work. Because of that, the U-16 check sudo -n -u nobody cat <secret> printed INFO in CI as well, and the unit count fell from 288 to 287. Adding sudo back to the isolated suite would give mutable code a privileged step. So the check is split off:

  • (a) code under test runs in the Docker isolation;
  • (b) a trusted step outside it runs no code under test.

The earlier check also ran below a 0700 mktemp -d directory. nobody could not reach any file below it, so the check would have passed even with an exposed secrets directory.

Implementation

  • tests/devenv/cross-user.sh (new):
    1. It refuses with exit 64 when:
      • it gets arguments;
      • DEVENV, GUARD, ADAPTED or NO_DOCKER_ACTIVE is set;
      • it does not run in a Git checkout;
      • bin/devenv, the script itself, tally.sh, no-docker.sh or no-docker-probe.py differs from HEAD.
    2. Without passwordless sudo -u nobody it prints INFO and exits 0. With CI=true it fails instead.
    3. Isolated part: the checkout's own bin/devenv (a fixed path, not taken from any input) runs ensure_secrets under no-docker.sh. Its runtime directory is a new 0755 directory below /tmp (not $TMPDIR, which is 0700 on macOS), and a fake docker reports no volume.
    4. Trusted part, outside the isolation:
      • It checks the result only as data: no symbolic links, regular files, owned by the caller, directory 0700, files 0444.
      • Then it runs /bin/ls on the secrets directory and /bin/cat on each of the four fixed secret names, as nobody.
      • Control: nobody must be able to read a 0444 file that the script creates in a 0755 directory next to the secrets directory.
      • It never changes, follows or executes a path that the isolated code could have replaced.
    5. It reports 7 results (CU-01 to CU-04) through tally.sh.
  • tests/devenv/unit.sh: the sudo branch is removed. U-16 prints an INFO line that names cross-user.sh as the owner of the check. The unit count stays at 287, now by design.
  • CI: a new Dev environment step, "Secrets unreadable by another local user (trusted, outside the isolation)", runs after the allocator unit tests. ShellCheck in that job now covers the script.
  • Docs: in docs/dev-environment.md, "Isolation from Docker" now has a "Cross-user check of the secrets" part, the test list includes the script, and the console example shows it.

Testing performed

Local, macOS 26, in a separate worktree:

  • tests/devenv/unit.sh: TOTAL 287 passed=287 failed=0, exit 0, inside the isolation. It printed INFO U-16 cross-user read check: tests/devenv/cross-user.sh (outside the isolation); permissions checked above.
  • Syntax: bash -n (bash 5) and /bin/bash -n (3.2.57) pass on cross-user.sh.
  • ShellCheck: 0.11.0 -x reports nothing on tests/devenv/unit.sh and tests/devenv/cross-user.sh.
  • Workflow file: .github/workflows/ci.yml parses with PyYAML and the new step is in the Dev environment job. actionlint is not installed locally, so it was not run.

Not run: tests/devenv/cross-user.sh itself, so none of its paths has been executed, including the refusals, the skip, the control and the CU checks; the guard suites; no-docker.test.sh.

CI runs only for pull requests that target main, so no CI ran for this PR. It will run once this PR is retargeted to main after #43 merges, and that run is the first execution of cross-user.sh.

Security considerations

  • Outside the isolation the script runs only fixed commands. With the caller's privileges it runs git, mktemp, stat and [ ] tests. As nobody it runs /bin/ls and /bin/cat. Every path is built from a mktemp directory and constant names.
  • sudo is used only to lower privileges to nobody. A symbolic link planted by the isolated code is followed only by nobody, so at worst it gives a false result. It never gives access beyond what nobody already has.
  • Code that runs outside the isolation never reads the contents of a secret. Error output from the isolated step is cut to 2000 bytes and reduced to printable characters before it is printed.
  • The refusals keep the script from being used as a mutation harness. They protect against accidents only: a deliberate edit can remove them, and a committed mutant passes the HEAD comparison.
  • git diff runs with core.fsmonitor=false, --no-ext-diff and --no-textconv.

Known limitations

  • On Linux the isolated part needs passwordless sudo for no-docker.sh, as unit.sh already does. Locally on macOS the trusted part needs passwordless sudo to nobody, which is usually not set up, so the script skips there.
  • An edited checkout is refused. To check local changes, commit them first.
  • The script checks the four secret names it lists itself. A new secret in bin/devenv is not covered until it is added to that list.
  • tests/app/stack.sh ST-34 has the same sudo -n -u nobody cat check, and its checkout copy appears to sit below a 0700 mktemp -d work directory. If so, that check passes regardless of the secrets directory mode. It is not changed here.

…e 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 runs as its own step after the allocator unit
tests, on the fresh checkout of the job, and ShellCheck covers it.
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.
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.
…tory

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant