From 75018c8ccddf584ffa120e136833656f8bc03da5 Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 09:35:35 +0900 Subject: [PATCH 01/28] feat: distribute runc and a seccomp profile generator in run's proxy image --- THIRD_PARTY_LICENSES | 24 +++++++++++ renovate.json | 7 ++++ run/docker/Dockerfile | 56 +++++++++++++++++++++++++- run/docker/files/THIRD_PARTY_LICENSES | 10 +++++ run/docker/gen-seccomp-profile/go.mod | 10 +++++ run/docker/gen-seccomp-profile/go.sum | 6 +++ run/docker/gen-seccomp-profile/main.go | 56 ++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 run/docker/gen-seccomp-profile/go.mod create mode 100644 run/docker/gen-seccomp-profile/go.sum create mode 100644 run/docker/gen-seccomp-profile/main.go 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/renovate.json b/renovate.json index 809bb58..a0e7723 100644 --- a/renovate.json +++ b/renovate.json @@ -15,6 +15,13 @@ ], "matchStrings": ["# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)\\nARG CNI_VERSION=(?v[\\d.]+)"] }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^run/docker/Dockerfile$/" + ], + "matchStrings": ["# renovate: datasource=(?[^\\s]+) depName=(?[^\\s]+)\\nARG RUNC_VERSION=(?v[\\d.]+)"] + }, { "customType": "regex", "managerFilePatterns": ["/\\.md$/"], diff --git a/run/docker/Dockerfile b/run/docker/Dockerfile index 4f957a6..f30d5d9 100644 --- a/run/docker/Dockerfile +++ b/run/docker/Dockerfile @@ -1,9 +1,54 @@ +# renovate: datasource=github-releases depName=opencontainers/runc +ARG RUNC_VERSION=v1.5.1 +ARG RUNC_SHA256_AMD64=177df879d50c913eb205e898d5c1c05a18f574053c0ce5524c471208eaf06f6f +ARG RUNC_SHA256_ARM64=ca70e7dbd6616ca782a59b5d3ac86909123fdaa9fa3f89dcf29051c70eee7ce9 + +# Prepare dependencies — runc's official release binaries are already +# statically linked (including libseccomp), so no compilation is needed, +# only a checksum-pinned download (see docs/development.md). +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS deps +ARG RUNC_VERSION +ARG RUNC_SHA256_AMD64 +ARG RUNC_SHA256_ARM64 +RUN apk add --no-cache curl && \ + mkdir -p /opt/buildcage/bin && \ + ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') && \ + EXPECTED_SHA256=$([ "$ARCH" = "amd64" ] && echo "$RUNC_SHA256_AMD64" || echo "$RUNC_SHA256_ARM64") && \ + curl -fsSL "https://github.com/opencontainers/runc/releases/download/${RUNC_VERSION}/runc.${ARCH}" \ + -o /opt/buildcage/bin/runc && \ + echo "${EXPECTED_SHA256} /opt/buildcage/bin/runc" | sha256sum -c && \ + chmod +x /opt/buildcage/bin/runc + +# gen-seccomp-profile is extracted onto the runner host (docker cp, same as +# runc below) and run natively there at `run` action startup, before any +# namespace isolation is set up for the sandboxed command — not run at image +# build time. Its output depends on the actual host kernel (a real +# uname(2) call gates a handful of syscalls) and architecture, both of +# which only match the real runner when resolved on the host itself. On a +# real GitHub Actions runner this is equivalent to running it inside the +# still-running proxy container (Linux containers share the host kernel), +# but extracting it onto the host lets `run` action startup reuse the same +# docker-cp extraction path already needed for runc, rather than adding a +# second (docker exec) mechanism. Building it here only compiles the +# binary; see gen-seccomp-profile/main.go and docs/development.md for why. +# Pure Go (CGO_ENABLED=0), so this cross-compiles natively via +# BUILDPLATFORM without needing QEMU, same as buildkit-proxy-build in +# setup/docker/explicit/Dockerfile. +FROM --platform=$BUILDPLATFORM golang:1.25-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS gen-seccomp-profile-build +ARG TARGETOS +ARG TARGETARCH +WORKDIR /src +COPY run/docker/gen-seccomp-profile/go.mod run/docker/gen-seccomp-profile/go.sum ./ +RUN go mod download +COPY run/docker/gen-seccomp-profile/main.go ./ +RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -ldflags="-s -w" -o /out/gen-seccomp-profile . + # Final image FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b LABEL org.opencontainers.image.title="buildcage-proxy" \ org.opencontainers.image.description="Network-isolated proxy for arbitrary GitHub Actions commands" \ - org.opencontainers.image.licenses="MIT AND GPL-2.0-or-later" \ + org.opencontainers.image.licenses="MIT AND GPL-2.0-or-later AND Apache-2.0" \ org.opencontainers.image.source="https://github.com/dash14/buildcage" RUN apk add --no-cache \ @@ -31,6 +76,15 @@ COPY run/docker/files/haproxy.cfg.template /etc/haproxy/haproxy.cfg.template COPY core/scripts/ /opt/buildcage/scripts/ COPY core/shared/ /opt/buildcage/shared/ +# runc — extracted onto the runner host at `run` action startup and used by +# run-isolated.sh to execute the isolated command (see docs/development.md) +COPY --from=deps /opt/buildcage/bin/runc /opt/buildcage/bin/runc + +# gen-seccomp-profile — extracted onto the runner host and run natively +# there (not inside this container) to produce the seccomp filter embedded +# in runc's config.json; see the build stage comment above for why. +COPY --from=gen-seccomp-profile-build /out/gen-seccomp-profile /opt/buildcage/bin/gen-seccomp-profile + # License COPY run/docker/files/THIRD_PARTY_LICENSES /opt/buildcage/THIRD_PARTY_LICENSES diff --git a/run/docker/files/THIRD_PARTY_LICENSES b/run/docker/files/THIRD_PARTY_LICENSES index 25561dc..dc75e24 100644 --- a/run/docker/files/THIRD_PARTY_LICENSES +++ b/run/docker/files/THIRD_PARTY_LICENSES @@ -36,6 +36,16 @@ QuickJS ------------------------------------------------------------------------------ +runc + 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 in + run/docker/Dockerfile. Extracted onto the runner host at `run` + action startup and used by run-isolated.sh. + +------------------------------------------------------------------------------ + Note on GPL components (HAProxy, dnsmasq): These components are installed from the Alpine Linux official package diff --git a/run/docker/gen-seccomp-profile/go.mod b/run/docker/gen-seccomp-profile/go.mod new file mode 100644 index 0000000..c027de8 --- /dev/null +++ b/run/docker/gen-seccomp-profile/go.mod @@ -0,0 +1,10 @@ +module github.com/dash14/buildcage/gen-seccomp-profile + +go 1.25 + +require ( + github.com/moby/profiles/seccomp v0.2.3 + github.com/opencontainers/runtime-spec v1.3.0 +) + +require golang.org/x/sys v0.33.0 // indirect diff --git a/run/docker/gen-seccomp-profile/go.sum b/run/docker/gen-seccomp-profile/go.sum new file mode 100644 index 0000000..aff7c01 --- /dev/null +++ b/run/docker/gen-seccomp-profile/go.sum @@ -0,0 +1,6 @@ +github.com/moby/profiles/seccomp v0.2.3 h1:nrHNSiECQQvq4WjgceCUgJXIXUJBIswVQ133k4Do2mA= +github.com/moby/profiles/seccomp v0.2.3/go.mod h1:8m3qkkWZXrRsMqlUUN2zyccnYmBf3EAdQYPMVJ3NBhk= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/run/docker/gen-seccomp-profile/main.go b/run/docker/gen-seccomp-profile/main.go new file mode 100644 index 0000000..68d2263 --- /dev/null +++ b/run/docker/gen-seccomp-profile/main.go @@ -0,0 +1,56 @@ +// gen-seccomp-profile resolves moby/profiles' Docker default seccomp +// profile into the OCI Runtime Spec form runc's config.json expects, +// against a synthetic spec with an empty capability set (matching the +// sandboxed process, which always runs with its capability bounding set +// fully cleared). Writes the result to stdout as JSON. +// +// This binary is compiled at buildcage's own image-build time but is not +// meant to be *run* then: its output depends on the architecture and the +// actual kernel of whatever machine runs it (a handful of syscalls in the +// profile are gated by a real uname(2) call via a minKernel condition), so +// it must be run on the real target — extracted from the proxy image onto +// the GitHub Actions runner host (docker cp, same mechanism used for +// runc) and invoked natively there, once per `run` step, before any +// namespace isolation is set up for the sandboxed command. See +// docs/development.md. +// +// If the kernel it happens to run on doesn't match the actual runner +// exactly (not expected in normal operation, since it's the same host), +// the risk is asymmetric and low-severity either way: a syscall wrongly +// included just fails with ENOSYS on a kernel that lacks it (not a +// security issue), and a syscall wrongly excluded only breaks a tool that +// needed it (a compatibility issue, not a widened attack surface) — it can +// never result in a *more* permissive filter than intended. +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/moby/profiles/seccomp" + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +func main() { + rs := &specs.Spec{ + Process: &specs.Process{ + Capabilities: &specs.LinuxCapabilities{ + Bounding: []string{}, + }, + }, + } + + linuxSeccomp, err := seccomp.GetDefaultProfile(rs) + if err != nil { + fmt.Fprintln(os.Stderr, "gen-seccomp-profile:", err) + os.Exit(1) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(linuxSeccomp); err != nil { + fmt.Fprintln(os.Stderr, "gen-seccomp-profile:", err) + os.Exit(1) + } +} From ea4a4d58862b61203fc15c65735f4e15b5fa988d Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 09:47:13 +0900 Subject: [PATCH 02/28] feat: extract runc/gen-seccomp-profile and build the OCI bundle in JS Adds docker cp extraction of runc and gen-seccomp-profile onto the runner host, runs gen-seccomp-profile natively to resolve the seccomp filter, and builds the full OCI Runtime Spec (config.json) in JS from runc's own `runc spec` baseline. run-isolated.sh itself isn't wired up to consume these yet. --- run/dist/main.cjs | 163 ++++++++++++++++++++++----- run/src/lib/container.js | 10 ++ run/src/lib/container.test.js | 11 +- run/src/lib/errors.js | 1 + run/src/lib/isolated-exec.js | 177 +++++++++++++++++++++++------- run/src/lib/isolated-exec.test.js | 132 ++++++++++++++++++++-- run/src/main.js | 79 +++++++++++-- 7 files changed, 488 insertions(+), 85 deletions(-) diff --git a/run/dist/main.cjs b/run/dist/main.cjs index 2c9264b..2077767 100644 --- a/run/dist/main.cjs +++ b/run/dist/main.cjs @@ -7265,6 +7265,10 @@ class SandboxError extends Error { } } +function buildDockerCpArgs({containerName: containerName, containerPath: containerPath, hostPath: hostPath}) { + return [ "cp", `${containerName}:${containerPath}`, hostPath ]; +} + function buildComposeUpArgs({composeFile: composeFile, projectName: projectName, pullPolicy: pullPolicy}) { return [ "compose", "-f", composeFile, "-p", projectName, "up", "-d", "--pull", pullPolicy, "--no-build", "--wait", "--quiet-pull" ]; } @@ -7275,24 +7279,7 @@ function buildComposeDownArgs({composeFile: composeFile, projectName: projectNam const __dirname$2 = path.dirname(node_url.fileURLToPath("undefined" == typeof document ? require("url").pathToFileURL(__filename).href : _documentCurrentScript && "SCRIPT" === _documentCurrentScript.tagName.toUpperCase() && _documentCurrentScript.src || new URL("main.cjs", document.baseURI).href)); -function runIsolated({scriptPath: scriptPath, proxyPid: proxyPid, workdir: workdir, home: home, writablePaths: writablePaths = [], env: env, runScriptDir: runScriptDir}) { - const runIsolatedShPath = path.join(__dirname$2, "..", "scripts", "run-isolated.sh"), envFilePath = function(env, dir) { - const envFilePath = path.join(dir, "env-dump.bin"), buf = Buffer.concat(Object.entries(env).filter(([, v]) => void 0 !== v).map(([k, v]) => Buffer.from(`${k}=${v}\0`, "utf8"))); - return node_fs.writeFileSync(envFilePath, buf, { - mode: 384 - }), envFilePath; - }(env, runScriptDir), args = [ "-n", "--", runIsolatedShPath, "--proxy-pid", String(proxyPid), "--uid", String(process.getuid()), "--gid", String(process.getgid()), "--env-file", envFilePath ]; - workdir && args.push("--workdir", workdir), home && args.push("--home", home); - for (const p of writablePaths) args.push("--writable", p); - args.push("--", scriptPath); - try { - return node_child_process.execFileSync("sudo", args, { - stdio: "inherit" - }), 0; - } catch (e) { - return "number" == typeof e.status ? e.status : 1; - } -} +const EXTRA_MASKED_PROC_PATHS = [ "/proc/kallsyms", "/proc/kmsg", "/proc/sysrq-trigger" ]; const ruleTypeToParam = { HTTPS: "allowed_https_rules", @@ -7482,6 +7469,7 @@ process.argv[1] === node_url.fileURLToPath("undefined" == typeof document ? requ } }(containerName); if (null === proxyPid) throw new SandboxError(`Sandbox proxy container ${containerName} is not running.`, "PROXY_NOT_RUNNING"); + const gateway = "172.20.0.1", dns = "172.20.0.1", targetIp = "172.20.0.101"; exitCode = function(fn) { const dir = node_fs.mkdtempSync(path.join(os.tmpdir(), "buildcage-sandbox-")); try { @@ -7493,20 +7481,143 @@ process.argv[1] === node_url.fileURLToPath("undefined" == typeof document ? requ }); } }(dir => { - const scriptPath = function(runInput, dir) { + const runcPath = path.join(dir, "runc"), genSeccompProfilePath = path.join(dir, "gen-seccomp-profile"); + try { + node_child_process.execFileSync("docker", buildDockerCpArgs({ + containerName: containerName, + containerPath: "/opt/buildcage/bin/runc", + hostPath: runcPath + })), node_child_process.execFileSync("docker", buildDockerCpArgs({ + containerName: containerName, + containerPath: "/opt/buildcage/bin/gen-seccomp-profile", + hostPath: genSeccompProfilePath + })), node_fs.chmodSync(runcPath, 493), node_fs.chmodSync(genSeccompProfilePath, 493); + } catch (e) { + throw new SandboxError(`Failed to extract runc/gen-seccomp-profile from the proxy image: ${e.message}`, "RUNC_EXTRACT_FAILED"); + } + const seccompProfile = JSON.parse(node_child_process.execFileSync(genSeccompProfilePath, { + encoding: "utf8" + })), workdir = env.GITHUB_WORKSPACE || "", home = env.HOME || "", resolvConfPath = function(dns, dir) { + const resolvConfPath = path.join(dir, "resolv.conf"); + return node_fs.writeFileSync(resolvConfPath, `nameserver ${dns}\n`, { + mode: 420 + }), resolvConfPath; + }(dns, dir), rootfsBindDir = path.join(dir, "rootfs"), netnsName = containerName.replace(/^buildcage-proxy-/, "buildcage-sandbox-"), scriptPath = function(runInput, dir) { const scriptPath = path.join(dir, "run-script.sh"), content = runInput.startsWith("#!") ? runInput : `#!/bin/sh\nset -e\n${runInput}\n`; return node_fs.writeFileSync(scriptPath, content, { mode: 448 }), scriptPath; - }(runInput, dir); - return runIsolated({ - scriptPath: scriptPath, - proxyPid: proxyPid, - workdir: env.GITHUB_WORKSPACE || "", - home: env.HOME || "", + }(runInput, dir), baseSpec = function(runcPath, bundleDir) { + return node_child_process.execFileSync(runcPath, [ "spec" ], { + cwd: bundleDir + }), JSON.parse(node_fs.readFileSync(path.join(bundleDir, "config.json"), "utf8")); + }(runcPath, dir), config = function(baseSpec, {uid: uid, gid: gid, workdir: workdir, home: home, writablePaths: writablePaths = [], env: env, netnsPath: netnsPath, rootfsBindDir: rootfsBindDir, resolvConfPath: resolvConfPath, seccompProfile: seccompProfile, scriptPath: scriptPath}) { + const disableReadonly = writablePaths.includes("/"), mounts = [ ...baseSpec.mounts, { + destination: "/etc/resolv.conf", + type: "none", + source: resolvConfPath, + options: [ "rbind", "ro" ] + } ]; + if (!disableReadonly) { + workdir && mounts.push({ + destination: workdir, + type: "none", + source: workdir, + options: [ "rbind", "rw" ] + }), home && mounts.push({ + destination: home, + type: "none", + source: home, + options: [ "rbind", "rw" ] + }), mounts.push({ + destination: "/tmp", + type: "none", + source: "/tmp", + options: [ "rbind", "rw" ] + }); + for (const p of writablePaths) mounts.push({ + destination: p, + type: "none", + source: p, + options: [ "rbind", "rw" ] + }); + } + const maskedPaths = Array.from(new Set([ ...baseSpec.linux.maskedPaths ?? [], ...EXTRA_MASKED_PROC_PATHS ])), readonlyPaths = (baseSpec.linux.readonlyPaths ?? []).filter(p => !EXTRA_MASKED_PROC_PATHS.includes(p)), namespaces = baseSpec.linux.namespaces.map(ns => "network" === ns.type ? { + ...ns, + path: netnsPath + } : ns); + return { + ...baseSpec, + root: { + path: rootfsBindDir, + readonly: !disableReadonly + }, + mounts: mounts, + process: { + ...baseSpec.process, + terminal: !1, + user: { + uid: uid, + gid: gid + }, + args: [ scriptPath ], + env: Object.entries(env).filter(([, v]) => void 0 !== v).map(([k, v]) => `${k}=${v}`), + cwd: workdir || "/", + capabilities: { + bounding: [], + effective: [], + permitted: [], + inheritable: [], + ambient: [] + }, + noNewPrivileges: !0 + }, + linux: { + ...baseSpec.linux, + namespaces: namespaces, + seccomp: seccompProfile, + maskedPaths: maskedPaths, + readonlyPaths: readonlyPaths + } + }; + }(baseSpec, { + uid: process.getuid(), + gid: process.getgid(), + workdir: workdir, + home: home, writablePaths: writablePaths, env: env, - runScriptDir: dir + netnsPath: `/var/run/netns/${netnsName}`, + rootfsBindDir: rootfsBindDir, + resolvConfPath: resolvConfPath, + seccompProfile: seccompProfile, + scriptPath: scriptPath + }); + return function(config, bundleDir) { + const configPath = path.join(bundleDir, "config.json"); + node_fs.writeFileSync(configPath, JSON.stringify(config), { + mode: 384 + }); + }(config, dir), function({runcPath: runcPath, proxyPid: proxyPid, bundleDir: bundleDir, containerId: containerId, netnsName: netnsName, rootfsBindDir: rootfsBindDir, gateway: gateway, dns: dns, targetIp: targetIp}) { + const args = [ "-n", "--", path.join(__dirname$2, "..", "scripts", "run-isolated.sh"), "--proxy-pid", String(proxyPid), "--runc", runcPath, "--bundle", bundleDir, "--container-id", containerId, "--netns-name", netnsName, "--rootfs-bind-dir", rootfsBindDir ]; + args.push("--gateway", gateway), args.push("--dns", dns), args.push("--target-ip", targetIp); + try { + return node_child_process.execFileSync("sudo", args, { + stdio: "inherit" + }), 0; + } catch (e) { + return "number" == typeof e.status ? e.status : 1; + } + }({ + runcPath: runcPath, + proxyPid: proxyPid, + bundleDir: dir, + containerId: containerName, + netnsName: netnsName, + rootfsBindDir: rootfsBindDir, + gateway: gateway, + dns: dns, + targetIp: targetIp }); }); } finally { diff --git a/run/src/lib/container.js b/run/src/lib/container.js index 1f718de..fadb7fe 100644 --- a/run/src/lib/container.js +++ b/run/src/lib/container.js @@ -34,6 +34,16 @@ export function deriveProjectName(containerName) { return containerName; } +/** + * Build the `docker cp` argv for extracting a single file from a running + * container onto the host filesystem — used to pull runc and + * gen-seccomp-profile out of the proxy image before run-isolated.sh runs + * (see lib/isolated-exec.js). + */ +export function buildDockerCpArgs({ containerName, containerPath, hostPath }) { + return ["cp", `${containerName}:${containerPath}`, hostPath]; +} + /** * Returns the container's PID (as seen from the Docker host's PID * namespace), or null if the container doesn't exist / isn't running. diff --git a/run/src/lib/container.test.js b/run/src/lib/container.test.js index 49bfb2e..e885460 100644 --- a/run/src/lib/container.test.js +++ b/run/src/lib/container.test.js @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { generateContainerName, getContainerPid, deriveProjectName } from "./container.js"; +import { generateContainerName, getContainerPid, deriveProjectName, buildDockerCpArgs } from "./container.js"; describe("generateContainerName", () => { it("always starts with the buildcage-proxy- prefix", () => { @@ -20,6 +20,15 @@ describe("getContainerPid", () => { }); }); +describe("buildDockerCpArgs", () => { + it("builds a `docker cp : ` argv", () => { + assert.deepEqual( + buildDockerCpArgs({ containerName: "buildcage-proxy-abcd1234", containerPath: "/opt/buildcage/bin/runc", hostPath: "/tmp/x/runc" }), + ["cp", "buildcage-proxy-abcd1234:/opt/buildcage/bin/runc", "/tmp/x/runc"], + ); + }); +}); + describe("deriveProjectName", () => { it("returns the container name unchanged", () => { assert.equal(deriveProjectName("buildcage-proxy-abcd1234"), "buildcage-proxy-abcd1234"); diff --git a/run/src/lib/errors.js b/run/src/lib/errors.js index 718fbf5..f6a9bc5 100644 --- a/run/src/lib/errors.js +++ b/run/src/lib/errors.js @@ -14,6 +14,7 @@ * INVALID_RULES – ACL rule syntax error * MISSING_RUN – required `run` input was empty * PROXY_NOT_RUNNING – sandbox proxy container isn't running after `docker compose up` + * RUNC_EXTRACT_FAILED – failed to `docker cp` runc/gen-seccomp-profile out of the proxy image */ export class SandboxError extends Error { constructor(message, code) { diff --git a/run/src/lib/isolated-exec.js b/run/src/lib/isolated-exec.js index e68d8e6..bb43151 100644 --- a/run/src/lib/isolated-exec.js +++ b/run/src/lib/isolated-exec.js @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { writeFileSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; @@ -21,40 +21,138 @@ export function writeRunScript(runInput, dir) { } /** - * Dump the current process environment to a NUL-separated KEY=VALUE file, - * for run-isolated.sh to re-apply inside the isolated namespace via - * `env -i`. sudo resets the environment by default (env_reset) and we - * deliberately don't use `sudo -E` (its availability depends on a sudoers - * SETENV tag, which isn't portable) — this file is the explicit channel - * instead. + * Generate runc's own default OCI bundle config via `runc spec` (run in + * `bundleDir`, which is where it writes `config.json`). Used as the + * starting point for buildOciConfig rather than hand-writing the full + * spec from scratch, so the baseline mounts/masked-paths/rlimits stay + * exactly what runc itself considers a sane default for its own version, + * and buildOciConfig only needs to override/extend the handful of fields + * this sandbox actually cares about. + */ +export function generateBaseOciSpec(runcPath, bundleDir) { + execFileSync(runcPath, ["spec"], { cwd: bundleDir }); + return JSON.parse(readFileSync(join(bundleDir, "config.json"), "utf8")); +} + +/** + * Sensitive /proc paths masked with /dev/null, matching the previous + * unshare-based implementation. runc's own `runc spec` default already + * masks /proc/kcore, /proc/keys, and /proc/timer_list (among others) and + * leaves /proc/sysrq-trigger merely read-only — buildOciConfig upgrades + * sysrq-trigger to fully masked (moving it out of readonlyPaths) and adds + * kallsyms/kmsg, which runc's default doesn't cover at all. + */ +const EXTRA_MASKED_PROC_PATHS = ["/proc/kallsyms", "/proc/kmsg", "/proc/sysrq-trigger"]; + +/** + * Build the final OCI Runtime Spec (config.json) for the isolated command, + * starting from runc's own `baseSpec` (see generateBaseOciSpec) and + * overriding only what this sandbox needs to control: + * + * - root: a bind-mounted copy of the host's own `/` (rootfsBindDir, set up + * by run-isolated.sh before invoking runc — pivot_root can't target `/` + * itself), read-only except workdir/home/tmp/writablePaths, mirroring + * the read-only-by-default policy the previous mountinfo-walking + * implementation enforced by hand. + * - linux.namespaces: same six namespace types runc's own default spec + * already requests (no user namespace — see docs/security.md's + * rationale for preserving the real UID/GID), just adding `path` to the + * network entry so it joins the netns run-isolated.sh already wired a + * veth into, instead of creating a fresh, unconnected one. + * - process.capabilities: fully cleared (all five sets empty) plus + * noNewPrivileges — runc applies this natively, no setpriv needed. + * - process.env: the step's real environment, replacing runc spec's + * invented PATH/TERM defaults (mirrors the previous `env -i` + + * re-apply-from-dump behavior: start from nothing, fill in exactly + * what the step's env contained). + * - linux.seccomp: the Docker-default-profile-derived filter (see + * gen-seccomp-profile), resolved against this same empty capability + * set. * - * Written 0600: this dump contains the whole process environment, including - * any secrets passed to the step via `env:`. The scratch dir is already - * 0700 (see withScratchDir), but restricting the file itself keeps the - * secrets unreadable to other local users even if the dir's mode is ever - * loosened, matching writeRunScript's own explicit mode. + * `writablePaths` containing "/" is a sentinel meaning "disable the + * read-only restriction entirely" (see docs/reference.md's `writable` + * input), matching the previous implementation's `DISABLE_READONLY` + * handling. + */ +export function buildOciConfig( + baseSpec, + { uid, gid, workdir, home, writablePaths = [], env, netnsPath, rootfsBindDir, resolvConfPath, seccompProfile, scriptPath }, +) { + const disableReadonly = writablePaths.includes("/"); + + const mounts = [...baseSpec.mounts, { destination: "/etc/resolv.conf", type: "none", source: resolvConfPath, options: ["rbind", "ro"] }]; + if (!disableReadonly) { + if (workdir) mounts.push({ destination: workdir, type: "none", source: workdir, options: ["rbind", "rw"] }); + if (home) mounts.push({ destination: home, type: "none", source: home, options: ["rbind", "rw"] }); + mounts.push({ destination: "/tmp", type: "none", source: "/tmp", options: ["rbind", "rw"] }); + for (const p of writablePaths) mounts.push({ destination: p, type: "none", source: p, options: ["rbind", "rw"] }); + } + + const maskedPaths = Array.from(new Set([...(baseSpec.linux.maskedPaths ?? []), ...EXTRA_MASKED_PROC_PATHS])); + const readonlyPaths = (baseSpec.linux.readonlyPaths ?? []).filter((p) => !EXTRA_MASKED_PROC_PATHS.includes(p)); + + const namespaces = baseSpec.linux.namespaces.map((ns) => (ns.type === "network" ? { ...ns, path: netnsPath } : ns)); + + return { + ...baseSpec, + root: { path: rootfsBindDir, readonly: !disableReadonly }, + mounts, + process: { + ...baseSpec.process, + terminal: false, + user: { uid, gid }, + args: [scriptPath], + env: Object.entries(env) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`), + cwd: workdir || "/", + capabilities: { bounding: [], effective: [], permitted: [], inheritable: [], ambient: [] }, + noNewPrivileges: true, + }, + linux: { + ...baseSpec.linux, + namespaces, + seccomp: seccompProfile, + maskedPaths, + readonlyPaths, + }, + }; +} + +/** + * Write the final OCI config to `bundleDir/config.json` (overwriting the + * `runc spec` placeholder generateBaseOciSpec left there). Mode 0600: + * `process.env` embeds the whole step environment, including any secrets + * passed via `env:` — same reasoning as writeEnvDump had in the previous + * implementation. */ -export function writeEnvDump(env, dir) { - const envFilePath = join(dir, "env-dump.bin"); - const buf = Buffer.concat( - Object.entries(env) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => Buffer.from(`${k}=${v}\0`, "utf8")), - ); - writeFileSync(envFilePath, buf, { mode: 0o600 }); - return envFilePath; +export function writeOciConfig(config, bundleDir) { + const configPath = join(bundleDir, "config.json"); + writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 }); + return configPath; +} + +/** Write the resolv.conf bind-mount source referenced by buildOciConfig. */ +export function writeResolvConf(dns, dir) { + const resolvConfPath = join(dir, "resolv.conf"); + writeFileSync(resolvConfPath, `nameserver ${dns}\n`, { mode: 0o644 }); + return resolvConfPath; } /** * Run the user's command inside the isolated sandbox via run-isolated.sh - * (invoked with `sudo -n`, since setting up the namespaces/veth/iptables - * requires root). Returns the exit code of the isolated command — never - * throws for a non-zero exit, since that's the user's command failing, - * not this function. + * (invoked with `sudo -n`, since setting up namespaces/veth/iptables/the + * rootfs bind-mount requires root). Returns the exit code of the isolated + * command — never throws for a non-zero exit, since that's the user's + * command failing, not this function. + * + * Unlike the previous unshare/setpriv-based implementation, uid/gid, + * capabilities, mounts, and env are entirely described by `config.json` + * (see buildOciConfig) — run-isolated.sh only needs enough to set up + * networking and the rootfs bind-mount before handing off to `runc run`. */ -export function runIsolated({ scriptPath, proxyPid, workdir, home, writablePaths = [], env, runScriptDir }) { +export function runIsolated({ runcPath, proxyPid, bundleDir, containerId, netnsName, rootfsBindDir, gateway, dns, targetIp }) { const runIsolatedShPath = join(__dirname, "..", "scripts", "run-isolated.sh"); - const envFilePath = writeEnvDump(env, runScriptDir); const args = [ "-n", @@ -62,17 +160,20 @@ export function runIsolated({ scriptPath, proxyPid, workdir, home, writablePaths runIsolatedShPath, "--proxy-pid", String(proxyPid), - "--uid", - String(process.getuid()), - "--gid", - String(process.getgid()), - "--env-file", - envFilePath, + "--runc", + runcPath, + "--bundle", + bundleDir, + "--container-id", + containerId, + "--netns-name", + netnsName, + "--rootfs-bind-dir", + rootfsBindDir, ]; - if (workdir) args.push("--workdir", workdir); - if (home) args.push("--home", home); - for (const p of writablePaths) args.push("--writable", p); - args.push("--", scriptPath); + if (gateway) args.push("--gateway", gateway); + if (dns) args.push("--dns", dns); + if (targetIp) args.push("--target-ip", targetIp); try { execFileSync("sudo", args, { stdio: "inherit" }); @@ -85,7 +186,7 @@ export function runIsolated({ scriptPath, proxyPid, workdir, home, writablePaths } } -/** Create/remove a scratch directory for this step's run-script + env-dump. */ +/** Create/remove a scratch directory for this step's OCI bundle + run-script. */ export function withScratchDir(fn) { const dir = mkdtempSync(join(tmpdir(), "buildcage-sandbox-")); try { diff --git a/run/src/lib/isolated-exec.test.js b/run/src/lib/isolated-exec.test.js index ace5fc8..52761f2 100644 --- a/run/src/lib/isolated-exec.test.js +++ b/run/src/lib/isolated-exec.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { writeRunScript, writeEnvDump, withScratchDir } from "./isolated-exec.js"; +import { writeRunScript, writeResolvConf, buildOciConfig, writeOciConfig, withScratchDir } from "./isolated-exec.js"; describe("writeRunScript", () => { it("wraps plain commands in a #!/bin/sh + set -e preamble", () => { @@ -31,25 +31,135 @@ describe("writeRunScript", () => { }); }); -describe("writeEnvDump", () => { - it("writes NUL-separated KEY=VALUE pairs", () => { +describe("writeResolvConf", () => { + it("writes a single nameserver line", () => { withScratchDir((dir) => { - const path = writeEnvDump({ FOO: "bar", BAZ: "qux" }, dir); - const content = readFileSync(path, "utf8"); - assert.equal(content, "FOO=bar\0BAZ=qux\0"); + const path = writeResolvConf("172.20.0.1", dir); + assert.equal(readFileSync(path, "utf8"), "nameserver 172.20.0.1\n"); }); }); +}); + +// A minimal stand-in for what `runc spec` actually produces (see +// isolated-exec.js's generateBaseOciSpec) — only the fields buildOciConfig +// reads/overrides are included. +function fakeBaseSpec() { + return { + ociVersion: "1.0.2", + root: { path: "rootfs", readonly: true }, + mounts: [ + { destination: "/proc", type: "proc", source: "proc" }, + { destination: "/sys", type: "none", source: "/sys", options: ["rbind", "ro"] }, + ], + process: { + terminal: true, + user: { uid: 0, gid: 0 }, + args: ["sh"], + env: ["PATH=/usr/local/sbin:/usr/local/bin", "TERM=xterm"], + cwd: "/", + capabilities: { + bounding: ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"], + effective: ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"], + permitted: ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"], + inheritable: [], + ambient: [], + }, + }, + linux: { + namespaces: [{ type: "pid" }, { type: "network" }, { type: "ipc" }, { type: "uts" }, { type: "mount" }, { type: "cgroup" }], + maskedPaths: ["/proc/acpi", "/proc/kcore", "/proc/keys", "/proc/timer_list"], + readonlyPaths: ["/proc/bus", "/proc/sysrq-trigger"], + }, + }; +} + +describe("buildOciConfig", () => { + const baseArgs = { + uid: 1000, + gid: 1000, + workdir: "/home/runner/work/repo/repo", + home: "/home/runner", + env: { FOO: "bar", UNSET: undefined }, + netnsPath: "/var/run/netns/buildcage-sandbox-abcd1234", + rootfsBindDir: "/tmp/buildcage-sandbox-xyz/rootfs", + resolvConfPath: "/tmp/buildcage-sandbox-xyz/resolv.conf", + seccompProfile: { defaultAction: "SCMP_ACT_ERRNO" }, + scriptPath: "/tmp/buildcage-sandbox-xyz/run-script.sh", + }; + + it("clears all five capability sets and sets noNewPrivileges", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + assert.deepEqual(config.process.capabilities, { bounding: [], effective: [], permitted: [], inheritable: [], ambient: [] }); + assert.equal(config.process.noNewPrivileges, true); + }); + + it("sets uid/gid, args, and cwd from the given options", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + assert.deepEqual(config.process.user, { uid: 1000, gid: 1000 }); + assert.deepEqual(config.process.args, [baseArgs.scriptPath]); + assert.equal(config.process.cwd, baseArgs.workdir); + }); + + it("replaces process.env with the given env, dropping undefined values", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + assert.deepEqual(config.process.env, ["FOO=bar"]); + }); + + it("adds `path` to the network namespace entry, leaving other namespace types untouched", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + const netNs = config.linux.namespaces.find((ns) => ns.type === "network"); + assert.equal(netNs.path, baseArgs.netnsPath); + assert.equal(config.linux.namespaces.length, 6); + }); + + it("extends maskedPaths with kallsyms/kmsg/sysrq-trigger and moves sysrq-trigger out of readonlyPaths", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + for (const p of ["/proc/kallsyms", "/proc/kmsg", "/proc/sysrq-trigger", "/proc/kcore", "/proc/keys", "/proc/timer_list"]) { + assert.ok(config.linux.maskedPaths.includes(p), `expected maskedPaths to include ${p}`); + } + assert.ok(!config.linux.readonlyPaths.includes("/proc/sysrq-trigger")); + assert.ok(config.linux.readonlyPaths.includes("/proc/bus")); + }); + + it("embeds the seccomp profile as-is", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + assert.deepEqual(config.linux.seccomp, baseArgs.seccompProfile); + }); + + it("makes root read-only and binds workdir/home/tmp/writablePaths as writable exceptions", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: ["/opt/cache"] }); + assert.equal(config.root.readonly, true); + assert.equal(config.root.path, baseArgs.rootfsBindDir); + const rw = config.mounts.filter((m) => m.options?.includes("rw")).map((m) => m.destination); + assert.deepEqual(rw.sort(), ["/opt/cache", "/tmp", baseArgs.home, baseArgs.workdir].sort()); + }); + + it("adds a read-only resolv.conf bind mount", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + const resolv = config.mounts.find((m) => m.destination === "/etc/resolv.conf"); + assert.deepEqual(resolv, { destination: "/etc/resolv.conf", type: "none", source: baseArgs.resolvConfPath, options: ["rbind", "ro"] }); + }); + + it("`writable: /` disables the read-only root and skips the individual writable-path mounts", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: ["/"] }); + assert.equal(config.root.readonly, false); + const rw = config.mounts.filter((m) => m.options?.includes("rw")); + assert.equal(rw.length, 0); + }); +}); - it("skips undefined values", () => { +describe("writeOciConfig", () => { + it("writes valid JSON matching the given config", () => { withScratchDir((dir) => { - const path = writeEnvDump({ FOO: "bar", SKIP: undefined }, dir); - assert.equal(readFileSync(path, "utf8"), "FOO=bar\0"); + const config = { ociVersion: "1.0.2", process: { args: ["/bin/true"] } }; + const path = writeOciConfig(config, dir); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), config); }); }); - it("writes the env dump 0600 (it can hold secrets from the step's env:)", () => { + it("writes config.json 0600 (process.env can hold secrets from the step's env:)", () => { withScratchDir((dir) => { - const path = writeEnvDump({ SECRET: "s3cr3t" }, dir); + const path = writeOciConfig({ process: { env: ["SECRET=s3cr3t"] } }, dir); const mode = statSync(path).mode & 0o777; assert.equal(mode, 0o600); }); diff --git a/run/src/main.js b/run/src/main.js index 56ac4d3..c02d897 100644 --- a/run/src/main.js +++ b/run/src/main.js @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; +import { appendFileSync, chmodSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,9 +7,17 @@ import { buildRules } from "../../core/shared/lib/rules.js"; import { resolveBuildcageImageRef } from "../../core/lib/image-ref.js"; import { verifyImageDigest } from "../../core/lib/verify-image.js"; import { SandboxError } from "./lib/errors.js"; -import { generateContainerName, getContainerPid, deriveProjectName } from "./lib/container.js"; +import { generateContainerName, getContainerPid, deriveProjectName, buildDockerCpArgs } from "./lib/container.js"; import { buildComposeUpArgs, buildComposeDownArgs } from "./lib/compose-args.js"; -import { writeRunScript, runIsolated, withScratchDir } from "./lib/isolated-exec.js"; +import { + writeRunScript, + writeResolvConf, + generateBaseOciSpec, + buildOciConfig, + writeOciConfig, + runIsolated, + withScratchDir, +} from "./lib/isolated-exec.js"; import { fetchReport, writeReport } from "./lib/report.js"; export { buildComposeUpArgs, buildComposeDownArgs }; @@ -160,16 +168,69 @@ async function main() { throw new SandboxError(`Sandbox proxy container ${containerName} is not running.`, "PROXY_NOT_RUNNING"); } + // Fixed addressing on the proxy's sandbox0 bridge — same values + // run-isolated.sh has always defaulted to. + const gateway = "172.20.0.1"; + const dns = "172.20.0.1"; + const targetIp = "172.20.0.101"; + exitCode = withScratchDir((dir) => { + const runcPath = join(dir, "runc"); + const genSeccompProfilePath = join(dir, "gen-seccomp-profile"); + try { + execFileSync("docker", buildDockerCpArgs({ containerName, containerPath: "/opt/buildcage/bin/runc", hostPath: runcPath })); + execFileSync( + "docker", + buildDockerCpArgs({ containerName, containerPath: "/opt/buildcage/bin/gen-seccomp-profile", hostPath: genSeccompProfilePath }), + ); + chmodSync(runcPath, 0o755); + chmodSync(genSeccompProfilePath, 0o755); + } catch (e) { + throw new SandboxError(`Failed to extract runc/gen-seccomp-profile from the proxy image: ${e.message}`, "RUNC_EXTRACT_FAILED"); + } + + // Run natively on the runner host (not `docker exec`, which would + // resolve against the container's kernel/arch instead of the real + // one) — see gen-seccomp-profile/main.go for why this matters. + const seccompProfile = JSON.parse(execFileSync(genSeccompProfilePath, { encoding: "utf8" })); + + const workdir = env.GITHUB_WORKSPACE || ""; + const home = env.HOME || ""; + const resolvConfPath = writeResolvConf(dns, dir); + const rootfsBindDir = join(dir, "rootfs"); + // Distinct from the Docker container name/Compose project name + // (different ID namespace — `ip netns`/runc container IDs), but + // derived from it to keep `ip netns`/`docker ps` output correlated + // per step, same reasoning as deriveProjectName. + const netnsName = containerName.replace(/^buildcage-proxy-/, "buildcage-sandbox-"); + const scriptPath = writeRunScript(runInput, dir); - return runIsolated({ - scriptPath, - proxyPid, - workdir: env.GITHUB_WORKSPACE || "", - home: env.HOME || "", + const baseSpec = generateBaseOciSpec(runcPath, dir); + const config = buildOciConfig(baseSpec, { + uid: process.getuid(), + gid: process.getgid(), + workdir, + home, writablePaths, env, - runScriptDir: dir, + netnsPath: `/var/run/netns/${netnsName}`, + rootfsBindDir, + resolvConfPath, + seccompProfile, + scriptPath, + }); + writeOciConfig(config, dir); + + return runIsolated({ + runcPath, + proxyPid, + bundleDir: dir, + containerId: containerName, + netnsName, + rootfsBindDir, + gateway, + dns, + targetIp, }); }); } finally { From 0d5723460d51f20b7b83c4ac32ba6ec80ef13f5a Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 10:08:11 +0900 Subject: [PATCH 03/28] feat: drive run-isolated.sh's sandbox via runc instead of unshare/setpriv Replaces the hand-rolled unshare placeholder namespace, setpriv privilege drop, and mountinfo-walking read-only remount with runc run against the OCI bundle isolated-exec.js now builds. run-isolated.sh's own job shrinks to what runc can't do itself: wiring the sandbox netns's veth into the proxy's sandbox0 bridge, and bind-mounting the host's own / for runc's rootfs. Verified end-to-end (network isolation, capability/seccomp enforcement, uid/gid drop, read-only filesystem policy) by driving the built run/dist/main.cjs against a real proxy container as a non-root user. --- run/scripts/run-isolated.sh | 250 +++++++++--------------------------- 1 file changed, 63 insertions(+), 187 deletions(-) diff --git a/run/scripts/run-isolated.sh b/run/scripts/run-isolated.sh index 6c90feb..c338044 100755 --- a/run/scripts/run-isolated.sh +++ b/run/scripts/run-isolated.sh @@ -1,132 +1,106 @@ #!/bin/bash -# run-isolated.sh — run a command in a network-isolated sandbox. +# run-isolated.sh — run a command in a network-isolated sandbox via runc. # -# Creates a throwaway network/pid/mount/uts/ipc/cgroup namespace, wires a -# veth pair into the buildcage-proxy container's "sandbox0" bridge, -# strips all capabilities and privilege-escalation paths from the target -# process, and execs the given script inside it. +# Creates a network namespace, wires a veth pair into the buildcage-proxy +# container's "sandbox0" bridge, bind-mounts the host's own "/" so it can +# be handed to runc as a read-only rootfs, and execs `runc run` against an +# OCI bundle (config.json) that isolated-exec.js has already fully built -- +# namespaces, capabilities, mounts, uid/gid, and the seccomp filter are all +# declared there. This script only sets up what runc itself cannot: the +# network namespace's veth wiring into the proxy's bridge, and the rootfs +# bind-mount runc needs as its root.path (pivot_root can't target "/" +# itself). # # Must be run as root (invoked via `sudo -n` from the run action). set -euo pipefail PROXY_PID="" -TARGET_UID="" -TARGET_GID="" +RUNC_PATH="" +BUNDLE_DIR="" +CONTAINER_ID="" +NETNS_NAME="" +ROOTFS_BIND_DIR="" GATEWAY="172.20.0.1" DNS="172.20.0.1" TARGET_IP="172.20.0.101" -WORKDIR="" -HOME_DIR="" -ENV_FILE="" -SCRIPT_PATH="" -WRITABLE_PATHS=() usage() { cat >&2 <<'EOF' -Usage: run-isolated.sh --proxy-pid --uid --gid +Usage: run-isolated.sh --proxy-pid --runc --bundle + --container-id --netns-name --rootfs-bind-dir [--gateway ] [--dns ] [--target-ip ] - [--workdir ] [--home ] [--writable ]... - [--env-file ] -- EOF } while [ $# -gt 0 ]; do case "$1" in --proxy-pid) PROXY_PID="$2"; shift 2 ;; - --uid) TARGET_UID="$2"; shift 2 ;; - --gid) TARGET_GID="$2"; shift 2 ;; + --runc) RUNC_PATH="$2"; shift 2 ;; + --bundle) BUNDLE_DIR="$2"; shift 2 ;; + --container-id) CONTAINER_ID="$2"; shift 2 ;; + --netns-name) NETNS_NAME="$2"; shift 2 ;; + --rootfs-bind-dir) ROOTFS_BIND_DIR="$2"; shift 2 ;; --gateway) GATEWAY="$2"; shift 2 ;; --dns) DNS="$2"; shift 2 ;; --target-ip) TARGET_IP="$2"; shift 2 ;; - --workdir) WORKDIR="$2"; shift 2 ;; - --home) HOME_DIR="$2"; shift 2 ;; - --writable) WRITABLE_PATHS+=("$2"); shift 2 ;; - --env-file) ENV_FILE="$2"; shift 2 ;; - --) shift; SCRIPT_PATH="${1:-}"; shift || true; break ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown argument: $1" >&2; usage; exit 1 ;; esac done [ -z "$PROXY_PID" ] && { echo "ERROR: --proxy-pid is required" >&2; usage; exit 1; } -[ -z "$TARGET_UID" ] && { echo "ERROR: --uid is required" >&2; usage; exit 1; } -[ -z "$TARGET_GID" ] && { echo "ERROR: --gid is required" >&2; usage; exit 1; } -[ -z "$SCRIPT_PATH" ] && { echo "ERROR: script path is required after --" >&2; usage; exit 1; } +[ -z "$RUNC_PATH" ] && { echo "ERROR: --runc is required" >&2; usage; exit 1; } +[ -z "$BUNDLE_DIR" ] && { echo "ERROR: --bundle is required" >&2; usage; exit 1; } +[ -z "$CONTAINER_ID" ] && { echo "ERROR: --container-id is required" >&2; usage; exit 1; } +[ -z "$NETNS_NAME" ] && { echo "ERROR: --netns-name is required" >&2; usage; exit 1; } +[ -z "$ROOTFS_BIND_DIR" ] && { echo "ERROR: --rootfs-bind-dir is required" >&2; usage; exit 1; } if [ "$(id -u)" != "0" ]; then echo "ERROR: run-isolated.sh must be run as root (via sudo)" >&2 exit 1 fi -for cmd in unshare nsenter setpriv ip env; do +for cmd in nsenter ip mount; do command -v "$cmd" >/dev/null 2>&1 || { echo "ERROR: required command not found: $cmd" >&2; exit 1; } done [ -e "/proc/${PROXY_PID}/ns/net" ] || { echo "ERROR: proxy netns not found for pid ${PROXY_PID}" >&2; exit 1; } -[ -x "$SCRIPT_PATH" ] || { echo "ERROR: script not found or not executable: ${SCRIPT_PATH}" >&2; exit 1; } +[ -x "$RUNC_PATH" ] || { echo "ERROR: runc not found or not executable: ${RUNC_PATH}" >&2; exit 1; } +[ -f "${BUNDLE_DIR}/config.json" ] || { echo "ERROR: OCI bundle config not found: ${BUNDLE_DIR}/config.json" >&2; exit 1; } RAND_ID=$(od -An -tx1 -N4 /dev/urandom 2>/dev/null | tr -d ' \n') [ -z "$RAND_ID" ] && RAND_ID=$(printf '%08x' "$$") VETH_T="sbxt${RAND_ID}" VETH_P="sbxp${RAND_ID}" -PLACEHOLDER_UNSHARE_PID="" -PLACEHOLDER_PID="" CODE=1 cleanup() { set +e - if [ -n "$PLACEHOLDER_PID" ]; then - # The proxy-side veth lives in the long-lived proxy container's netns, - # so it must be explicitly removed; the target-side end disappears - # automatically when the placeholder namespace is torn down below. - nsenter --net="/proc/${PROXY_PID}/ns/net" -- ip link del "$VETH_P" >/dev/null 2>&1 - fi - # The placeholder ("sleep infinity") is pid 1 inside its own new pid - # namespace, and pid 1 ignores signals with a default action of - # terminate unless it installed a handler -- SIGTERM will not touch it. - # SIGKILL cannot be caught or ignored, so it always tears the namespace - # down. - [ -n "$PLACEHOLDER_PID" ] && kill -9 "$PLACEHOLDER_PID" >/dev/null 2>&1 - [ -n "$PLACEHOLDER_UNSHARE_PID" ] && kill -9 "$PLACEHOLDER_UNSHARE_PID" >/dev/null 2>&1 - wait >/dev/null 2>&1 + # -f/--force also kills the container's process tree if it's still + # running (e.g. this trap fired from INT/TERM mid-run), so it must run + # before the network/mount resources below are torn out from under it. + "$RUNC_PATH" delete -f "$CONTAINER_ID" >/dev/null 2>&1 + umount -R "$ROOTFS_BIND_DIR" >/dev/null 2>&1 + # The proxy-side veth lives in the long-lived proxy container's netns, so + # it must be explicitly removed -- unlike the target-side end (torn down + # for free when the sandbox netns below is deleted), a still-alive + # namespace doesn't lose its interfaces just because its veth peer's + # namespace went away. + nsenter --net="/proc/${PROXY_PID}/ns/net" -- ip link del "$VETH_P" >/dev/null 2>&1 + ip netns del "$NETNS_NAME" >/dev/null 2>&1 exit "$CODE" } trap cleanup EXIT INT TERM -echo "run-isolated: creating placeholder namespace..." >&2 -unshare --net --pid --mount --uts --ipc --cgroup --mount-proc --fork -- sh -c 'exec sleep infinity' & -PLACEHOLDER_UNSHARE_PID=$! - -# unshare --pid does not move the calling (unshare) process itself into the -# new pid namespace -- only the first forked child does, and *that* child's -# host-visible PID is what nsenter needs. Discover it via procfs. -i=0 -while [ "$i" -lt 200 ]; do - CHILD=$(cat "/proc/${PLACEHOLDER_UNSHARE_PID}/task/${PLACEHOLDER_UNSHARE_PID}/children" 2>/dev/null | awk '{print $1}') - if [ -n "$CHILD" ] && [ -e "/proc/${CHILD}/ns/net" ]; then - PLACEHOLDER_PID="$CHILD" - break - fi - i=$((i + 1)) - sleep 0.02 -done -[ -z "$PLACEHOLDER_PID" ] && { echo "ERROR: timed out waiting for placeholder namespace" >&2; exit 1; } -echo "run-isolated: placeholder pid=${PLACEHOLDER_PID}" >&2 - -# The placeholder's mount namespace starts out as a clone of the host's, and -# a cloned mount keeps the same propagation type (typically "shared" under -# systemd) as its origin -- meaning every bind-mount/remount below would -# otherwise propagate straight back out to the host's real mount namespace. -# `--make-rprivate` (recursive) detaches the whole tree from that peer group -# before anything else touches it. -nsenter --mount="/proc/${PLACEHOLDER_PID}/ns/mnt" -- mount --make-rprivate / +echo "run-isolated: creating sandbox network namespace..." >&2 +ip netns add "$NETNS_NAME" echo "run-isolated: creating veth pair ${VETH_T} <-> ${VETH_P}..." >&2 ip link add "$VETH_T" type veth peer name "$VETH_P" -ip link set "$VETH_T" netns "$PLACEHOLDER_PID" +ip link set "$VETH_T" netns "$NETNS_NAME" ip link set "$VETH_P" netns "$PROXY_PID" -echo "run-isolated: configuring target namespace network..." >&2 -nsenter --net="/proc/${PLACEHOLDER_PID}/ns/net" -- sh -c " +echo "run-isolated: configuring sandbox namespace network..." >&2 +ip netns exec "$NETNS_NAME" sh -c " set -e ip link set '${VETH_T}' name eth0 ip addr add '${TARGET_IP}/24' dev eth0 @@ -135,94 +109,14 @@ nsenter --net="/proc/${PLACEHOLDER_PID}/ns/net" -- sh -c " ip route add default via '${GATEWAY}' " -echo "run-isolated: rewriting resolv.conf inside target mount namespace..." >&2 -nsenter --mount="/proc/${PLACEHOLDER_PID}/ns/mnt" --net="/proc/${PLACEHOLDER_PID}/ns/net" -- sh -c " - set -e - printf 'nameserver %s\n' '${DNS}' > /tmp/.buildcage-resolv.conf - mount --bind /tmp/.buildcage-resolv.conf /etc/resolv.conf -" - -echo "run-isolated: masking sensitive /proc paths..." >&2 -nsenter --mount="/proc/${PLACEHOLDER_PID}/ns/mnt" -- sh -c ' - for p in /proc/kcore /proc/kallsyms /proc/kmsg /proc/sysrq-trigger /proc/timer_list /proc/keys; do - [ -e "$p" ] && mount --bind /dev/null "$p" 2>/dev/null - done - true -' - -# Paths that stay writable: workdir/home/tmp plus whatever --writable added. -# "/" among the extras is a sentinel meaning "disable this restriction -# entirely" (see usage()) rather than literally protecting just the "/" -# mount entry, since most of the filesystem below "/" isn't a separate -# mount point and so wouldn't be covered by protecting "/" alone. -PROTECTED_PATHS=("$WORKDIR" "$HOME_DIR" /tmp) -[ ${#WRITABLE_PATHS[@]} -gt 0 ] && PROTECTED_PATHS+=("${WRITABLE_PATHS[@]}") -DISABLE_READONLY=false -for p in "${WRITABLE_PATHS[@]}"; do - [ "$p" = "/" ] && DISABLE_READONLY=true -done - -if [ "$DISABLE_READONLY" = "true" ]; then - echo "run-isolated: 'writable: /' given -- leaving the filesystem fully writable" >&2 -else - echo "run-isolated: restricting filesystem to read-only (except workdir/home/tmp/writable)..." >&2 - # --target (not just --mount=) is required here: /proc/self/mountinfo only - # resolves "self" correctly when this process is actually a member of the - # pid namespace that the target's /proc instance was mounted for. - nsenter --target "$PLACEHOLDER_PID" --mount --pid -- sh -c ' - set -e - # Bind-mounting a path onto itself gives it its own mount-table entry, so - # remounting everything else read-only below does not affect it. - for d in "$@"; do - [ -n "$d" ] && [ -d "$d" ] && mount --bind "$d" "$d" - done - # Walk existing mounts and remount each read-only in place, skipping the - # paths just made writable above. "bind" is required: a plain - # "remount,ro" changes the underlying superblock, which is shared with - # the mount this was cloned from (i.e. the real host mount namespace) - # even after make-rprivate -- only "remount,bind,ro" scopes the - # read-only flag to this one mount entry. - # - # A failed remount is NOT silently ignored: pseudo-filesystems that - # legitimately reject a read-only remount are tolerated (their - # writability is not a payload-planting surface for a later step), but - # any *real* filesystem left writable fails the run closed, so a silent - # remount failure can never quietly weaken the read-only guarantee - # documented in docs/security.md. In each mountinfo line the 6th field - # holds the per-mount options, and the field immediately after the - # " - " separator is the filesystem type. - tac /proc/self/mountinfo | while read -r _ _ _ _ mnt_point mnt_opts rest; do - skip=0 - for p in "$@"; do - [ "$mnt_point" = "$p" ] && skip=1 && break - done - [ "$skip" = 1 ] && continue - # Already read-only per mountinfo -- no remount needed. - case ",${mnt_opts}," in - *,ro,*) continue ;; - esac - mount -o remount,bind,ro "$mnt_point" 2>/dev/null && continue - # Retry once, re-bound onto itself: a mount that predates this - # namespace (e.g. a Docker maskedPath tmpfs re-parented under - # --mount-proc, seen on /proc/scsi and /proc/interrupts) can reject an - # otherwise-safe remount until re-bound gives it a fresh mount entry. - mount --bind "$mnt_point" "$mnt_point" 2>/dev/null && - mount -o remount,bind,ro "$mnt_point" 2>/dev/null && continue - fstype=${rest#*- } - fstype=${fstype%% *} - case "$fstype" in - proc|procfs|sysfs|cgroup|cgroup2|devpts|mqueue|debugfs|tracefs|securityfs|\ -pstore|bpf|configfs|fusectl|hugetlbfs|binfmt_misc|autofs|efivarfs|nsfs|rpc_pipefs) - echo "run-isolated: note: pseudo-fs ${mnt_point} (${fstype}) rejected read-only remount; tolerated" >&2 - ;; - *) - echo "ERROR: failed to remount ${mnt_point} (${fstype:-unknown}) read-only; refusing to run with it left writable" >&2 - exit 1 - ;; - esac - done - ' sh "${PROTECTED_PATHS[@]}" -fi +echo "run-isolated: bind-mounting host root for runc's rootfs..." >&2 +mkdir -p "$ROOTFS_BIND_DIR" +mount --rbind / "$ROOTFS_BIND_DIR" +# Scope privacy to this one bind-mount rather than the host's real "/" -- +# runc's own (later, further-nested) mount namespace for the container +# takes care of making *that* private; this only needs to stop propagation +# of what happens inside runc's rootfs back out to the host. +mount --make-rprivate "$ROOTFS_BIND_DIR" echo "run-isolated: attaching proxy-side veth to sandbox0 bridge..." >&2 nsenter --net="/proc/${PROXY_PID}/ns/net" -- sh -c " @@ -231,33 +125,15 @@ nsenter --net="/proc/${PROXY_PID}/ns/net" -- sh -c " ip link set '${VETH_P}' up " -echo "run-isolated: executing isolated command..." >&2 +echo "run-isolated: executing isolated command via runc..." >&2 set +e -NSENTER_ARGS=(--target "$PLACEHOLDER_PID" --net --mount --uts --ipc --cgroup --pid) -[ -n "$WORKDIR" ] && NSENTER_ARGS+=(--wd="$WORKDIR") - -if [ -n "$ENV_FILE" ]; then - # Read the NUL-separated KEY=VALUE dump directly into an array rather than - # piping it through `xargs -0`: GNU xargs maps *any* exit status 1-125 from - # the command it runs to its own fixed exit status 123 (255 becomes 124), - # which would make it impossible for this script to report the isolated - # command's actual exit code. - # No "--" before setpriv: env treats the first non-NAME=VALUE token as - # the command to run on its own. - mapfile -d '' -t ENV_ASSIGNMENTS < "$ENV_FILE" - nsenter "${NSENTER_ARGS[@]}" -- \ - env -i "${ENV_ASSIGNMENTS[@]}" \ - setpriv --reuid="$TARGET_UID" --regid="$TARGET_GID" --clear-groups \ - --bounding-set=-all --no-new-privs -- \ - "$SCRIPT_PATH" - CODE=$? -else - nsenter "${NSENTER_ARGS[@]}" -- \ - setpriv --reuid="$TARGET_UID" --regid="$TARGET_GID" --clear-groups \ - --bounding-set=-all --no-new-privs -- \ - "$SCRIPT_PATH" - CODE=$? -fi +# No nsenter wrapper needed here: config.json's linux.namespaces network +# entry already points at /var/run/netns/${NETNS_NAME} (see +# isolated-exec.js's buildOciConfig), so runc joins it itself as part of +# its own container setup -- namespaces, capabilities, mounts, uid/gid, +# and the seccomp filter are all declared in config.json. +"$RUNC_PATH" run --bundle "$BUNDLE_DIR" "$CONTAINER_ID" +CODE=$? set -e echo "run-isolated: command exited with code ${CODE}" >&2 From 797bd4f4b8b0539d81e11f189a793ddf2c199ef1 Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 10:17:46 +0900 Subject: [PATCH 04/28] feat: add a die-with-parent equivalent via setpriv --pdeathsig Ties the sandboxed process's life to run-isolated.sh's via a two-hop setpriv --pdeathsig=KILL chain (run-isolated.sh -> runc run -> sandboxed command), since `runc run`'s own process sits between them. Closes the same SIGKILL-orphaning gap bwrap's --die-with-parent would have, without needing a new binary -- confirmed util-linux's setpriv already supports --pdeathsig. Verified empirically: killing run-isolated.sh directly tears down the whole chain. --- run/dist/main.cjs | 2 +- run/scripts/run-isolated.sh | 27 +++++++++++++++++++++++++-- run/src/lib/isolated-exec.js | 14 +++++++++++++- run/src/lib/isolated-exec.test.js | 8 ++++++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/run/dist/main.cjs b/run/dist/main.cjs index 2077767..5d292df 100644 --- a/run/dist/main.cjs +++ b/run/dist/main.cjs @@ -7560,7 +7560,7 @@ process.argv[1] === node_url.fileURLToPath("undefined" == typeof document ? requ uid: uid, gid: gid }, - args: [ scriptPath ], + args: [ "setpriv", "--pdeathsig=KILL", "--", scriptPath ], env: Object.entries(env).filter(([, v]) => void 0 !== v).map(([k, v]) => `${k}=${v}`), cwd: workdir || "/", capabilities: { diff --git a/run/scripts/run-isolated.sh b/run/scripts/run-isolated.sh index c338044..94bd2fa 100755 --- a/run/scripts/run-isolated.sh +++ b/run/scripts/run-isolated.sh @@ -59,7 +59,7 @@ if [ "$(id -u)" != "0" ]; then echo "ERROR: run-isolated.sh must be run as root (via sudo)" >&2 exit 1 fi -for cmd in nsenter ip mount; do +for cmd in nsenter ip mount setpriv; do command -v "$cmd" >/dev/null 2>&1 || { echo "ERROR: required command not found: $cmd" >&2; exit 1; } done [ -e "/proc/${PROXY_PID}/ns/net" ] || { echo "ERROR: proxy netns not found for pid ${PROXY_PID}" >&2; exit 1; } @@ -132,7 +132,30 @@ set +e # isolated-exec.js's buildOciConfig), so runc joins it itself as part of # its own container setup -- namespaces, capabilities, mounts, uid/gid, # and the seccomp filter are all declared in config.json. -"$RUNC_PATH" run --bundle "$BUNDLE_DIR" "$CONTAINER_ID" +# +# setpriv --pdeathsig here (targeting this script's own life) is the first +# half of a two-hop chain: `runc run`'s own process, not the container +# process it starts, is this script's direct child, so a plain +# --die-with-parent-style guard on the *sandboxed* process alone (set in +# config.json's process.args, see buildOciConfig) would only protect +# against `runc run` itself dying -- if this script gets SIGKILL'd, +# `runc run` would just become an orphan (still alive, unaffected) without +# this. Verified empirically: killing this script's own bash process tears +# down the whole chain (runc and the sandboxed command both die); without +# the outer setpriv, the sandboxed command survives as an orphan. +# +# Known residual gap: `sudo -n` itself (invoked by isolated-exec.js) forks +# a separate monitor process ahead of this script on distros with the +# common `Defaults use_pty` sudoers setting -- if *that* specific process +# were killed in isolation (without this script also dying), this chain +# wouldn't trigger, since this script would merely become sudo's monitor's +# orphan, still alive. Not addressed here: this is a low-severity gap +# (an orphaned, still-fully-sandboxed process, not a security boundary +# issue -- see docs/security.md), and the realistic failure modes this +# guards against (the runner process/job being torn down, an OOM kill +# landing on this script itself) target this script directly, not +# specifically sudo's monitor process in isolation. +setpriv --pdeathsig=KILL -- "$RUNC_PATH" run --bundle "$BUNDLE_DIR" "$CONTAINER_ID" CODE=$? set -e diff --git a/run/src/lib/isolated-exec.js b/run/src/lib/isolated-exec.js index bb43151..7b2fd22 100644 --- a/run/src/lib/isolated-exec.js +++ b/run/src/lib/isolated-exec.js @@ -101,7 +101,19 @@ export function buildOciConfig( ...baseSpec.process, terminal: false, user: { uid, gid }, - args: [scriptPath], + // setpriv --pdeathsig ties this process's life to its direct + // parent's -- the `runc run` process, not run-isolated.sh itself + // (runc's own process sits in between). This is the second hop of a + // two-hop chain: run-isolated.sh also wraps its own `runc run` + // invocation in `setpriv --pdeathsig=KILL` (targeting itself), so if + // run-isolated.sh is SIGKILL'd, `runc run` dies too, which then + // kills this process in turn -- without the outer hop, `runc run` + // would merely become an orphan (still alive) and this process, + // whose parent never actually died, would never receive anything. + // No other setpriv flags are needed here -- uid/gid, capabilities, + // and no_new_privs are already applied by runc itself (above/below) + // before this execs. + args: ["setpriv", "--pdeathsig=KILL", "--", scriptPath], env: Object.entries(env) .filter(([, v]) => v !== undefined) .map(([k, v]) => `${k}=${v}`), diff --git a/run/src/lib/isolated-exec.test.js b/run/src/lib/isolated-exec.test.js index 52761f2..9c19826 100644 --- a/run/src/lib/isolated-exec.test.js +++ b/run/src/lib/isolated-exec.test.js @@ -93,13 +93,17 @@ describe("buildOciConfig", () => { assert.equal(config.process.noNewPrivileges, true); }); - it("sets uid/gid, args, and cwd from the given options", () => { + it("sets uid/gid and cwd from the given options", () => { const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); assert.deepEqual(config.process.user, { uid: 1000, gid: 1000 }); - assert.deepEqual(config.process.args, [baseArgs.scriptPath]); assert.equal(config.process.cwd, baseArgs.workdir); }); + it("wraps the script in `setpriv --pdeathsig=KILL` (die-with-parent, see run-isolated.sh)", () => { + const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); + assert.deepEqual(config.process.args, ["setpriv", "--pdeathsig=KILL", "--", baseArgs.scriptPath]); + }); + it("replaces process.env with the given env, dropping undefined values", () => { const config = buildOciConfig(fakeBaseSpec(), { ...baseArgs, writablePaths: [] }); assert.deepEqual(config.process.env, ["FOO=bar"]); From 6f88433974b741decb316bb7fd57b0774f4d185d Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 10:33:10 +0900 Subject: [PATCH 05/28] test: add seccomp and die-with-parent integration tests, fix the dev loop integration-test-seccomp.sh verifies unshare -U and io_uring_setup are actually blocked (the two CVE classes docs/security.md names) while ordinary syscalls still work. integration-test-die-with-parent.sh SIGKILLs run-isolated.sh mid-run and verifies the whole sandboxed process tree dies with it rather than surviving as an orphan. Both wired into test_sandbox_integration, so CI's existing test_sandbox job picks them up. Also updates the Mac-friendly dev loop (run/dev/Dockerfile, compose.sandbox-dev.yaml, Makefile's test_sandbox_mode) for run-isolated.sh's new runc-based interface: runc and gen-seccomp-profile are now built directly into the dev image (mirroring docker/Dockerfile), and a new build-test-bundle.sh stands in for isolated-exec.js's buildOciConfig so the dev loop can keep testing run-isolated.sh directly without needing the Docker socket mounted in. The dev-runner service also needs the same writable-/sys/fs/cgroup treatment the "builder" service already has, for the same reason (runc's own cgroup management) -- not needed on the real runner host this dev loop stands in for. --- Makefile | 11 ++- run/compose.sandbox-dev.yaml | 9 +++ run/dev/Dockerfile | 39 +++++++++- run/dev/build-test-bundle.sh | 58 ++++++++++++++ run/test/integration-test-die-with-parent.sh | 79 ++++++++++++++++++++ run/test/integration-test-seccomp.sh | 65 ++++++++++++++++ 6 files changed, 257 insertions(+), 4 deletions(-) create mode 100755 run/dev/build-test-bundle.sh create mode 100755 run/test/integration-test-die-with-parent.sh create mode 100755 run/test/integration-test-seccomp.sh diff --git a/Makefile b/Makefile index 8d8ee87..3abc89e 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 /tmp/bundle; \ + run-isolated.sh --proxy-pid $$PROXY_PID --runc /usr/local/bin/runc --bundle /tmp/bundle \ + --container-id buildcage-sandbox-dev --netns-name buildcage-sandbox-dev --rootfs-bind-dir /tmp/bundle/rootfs \ + --gateway 172.20.0.1 --dns 172.20.0.1" @$(MAKE) clean_sandbox_mode .PHONY: clean_sandbox_mode @@ -173,6 +176,8 @@ 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-concurrent.sh .PHONY: test_unit diff --git a/run/compose.sandbox-dev.yaml b/run/compose.sandbox-dev.yaml index fd80d70..7e9486b 100644 --- a/run/compose.sandbox-dev.yaml +++ b/run/compose.sandbox-dev.yaml @@ -16,9 +16,18 @@ services: security_opt: - seccomp=unconfined - apparmor=unconfined + # runc needs to create/manage a cgroup for the sandboxed process, which + # needs a writable /sys/fs/cgroup -- read-only by default in a + # non-privileged container, same reasoning as the "builder" service in + # ../../compose.yaml. Not needed on the real GitHub Actions runner host + # this dev loop stands in for (root already has full cgroup access + # there without this). + cgroup: host volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw - ./run/scripts/run-isolated.sh:/usr/local/bin/run-isolated.sh:ro - ./run/dev/smoke-test.sh:/usr/local/bin/smoke-test.sh:ro + - ./run/dev/build-test-bundle.sh:/usr/local/bin/build-test-bundle.sh:ro depends_on: proxy: condition: service_started diff --git a/run/dev/Dockerfile b/run/dev/Dockerfile index 6f8a04b..5f93689 100644 --- a/run/dev/Dockerfile +++ b/run/dev/Dockerfile @@ -9,8 +9,45 @@ # used in production or in CI's test_sandbox job (see # docs/development.md) — those run run-isolated.sh directly on the runner # host, matching what actually happens when a user's workflow executes. +# +# runc and gen-seccomp-profile are built directly into this image (mirroring +# ../docker/Dockerfile's own stages) rather than `docker cp`-extracted from +# the proxy image at runtime, so this dev loop doesn't need the Docker +# socket mounted in just to reach a sibling container — it stays scoped to +# testing run-isolated.sh itself, the same as before this file started +# needing these two binaries at all. +# renovate: datasource=github-releases depName=opencontainers/runc +ARG RUNC_VERSION=v1.5.1 +ARG RUNC_SHA256_AMD64=177df879d50c913eb205e898d5c1c05a18f574053c0ce5524c471208eaf06f6f +ARG RUNC_SHA256_ARM64=ca70e7dbd6616ca782a59b5d3ac86909123fdaa9fa3f89dcf29051c70eee7ce9 + +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS deps +ARG RUNC_VERSION +ARG RUNC_SHA256_AMD64 +ARG RUNC_SHA256_ARM64 +RUN apk add --no-cache curl && \ + mkdir -p /opt/buildcage/bin && \ + ARCH=$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') && \ + EXPECTED_SHA256=$([ "$ARCH" = "amd64" ] && echo "$RUNC_SHA256_AMD64" || echo "$RUNC_SHA256_ARM64") && \ + curl -fsSL "https://github.com/opencontainers/runc/releases/download/${RUNC_VERSION}/runc.${ARCH}" \ + -o /opt/buildcage/bin/runc && \ + echo "${EXPECTED_SHA256} /opt/buildcage/bin/runc" | sha256sum -c && \ + chmod +x /opt/buildcage/bin/runc + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine@sha256:523c3effe300580ed375e43f43b1c9b091b68e935a7c3a92bfcc4e7ed55b18c2 AS gen-seccomp-profile-build +ARG TARGETOS +ARG TARGETARCH +WORKDIR /src +COPY docker/gen-seccomp-profile/go.mod docker/gen-seccomp-profile/go.sum ./ +RUN go mod download +COPY docker/gen-seccomp-profile/main.go ./ +RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build -trimpath -ldflags="-s -w" -o /out/gen-seccomp-profile . + FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b -RUN apk add --no-cache bash util-linux iproute2 findutils +RUN apk add --no-cache bash util-linux iproute2 findutils jq + +COPY --from=deps /opt/buildcage/bin/runc /usr/local/bin/runc +COPY --from=gen-seccomp-profile-build /out/gen-seccomp-profile /usr/local/bin/gen-seccomp-profile CMD ["sleep", "infinity"] diff --git a/run/dev/build-test-bundle.sh b/run/dev/build-test-bundle.sh new file mode 100755 index 0000000..0a8f7fa --- /dev/null +++ b/run/dev/build-test-bundle.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# build-test-bundle.sh — dev-only stand-in for isolated-exec.js's +# buildOciConfig, used by `make test_sandbox_mode` to build just enough of +# an OCI bundle to exercise run-isolated.sh directly (see +# ../compose.sandbox-dev.yaml and ../dev/Dockerfile for why this dev loop +# doesn't run the real run/dist/main.cjs, and so needs a minimal +# hand-built substitute for the config.json JS would normally produce). +# +# Not a full reimplementation: no writable-path/writable-/ handling, no +# read env passthrough beyond PATH -- just enough to run the smoke test +# with the same namespaces/capabilities/seccomp policy production uses. +set -euo pipefail + +NETNS_NAME="" +SCRIPT_PATH="" +BUNDLE_DIR="" + +while [ $# -gt 0 ]; do + case "$1" in + --netns-name) NETNS_NAME="$2"; shift 2 ;; + --script) SCRIPT_PATH="$2"; shift 2 ;; + --bundle) BUNDLE_DIR="$2"; shift 2 ;; + *) echo "ERROR: unknown argument: $1" >&2; exit 1 ;; + esac +done +[ -z "$NETNS_NAME" ] && { echo "ERROR: --netns-name is required" >&2; exit 1; } +[ -z "$SCRIPT_PATH" ] && { echo "ERROR: --script is required" >&2; exit 1; } +[ -z "$BUNDLE_DIR" ] && { echo "ERROR: --bundle is required" >&2; exit 1; } + +mkdir -p "$BUNDLE_DIR" +(cd "$BUNDLE_DIR" && runc spec) +gen-seccomp-profile > "$BUNDLE_DIR/seccomp.json" + +printf 'nameserver 172.20.0.1\n' > "$BUNDLE_DIR/resolv.conf" + +jq \ + --arg netnsPath "/var/run/netns/${NETNS_NAME}" \ + --arg rootfsBindDir "${BUNDLE_DIR}/rootfs" \ + --arg resolvConf "${BUNDLE_DIR}/resolv.conf" \ + --arg scriptPath "$SCRIPT_PATH" \ + --slurpfile seccomp "$BUNDLE_DIR/seccomp.json" \ + ' + .root.path = $rootfsBindDir | .root.readonly = true | + .mounts += [ + {"destination":"/etc/resolv.conf","type":"none","source":$resolvConf,"options":["rbind","ro"]}, + {"destination":"/tmp","type":"none","source":"/tmp","options":["rbind","rw"]} + ] | + .linux.namespaces = (.linux.namespaces | map(if .type == "network" then . + {"path": $netnsPath} else . end)) | + .linux.seccomp = $seccomp[0] | + .linux.maskedPaths += ["/proc/kallsyms", "/proc/kmsg", "/proc/sysrq-trigger"] | + .linux.readonlyPaths -= ["/proc/sysrq-trigger"] | + .process.terminal = false | + .process.user = {"uid": 1000, "gid": 1000} | + .process.args = ["setpriv", "--pdeathsig=KILL", "--", $scriptPath] | + .process.capabilities = {"bounding":[],"effective":[],"permitted":[],"inheritable":[],"ambient":[]} | + .process.noNewPrivileges = true + ' "$BUNDLE_DIR/config.json" > "$BUNDLE_DIR/config.json.new" +mv "$BUNDLE_DIR/config.json.new" "$BUNDLE_DIR/config.json" diff --git a/run/test/integration-test-die-with-parent.sh b/run/test/integration-test-die-with-parent.sh new file mode 100755 index 0000000..cc67b39 --- /dev/null +++ b/run/test/integration-test-die-with-parent.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Verifies the setpriv --pdeathsig chain (see run-isolated.sh and +# isolated-exec.js's buildOciConfig): SIGKILL-ing run-isolated.sh's own +# process must tear down the whole sandboxed process tree rather than +# leaving it running as an orphan. Drives run/dist/main.cjs directly, not +# through the real action wrapper, so this script can reach in and kill +# run-isolated.sh mid-run. +set -uo pipefail + +: "${BUILDCAGE_LOCAL_IMAGE_REF:?BUILDCAGE_LOCAL_IMAGE_REF must be set to the locally built proxy image}" + +WORKDIR=$(mktemp -d) +touch "$WORKDIR/state.env" +FAILURES=0 + +cleanup() { + [ -n "${NODE_PID:-}" ] && kill -9 "$NODE_PID" >/dev/null 2>&1 + pkill -9 -f "sudo -n -- .*/run/scripts/run-isolated.sh" >/dev/null 2>&1 + docker ps -aq --filter "name=buildcage-proxy-" | xargs -r docker rm -f >/dev/null 2>&1 + docker network ls --filter "name=buildcage-proxy-" -q | xargs -r docker network rm >/dev/null 2>&1 + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +GITHUB_WORKSPACE="$WORKDIR" \ +GITHUB_STATE="$WORKDIR/state.env" \ +GITHUB_STEP_SUMMARY="$WORKDIR/summary.md" \ +BUILDCAGE_BUILD_TEST_HOOKS=1 \ +BUILDCAGE_LOCAL_IMAGE_REF="$BUILDCAGE_LOCAL_IMAGE_REF" \ +INPUT_RUN='echo sandboxed-process-started; sleep 300' \ + node run/dist/main.cjs > "$WORKDIR/out.log" 2>&1 & +NODE_PID=$! + +echo "waiting for the sandboxed 'sleep 300' to start..." >&2 +FOUND=0 +for _ in $(seq 1 60); do + if pgrep -f "sleep 300" >/dev/null 2>&1; then + FOUND=1 + break + fi + sleep 0.5 +done +if [ "$FOUND" != "1" ]; then + echo " FAIL sandboxed process never started; see log below" + cat "$WORKDIR/out.log" + exit 1 +fi +sleep 0.5 + +# Sudo's `use_pty` setting (common on Ubuntu) forks a monitor process ahead +# of the actual script, which also matches "run-isolated.sh" in its argv -- +# this must target the real bash instance, not sudo's monitor (see +# run-isolated.sh's own comment on this same distinction). +BASH_PID=$(pgrep -f "/bin/bash .*/run/scripts/run-isolated.sh") +if [ -z "$BASH_PID" ]; then + echo " FAIL could not find run-isolated.sh's own bash process" + exit 1 +fi + +kill -9 "$BASH_PID" +sleep 2 + +echo "" +echo "=== Sandbox die-with-parent Assertions ===" +echo "" +if pgrep -f "sleep 300" >/dev/null 2>&1; then + echo " FAIL sandboxed process survived as an orphan after run-isolated.sh was killed" + FAILURES=$((FAILURES + 1)) +else + echo " PASS entire sandbox process tree died with run-isolated.sh" +fi + +echo "" +if [ "$FAILURES" -gt 0 ]; then + echo "❌ FAILED: $FAILURES assertion(s) failed" + exit 1 +fi +echo "✅ All assertions passed." +echo "" diff --git a/run/test/integration-test-seccomp.sh b/run/test/integration-test-seccomp.sh new file mode 100755 index 0000000..5bb1acc --- /dev/null +++ b/run/test/integration-test-seccomp.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Verifies the Docker-default-profile-derived seccomp filter actually +# blocks the two syscall classes docs/security.md names as motivating +# examples (unprivileged user-namespace creation, io_uring) by driving +# run/dist/main.cjs directly, without the real action wrapper — see +# test-e2e.yml's test_sandbox_enforcement for the one case that does. +set -uo pipefail + +: "${BUILDCAGE_LOCAL_IMAGE_REF:?BUILDCAGE_LOCAL_IMAGE_REF must be set to the locally built proxy image}" + +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +touch "$WORKDIR/state.env" + +GITHUB_WORKSPACE="$WORKDIR" \ +GITHUB_STATE="$WORKDIR/state.env" \ +GITHUB_STEP_SUMMARY="$WORKDIR/summary.md" \ +BUILDCAGE_BUILD_TEST_HOOKS=1 \ +BUILDCAGE_LOCAL_IMAGE_REF="$BUILDCAGE_LOCAL_IMAGE_REF" \ +INPUT_RUN='set -e +echo "=== unshare -U (unprivileged user-namespace creation) must be blocked ===" +if unshare -U true 2>/dev/null; then + echo "UNEXPECTED: unshare -U succeeded" + exit 1 +fi +echo "OK: unshare -U blocked" + +echo "=== io_uring_setup must be blocked ===" +if ! command -v cc >/dev/null 2>&1; then + echo "SKIP: no C compiler available to exercise the raw syscall" +else + # No $ or backticks below, so an unquoted heredoc delimiter is safe (no + # accidental expansion by the shell that processes it). + cat > /tmp/io_uring_check.c < +#include +#include +#include +int main() { + long ret = syscall(__NR_io_uring_setup, 1, NULL); + if (ret == -1 && errno == EPERM) { printf("OK: io_uring_setup blocked\n"); return 0; } + printf("UNEXPECTED: io_uring_setup returned %ld errno=%d\n", ret, errno); + return 1; +} +EOF + cc -O0 -o /tmp/io_uring_check /tmp/io_uring_check.c + /tmp/io_uring_check +fi + +echo "=== an ordinary syscall-heavy operation must still work (filter is not overly broad) ===" +echo hello | grep -q hello && echo "OK: basic pipeline works" +' \ + node run/dist/main.cjs +CODE=$? + +echo "" +echo "=== Sandbox Seccomp Assertions ===" +echo "" +if [ "$CODE" = "0" ]; then + echo " PASS seccomp filter blocks unshare -U / io_uring_setup while ordinary syscalls still work" +else + echo " FAIL seccomp assertion check failed (exit $CODE)" + exit 1 +fi +echo "" From 4f2ad1c9e2a9dc6182c7402753712aa032143806 Mon Sep 17 00:00:00 2001 From: dash14 Date: Sun, 19 Jul 2026 10:35:52 +0900 Subject: [PATCH 06/28] docs: describe the runc-based sandbox and seccomp filter Rewrites docs/security.md's Isolation Mechanisms (seccomp filter, die-with-parent, capabilities/mounts now enforced by runc via config.json rather than setpriv/manual remounts) and removes the now-resolved 'No seccomp profile' limitation. Rewrites docs/development.md's Run Action Internals to match (runc/gen-seccomp-profile extraction, OCI bundle construction in isolated-exec.js, the two-hop pdeathsig chain) and updates the Sandbox Dev Loop section and directory tree listing. Flips docs/reference.md's seccomp NOTE from 'not applied' to 'applied'. --- docs/development.md | 113 +++++++++++++++++++++++++------------------- docs/reference.md | 5 +- docs/security.md | 71 ++++++++++++++++++---------- 3 files changed, 112 insertions(+), 77 deletions(-) diff --git a/docs/development.md b/docs/development.md index 06c0959..7258f76 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. @@ -153,47 +157,56 @@ Security Details and the [Reference](./reference.md#run-action) doc. 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 --