From 689b0f3ebf79e78db46eb0bcb66d7b00e1aa028e Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Mon, 10 Aug 2026 09:57:26 -0400 Subject: [PATCH 1/3] feat: gate releases on quint verification and full test suite - Add ci-verify and release-verify Make targets with backend-parameterized quint verify - CI runs on pull requests only, reusing Make targets per job - Release workflow gates tag creation on release-verify, publishes draft prerelease, then flips to release only after all artifacts upload - Document local verification gates in docs/release.md --- .github/workflows/ci.yml | 40 +++++++------------ .github/workflows/release.yml | 74 ++++++++++++++++++----------------- Makefile | 23 ++++++++++- docs/release.md | 8 ++++ 4 files changed, 81 insertions(+), 64 deletions(-) create mode 100644 docs/release.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 058dfe6..80f2658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] @@ -19,40 +17,34 @@ jobs: with: node-version: "22" - run: npm install -g @informalsystems/quint - - run: quint typecheck spec/docker_socket_policy.qnt - - run: quint run --max-steps=100 --invariants allInvariants --backend typescript spec/docker_socket_policy.qnt + - run: make typecheck + - run: make verify BACKEND=typescript go: runs-on: ubuntu-latest - defaults: - run: - working-directory: go steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.22" cache-dependency-path: go/go.sum - - run: go build ./... - - run: go test ./... -count=1 - - run: go vet ./... + - run: make lint-go + - run: make test-go + - run: make build-go rust: runs-on: ubuntu-latest - defaults: - run: - working-directory: rs steps: - uses: actions/checkout@v4 - uses: actions-rust-lang/setup-rust-toolchain@v1 - - run: cargo build --release - - run: cargo test + with: + toolchain: "1.85" + - run: make lint-rs + - run: make test-rs + - run: make build-rs typescript: runs-on: ubuntu-latest - defaults: - run: - working-directory: ts steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -61,8 +53,9 @@ jobs: cache: npm cache-dependency-path: ts/package-lock.json - run: npm ci - - run: npx tsc --noEmit - - run: npx tsc && node --test dist/*.test.js + - run: make lint-ts + - run: make test-ts + - run: make build-ts integration: runs-on: ubuntu-latest @@ -76,9 +69,4 @@ jobs: needs: [go, rust, typescript] steps: - uses: actions/checkout@v4 - - name: Verify Go reproducibility - run: make verify-reproducible-go - - name: Verify Rust reproducibility - run: make verify-reproducible-rs - - name: Verify TypeScript reproducibility - run: make verify-reproducible-ts + - run: make verify-reproducible-all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfd70c1..e295d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,27 @@ permissions: id-token: write jobs: + release-verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: "1.85" + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: ts/package-lock.json + - run: npm ci + - name: Release verification gate + run: make release-verify + version: + needs: [release-verify] runs-on: ubuntu-latest outputs: tag: ${{ steps.bump.outputs.tag }} @@ -36,48 +56,18 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag "${{ steps.bump.outputs.tag }}" git push origin "${{ steps.bump.outputs.tag }}" - - name: Create draft release + - name: Create draft prerelease run: | gh release create "${{ steps.bump.outputs.tag }}" \ --title "${{ steps.bump.outputs.tag }}" \ --generate-notes \ - --draft + --draft \ + --prerelease env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - validate: - needs: [version] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - - name: Validate Go - run: | - cd go && go vet ./... - go test ./... -count=1 - go build -o /dev/null . - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: "1.85" - - name: Validate Rust - run: | - cd rs && cargo test - cargo build --release - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: ts/package-lock.json - - name: Validate TypeScript - run: | - cd ts && npm ci - npm run build - node --test dist/*.test.js - release-go: - needs: [version, validate] + needs: [version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -146,7 +136,7 @@ jobs: ghcr.io/chainsafe/docker-socket-policy-go:${{ needs.version.outputs.tag }} release-rust: - needs: [version, validate] + needs: [version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -208,7 +198,7 @@ jobs: ghcr.io/chainsafe/docker-socket-policy-rs:${{ needs.version.outputs.tag }} release-ts: - needs: [version, validate] + needs: [version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -276,3 +266,15 @@ jobs: run: | cosign sign --yes \ ghcr.io/chainsafe/docker-socket-policy-ts:${{ needs.version.outputs.tag }} + + publish-release: + runs-on: ubuntu-latest + needs: [version, release-go, release-rust, release-ts] + steps: + - name: Publish release + run: | + gh release edit "${{ needs.version.outputs.tag }}" \ + --draft=false \ + --prerelease=false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index c9a981b..633b24d 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,9 @@ OUTPUT_DIR ?= . VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) QUINT ?= $(shell command -v quint 2>/dev/null || echo node $$HOME/.hermes/node/lib/node_modules/@informalsystems/quint/dist/src/cli.js) SPEC ?= spec/docker_socket_policy.qnt +BACKEND ?= -.PHONY: build clean test lint verify typecheck validate +.PHONY: build clean test lint verify typecheck validate ci-verify release-verify .PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts # ─── Go ────────────────────────────────────────────── @@ -66,11 +67,29 @@ typecheck: $(QUINT) typecheck $(SPEC) verify: - $(QUINT) run --max-steps=100 --invariants allInvariants $(SPEC) + if [ -n "$(BACKEND)" ]; then \ + $(QUINT) run --max-steps=100 --invariants allInvariants --backend $(BACKEND) $(SPEC); \ + else \ + $(QUINT) run --max-steps=100 --invariants allInvariants $(SPEC); \ + fi verify-ts: $(QUINT) run --max-steps=50 --invariants allInvariants --backend typescript $(SPEC) +ci-verify: + $(MAKE) typecheck + $(MAKE) verify BACKEND=typescript + $(MAKE) test-all + $(MAKE) test-integration + $(MAKE) verify-reproducible-all + +release-verify: + $(MAKE) typecheck + $(MAKE) verify BACKEND=rust + $(MAKE) test-all + $(MAKE) test-integration + $(MAKE) verify-reproducible-all + # ─── Integration tests ─────────────────────────────── test-integration: diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..193fc4e --- /dev/null +++ b/docs/release.md @@ -0,0 +1,8 @@ +# Release Verification + +## Local Gates + +- `make ci-verify` — mirrors pull-request CI coverage (Quint TypeScript backend, unit tests, integration tests, reproducible builds). +- `make release-verify` — mirrors release workflow coverage (Quint Rust backend, same test suite) and must pass before tagging. + +Run the appropriate target before pushing changes that affect build or policy logic. Expect both commands to take 10–15 minutes because reproducible builds rebuild all artifacts from scratch. \ No newline at end of file From 3f6e99193a2d0180596406dbbb3138a96a318498 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Tue, 11 Aug 2026 07:29:32 -0400 Subject: [PATCH 2/3] fix: close test-parity gaps found during audit Cross-language parity fixes surfaced by auditing test coverage and running the full suite from a clean checkout: - Go: match image prefixes only on namespace boundaries, so 'evilfoo/bar' or 'foo-backup' can no longer match a 'foo' prefix; add readonly and validation-path middleware tests - Rust: rewrite the forwarded Content-Length to match the (possibly mutated) body length, preventing daemon-side JSON truncation - TS: honor --flag=value form; listen on the parsed host/port (the old string overload made the listen a no-op); set Content-Length from the actual forwarded body bytes; port handler tests to match Go/Rust - Integration: parameterize compose build context and project name by IMPL (go/rs/ts), rebuild images with --build, add -rs/-ts targets, run all three in CI; use numeric user uid:gid for scratch-based images - Spec: route pause to allowAction and record 9 invariants in README - Beacon sample policy: pin the host systemd account to uid/gid 100 (system user), independent of the in-container user --- .github/workflows/ci.yml | 2 + .gitignore | 3 + AGENTS.md | 2 +- Makefile | 14 ++- deploy/config/beacon.yaml | 6 +- deploy/docker-compose.sock.yml | 6 +- deploy/docker-compose.tcp.yml | 6 +- deploy/docker-compose.yml | 6 +- go/internal/middleware/chain_test.go | 125 +++++++++++++++++++++ go/internal/policy/manager.go | 17 ++- go/internal/policy/manager_test.go | 28 +++++ rs/src/handler.rs | 40 +++++-- spec/README.md | 2 +- spec/docker_socket_policy.qnt | 4 +- ts/src/flags.test.ts | 75 +++++++++++++ ts/src/flags.ts | 34 ++++++ ts/src/handler.test.ts | 158 +++++++++++++++++++++++++++ ts/src/index.ts | 23 ++-- ts/src/transport.ts | 14 ++- 19 files changed, 514 insertions(+), 51 deletions(-) create mode 100644 ts/src/flags.test.ts create mode 100644 ts/src/flags.ts create mode 100644 ts/src/handler.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80f2658..0a2d582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: steps: - uses: actions/checkout@v4 - run: make test-integration + - run: make test-integration-rs + - run: make test-integration-ts reproducible-build: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index bbc3adb..e036fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ go/docker-socket-policy # Dependencies /quint-llm-kit/ +# Internal planning docs +/docs/superpowers/ + # TypeScript ts/dist/ ts/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 2d623c7..421130f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ deploy/ — Docker Compose + integration tests ## Test Coverage - Go: 58 unit tests (policy: 9, middleware: 21, proxy: 28) - Rust: 104 unit tests (policy: 15, middleware: 48, proxy: 41) -- TypeScript: 80 unit tests (policy: 10, middleware: 37, proxy: 26, transport: 3, handler: 3) +- TypeScript: 99 unit tests (policy: 10, middleware: 37, proxy: 26, transport: 3, handler: 6, flags: 13) - 26 integration tests via deploy/test.sh + docker-compose ## Test Conventions diff --git a/Makefile b/Makefile index 633b24d..c95e1a8 100644 --- a/Makefile +++ b/Makefile @@ -92,13 +92,21 @@ release-verify: # ─── Integration tests ─────────────────────────────── +IMPL ?= go + test-integration: - docker compose -f deploy/docker-compose.yml down --remove-orphans -v 2>/dev/null; \ - docker compose -f deploy/docker-compose.yml run --rm test; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.yml down --remove-orphans -v 2>/dev/null; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.yml run --build --rm test; \ rc=$$?; \ - docker compose -f deploy/docker-compose.yml down --remove-orphans -v; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.yml down --remove-orphans -v; \ exit $$rc +test-integration-rs: + $(MAKE) test-integration IMPL=rs + +test-integration-ts: + $(MAKE) test-integration IMPL=ts + test-integration-tcp: docker compose -f deploy/docker-compose.tcp.yml down --remove-orphans -v 2>/dev/null; \ docker compose -f deploy/docker-compose.tcp.yml run --rm test; \ diff --git a/deploy/config/beacon.yaml b/deploy/config/beacon.yaml index c44c866..733538d 100644 --- a/deploy/config/beacon.yaml +++ b/deploy/config/beacon.yaml @@ -1,6 +1,8 @@ service_name: beacon -user_id: '2001' -group_id: '2001' +# Host systemd service account (must be a system-level user < 1000). +# The in-container user is set separately via container_config.user. +user_id: '100' +group_id: '100' allowed_image_prefixes: - chainsafe/lodestar diff --git a/deploy/docker-compose.sock.yml b/deploy/docker-compose.sock.yml index 642ec78..fadf433 100644 --- a/deploy/docker-compose.sock.yml +++ b/deploy/docker-compose.sock.yml @@ -1,4 +1,4 @@ -name: docker-socket-policy-test-sock +name: docker-socket-policy-test-sock-${IMPL:-go} volumes: sock-data: @@ -20,7 +20,7 @@ services: proxy-granted: build: - context: ../go + context: ../${IMPL:-go} dockerfile: Dockerfile # User 65532 with GID 2001 — has group access to the restricted socket user: 65532:2001 @@ -41,7 +41,7 @@ services: proxy-denied: build: - context: ../go + context: ../${IMPL:-go} dockerfile: Dockerfile # User 65532 with GID 3001 — NOT in dockertest group, should fail user: 65532:3001 diff --git a/deploy/docker-compose.tcp.yml b/deploy/docker-compose.tcp.yml index 3261dc0..638ec59 100644 --- a/deploy/docker-compose.tcp.yml +++ b/deploy/docker-compose.tcp.yml @@ -1,4 +1,4 @@ -name: docker-socket-policy-test-tcp +name: docker-socket-policy-test-tcp-${IMPL:-go} services: dind: @@ -19,9 +19,9 @@ services: proxy: build: - context: ../go + context: ../${IMPL:-go} dockerfile: Dockerfile - user: root + user: "0:0" depends_on: dind: condition: service_healthy diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 8c7b1dc..9dc9392 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,11 +1,11 @@ -name: docker-socket-policy-test +name: docker-socket-policy-test-${IMPL:-go} services: proxy: build: - context: ../go + context: ../${IMPL:-go} dockerfile: Dockerfile - user: root + user: "0:0" ports: - "12375:2375" volumes: diff --git a/go/internal/middleware/chain_test.go b/go/internal/middleware/chain_test.go index 43aebb5..ddd3b93 100644 --- a/go/internal/middleware/chain_test.go +++ b/go/internal/middleware/chain_test.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" "net/url" + "strings" "testing" "github.com/ChainSafe/docker-socket-policy/go/internal/policy" @@ -74,6 +75,35 @@ func TestExecGate_DeniesExec(t *testing.T) { } } +func TestChain_ReadonlyDeniesWrites(t *testing.T) { + for _, method := range []string{"POST", "PUT", "DELETE", "PATCH"} { + c := NewChain(true) + r := testRequest(method, "/containers/create") + body := map[string]interface{}{"Image": "chainsafe/lodestar:next"} + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatalf("expected %s to be denied in read-only mode", method) + } + if !strings.Contains(result.Reason, "read-only") { + t.Fatalf("expected read-only reason, got: %s", result.Reason) + } + } +} + +func TestChain_ReadonlyAllowsReads(t *testing.T) { + c := NewChain(true) + for _, method := range []string{"GET", "HEAD"} { + r := testRequest(method, "/containers/json") + body := map[string]interface{}{} + p := testPolicy(nil) + result := c.Execute(r, p, body) + if !result.Allowed { + t.Fatalf("expected %s to be allowed in read-only mode, got: %s", method, result.Reason) + } + } +} + func TestExecGate_AllowsNonExec(t *testing.T) { c := NewChain(false) r := testRequest("POST", "/containers/create") @@ -337,3 +367,98 @@ func TestChain_MutatorRunsBeforeGates(t *testing.T) { t.Fatalf("expected mutator to strip Privileged before gate check, got: %s", result.Reason) } } + +func TestRegistryGate_RejectsInvalidDigestFormat(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{"Image": "chainsafe/lodestar@sha256:xyz"} + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected invalid digest format to be rejected") + } +} + +func TestRegistryGate_RejectsOversizedTag(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + longTag := strings.Repeat("a", 129) + body := map[string]interface{}{"Image": "chainsafe/lodestar:" + longTag} + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected tag exceeding 128 chars to be rejected") + } +} + +func TestRegistryGate_RejectsTagOutsidePattern(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{"Image": "chainsafe/lodestar:2.1"} + p := testPolicy(nil) + p.ImageTagPattern = `^v?[0-9]+\.[0-9]+\.[0-9]+$` + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected tag outside pattern to be rejected") + } +} + +func TestCmdGate_ValidatesInlineFlagValue(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{ + "Image": "chainsafe/lodestar:next", + "Cmd": []interface{}{"--network=mainnet"}, + } + p := testPolicy(nil) + result := c.Execute(r, p, body) + if !result.Allowed { + t.Fatalf("expected --flag=value form to be allowed, got: %s", result.Reason) + } +} + +func TestCmdGate_RejectsInvalidInlineFlagValue(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{ + "Image": "chainsafe/lodestar:next", + "Cmd": []interface{}{"--network=hacker-chan"}, + } + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected invalid --flag=value to be rejected") + } +} + +func TestEnvFileGate_RejectsHostConfigEnv(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{ + "Image": "chainsafe/lodestar:next", + "HostConfig": map[string]interface{}{ + "Env": []interface{}{"SECRET=leaked"}, + }, + } + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected HostConfig.Env to be denied when env_file is set") + } +} + +func TestMountSourceGate_DeniesTopLevelVolumes(t *testing.T) { + c := NewChain(false) + r := testRequest("POST", "/containers/create") + body := map[string]interface{}{ + "Image": "chainsafe/lodestar:next", + "Volumes": map[string]interface{}{ + "/etc/passwd": map[string]interface{}{}, + }, + } + p := testPolicy(nil) + result := c.Execute(r, p, body) + if result.Allowed { + t.Fatal("expected non-whitelisted top-level Volumes key to be denied") + } +} diff --git a/go/internal/policy/manager.go b/go/internal/policy/manager.go index 91b004b..72f8f71 100644 --- a/go/internal/policy/manager.go +++ b/go/internal/policy/manager.go @@ -87,16 +87,25 @@ func (m *Manager) GetByImage(imageRef string) (*Policy, error) { imageName := extractImageName(imageRef) for _, p := range m.policiesByName { - for _, prefix := range p.AllowedImagePrefixes { - if strings.HasPrefix(imageName, prefix) { - return p, nil - } + if matchesImagePrefix(imageName, p.AllowedImagePrefixes) { + return p, nil } } return nil, fmt.Errorf("no policy found for image: %s", imageRef) } +// matchesImagePrefix matches a full image name against a prefix allowing only +// namespace boundaries ("prefix" or "prefix/..."), never a bare substring. +func matchesImagePrefix(imageName string, prefixes []string) bool { + for _, prefix := range prefixes { + if imageName == prefix || strings.HasPrefix(imageName, prefix+"/") { + return true + } + } + return false +} + func (m *Manager) List() []string { var names []string for name := range m.policiesByName { diff --git a/go/internal/policy/manager_test.go b/go/internal/policy/manager_test.go index f60a99a..a9b2bc8 100644 --- a/go/internal/policy/manager_test.go +++ b/go/internal/policy/manager_test.go @@ -159,6 +159,34 @@ allowed_image_prefixes: } } +func TestGetByImage_RejectsUnrelatedPrefix(t *testing.T) { + dir := t.TempDir() + writePolicyFile(t, dir, "beacon.yaml", ` +service_name: beacon +allowed_image_prefixes: + - chainsafe/lodestar +`) + + m, err := NewManager(dir) + if err != nil { + t.Fatalf("NewManager failed: %v", err) + } + + if _, err := m.GetByImage("evilchainsafe-lodestar:latest"); err == nil { + t.Fatal("expected error for bare-substring prefix match, got nil") + } + if _, err := m.GetByImage("chainsafe/lodestar-backup:latest"); err == nil { + t.Fatal("expected error for sibling image matching prefix, got nil") + } + p, err := m.GetByImage("chainsafe/lodestar/beacon:latest") + if err != nil { + t.Fatalf("expected nested image under prefix to match: %v", err) + } + if p.ServiceName != "beacon" { + t.Fatalf("expected 'beacon', got '%s'", p.ServiceName) + } +} + func TestGet_UnknownService(t *testing.T) { dir := t.TempDir() writePolicyFile(t, dir, "beacon.yaml", ` diff --git a/rs/src/handler.rs b/rs/src/handler.rs index 22cbdcb..a422dc8 100644 --- a/rs/src/handler.rs +++ b/rs/src/handler.rs @@ -4,7 +4,7 @@ use crate::proxy::{Action, Router}; use crate::transport::Transport; use bytes::Bytes; use http_body_util::BodyExt; -use hyper::{Request, Response, StatusCode}; +use hyper::{Request, Response, StatusCode, header}; use http_body_util::Full; use std::collections::HashMap; use std::sync::Arc; @@ -87,9 +87,12 @@ impl Handler { .method(method.as_str()) .uri(&full_uri) .version(version) - .body(Full::new(body_bytes)) + .body(Full::new(body_bytes.clone())) .unwrap(); *forwarded.headers_mut() = headers; + forwarded + .headers_mut() + .insert(header::CONTENT_LENGTH, body_bytes.len().into()); match self.transport.forward(forwarded).await { Ok(resp) => resp, @@ -115,12 +118,18 @@ mod tests { struct MockTransport { captured_body: Arc>>, + captured_headers: Arc>>, } impl MockTransport { - fn new() -> (Self, Arc>>) { + fn new() -> (Self, Arc>>, Arc>>) { let captured = Arc::new(std::sync::Mutex::new(None)); - (MockTransport { captured_body: captured.clone() }, captured) + let captured_headers = Arc::new(std::sync::Mutex::new(None)); + ( + MockTransport { captured_body: captured.clone(), captured_headers: captured_headers.clone() }, + captured, + captured_headers, + ) } } @@ -130,6 +139,7 @@ mod tests { &self, req: Request>, ) -> Result>, Box> { + *self.captured_headers.lock().unwrap() = Some(req.headers().clone()); let body = req.into_body(); let collected = http_body_util::BodyExt::collect(body).await?; let bytes = collected.to_bytes(); @@ -141,7 +151,7 @@ mod tests { } } - fn make_test_handler() -> (Handler, Arc>>) { + fn make_test_handler() -> (Handler, Arc>>, Arc>>) { use crate::policy::ContainerConfig; let mut policies = std::collections::HashMap::new(); policies.insert( @@ -173,14 +183,14 @@ mod tests { let router = Arc::new(Router::new(manager)); let chain = Chain::new(false); let audit = AuditLogger::new("/dev/null").unwrap(); - let (mock_transport, captured) = MockTransport::new(); + let (mock_transport, captured, captured_headers) = MockTransport::new(); let handler = Handler::new(router, chain, audit, Box::new(mock_transport)); - (handler, captured) + (handler, captured, captured_headers) } #[tokio::test] async fn test_handler_forwards_modified_body() { - let (handler, captured) = make_test_handler(); + let (handler, captured, captured_headers) = make_test_handler(); let body = serde_json::json!({"Image": "alpine", "Cmd": ["sleep", "100"]}); let body_bytes = serde_json::to_vec(&body).unwrap(); @@ -198,6 +208,18 @@ mod tests { assert!(captured_body.get("HostConfig").is_some()); let hc = captured_body.get("HostConfig").unwrap(); assert_eq!(hc.get("Privileged"), Some(&serde_json::Value::Bool(false))); + + let headers = captured_headers.lock().unwrap(); + let cl = headers + .as_ref() + .unwrap() + .get(hyper::header::CONTENT_LENGTH) + .expect("content-length header must be present"); + assert_eq!( + cl.to_str().unwrap().parse::().unwrap(), + captured.as_ref().unwrap().len(), + "content-length must match the modified body length" + ); } fn make_deny_handler() -> (Handler, Arc>>) { @@ -232,7 +254,7 @@ mod tests { let router = Arc::new(Router::new(manager)); let chain = Chain::new(false); let audit = AuditLogger::new("/dev/null").unwrap(); - let (mock_transport, captured) = MockTransport::new(); + let (mock_transport, captured, _captured_headers) = MockTransport::new(); let handler = Handler::new(router, chain, audit, Box::new(mock_transport)); (handler, captured) } diff --git a/spec/README.md b/spec/README.md index 90dda34..80838fc 100644 --- a/spec/README.md +++ b/spec/README.md @@ -6,7 +6,7 @@ This directory contains a [Quint](https://quint-lang.org/) formal specification | File | Purpose | |------|---------| -| `docker_socket_policy.qnt` | Single-file spec: policy types, state machine, endpoint routing table, 7 invariants (P0/P1/system/composite), 6 attack scenario simulations | +| `docker_socket_policy.qnt` | Single-file spec: policy types, state machine, endpoint routing table, 9 invariants (6 P0 / 3 P1), 6 attack scenario simulations | ## How to Run diff --git a/spec/docker_socket_policy.qnt b/spec/docker_socket_policy.qnt index fbfa966..36852af 100644 --- a/spec/docker_socket_policy.qnt +++ b/spec/docker_socket_policy.qnt @@ -171,6 +171,7 @@ module docker_socket_policy { { method: "POST", path: "/containers/:name/restart",kind: "allowAction" }, { method: "POST", path: "/containers/:name/kill", kind: "allowAction" }, { method: "POST", path: "/containers/:name/wait", kind: "allowAction" }, + { method: "POST", path: "/containers/:name/pause",kind: "allowAction" }, { method: "POST", path: "/containers/:name/unpause",kind: "allowAction" }, { method: "DELETE", path: "/containers/:name", kind: "allowAction" }, { method: "POST", path: "/images/create", kind: "pullImage" }, @@ -180,7 +181,6 @@ module docker_socket_policy { { method: "POST", path: "/containers/:name/exec", kind: "deny" }, { method: "POST", path: "/containers/:name/rename",kind: "deny" }, { method: "POST", path: "/containers/:name/update",kind: "deny" }, - { method: "POST", path: "/containers/:name/pause",kind: "deny" }, { method: "POST", path: "/containers/:name/attach",kind: "deny" }, { method: "POST", path: "/containers/:name/prune", kind: "deny" }, { method: "POST", path: "/networks/create", kind: "deny" }, @@ -432,6 +432,7 @@ module docker_socket_policy { endpointsTable.contains({ method: "POST", path: "/containers/:name/restart",kind: "allowAction" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/kill", kind: "allowAction" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/wait", kind: "allowAction" }), + endpointsTable.contains({ method: "POST", path: "/containers/:name/pause",kind: "allowAction" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/unpause",kind: "allowAction" }), endpointsTable.contains({ method: "DELETE", path: "/containers/:name", kind: "allowAction" }), @@ -443,7 +444,6 @@ module docker_socket_policy { endpointsTable.contains({ method: "POST", path: "/containers/:name/exec", kind: "deny" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/rename",kind: "deny" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/update",kind: "deny" }), - endpointsTable.contains({ method: "POST", path: "/containers/:name/pause",kind: "deny" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/attach",kind: "deny" }), endpointsTable.contains({ method: "POST", path: "/containers/:name/prune", kind: "deny" }), endpointsTable.contains({ method: "POST", path: "/networks/create", kind: "deny" }), diff --git a/ts/src/flags.test.ts b/ts/src/flags.test.ts new file mode 100644 index 0000000..b394fc2 --- /dev/null +++ b/ts/src/flags.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { getFlag, hasFlag, parseHostPort } from "./flags.js"; + +describe("flags", () => { + describe("getFlag", () => { + it("reads --name value (space form)", () => { + assert.equal(getFlag(["--config-dir", "/x"], "--config-dir", "d"), "/x"); + }); + + it("reads --name=value (equals form)", () => { + assert.equal(getFlag(["--config-dir=/x"], "--config-dir", "d"), "/x"); + }); + + it("prefers first occurrence", () => { + assert.equal( + getFlag(["--config-dir=/a", "--config-dir", "/b"], "--config-dir", "d"), + "/a", + ); + }); + + it("returns default when absent", () => { + assert.equal(getFlag([], "--config-dir", "d"), "d"); + assert.equal(getFlag(["--other"], "--config-dir", "d"), "d"); + }); + + it("returns default when --name is last arg with no value", () => { + assert.equal(getFlag(["--config-dir"], "--config-dir", "d"), "d"); + }); + + it("does not match a different flag's value", () => { + assert.equal( + getFlag(["--listen-tcp", "0.0.0.0:2375"], "--config-dir", "d"), + "d", + ); + }); + + it("handles --name=value where value contains equals", () => { + assert.equal( + getFlag(["--log-file=/a=/b"], "--log-file", "d"), + "/a=/b", + ); + }); + }); + + describe("hasFlag", () => { + it("detects --name (space form)", () => { + assert.equal(hasFlag(["--readonly"], "--readonly"), true); + }); + + it("detects --name=value (equals form)", () => { + assert.equal(hasFlag(["--readonly=true"], "--readonly"), true); + }); + + it("returns false when absent", () => { + assert.equal(hasFlag([], "--readonly"), false); + assert.equal(hasFlag(["--other"], "--readonly"), false); + }); + }); + + describe("parseHostPort", () => { + it("parses host:port", () => { + assert.deepEqual(parseHostPort("0.0.0.0:2375"), { host: "0.0.0.0", port: 2375 }); + assert.deepEqual(parseHostPort("127.0.0.1:2375"), { host: "127.0.0.1", port: 2375 }); + }); + + it("defaults to all interfaces for a bare port", () => { + assert.deepEqual(parseHostPort("2375"), { host: "0.0.0.0", port: 2375 }); + }); + + it("accepts a custom default host for bare port", () => { + assert.deepEqual(parseHostPort("2375", "127.0.0.1"), { host: "127.0.0.1", port: 2375 }); + }); + }); +}); \ No newline at end of file diff --git a/ts/src/flags.ts b/ts/src/flags.ts new file mode 100644 index 0000000..9c0872c --- /dev/null +++ b/ts/src/flags.ts @@ -0,0 +1,34 @@ +// Minimal CLI flag parser supporting both "--name value" and "--name=value" +// forms, matching Go's flag package and Rust's clap behavior. + +export function getFlag( + args: string[], + name: string, + defaultVal: string, +): string { + const prefix = name + "="; + for (let i = 0; i < args.length; i++) { + if (args[i] === name && i + 1 < args.length) return args[i + 1]; + if (args[i].startsWith(prefix)) return args[i].slice(prefix.length); + } + return defaultVal; +} + +export function hasFlag(args: string[], name: string): boolean { + const prefix = name + "="; + return args.includes(name) || args.some((a) => a.startsWith(prefix)); +} + +// Parses a "host:port" listen address (the form Go's net.Listen and Rust's +// bind accept) into the (host, port) pair that Node's http.Server.listen +// requires. A bare port string defaults to binding all interfaces. +export function parseHostPort( + input: string, + defaultHost = "0.0.0.0", +): { host: string; port: number } { + const colon = input.lastIndexOf(":"); + if (colon === -1) { + return { host: defaultHost, port: parseInt(input, 10) }; + } + return { host: input.slice(0, colon), port: parseInt(input.slice(colon + 1), 10) }; +} \ No newline at end of file diff --git a/ts/src/handler.test.ts b/ts/src/handler.test.ts new file mode 100644 index 0000000..3e898f0 --- /dev/null +++ b/ts/src/handler.test.ts @@ -0,0 +1,158 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Readable } from "node:stream"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Handler } from "./handler.js"; +import { Chain } from "./middleware.js"; +import { Router } from "./proxy.js"; +import { Manager } from "./policy.js"; +import { AuditLogger } from "./audit.js"; +import { Transport } from "./transport.js"; + +class FakeTransport extends Transport { + lastBody?: Buffer; + lastRequest?: IncomingMessage; + override async forward(req: IncomingMessage, _res: ServerResponse, body?: Buffer): Promise { + this.lastRequest = req; + this.lastBody = body; + } +} + +interface RecorderRes { + statusCode: number; + body: string; + headers: Record; +} + +function makeResponse(): { res: ServerResponse; recorder: RecorderRes } { + const recorder: RecorderRes = { statusCode: 200, body: "", headers: {} }; + const res = { + writeHead: (code: number, headers?: Record) => { + recorder.statusCode = code; + if (headers) recorder.headers = headers; + }, + end: (chunk?: string) => { + if (chunk) recorder.body = String(chunk); + }, + } as unknown as ServerResponse; + return { res, recorder }; +} + +function makeRequest(method: string, url: string, body?: string): IncomingMessage { + const stream = Readable.from(body ? [Buffer.from(body)] : []); + (stream as IncomingMessage).method = method; + (stream as IncomingMessage).url = url; + (stream as IncomingMessage).headers = body + ? { "content-length": String(Buffer.byteLength(body)) } + : {}; + return stream as IncomingMessage; +} + +function makeEnv(configFiles: Record) { + const dir = mkdtempSync(join(tmpdir(), "handler-test-")); + for (const [name, content] of Object.entries(configFiles)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +function newHandler(dir: string) { + const manager = new Manager(dir); + const router = new Router(manager); + const chain = new Chain(false); + const audit = new AuditLogger(join(dir, "audit.log")); + const transport = new FakeTransport(); + return { handler: new Handler(router, chain, audit, transport), transport }; +} + +const defaultConfig = { + "default.yaml": "service_name: default\nallowed_image_prefixes:\n - scratch\n", +}; + +const beaconConfig = { + "beacon.yaml": + "service_name: beacon\nallowed_image_prefixes:\n - chainsafe/lodestar\ncontainer_config:\n network_mode: host\n", +}; + +describe("Handler", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "handler-test-")); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("denies a routed-denied endpoint with 403", async () => { + const dir = makeEnv(defaultConfig); + const { handler } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle(makeRequest("POST", "/build"), res); + assert.equal(recorder.statusCode, 403); + assert.ok(recorder.body.includes("build")); + }); + + it("forwards a valid container create with the modified body", async () => { + const dir = makeEnv(beaconConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle( + makeRequest("POST", "/containers/create", JSON.stringify({ Image: "chainsafe/lodestar:next" })), + res, + ); + assert.notEqual(recorder.statusCode, 403); + assert.ok(transport.lastRequest, "expected request to be forwarded"); + assert.ok(transport.lastBody, "expected a modified body"); + const forwarded = JSON.parse(transport.lastBody!.toString("utf-8")); + const hostConfig = forwarded["HostConfig"] as Record; + assert.equal(hostConfig["NetworkMode"], "host"); + }); + + it("denies a container create that fails the gate chain with 403", async () => { + const dir = makeEnv(defaultConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle( + makeRequest("POST", "/containers/create", JSON.stringify({ Image: "ubuntu:latest" })), + res, + ); + assert.equal(recorder.statusCode, 403); + assert.equal(transport.lastRequest, undefined); + }); + + it("denies container create with empty body", async () => { + const dir = makeEnv(defaultConfig); + const { handler } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle(makeRequest("POST", "/containers/create"), res); + assert.equal(recorder.statusCode, 403); + }); + + it("passes through non-JSON body on read-only endpoint", async () => { + const dir = makeEnv(defaultConfig); + const { handler, transport } = newHandler(dir); + const { res, recorder } = makeResponse(); + await handler.handle(makeRequest("GET", "/_ping", "not json at all"), res); + assert.notEqual(recorder.statusCode, 400); + assert.ok(transport.lastRequest, "expected request to be forwarded"); + }); + + it("preserves headers on forwarded create", async () => { + const dir = makeEnv(beaconConfig); + const { handler, transport } = newHandler(dir); + const { res } = makeResponse(); + const body = JSON.stringify({ Image: "chainsafe/lodestar:next" }); + await handler.handle(makeRequest("POST", "/containers/create", body), res); + assert.ok(transport.lastRequest, "expected request to be forwarded"); + assert.ok(transport.lastBody, "expected a modified body"); + assert.equal(Number(transport.lastRequest.headers["content-length"]), Buffer.byteLength(body)); + const forwarded = JSON.parse(transport.lastBody!.toString("utf-8")); + const hostConfig = forwarded["HostConfig"] as Record; + assert.equal(hostConfig["NetworkMode"], "host"); + }); +}); \ No newline at end of file diff --git a/ts/src/index.ts b/ts/src/index.ts index d9f5cb6..dbf60e4 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -5,22 +5,15 @@ import { Manager } from "./policy.js"; import { Router } from "./proxy.js"; import { Handler } from "./handler.js"; import { Transport } from "./transport.js"; +import { getFlag, hasFlag, parseHostPort } from "./flags.js"; const args = process.argv.slice(2); -function getFlag(name: string, defaultVal: string): string { - const idx = args.indexOf(name); - return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : defaultVal; -} - -function hasFlag(name: string): boolean { - return args.includes(name); -} - -const listenTCP = getFlag("--listen-tcp", "127.0.0.1:2375"); -const configDir = getFlag("--config-dir", "/etc/docker-socket-policy/services"); -const logFile = getFlag("--log-file", "/var/log/docker-socket-policy.log"); -const readonly = hasFlag("--readonly"); +const listenTCP = getFlag(args, "--listen-tcp", "127.0.0.1:2375"); +const { host: listenHost, port: listenPort } = parseHostPort(listenTCP, "127.0.0.1"); +const configDir = getFlag(args, "--config-dir", "/etc/docker-socket-policy/services"); +const logFile = getFlag(args, "--log-file", "/var/log/docker-socket-policy.log"); +const readonly = hasFlag(args, "--readonly"); console.log(`loading policies from ${configDir}...`); @@ -41,8 +34,8 @@ const server = createServer((req, res) => { }); }); -server.listen(listenTCP, () => { - console.log(`listening on ${listenTCP}`); +server.listen(listenPort, listenHost, () => { + console.log(`listening on ${listenHost}:${listenPort}`); }); function shutdown(signal: string) { diff --git a/ts/src/transport.ts b/ts/src/transport.ts index 4bee608..2c32038 100644 --- a/ts/src/transport.ts +++ b/ts/src/transport.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; +import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from "node:http"; import { request } from "node:http"; const DEFAULT_SOCKET_PATH = "/var/run/docker.sock"; @@ -8,15 +8,19 @@ export class Transport { forward(req: IncomingMessage, res: ServerResponse, body?: Buffer): Promise { return new Promise((resolve, reject) => { + const headers: OutgoingHttpHeaders = { ...req.headers, Host: "docker" }; + // When forwarding a (possibly mutated) body, Content-Length must reflect + // the bytes actually written, not the original request's header — + // otherwise the daemon truncates the JSON ("unexpected EOF"). + if (body && body.length > 0) { + headers["content-length"] = body.length; + } const dockerReq = request( { socketPath: this.socketPath, path: req.url, method: req.method, - headers: { - ...req.headers, - Host: "docker", - }, + headers, }, (dockerRes) => { res.writeHead(dockerRes.statusCode ?? 500, dockerRes.headers); From ececc912289cc1f1b590065ce06aa007519ed644 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Tue, 11 Aug 2026 08:46:59 -0400 Subject: [PATCH 3/3] feat: surface socket permission denial as 403; add audit and transport tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-restricted Unix socket is the security boundary, so a connect that fails with EACCES/EPERM must be indistinguishable from a middleware policy denial: return 403 Forbidden instead of 502 Bad Gateway. - Go: ReverseProxy ErrorHandler maps EACCES/EPERM on the socket dial to 403 (transport.go); add transport tests (502 for missing socket, 403 for permission, errno unwrapping) - Rust: new TransportError::SocketPermission variant mapped to 403 in the handler; transport + handler tests for both 403 and 502 paths - TS: Transport emits 403 for EACCES/EPERM; new isPermissionDenied helper with tests - TS --docker-host flag accepts only a Unix socket path (rejects tcp/http/ unix schemes) so all three implementations run the sock integration suite - Makefile: parameterize test-integration-sock by IMPL with --build, add -rs/-ts variants; remove the TCP variant (docker-compose.tcp.yml) — TCP bypasses user/group socket ownership and its dial was already broken for Go/Rust which are unix-only - Add audit-log unit tests in all three languages (temp-file JSON round-trip) - AGENTS.md: update test counts (Go 74, Rust 112, TS 108) Verified: unit suites green; sock suite 8/8 for go and rs (403 on denied socket); main integration 26/26 for go, rs, and native ts --- AGENTS.md | 6 +- Makefile | 23 +++--- deploy/docker-compose.tcp.yml | 47 ----------- go/internal/audit/audit_test.go | 120 ++++++++++++++++++++++++++++ go/internal/proxy/transport.go | 22 +++++ go/internal/proxy/transport_test.go | 83 +++++++++++++++++++ rs/src/audit.rs | 73 +++++++++++++++++ rs/src/handler.rs | 97 +++++++++++++++++++++- rs/src/transport.rs | 78 ++++++++++++++++-- ts/src/audit.test.ts | 64 +++++++++++++++ ts/src/flags.test.ts | 20 ++++- ts/src/flags.ts | 21 ++++- ts/src/index.ts | 10 ++- ts/src/transport.test.ts | 21 ++++- ts/src/transport.ts | 17 +++- 15 files changed, 628 insertions(+), 74 deletions(-) delete mode 100644 deploy/docker-compose.tcp.yml create mode 100644 go/internal/audit/audit_test.go create mode 100644 go/internal/proxy/transport_test.go create mode 100644 ts/src/audit.test.ts diff --git a/AGENTS.md b/AGENTS.md index 421130f..cdabecb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,9 @@ deploy/ — Docker Compose + integration tests - Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml) ## Test Coverage -- Go: 58 unit tests (policy: 9, middleware: 21, proxy: 28) -- Rust: 104 unit tests (policy: 15, middleware: 48, proxy: 41) -- TypeScript: 99 unit tests (policy: 10, middleware: 37, proxy: 26, transport: 3, handler: 6, flags: 13) +- Go: 74 unit tests (policy: 10, middleware: 29, proxy: 31, audit: 4) +- Rust: 112 unit tests (policy: 15, middleware: 50, proxy: 37, handler: 4, audit: 4, transport: 2) +- TypeScript: 108 unit tests (policy: 10, middleware: 37, proxy: 26, transport: 5, handler: 6, flags: 16, audit: 4) - 26 integration tests via deploy/test.sh + docker-compose ## Test Conventions diff --git a/Makefile b/Makefile index c95e1a8..1a52189 100644 --- a/Makefile +++ b/Makefile @@ -107,20 +107,23 @@ test-integration-rs: test-integration-ts: $(MAKE) test-integration IMPL=ts -test-integration-tcp: - docker compose -f deploy/docker-compose.tcp.yml down --remove-orphans -v 2>/dev/null; \ - docker compose -f deploy/docker-compose.tcp.yml run --rm test; \ - rc=$$?; \ - docker compose -f deploy/docker-compose.tcp.yml down --remove-orphans -v; \ - exit $$rc - +# Unix-socket provisioning tests. The proxy only connects to the Docker +# daemon over a Unix socket — TCP would bypass user/group socket ownership, +# which is the security model this target exercises. Not run in CI (uses +# group-restricted socket setup); run locally per IMPL. test-integration-sock: - docker compose -f deploy/docker-compose.sock.yml down --remove-orphans -v 2>/dev/null; \ - docker compose -f deploy/docker-compose.sock.yml run --rm test; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.sock.yml down --remove-orphans -v 2>/dev/null; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.sock.yml run --build --rm test; \ rc=$$?; \ - docker compose -f deploy/docker-compose.sock.yml down --remove-orphans -v; \ + IMPL=$(IMPL) docker compose -f deploy/docker-compose.sock.yml down --remove-orphans -v; \ exit $$rc +test-integration-sock-rs: + $(MAKE) test-integration-sock IMPL=rs + +test-integration-sock-ts: + $(MAKE) test-integration-sock IMPL=ts + validate: typecheck verify lint-go test-go # ─── Reproducible build verification ───────────────── diff --git a/deploy/docker-compose.tcp.yml b/deploy/docker-compose.tcp.yml deleted file mode 100644 index 638ec59..0000000 --- a/deploy/docker-compose.tcp.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: docker-socket-policy-test-tcp-${IMPL:-go} - -services: - dind: - image: docker:28-dind - privileged: true - environment: - DOCKER_TLS_CERTDIR: "" - command: - - dockerd - - --host=tcp://0.0.0.0:2375 - - --tls=false - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:2375/_ping"] - interval: 3s - retries: 20 - timeout: 10s - start_period: 5s - - proxy: - build: - context: ../${IMPL:-go} - dockerfile: Dockerfile - user: "0:0" - depends_on: - dind: - condition: service_healthy - ports: - - "12375:2375" - volumes: - - ./config:/etc/docker-socket-policy/services:ro - command: - - --docker-host=tcp://dind:2375 - - --listen-tcp=0.0.0.0:2375 - - --listen-socket=/tmp/docker-socket-policy.sock - - --config-dir=/etc/docker-socket-policy/services - - --log-file=/tmp/docker-socket-policy.log - - test: - image: docker:28-cli - depends_on: - - proxy - environment: - DOCKER_HOST: tcp://proxy:2375 - volumes: - - ./test.sh:/test.sh:ro - entrypoint: ["/bin/sh", "/test.sh"] diff --git a/go/internal/audit/audit_test.go b/go/internal/audit/audit_test.go new file mode 100644 index 0000000..e95b501 --- /dev/null +++ b/go/internal/audit/audit_test.go @@ -0,0 +1,120 @@ +package audit + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func readEntries(t *testing.T, path string) []map[string]interface{} { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open audit log: %v", err) + } + defer f.Close() + + var entries []map[string]interface{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var entry map[string]interface{} + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + t.Fatalf("invalid audit JSON line: %v", err) + } + entries = append(entries, entry) + } + if err := scanner.Err(); err != nil { + t.Fatalf("failed to read audit log: %v", err) + } + return entries +} + +func TestLogger_AllowWritesEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + l, err := NewLogger(path) + if err != nil { + t.Fatalf("NewLogger failed: %v", err) + } + extra := map[string]interface{}{"service": "beacon", "image": "chainsafe/lodestar:next"} + l.Allow("GET", "/_ping", "request allowed", extra) + l.Close() + + entries := readEntries(t, path) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + e := entries[0] + assertField(t, e, "decision", "ALLOW") + assertField(t, e, "method", "GET") + assertField(t, e, "uri", "/_ping") + assertField(t, e, "reason", "request allowed") + if extra, ok := e["extra"].(map[string]interface{}); !ok { + t.Fatalf("expected extra object, got %T", e["extra"]) + } else { + if extra["service"] != "beacon" { + t.Fatalf("expected extra.service beacon, got %v", extra["service"]) + } + if extra["image"] != "chainsafe/lodestar:next" { + t.Fatalf("expected extra.image, got %v", extra["image"]) + } + } + if e["request_id"] == "" { + t.Fatal("expected non-empty request_id") + } + if e["timestamp"] == "" { + t.Fatal("expected non-empty timestamp") + } +} + +func TestLogger_DenyWritesEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + l, err := NewLogger(path) + if err != nil { + t.Fatalf("NewLogger failed: %v", err) + } + l.Deny("POST", "/containers/create", "exec denied by policy", nil) + l.Close() + + entries := readEntries(t, path) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + e := entries[0] + assertField(t, e, "decision", "DENY") + assertField(t, e, "method", "POST") + assertField(t, e, "uri", "/containers/create") + assertField(t, e, "reason", "exec denied by policy") +} + +func TestLogger_MultipleEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + l, err := NewLogger(path) + if err != nil { + t.Fatalf("NewLogger failed: %v", err) + } + for i := 0; i < 5; i++ { + l.Allow("GET", "/version", "request allowed", nil) + } + l.Close() + + entries := readEntries(t, path) + if len(entries) != 5 { + t.Fatalf("expected 5 entries, got %d", len(entries)) + } +} + +func TestLogger_NopLoggerWritesNowhere(t *testing.T) { + l := NewNopLogger() + l.Allow("GET", "/_ping", "request allowed", nil) + l.Deny("POST", "/containers/create", "denied", nil) + l.Close() +} + +func assertField(t *testing.T, e map[string]interface{}, key, expected string) { + t.Helper() + if got, ok := e[key].(string); !ok || got != expected { + t.Fatalf("expected %s=%q, got %v", key, expected, e[key]) + } +} \ No newline at end of file diff --git a/go/internal/proxy/transport.go b/go/internal/proxy/transport.go index 783fd1b..786c942 100644 --- a/go/internal/proxy/transport.go +++ b/go/internal/proxy/transport.go @@ -1,9 +1,11 @@ package proxy import ( + "errors" "net" "net/http" "net/http/httputil" + "syscall" ) type ReverseProxy struct { @@ -23,10 +25,30 @@ func NewTransport(dockerHost string) *ReverseProxy { return net.Dial("unix", dockerHost) }, }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + if isSocketPermissionDenied(err) { + http.Error(w, "permission denied on Docker socket", http.StatusForbidden) + return + } + http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway) + }, }, } } +// isSocketPermissionDenied reports whether the error chain originates from a +// permission failure connecting to the Docker Unix socket (EACCES or EPERM). +// Group-restricted sockets are the security model, so a permission denial on +// the socket surfaces as 403 Forbidden, indistinguishable from a middleware +// policy denial. +func isSocketPermissionDenied(err error) bool { + var errno syscall.Errno + if !errors.As(err, &errno) { + return false + } + return errno == syscall.EACCES || errno == syscall.EPERM +} + func (t *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { t.proxy.ServeHTTP(w, r) } diff --git a/go/internal/proxy/transport_test.go b/go/internal/proxy/transport_test.go new file mode 100644 index 0000000..ad7851a --- /dev/null +++ b/go/internal/proxy/transport_test.go @@ -0,0 +1,83 @@ +package proxy + +import ( + "errors" + "net/http" + "net/http/httptest" + "syscall" + "testing" +) + +func TestTransport_NonExistentSocketReturns502(t *testing.T) { + tr := NewTransport("/nonexistent/docker.sock") + req := httptest.NewRequest("GET", "/_ping", nil) + rec := httptest.NewRecorder() + + tr.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected 502 for non-existent socket, got %d", rec.Code) + } +} + +func TestTransport_SocketPermissionReturns403(t *testing.T) { + // EACCES mapped to 403 — group-restricted socket denial must be + // indistinguishable from a middleware policy denial. + tr := NewTransport("/nonexistent/docker.sock") + proxy := tr.proxy + + errHandler := proxy.ErrorHandler + if errHandler == nil { + t.Fatal("expected proxy to have an error handler") + } + + rec := httptest.NewRecorder() + errHandler(rec, httptest.NewRequest("GET", "/_ping", nil), syscall.EACCES) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for EACCES, got %d", rec.Code) + } + + rec = httptest.NewRecorder() + errHandler(rec, httptest.NewRequest("GET", "/_ping", nil), syscall.EPERM) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403 for EPERM, got %d", rec.Code) + } +} + +func TestIsSocketPermissionDenied(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"bare EACCES", syscall.EACCES, true}, + {"bare EPERM", syscall.EPERM, true}, + {"wrapped EACCES", &osPathError{parent: &osSyscallError{err: syscall.EACCES}}, true}, + {"ECONNREFUSED", syscall.ECONNREFUSED, false}, + {"generic error", errors.New("boom"), false}, + {"nil", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isSocketPermissionDenied(tc.err); got != tc.want { + t.Fatalf("isSocketPermissionDenied(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +// Minimal wrap chain replicating the net/http error shape for a failed dial +// (os.PathError -> os.SyscallError -> underlying errno). +type osPathError struct { + parent error +} + +func (e *osPathError) Error() string { return "dial: " + e.parent.Error() } +func (e *osPathError) Unwrap() error { return e.parent } + +type osSyscallError struct { + err error +} + +func (e *osSyscallError) Error() string { return "connect: " + e.err.Error() } +func (e *osSyscallError) Unwrap() error { return e.err } \ No newline at end of file diff --git a/rs/src/audit.rs b/rs/src/audit.rs index 3a162b3..103253b 100644 --- a/rs/src/audit.rs +++ b/rs/src/audit.rs @@ -56,3 +56,76 @@ impl AuditLogger { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + fn read_log(path: &str) -> Vec { + let mut f = File::open(path).unwrap(); + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + contents + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + #[test] + fn test_allow_writes_entry() { + let dir = std::env::temp_dir().join(format!("audit-test-{}.log", std::process::id())); + let _ = std::fs::remove_file(&dir); + let logger = AuditLogger::new(dir.to_str().unwrap()).unwrap(); + logger.allow("GET", "/_ping"); + + let entries = read_log(dir.to_str().unwrap()); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e["decision"], "ALLOW"); + assert_eq!(e["method"], "GET"); + assert_eq!(e["uri"], "/_ping"); + assert_ne!(e["request_id"], ""); + assert_ne!(e["timestamp"], ""); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn test_deny_writes_entry() { + let dir = std::env::temp_dir().join(format!("audit-test-deny-{}.log", std::process::id())); + let _ = std::fs::remove_file(&dir); + let logger = AuditLogger::new(dir.to_str().unwrap()).unwrap(); + logger.deny("POST", "/containers/create", "exec denied by policy"); + + let entries = read_log(dir.to_str().unwrap()); + assert_eq!(entries.len(), 1); + let e = &entries[0]; + assert_eq!(e["decision"], "DENY"); + assert_eq!(e["method"], "POST"); + assert_eq!(e["uri"], "/containers/create"); + assert_eq!(e["reason"], "exec denied by policy"); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn test_multiple_entries() { + let dir = std::env::temp_dir().join(format!("audit-test-multi-{}.log", std::process::id())); + let _ = std::fs::remove_file(&dir); + let logger = AuditLogger::new(dir.to_str().unwrap()).unwrap(); + for _ in 0..5 { + logger.allow("GET", "/version"); + } + + let entries = read_log(dir.to_str().unwrap()); + assert_eq!(entries.len(), 5); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn test_nop_writes_nowhere() { + let logger = AuditLogger::nop(); + logger.allow("GET", "/_ping"); + logger.deny("POST", "/containers/create", "denied"); + } +} diff --git a/rs/src/handler.rs b/rs/src/handler.rs index a422dc8..ca5ea25 100644 --- a/rs/src/handler.rs +++ b/rs/src/handler.rs @@ -2,6 +2,7 @@ use crate::audit::AuditLogger; use crate::middleware::Chain; use crate::proxy::{Action, Router}; use crate::transport::Transport; +use crate::transport::TransportError; use bytes::Bytes; use http_body_util::BodyExt; use hyper::{Request, Response, StatusCode, header}; @@ -96,6 +97,13 @@ impl Handler { match self.transport.forward(forwarded).await { Ok(resp) => resp, + Err(TransportError::SocketPermission(e)) => { + warn!("denied: socket permission error: {}", e); + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Full::new(Bytes::from("permission denied on Docker socket"))) + .unwrap() + } Err(e) => { warn!("forward error: {}", e); Response::builder() @@ -138,10 +146,12 @@ mod tests { async fn forward( &self, req: Request>, - ) -> Result>, Box> { + ) -> Result>, TransportError> { *self.captured_headers.lock().unwrap() = Some(req.headers().clone()); let body = req.into_body(); - let collected = http_body_util::BodyExt::collect(body).await?; + let collected = http_body_util::BodyExt::collect(body) + .await + .expect("collecting in-memory body cannot fail"); let bytes = collected.to_bytes(); *self.captured_body.lock().unwrap() = Some(bytes.clone()); Ok(Response::builder() @@ -222,6 +232,89 @@ mod tests { ); } + #[derive(Copy, Clone)] + enum FailureKind { + SocketPermission, + Other, + } + + struct FailingTransport { + kind: FailureKind, + } + + #[async_trait] + impl Transport for FailingTransport { + async fn forward( + &self, + _req: Request>, + ) -> Result>, TransportError> { + match self.kind { + FailureKind::SocketPermission => Err(TransportError::SocketPermission( + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )), + FailureKind::Other => Err(TransportError::Other( + Box::::from("connection refused"), + )), + } + } + } + + fn make_forward_failing_handler(kind: FailureKind) -> Handler { + use crate::policy::ContainerConfig; + let mut policies = std::collections::HashMap::new(); + policies.insert( + "svc".into(), + Policy { + service_name: "svc".into(), + user_id: None, + group_id: None, + allowed_image_prefixes: vec!["alpine".into()], + image_tag_pattern: None, + image_digest_allowed: false, + container_config: Some(ContainerConfig { + network_mode: Some("bridge".into()), + restart_policy: None, + security_options: None, + user: None, + log_driver: None, + log_options: None, + }), + volumes: None, + ports: None, + env_file: None, + allowed_cli_flags: None, + flag_rules: None, + denied_flags: None, + }, + ); + let manager = Manager::from_map(policies); + let router = Arc::new(Router::new(manager)); + let chain = Chain::new(false); + let audit = AuditLogger::new("/dev/null").unwrap(); + let transport = FailingTransport { kind }; + Handler::new(router, chain, audit, Box::new(transport)) + } + + #[tokio::test] + async fn test_handler_socket_permission_returns_403() { + let handler = make_forward_failing_handler(FailureKind::SocketPermission); + let req = Request::get("http://localhost/_ping") + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = handler.handle(req).await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn test_handler_forward_error_returns_502() { + let handler = make_forward_failing_handler(FailureKind::Other); + let req = Request::get("http://localhost/_ping") + .body(Full::new(Bytes::new())) + .unwrap(); + let resp = handler.handle(req).await; + assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); + } + fn make_deny_handler() -> (Handler, Arc>>) { use crate::policy::ContainerConfig; let mut policies = std::collections::HashMap::new(); diff --git a/rs/src/transport.rs b/rs/src/transport.rs index 97f6fd4..3dde74b 100644 --- a/rs/src/transport.rs +++ b/rs/src/transport.rs @@ -4,13 +4,34 @@ use http_body_util::Full; use hyper::client::conn::http1; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; +use std::io; + +/// A forwarding failure. `SocketPermission` is a distinct variant so the +/// gateway can surface group-restricted socket denial as 403 Forbidden, +/// mirroring middleware policy denials (the socket is the security boundary). +#[derive(Debug)] +pub enum TransportError { + SocketPermission(io::Error), + Other(Box), +} + +impl std::fmt::Display for TransportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TransportError::SocketPermission(e) => write!(f, "permission denied on Docker socket: {e}"), + TransportError::Other(e) => write!(f, "proxy error: {e}"), + } + } +} + +impl std::error::Error for TransportError {} #[async_trait] pub trait Transport: Send + Sync { async fn forward( &self, req: Request>, - ) -> Result>, Box>; + ) -> Result>, TransportError>; } pub struct UnixSocketTransport { @@ -25,18 +46,29 @@ impl UnixSocketTransport { } } +fn map_connect_error(e: io::Error) -> TransportError { + match e.kind() { + io::ErrorKind::PermissionDenied => TransportError::SocketPermission(e), + _ => TransportError::Other(Box::new(e)), + } +} + #[async_trait] impl Transport for UnixSocketTransport { async fn forward( &self, req: Request>, - ) -> Result>, Box> { - let stream = tokio::net::UnixStream::connect(&self.docker_host).await?; + ) -> Result>, TransportError> { + let stream = match tokio::net::UnixStream::connect(&self.docker_host).await { + Ok(s) => s, + Err(e) => return Err(map_connect_error(e)), + }; let io = TokioIo::new(stream); let (mut sender, conn) = http1::Builder::new() .handshake::<_, Full>(io) - .await?; + .await + .map_err(|e| TransportError::Other(Box::new(e)))?; tokio::spawn(async move { if let Err(e) = conn.await { @@ -44,12 +76,46 @@ impl Transport for UnixSocketTransport { } }); - let resp = sender.send_request(req).await?; + let resp = sender + .send_request(req) + .await + .map_err(|e| TransportError::Other(Box::new(e)))?; let (parts, body) = resp.into_parts(); - let collected = http_body_util::BodyExt::collect(body).await?; + let collected = http_body_util::BodyExt::collect(body) + .await + .map_err(|e| TransportError::Other(Box::new(e)))?; let bytes = collected.to_bytes(); Ok(Response::from_parts(parts, Full::new(bytes))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_forward_missing_socket_errors() { + let transport = UnixSocketTransport::new("/nonexistent/docker.sock"); + let req = Request::builder() + .uri("/_ping") + .body(Full::new(Bytes::new())) + .unwrap(); + let res = transport.forward(req).await; + assert!(res.is_err(), "expected forwarding to a missing socket to fail"); + assert!(!matches!(res, Err(TransportError::SocketPermission(_)))); + } + + #[test] + fn test_map_connect_error_kinds() { + let perm = io::Error::from(io::ErrorKind::PermissionDenied); + assert!(matches!(map_connect_error(perm), TransportError::SocketPermission(_))); + + let not_found = io::Error::from(io::ErrorKind::NotFound); + assert!(matches!(map_connect_error(not_found), TransportError::Other(_))); + + let conn_refused = io::Error::from(io::ErrorKind::ConnectionRefused); + assert!(matches!(map_connect_error(conn_refused), TransportError::Other(_))); + } +} \ No newline at end of file diff --git a/ts/src/audit.test.ts b/ts/src/audit.test.ts new file mode 100644 index 0000000..60fbd26 --- /dev/null +++ b/ts/src/audit.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuditLogger } from "./audit.js"; + +describe("AuditLogger", () => { + it("writes a well-formed ALLOW entry", () => { + const dir = mkdtempSync(join(tmpdir(), "audit-")); + const path = join(dir, "audit.log"); + const logger = new AuditLogger(path); + logger.allow("GET", "/_ping"); + + const lines = readFileSync(path, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.equal(entry.decision, "ALLOW"); + assert.equal(entry.method, "GET"); + assert.equal(entry.uri, "/_ping"); + assert.equal(entry.reason, ""); + assert.ok(entry.request_id); + assert.ok(entry.timestamp); + rmSync(dir, { recursive: true, force: true }); + }); + + it("writes a well-formed DENY entry with reason", () => { + const dir = mkdtempSync(join(tmpdir(), "audit-")); + const path = join(dir, "audit.log"); + const logger = new AuditLogger(path); + logger.deny("POST", "/containers/create", "exec denied by policy"); + + const lines = readFileSync(path, "utf8").trim().split("\n"); + assert.equal(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.equal(entry.decision, "DENY"); + assert.equal(entry.method, "POST"); + assert.equal(entry.uri, "/containers/create"); + assert.equal(entry.reason, "exec denied by policy"); + rmSync(dir, { recursive: true, force: true }); + }); + + it("appends one JSON line per event", () => { + const dir = mkdtempSync(join(tmpdir(), "audit-")); + const path = join(dir, "audit.log"); + const logger = new AuditLogger(path); + for (let i = 0; i < 5; i++) { + logger.allow("GET", "/version"); + } + + const lines = readFileSync(path, "utf8").trim().split("\n"); + assert.equal(lines.length, 5); + for (const line of lines) { + const entry = JSON.parse(line); + assert.equal(entry.decision, "ALLOW"); + } + rmSync(dir, { recursive: true, force: true }); + }); + + it("never throws when the log file cannot be written", () => { + const logger = new AuditLogger("/nonexistent-dir/audit.log"); + assert.doesNotThrow(() => logger.allow("GET", "/_ping")); + }); +}); \ No newline at end of file diff --git a/ts/src/flags.test.ts b/ts/src/flags.test.ts index b394fc2..921160a 100644 --- a/ts/src/flags.test.ts +++ b/ts/src/flags.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getFlag, hasFlag, parseHostPort } from "./flags.js"; +import { getFlag, hasFlag, parseHostPort, parseSocketPath } from "./flags.js"; describe("flags", () => { describe("getFlag", () => { @@ -72,4 +72,22 @@ describe("flags", () => { assert.deepEqual(parseHostPort("2375", "127.0.0.1"), { host: "127.0.0.1", port: 2375 }); }); }); + + describe("parseSocketPath", () => { + it("accepts a plain unix socket path", () => { + assert.equal(parseSocketPath("/var/run/docker.sock"), null); + assert.equal(parseSocketPath("/sock/docker.sock"), null); + }); + + it("rejects TCP and HTTP daemon addresses", () => { + assert.match(parseSocketPath("tcp://dind:2375") ?? "", /Unix socket paths/); + assert.match(parseSocketPath("http://dind:2375") ?? "", /Unix socket paths/); + assert.match(parseSocketPath("https://dind:2375") ?? "", /Unix socket paths/); + assert.match(parseSocketPath("unix:///var/run/docker.sock") ?? "", /Unix socket paths/); + }); + + it("rejects empty values", () => { + assert.match(parseSocketPath("") ?? "", /must not be empty/); + }); + }); }); \ No newline at end of file diff --git a/ts/src/flags.ts b/ts/src/flags.ts index 9c0872c..d16930d 100644 --- a/ts/src/flags.ts +++ b/ts/src/flags.ts @@ -31,4 +31,23 @@ export function parseHostPort( return { host: defaultHost, port: parseInt(input, 10) }; } return { host: input.slice(0, colon), port: parseInt(input.slice(colon + 1), 10) }; -} \ No newline at end of file +} + +// Validates a Docker daemon address supplied via --docker-host. Only Unix +// socket paths are accepted: connecting to the daemon over TCP would bypass +// the Linux user/group ownership on the socket, which is the security model +// of this proxy. Returns an error message when the value is unusable. +export function parseSocketPath(input: string): string | null { + if ( + input.startsWith("tcp://") || + input.startsWith("http://") || + input.startsWith("https://") || + input.startsWith("unix://") + ) { + return `--docker-host only supports Unix socket paths, got: ${input}`; + } + if (input.length === 0) { + return "--docker-host must not be empty"; + } + return null; +} diff --git a/ts/src/index.ts b/ts/src/index.ts index dbf60e4..80901a7 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -5,10 +5,16 @@ import { Manager } from "./policy.js"; import { Router } from "./proxy.js"; import { Handler } from "./handler.js"; import { Transport } from "./transport.js"; -import { getFlag, hasFlag, parseHostPort } from "./flags.js"; +import { getFlag, hasFlag, parseHostPort, parseSocketPath } from "./flags.js"; const args = process.argv.slice(2); +const dockerHost = getFlag(args, "--docker-host", "/var/run/docker.sock"); +const socketPathError = parseSocketPath(dockerHost); +if (socketPathError) { + console.error(socketPathError); + process.exit(2); +} const listenTCP = getFlag(args, "--listen-tcp", "127.0.0.1:2375"); const { host: listenHost, port: listenPort } = parseHostPort(listenTCP, "127.0.0.1"); const configDir = getFlag(args, "--config-dir", "/etc/docker-socket-policy/services"); @@ -23,7 +29,7 @@ console.log(`loaded ${policyManager.list().length} policies`); const router = new Router(policyManager); const chain = new Chain(readonly); const audit = new AuditLogger(logFile); -const transport = new Transport(); +const transport = new Transport(dockerHost); const handler = new Handler(router, chain, audit, transport); const server = createServer((req, res) => { diff --git a/ts/src/transport.test.ts b/ts/src/transport.test.ts index e63ef8d..aa6ddbf 100644 --- a/ts/src/transport.test.ts +++ b/ts/src/transport.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { Transport } from "./transport.js"; +import { Transport, isPermissionDenied } from "./transport.js"; describe("Transport", () => { it("constructs with default socket path", () => { @@ -21,3 +21,22 @@ describe("Transport", () => { await assert.rejects(t.forward(req, res), { name: "Error" }); }); }); + +describe("isPermissionDenied", () => { + const err = (code: string) => { + const e = new Error(code) as NodeJS.ErrnoException; + e.code = code; + return e; + }; + + it("returns true for EACCES and EPERM", () => { + assert.equal(isPermissionDenied(err("EACCES")), true); + assert.equal(isPermissionDenied(err("EPERM")), true); + }); + + it("returns false for other socket error codes", () => { + assert.equal(isPermissionDenied(err("ENOENT")), false); + assert.equal(isPermissionDenied(err("ECONNREFUSED")), false); + assert.equal(isPermissionDenied(err("ECONNRESET")), false); + }); +}); diff --git a/ts/src/transport.ts b/ts/src/transport.ts index 2c32038..d130409 100644 --- a/ts/src/transport.ts +++ b/ts/src/transport.ts @@ -3,6 +3,13 @@ import { request } from "node:http"; const DEFAULT_SOCKET_PATH = "/var/run/docker.sock"; +// Reports whether a socket connection failure is a permission denial. The +// group-restricted Unix socket is the security boundary, so EACCES/EPERM +// surfaces as 403 — indistinguishable from a middleware policy denial. +export function isPermissionDenied(err: NodeJS.ErrnoException): boolean { + return err.code === "EACCES" || err.code === "EPERM"; +} + export class Transport { constructor(private socketPath: string = DEFAULT_SOCKET_PATH) {} @@ -29,7 +36,15 @@ export class Transport { }, ); - dockerReq.on("error", reject); + dockerReq.on("error", (err: NodeJS.ErrnoException) => { + if (isPermissionDenied(err)) { + res.writeHead(403, { "content-type": "text/plain" }); + res.end("permission denied on Docker socket"); + resolve(); + return; + } + reject(err); + }); if (body && body.length > 0) { dockerReq.write(body);