From 0b1eba9cb1800d884ad18afdf7f017e9fae08665 Mon Sep 17 00:00:00 2001 From: Kevin Tang <73975146+vt128@users.noreply.github.com> Date: Sun, 20 Sep 2026 07:25:50 +0800 Subject: [PATCH] [fix] verify non-root container delivery (STAR-57) --- .dockerignore | 3 + .github/workflows/build.yml | 17 +++++ Dockerfile | 29 ++++---- README.md | 31 +++++++-- e2e/e2e_test.go | 131 ++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4164f34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +* +!Dockerfile +!starcli diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 078b15d..4074361 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,23 @@ on: permissions: read-all jobs: + container: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.27.1" + - name: Build the runtime image + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o starcli . + docker build --platform linux/amd64 -t starcli:test . + - name: Test the runtime image + env: + STARCLI_TEST_IMAGE: starcli:test + run: go test -v ./e2e -run TestContainer -count=1 + ci: permissions: contents: read diff --git a/Dockerfile b/Dockerfile index 2fe4b6a..b8c522e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,18 @@ -FROM ubuntu:20.04 +FROM debian:bookworm-slim -ARG ROOT_PATH=/root -ARG BINARY=starcli - -ENV BINPATH=${ROOT_PATH}/${BINARY} +# Build the static linux/amd64 binary first with `make build_linux`. +# Named time zones are embedded by main.go's time/tzdata import. ENV TERM=xterm-256color -ENV TZ=Asia/Shanghai -ENV PORT=80 - -EXPOSE 80 -WORKDIR ${ROOT_PATH} - -COPY ${BINARY} ${ROOT_PATH}/ +ENV HOME=/work RUN set -eux; \ apt-get update \ - && apt-get install -y --no-install-recommends apt-transport-https ca-certificates tzdata \ - && apt-get clean && rm -rf /var/lib/apt/lists/* ; \ - ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo ${TZ} > /etc/timezone ; \ - chmod +x ${BINPATH} + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir /work && chown 65532:65532 /work -CMD ["sh", "-c", "${BINPATH}"] +COPY --chmod=755 starcli /usr/local/bin/starcli +USER 65532:65532 +WORKDIR /work +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/starcli"] diff --git a/README.md b/README.md index ef14d4d..b8d1223 100644 --- a/README.md +++ b/README.md @@ -45,19 +45,40 @@ make build ### Docker -The project includes a Dockerfile to build and run StarCLI in a container: +Build the static Linux binary before building the image. The image runs as UID +65532, includes system CA certificates, and uses the binary's embedded time-zone +data. Arguments go directly to StarCLI, which also receives stop signals. ```bash -# Build the Docker image -docker build -t starcli . +# Build and test the linux/amd64 image (also from an Apple Silicon host) +make build_linux +docker build --platform linux/amd64 -t starcli . +STARCLI_TEST_IMAGE=starcli go test ./e2e -run TestContainer -count=1 # Run in interactive mode -docker run -it starcli +docker run --rm --platform linux/amd64 -it starcli # Run a specific script -docker run -v $(pwd):/scripts starcli sh -c "/root/starcli /scripts/your-script.star" +docker run --rm --platform linux/amd64 --read-only \ + --cap-drop=ALL --security-opt=no-new-privileges \ + --memory=256m --cpus=1 --pids-limit=64 \ + --mount "type=bind,src=$(pwd),dst=/scripts,readonly" \ + starcli --caps safe -I /scripts /scripts/your-script.star + +# Serve a host-selected script; publish only on the host's loopback interface +docker run --rm --platform linux/amd64 --read-only \ + --cap-drop=ALL --security-opt=no-new-privileges \ + --memory=256m --cpus=1 --pids-limit=64 \ + -p 127.0.0.1:8080:8080 starcli --caps safe \ + --web-host 0.0.0.0 --web 8080 -c 'response.set_text("ready")' ``` +Mounted scripts must be readable by UID 65532. Mount a separate writable +directory when a trusted script needs persistent data. These examples are for +host-selected scripts; the image is not an untrusted multi-tenant execution +service. The container no longer uses `/root`, port 80, or a shell command as +its default entrypoint. Override `--entrypoint` explicitly if you need a shell. + ## Usage ```bash diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 19fc005..d35e197 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -3,15 +3,26 @@ // the "build a binary -> run a .star -> compare stdout/exit" coverage the v0.1.0 // cost-price audit flagged as missing: it proves the wired modules actually // *work* through the CLI, not merely that they load. +// +// Sections: local imports, golden scripts, domain modules, stdin, containers. package e2e import ( "bytes" + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" + "time" ) // binPath is the freshly built starcli binary, set up once in TestMain. @@ -210,3 +221,123 @@ func TestStdinConsumption(t *testing.T) { }) } } + +// TestContainer exercises the repository Dockerfile, including its entrypoint, +// trust store, embedded time zones, non-root user, and PID 1 shutdown behavior. +// Build it for linux/amd64 and set STARCLI_TEST_IMAGE to opt in. +func TestContainer(t *testing.T) { + image := os.Getenv("STARCLI_TEST_IMAGE") + if image == "" { + t.Skip("set STARCLI_TEST_IMAGE to test a built container image") + } + docker := func(args ...string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() + return string(out), err + } + runArgs := []string{"run", "--rm", "--platform", "linux/amd64", "--read-only", + "--cap-drop=ALL", "--security-opt=no-new-privileges", "--memory=256m", "--cpus=1", + "--pids-limit=64", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m"} + run := func(options, args []string) (string, error) { + all := append(append([]string{}, runArgs...), options...) + all = append(all, image) + return docker(append(all, args...)...) + } + for _, tc := range []struct { + name string + args []string + want string + }{ + {"arguments reach CLI", []string{"-c", `print(6 * 7)`}, "42\n"}, + {"non-root user", []string{"--allow-cmd", "-c", `load("cmd", "run"); print(run("id -u").stdout.strip())`}, "65532\n"}, + {"embedded timezone", []string{"-c", `load("time", "parse_time"); print(parse_time("2021-03-22T12:00:00Z").in_location("America/New_York").hour)`}, "8\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := run(nil, tc.args) + if err != nil || out != tc.want { + t.Fatalf("output=%q error=%v, want %q", out, err, tc.want) + } + }) + } + t.Run("exit status", func(t *testing.T) { + out, err := run(nil, []string{"-c", `fail("container failure")`}) + if err == nil || !strings.Contains(out, "container failure") { + t.Fatalf("output=%q error=%v, want script failure", out, err) + } + }) + t.Run("HTTPS trust", func(t *testing.T) { + bundle, err := run([]string{"--entrypoint", "/bin/cat"}, []string{"/etc/ssl/certs/ca-certificates.crt"}) + if err != nil || !x509.NewCertPool().AppendCertsFromPEM([]byte(bundle)) { + t.Fatalf("image must include a usable CA bundle: %v", err) + } + // The standard httptest certificate covers example.com. Resolve that + // name to the local Docker host, so this test needs no public service. + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "trusted fixture") + })) + _ = srv.Listener.Close() + srv.Listener, err = net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatal(err) + } + srv.StartTLS() + defer srv.Close() + port := srv.Listener.Addr().(*net.TCPAddr).Port + code := fmt.Sprintf(`load("http", "get"); print(get("https://example.com:%d").status_code)`, port) + options := []string{"--add-host", "example.com:host-gateway"} + out, err := run(options, []string{"-c", code}) + if err == nil || !strings.Contains(out, "certificate signed by unknown authority") { + t.Fatalf("untrusted certificate: output=%q error=%v", out, err) + } + caFile := filepath.Join(t.TempDir(), "ca-certificates.crt") + bundle += string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw})) + if err := os.WriteFile(caFile, []byte(bundle), 0o644); err != nil { + t.Fatal(err) + } + options = append(options, "--mount", "type=bind,src="+caFile+",dst=/etc/ssl/certs/ca-certificates.crt,readonly") + out, err = run(options, []string{"-c", code}) + if err != nil || out != "200\n" { + t.Fatalf("trusted certificate: output=%q error=%v", out, err) + } + }) + t.Run("HTTP and graceful stop", func(t *testing.T) { + args := []string{"run", "-d", "--platform", "linux/amd64", "--read-only", "--cap-drop=ALL", + "--security-opt=no-new-privileges", "--memory=256m", "--cpus=1", "--pids-limit=64", + "-p", "127.0.0.1::8080", image, "--caps", "safe", "--web-host", "0.0.0.0", "--web", "8080", + "-c", `response.set_text("container ready")`} + id, err := docker(args...) + if err != nil { + t.Fatalf("start: %v: %s", err, id) + } + id = strings.TrimSpace(id) + t.Cleanup(func() { _, _ = docker("rm", "-f", id) }) + port, err := docker("port", id, "8080/tcp") + if err != nil { + t.Fatalf("published port: %v: %s", err, port) + } + client := &http.Client{Timeout: time.Second} + ready := false + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + resp, err := client.Get("http://" + strings.TrimSpace(port)) + if err == nil { + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + ready = readErr == nil && resp.StatusCode == http.StatusOK && string(body) == "container ready" + break + } + time.Sleep(100 * time.Millisecond) + } + if !ready { + logs, _ := docker("logs", id) + t.Fatalf("HTTP endpoint did not become ready: %s", logs) + } + if out, err := docker("stop", "--time", "5", id); err != nil { + t.Fatalf("stop: %v: %s", err, out) + } + if out, err := docker("inspect", "--format", "{{.State.ExitCode}}", id); err != nil || strings.TrimSpace(out) != "0" { + t.Fatalf("graceful exit: %v: %s", err, out) + } + }) +}