Skip to content

Make the pruner safe to run against code it did not write - #22

Merged
MarkusPaulsen merged 3 commits into
mainfrom
ci/prune-safe-for-untrusted-code
Sep 11, 2026
Merged

MarkusPaulsen merged 3 commits into
mainfrom
ci/prune-safe-for-untrusted-code

Conversation

@MarkusPaulsen

@MarkusPaulsen MarkusPaulsen commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The prune phase decides which paths a language environment needs, and what it decides becomes an allow-list. Running it against a pull request means running whatever that pull request brought with it, and it was not built for that. This closes the ways it could reach outside what it was asked to inspect, makes the generated policy match the sandbox the measurement was made in, and adds a test that runs the real pruner against a fixture tree.

Linked issues

Was stacked on #19. That branch has landed, so GitHub retargeted this pull request onto main, and the four temporary base-branch filters it carried are removed: each said to remove it once that branch had landed, and all four workflow files are byte identical to main again.

1. Problem

The pruner could reach outside what it was told to inspect. --target defaulted to the whole filesystem, and the descent into it was a prefix match: a target of /srv/work also caught /srv/work-secrets, a sibling nobody named, which entered writable and was never revisited, all the way into the generated policy. Two further comparisons had the same shape, so /proc-secret counted as the /proc pseudo-filesystem.

The invocation was rendered into one string and handed to bash -c, so a directory whose name held a quote or a semicolon was a command waiting for its turn. The environment was inherited whole, and the host /tmp was mounted over the sandbox's own tmpfs.

Separately, what a prune generates was not the sandbox it measured in: both tail sanitisers kept three of the options the pruner passes, so the policy ran without /proc, without /dev and without a PID namespace of its own.

2. Improvement from the user's perspective

A generated policy now describes the sandbox the pruning run was actually measured in. A submission running under one keeps the process isolation and the /proc and /dev that the measurement assumed, rather than a weaker sandbox that happens to share its name.

3. Improvement from the maintainer's perspective

The prune phase can be tested at all. tests/prune_sandbox.sh runs the real pruner against a fixture tree in seconds, needs no exercise repository, and keeps its scratch and its logs inside its own fixture. A bwrap on PATH records the argument vector, so a check reads what reached the kernel rather than what a diagnostic said about it afterwards.

That harness immediately earned itself: it found that log() in the pruner had the same defect the producer had, ending any non-verbose run at its first line. Every check anyone had run by hand used --verbose, so nothing had noticed.

4. Testing manual

Prerequisites

  1. A checkout of this branch, bubblewrap, python3 and a shell. No exercise repository and no container image.
  2. An unprivileged user namespace. Ubuntu restricts these through AppArmor, so on a host that refuses one the sandbox checks skip; PHOBOS_REQUIRE_BWRAP=1 turns that skip into a failure. In a container, run as a non-root user: bwrap falls back on privileges that root inside a container does not have. On Docker Desktop add --security-opt seccomp=unconfined --security-opt systempaths=unconfined as well, or the masked /proc refuses --proc /proc. Neither flag is --privileged, and nothing else here needs either.

Steps

  1. Run PHOBOS_REQUIRE_BWRAP=1 bash tests/prune_sandbox.sh.
  2. Run bash tests/prune_producer.sh and python3 -m pytest tests/python -q.
  3. Confirm each fix is load-bearing by removing it and re-running step 1. Remove --clearenv from BASE_OPTIONS; put --bind /tmp /tmp back after --tmpfs /tmp; restore TARGET="/" together with the three lines that validate it, the -n guard, the realpath -e and the -d check, since the default alone only reaches one of the two checks; change the glob in init_config back to "$TARGET"*; and replace the BWRAP_COMMAND array with the old string handed to bash -c.
  4. Confirm the tail fix likewise, by reverting both sanitisers entirely, each one's allow-list and its _TAIL_MOUNTS handling together, and re-running step 2. Reverting only the two allow-lists fails three of the five, because the mount handling is a second mechanism rather than another entry in the list.

Expected result

  1. 10 passed, 0 failed, 0 skipped. A skip here means the host refused a user namespace, and with the variable set that is a failure instead.
  2. 7 passed and 18 passed.
  3. Each removal fails exactly the check named for it: the environment one, the host /tmp one, the two --target ones, a sibling sharing the target's name is never reached, and, for the string invocation, the run stops completing at all, which is itself the point.
  4. Two orchestrator tests and three tail tests fail.

Negative case (what must still be rejected)

This narrows the sandbox rather than widening it, and each narrowing has a check that fails when it is undone, listed above. The two that matter most for a pull request:

  1. A sibling directory sharing the target's name must appear in neither the recorded argument vector nor the artefact. The fixture has target/ and target-secret/ for exactly this.
  2. A directory whose name is full of shell syntax must arrive at the kernel as one argument. The check reads the recorded vector rather than a log line.

There is deliberately no check for a file the injected payload would have created: the old code would have evaluated it inside the per-exercise workroot, which the producer removes as soon as that exercise is done, so such a marker is gone before anything could look for it.

Layers exercised

  • Filesystem layer
  • Network layer
  • Timeout layer
  • All three together, as a run uses them by default

The prune phase produces the allow-list the filesystem layer reads. The network and timeout layers are not involved, and the suites covering them are run above only to show they still pass.

5. Test case coverage regarding this PR

Suite Passed Failed Skipped What it covers regarding this PR
tests/prune_sandbox.sh 10 0 0 New. The real pruner: a refused target, a fixture pruned in both directions, a sibling never reached, a hostile name as one argument, the host /tmp invisible, the environment not inherited
tests/python/test_orchestrate.py 10 0 0 The orchestrator, including two new checks that the runtime tail keeps its mounts and namespaces and that a repeated mount does not lose its operand
tests/python/test_emit_artifacts.py 8 0 0 The tail merge, including three new checks for the mounts, the PID namespace and the full option set
tests/prune_producer.sh 7 0 0 Unchanged. The producer around the pruner
tests/timeout_units.sh 58 0 0 Unchanged, run to show nothing here disturbed it
tests/network_cache_ports.sh 20 0 0 Unchanged, for the same reason

Run on ubuntu:24.04, kernel 7.0.12-linuxkit, aarch64. The sandbox suite needs a user namespace, which that container was given with --security-opt seccomp=unconfined --security-opt systempaths=unconfined and a non-root user, never --privileged; every other suite runs in a plain docker run --rm.

Breaking changes and migration

--target is now required. run_minimal_fs_all.sh passes --target "${PRUNE_TARGET:-/}", so the production path is unchanged, but a caller invoking detect_minimal_fs.sh directly must name the tree to prune.

The sandbox no longer inherits the environment. PRUNE_ENV_PASSTHROUGH names what survives, defaulting to PATH HOME LANG LC_ALL TERM, and --env adds to it. A build script that relied on something else being set must have it named. The host /tmp is no longer visible inside the sandbox, and a generated tail now carries --proc, --dev, --unshare-pid and --new-session, so a policy regenerated after this change is narrower than one generated before it.

PRUNE_LOG_DIR is new and defaults to /tmp, so nothing moves unless it is set.

detect_minimal_fs.sh keeps the --lang value in PRUNE_LANG rather than in LANG. LANG is the locale variable, and the passthrough above was carrying the language name into the sandbox as one. Nothing read the old name.

Checklist

  • The title of this pull request describes the change, not the implementation.
  • I have self-reviewed the diff of this pull request.
  • Tests were added or updated for the behaviour changed here, in both directions: the forbidden case stays denied and the permitted case still works.
  • Any weakening of the sandbox boundary is stated explicitly above, including what it now permits that it did not permit before.
  • The change was exercised in a container started without --privileged, --cap-add or --security-opt, or the manual says why that was not possible.
  • CI is green, or every remaining failure is explained above.
  • No secrets, tokens or absolute local paths are contained in the diff.

Review progress

  • Code review
  • Manual test

The prune phase decides which paths a language environment needs, and
what it decides becomes an allow-list. Running it against a pull request
means running whatever that pull request brought with it, and it was not
built for that.

The invocation was rendered into one string and handed to bash -c, so
every path went through a second round of shell parsing on the way in: a
directory whose name held a quote, a dollar or a semicolon was a command
waiting for its turn. It is an argument vector now, and the build script
is passed as an argument rather than as text for a shell to interpret.

--target had no business defaulting to the whole filesystem, and the
descent into it was a prefix match rather than a descent: a target of
/srv/work also caught /srv/work-secrets, a sibling nobody named, entered
it writable and never revisited it, all the way into the policy. Two more
comparisons had the same shape, so a child called /proc-secret counted as
the /proc pseudo-filesystem and /target/app-extra counted as living under
/target/app.

The environment is cleared and rebuilt rather than inherited, so a token
or a path to a runner control file is not visible to code the sandbox is
meant to contain. The host /tmp is no longer mounted over the sandbox's
own tmpfs, which had made that tmpfs pointless. --new-session detaches
the sandbox from the terminal that started it, which without a seccomp
filter is what stops TIOCSTI pushing characters back out.

What is generated has to be the sandbox the measurement was made in. Both
tail sanitisers kept a subset, so a policy built from a prune ran without
/proc, without /dev, without a PID namespace of its own and without a new
session. They now keep validated pairs, deduplicate whole options rather
than tokens, and hold the same allow-list, which they had already stopped
doing.

detect_minimal_fs.sh had the same log() defect the producer had, ending
the run at its first line whenever it was not verbose. Every check run by
hand had used --verbose, so nothing had noticed.

tests/prune_sandbox.sh runs the pruner for real against a fixture tree,
with a bwrap on PATH that records the argument vector so a check can read
what reached the kernel rather than what a diagnostic said afterwards. It
keeps its scratch and its logs inside its own fixture, which is why the
pruner takes a log directory and the producer honours TMPDIR.

No CI job yet. It could only skip where a user namespace is refused, and
a green job meaning either everything passed or nothing ran is not a
check. It goes in when the capability probe has answered.
@MarkusPaulsen
MarkusPaulsen requested review from a team and krusche as code owners September 10, 2026 07:27
--lang assigned the programming language to LANG, which is the locale
variable every C library reads. That was harmless while the sandbox
inherited the environment wholesale and nothing looked at it, but this
branch added PRUNE_ENV_PASSTHROUGH, whose default list names LANG as one
of the few variables worth carrying across --clearenv. The sandbox
therefore received --setenv LANG java: not the caller's locale, and not a
locale at all.

The pruner's own value is now PRUNE_LANG, so the passthrough carries
whatever locale the caller actually has. Nothing else read the old name.
Base automatically changed from fix/prune-and-helper-defects to main September 11, 2026 15:08
@MarkusPaulsen
MarkusPaulsen requested a review from a team September 11, 2026 15:08
#19 merged, so GitHub retargeted this pull request from
fix/prune-and-helper-defects to main, and main has since taken #21 and #23
as well.

.gitignore was the only textual conflict. #23 rewrote the file with
comments and section headings, and the two lines this branch added,
__pycache__/ and *.pyc, are already covered there by __pycache__/ and
*.py[cod]. Main's version is taken whole; the intent is checked rather
than assumed, with git check-ignore over .pyc, .pyo and two __pycache__
paths.

The four base-branch filters naming fix/prune-and-helper-defects are
removed. Each carried a comment saying to remove it once that branch had
landed, and it has: a filter naming a branch that no longer exists would
be dead weight, and this pull request now targets main, which the
remaining entry already matches. All four workflow files are byte
identical to main again, which is what those temporary entries were always
going to collapse to.

What is left over main is exactly this branch's own work: the pruner, the
two tail sanitisers, their tests and the new sandbox suite.
@MarkusPaulsen
MarkusPaulsen merged commit 1a11b76 into main Sep 11, 2026
1 check passed
@MarkusPaulsen
MarkusPaulsen deleted the ci/prune-safe-for-untrusted-code branch September 11, 2026 15:19
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