diff --git a/Makefile b/Makefile index 8d8ee87..11092a7 100644 --- a/Makefile +++ b/Makefile @@ -95,9 +95,12 @@ run_sandbox_mode: ## Start sandbox proxy + dev runner (mac-friendly dev loop) test_sandbox_mode: ## Run a sample isolated command in the dev loop and verify isolation @$(MAKE) run_sandbox_mode @PROXY_PID=$$(docker inspect --format '{{.State.Pid}}' buildcage-proxy); \ - docker compose -f compose.yaml -f run/compose.sandbox-dev.yaml exec sandbox-dev-runner \ - run-isolated.sh --proxy-pid $$PROXY_PID --uid 1000 --gid 1000 \ - --gateway 172.20.0.1 --dns 172.20.0.1 -- /usr/local/bin/smoke-test.sh + docker compose -f compose.yaml -f run/compose.sandbox-dev.yaml exec sandbox-dev-runner sh -c " \ + set -e; \ + build-test-bundle.sh --netns-name buildcage-sandbox-dev --script /usr/local/bin/smoke-test.sh --bundle /var/tmp/buildcage/dev-bundle; \ + run-isolated.sh --proxy-pid $$PROXY_PID --runc /usr/local/bin/runc --bundle /var/tmp/buildcage/dev-bundle \ + --container-id buildcage-sandbox-dev --netns-name buildcage-sandbox-dev --rootfs-bind-dir /var/tmp/buildcage/dev-bundle/rootfs \ + --gateway 172.20.0.1 --dns 172.20.0.1 --target-ip 172.20.0.101" @$(MAKE) clean_sandbox_mode .PHONY: clean_sandbox_mode @@ -173,6 +176,12 @@ test_sandbox_integration: ## Run the run action's integration tests (needs BUILD @./run/test/integration-test-writable-dir.sh @./run/test/integration-test-writable-disabled.sh @./run/test/integration-test-defaults.sh + @./run/test/integration-test-seccomp.sh + @./run/test/integration-test-die-with-parent.sh + @./run/test/integration-test-fs-escape.sh + @./run/test/integration-test-runner-temp.sh + @./run/test/integration-test-nested-mount-readonly.sh + @./run/test/integration-test-non-runc-default-pseudofs-readonly.sh @./run/test/integration-test-concurrent.sh .PHONY: test_unit diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index dd08a6b..6317eb2 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -50,6 +50,30 @@ QuickJS ------------------------------------------------------------------------------ +runc (used only by the `run` action's proxy image, docker/Dockerfile) + Website : https://github.com/opencontainers/runc + License : Apache License 2.0 (Apache-2.0) + Source : https://github.com/opencontainers/runc + Note : Official pre-built release binary, checksum-pinned. Extracted + onto the runner host at `run` action startup and used by + run-isolated.sh to execute the isolated command. + +------------------------------------------------------------------------------ + +run/scripts/seccomp.json (used only by the `run` action) + + This file is not part of the Docker image; it ships as part of the + action's own source and is loaded directly by run/src/lib/isolated-exec.js. + It is generated offline (not at image build time) from moby/profiles' + default seccomp profile, resolved against an empty capability set with + the exported `LoadProfile` function. + + moby/profiles + License : Apache License 2.0 (Apache-2.0) + Source : https://github.com/moby/profiles + +------------------------------------------------------------------------------ + Note on GPL components (HAProxy, dnsmasq): These components are installed from the Alpine Linux official package diff --git a/docs/development.md b/docs/development.md index 06c0959..e913a81 100644 --- a/docs/development.md +++ b/docs/development.md @@ -51,12 +51,16 @@ make clean ### Sandbox Dev Loop (mac-friendly) The `run` action's own isolation mechanism (`run-isolated.sh`) uses Linux-only primitives -(`unshare`, `nsenter`, `setpriv`) that can't run natively on macOS. `make run_sandbox_mode` / +(`ip netns`, `nsenter`, `runc`) that can't run natively on macOS. `make run_sandbox_mode` / `make test_sandbox_mode` instead drive it from inside a container with `pid: host` (see `run/dev/Dockerfile` and `run/compose.sandbox-dev.yaml`), which can see the proxy container's PID/netns via `/proc` — close enough to the real "runner host + separate proxy container" arrangement for day-to-day iteration, though it can't validate the container-boundary -parts of production (see [Run Action Internals](#run-action-internals) below). CI's +parts of production (see [Run Action Internals](#run-action-internals) below). `runc` and +`gen-seccomp-profile` are built directly into the dev-loop image (mirroring `run/docker/Dockerfile`) +rather than `docker cp`-extracted from the proxy image at runtime, so the dev loop doesn't need the +Docker socket mounted in just to reach a sibling container; `run/dev/build-test-bundle.sh` stands in +for `isolated-exec.js`'s `buildOciConfig` to build a minimal OCI bundle for the smoke test. CI's `test_sandbox_*` e2e jobs run `run-isolated.sh` directly on the runner host instead, matching production exactly — treat those as the final word on whether a change actually works, not this dev loop. @@ -121,84 +125,74 @@ behavior — what's enforced, what's visible in the report — see ## Run Action Internals -This section covers how the `run` action (`run/`) is implemented internally. For the -user-facing behavior and threat model, see [Run Action](./security.md#run-action) in -Security Details and the [Reference](./reference.md#run-action) doc. - -- `run/docker/` is a stripped-down build of the `transparent` engine's image with `buildkitd` - removed entirely — only the bridge (`sandbox0`, not `buildkit0` — there's no BuildKit here to - share the name with), iptables `REDIRECT`/`DROP` rules, dnsmasq, and HAProxy remain. Since - BuildKit's CNI integration normally creates that bridge, a new `init-cni-bridge` s6 service - (`run/docker/files/s6-scripts/init-cni-bridge`) creates it directly via `ip link add ... type - bridge` instead. `run/docker/files/s6-scripts/init-iptables` is `setup/docker/transparent`'s - script with the bridge name swapped; `init-haproxy-cfg`, `dnsmasq.conf`, and `haproxy.cfg.template` - are unmodified copies. -- `run/action.yml` is a `node24` action (`main` + `post`, mirroring `setup`'s shape). Each - invocation is fully self-contained — `run/src/main.js` verifies and pulls the proxy image - (same Sigstore flow as `setup`, published under the `-proxy` tag suffix — see - `imageTagFromRef` in `core/lib/verify-image.js`), starts a uniquely-named throwaway proxy - container, runs the isolated command, appends this step's report section to the Job Summary, and - stops the container again, all inside `main`'s own `try`/`finally`. `run/src/post.js` is a - fallback only — it reads the container name and Compose project name back from `GITHUB_STATE` - (`STATE_container_name`/`STATE_project_name`) and stops it, in case the process was killed before - reaching `main`'s own `finally`. If `STATE_project_name` is missing, `post.js` skips cleanup - entirely rather than falling back to an unscoped `docker compose down` (see below). -- Every `docker compose` invocation passes `-p ` (`lib/container.js`'s - `deriveProjectName`, currently the identity function — container names and Compose project names - live in separate Docker namespaces, so reusing the same string for both is safe and keeps - `docker ps`/`docker network ls` output easy to correlate). This matters once GitHub Actions' - `background`/`wait`/`parallel` step keywords let multiple `run` steps in the same job run - truly concurrently: without an explicit `-p`, Compose falls back to an implicit, - directory-derived project name shared by every invocation, and it identifies "the" container for - a service by its project+service label rather than by container name — so one step's `up`/`down` - could recreate or tear down another step's still-running proxy container even though their - container names never collide. -- The actual isolation is `run/scripts/run-isolated.sh`, invoked via `sudo -n` since setting up - namespaces/veth/iptables requires root: - 1. `unshare --net --pid --mount --uts --ipc --cgroup --mount-proc --fork -- sleep infinity` creates - a placeholder process holding the new namespaces. `unshare --pid` doesn't move the caller - itself into the new PID namespace — only the first forked child does — so the script discovers - that child's host-visible PID via `/proc//task//children` before it - can `nsenter` into it. Immediately after, `mount --make-rprivate /` (recursive) runs inside the - placeholder's mount namespace: a cloned mount namespace starts out in the same propagation peer - group as the one it was cloned from (typically "shared" under systemd), so without this, every - mount change made in the steps below would propagate straight back out to the host's real mount - namespace. - 2. A veth pair is created with one end moved into the placeholder's netns, the other moved - directly into the (already-running) proxy container's netns and attached to its `sandbox0` - bridge as a bridge port — the same role a BuildKit `RUN` step container's veth plays under - `transparent` mode. - 3. `/etc/resolv.conf` is bind-mounted over inside the placeholder's mount namespace only; a - handful of sensitive `/proc` paths are bind-mounted over with `/dev/null` the same way. - 4. The rest of the filesystem is then restricted to read-only, except `$GITHUB_WORKSPACE`, `$HOME`, - `/tmp`, and any paths from the action's `writable` input (`--writable`, repeatable). Each of - those is first bind-mounted onto itself, giving it its own mount-table entry, then every - *other* existing mount is remounted read-only via `mount -o remount,bind,ro` — the `bind` - there is required: a plain `remount,ro` changes the underlying filesystem's shared state - (still shared with the host even after step 1's `make-rprivate`, since that only stops - mount/unmount *event* propagation, not this kind of flag change), and would leak the read-only - flag back to the host, whereas `remount,bind,ro` scopes it to this one mount entry only. A - literal `/` among the `writable` paths is a sentinel that skips this whole step instead of - bind-mounting "/" itself, since most paths below "/" aren't separate mount points and so - wouldn't be covered by protecting "/" alone. - 5. The command finally executes via `nsenter --target --net --mount --uts --ipc - --cgroup --pid -- env -i "${ENV_ASSIGNMENTS[@]}" setpriv --reuid= --regid= - --clear-groups --bounding-set=-all --no-new-privs --