diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml index 0c4106555..af6b15ff6 100644 --- a/.github/workflows/codecov-upload.yml +++ b/.github/workflows/codecov-upload.yml @@ -17,6 +17,11 @@ on: required: false default: true type: boolean + run_agent: + description: 'Run agent coverage upload' + required: false + default: true + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -189,3 +194,49 @@ jobs: directory: ./frontend/coverage flags: frontend fail_ci_if_error: true + + agent-codecov: + name: Agent Codecov Upload + runs-on: ubuntu-latest + timeout-minutes: 15 + if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_agent }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + # No "Resolve encryption key" step: agent has no encryption-key + # bootstrapping (consistent with quality-checks.yml's agent-quality + # job) — agent's tests don't touch CHARON_ENCRYPTION_KEY_TEST. + # No CGO_ENABLED: 1 either: agent/'s Docker image builds with + # CGO_ENABLED=0 (FROM scratch), so there is no cgo dependency here. + + - name: Run Go tests with coverage + working-directory: ${{ github.workspace }} + run: | + bash scripts/agent-test-coverage.sh 2>&1 | tee agent/test-output.txt + exit "${PIPESTATUS[0]}" + + - name: Upload test output artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-test-output + path: agent/test-output.txt + retention-days: 7 + + - name: Upload agent coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./agent/coverage.txt + flags: agent + fail_ci_if_error: true diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 0145a3e71..85a3426b9 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -428,6 +428,25 @@ jobs: - name: Set lowercase image name run: echo "ORTHRUS_IMAGE_NAME_LC=${ORTHRUS_IMAGE_NAME,,}" >> "$GITHUB_ENV" + # Gates this nightly image publish on the agent module's own test + # suite, mirroring orthrus-build.yml's PR-facing gate — needed here + # too because sync-development-to-nightly performs a git reset --hard + # / force-push path that does not itself go through a reviewed PR + # against nightly, so gating only the PR-facing workflow would leave + # this production-image-publishing path untested. See Section 3.3.5 of + # docs/plans/current_spec.md and GH #1161. + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + - name: Run Orthrus agent tests + run: | + set -euo pipefail + cd agent + go test ./... + - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/orthrus-build.yml b/.github/workflows/orthrus-build.yml index 420448404..79afca2ec 100644 --- a/.github/workflows/orthrus-build.yml +++ b/.github/workflows/orthrus-build.yml @@ -98,6 +98,26 @@ jobs: fi fi + # Gates the Docker build on the agent module's own test suite, + # including the shared anti-drift corpus with backend/internal/orthrus + # (testdata/muzzle_corpus.json) — see Section 3.3.5 of + # docs/plans/current_spec.md and GH #1161. Prior to this step, agent/ + # had zero CI-enforced test execution: every workflow touching it went + # straight from checkout to docker/build-push-action, only building the + # module's source as an opaque COPY . . step inside agent/Dockerfile's + # multi-stage build. + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + - name: Run Orthrus agent tests + run: | + set -euo pipefail + cd agent + go test ./... + - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 634ed3444..933080a04 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -43,6 +43,25 @@ jobs: cd backend go test ./internal/api/routes -run 'TestRegister_StateChangingRoutesRequireAuthentication|TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist|TestRegister_AuthenticatedRoutes' -count=1 -v + muzzle-allowlist-parity: + name: Muzzle Allowlist Parity (backend/agent, #1160/#1161) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: backend/go.mod + + - name: Run structural allowlist parity checker + run: | + set -euo pipefail + go run scripts/ci/check_muzzle_allowlist_parity.go + codecov-trigger-parity-guard: name: Codecov Trigger/Comment Parity Guard runs-on: ubuntu-latest @@ -250,6 +269,76 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" exit "$PERF_STATUS" + agent-quality: + name: Agent (Go) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + # No "Resolve encryption key" step here: unlike backend-quality, agent + # has no CHARON_ENCRYPTION_KEY_TEST-dependent tests to bootstrap for + # (agent is a standalone module with no models/GORM/encryption + # concerns). No GORM Security Scanner step for the same reason (agent + # has no models). No Perf Asserts step: agent has no equivalent + # benchmark suite. + + - name: go vet + working-directory: agent + run: go vet ./... + + - name: Run golangci-lint (fast config, root-shared) + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + # renovate: datasource=github-releases depName=golangci/golangci-lint + version: v2.12.2 + working-directory: agent + args: --config ../.golangci-fast.yml --timeout=5m + + - name: Run Go tests with coverage gate + id: go-tests + working-directory: ${{ github.workspace }} + run: | + bash "scripts/agent-test-coverage.sh" 2>&1 | tee agent/test-output.txt; exit "${PIPESTATUS[0]}" + + - name: Upload test output artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent-test-output + path: agent/test-output.txt + retention-days: 7 + + - name: Go Test Summary + if: always() + working-directory: agent + run: | + { + echo "## 🔧 Agent Test Results" + if [ "${{ steps.go-tests.outcome }}" == "success" ]; then + echo "✅ **All tests passed**" + PASS_COUNT=$(grep -c "^--- PASS" test-output.txt || echo "0") + echo "- Tests passed: ${PASS_COUNT}" + else + echo "❌ **Tests failed**" + echo "" + echo "### Failed Tests:" + echo '```' + grep -E "^--- FAIL|FAIL\s+github" test-output.txt || echo "See logs for details" + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + # Codecov upload handled separately in codecov-upload.yml (agent-codecov job). + frontend-quality: name: Frontend (React) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6746f0ae5..b309a0239 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ backend/tr_no_cover.txt backend/nohup.out backend/charon backend/main +backend/localpatchreport backend/codeql-db/ backend/codeql-db-*/ backend/.venv/ diff --git a/backend/.golangci-fast.yml b/.golangci-fast.yml similarity index 100% rename from backend/.golangci-fast.yml rename to .golangci-fast.yml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5dfe2ca7b..7cf190632 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1323,7 +1323,7 @@ go test ./integration/... ### Pre-Commit Checks -**Automated Hooks (via `.pre-commit-config.yaml`):** +**Automated Hooks (via `lefthook.yml`):** **Fast Stage (< 5 seconds):** @@ -1332,10 +1332,21 @@ go test ./integration/... - YAML syntax check - JSON syntax check - Markdown link validation +- `go vet` and golangci-lint (fast config), run separately for `backend/` + and `agent/` (`go-vet` / `go-vet-agent`, both glob-scoped to their + respective module directory) — `agent/` is a standalone Go module (see + "Orthrus Agent" below) and gets the same enforcement `backend/` does, via + a shared root-level `.golangci-fast.yml` config +- `scripts/ci/check_muzzle_allowlist_parity.go`: a structural (AST-based) + guard that fails the commit if the Docker API allowlist declarations in + `backend/internal/orthrus/muzzle.go` and `agent/muzzle/muzzle.go` (the two + independently-maintained copies described in "Orthrus" below) diverge — + glob-scoped to those two files **Manual Stage (run explicitly):** - Backend coverage tests (60-90s) +- Agent coverage tests (`scripts/agent-test-coverage.sh`) - Frontend coverage tests (30-60s) - TypeScript type checking (10-20s) @@ -1358,7 +1369,11 @@ go test ./integration/... 2. **Test:** Go tests, Vitest, Playwright 3. **Security:** Trivy, CodeQL, Grype, Govulncheck 4. **Build:** Docker image build -5. **Coverage:** Upload to Codecov (85% gate) +5. **Coverage:** Upload to Codecov (85% gate) — `backend`, `frontend`, and + `agent` each upload under a distinct Codecov flag + (`.github/workflows/codecov-upload.yml`); `quality-checks.yml` runs a + matching `agent-quality` job (go vet, lint, coverage gate) unconditionally + on every PR, not only PRs that touch `agent/**` 6. **Supply Chain:** SBOM generation, Cosign signing --- diff --git a/CHANGELOG.md b/CHANGELOG.md index aa60f4196..90e8692dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,11 +64,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### CI/CD +- **Orthrus Agent CI Parity**: `agent/` (the standalone Orthrus agent Go + module) now gets the same enforcement `backend/` already had — `go vet`, + golangci-lint (fast config, now shared from repo-root + `.golangci-fast.yml`), and a test-coverage gate, both in pre-commit + (`go-vet-agent`, `golangci-lint-fast`, `agent-test-coverage`) and in CI + (`quality-checks.yml`'s new `agent-quality` job, unconditional on every + PR). Previously `agent/`'s pre-commit hooks passed trivially because they + were hardcoded to `backend/` — the exact mechanism by which a prior + incident's agent-side fix omission went undetected locally (GH #1161). + - New `scripts/agent-test-coverage.sh` and `scripts/check-module-coverage.sh` + (the latter fixes a pre-existing dangling `Makefile` reference) + - `scripts/local-patch-report.sh` and the local patch-coverage tool now + report `agent/` patch coverage alongside backend/frontend, so + CLAUDE.md's mandatory patch-coverage preflight has visibility into + agent changes + - New `agent-codecov` job uploads `agent/coverage.txt` to Codecov under a + distinct `agent` flag + - New `scripts/ci/check_muzzle_allowlist_parity.go`: a structural + (AST-based) guard that fails the build if the Docker API allowlist + declarations in `backend/internal/orthrus/muzzle.go` and + `agent/muzzle/muzzle.go` diverge — closing the drift-detection gap that + let the underlying normalization bug (see Security, below) go + unnoticed for two release cycles + - **Supply Chain**: Optimized verification workflow to prevent redundant builds - Change: Removed direct Push/PR triggers; now waits for 'Docker Build' via `workflow_run` ### Security +- **Orthrus Muzzle Normalization Order (GH #1160)**: Fixed a divergence + between the backend and agent-side Docker API allowlist filters where the + agent normalized a request path (version-prefix strip, then + `path.Clean`) in a different order than the backend, and additionally + accepted a non-numeric `v`-prefixed segment as a version prefix via a + loose `path.Match("v*", ...)` wildcard. A crafted path (e.g. a + traversal-disguised or non-numeric version prefix) could be classified + differently by each filter in isolation; today's pipeline always runs + the backend filter first, masking the bug, but the agent-side filter + alone was strictly more permissive on 3 confirmed input classes, + including one reaching a write endpoint. Both filters now apply the + identical strip-then-clean order via a `normalizeDockerPath` helper + present in both files, and agent's allowlists were collapsed to + unversioned-only entries (dropping the redundant, overly-permissive + `/v*/...` duplicates). + - chore(security): verify CVE-2026-39824 (golang.org/x/sys) is not present — golang.org/x/sys already at v0.46.0, exceeding the v0.44.0 fix; no action required - chore(security): pin gosu-builder's golang.org/x/sys to v0.46.0 (GO-2026-5024 / CVE-2026-39824) — upstream tianon/gosu@1.17 vendors v0.13.0; vulnerable code is Windows-only and never compiled for this Linux-only binary; v0.46.0 matches the existing pin used elsewhere in the Dockerfile for the same advisory diff --git a/Makefile b/Makefile index bc1f2707f..31fb347d3 100644 --- a/Makefile +++ b/Makefile @@ -175,12 +175,14 @@ lint-backend: cd backend && docker run --rm -v $(PWD)/backend:/app -w /app golangci/golangci-lint:latest golangci-lint run -v lint-fast: - @echo "Running fast linters (staticcheck, govet, errcheck, ineffassign, unused)..." - cd backend && golangci-lint run --config .golangci-fast.yml ./... + @echo "Running fast linters (staticcheck, govet, errcheck, ineffassign, unused) — backend + agent..." + cd backend && golangci-lint run --config ../.golangci-fast.yml ./... + cd agent && golangci-lint run --config ../.golangci-fast.yml ./... lint-staticcheck-only: - @echo "Running staticcheck only..." - cd backend && golangci-lint run --config .golangci-fast.yml --disable-all --enable staticcheck ./... + @echo "Running staticcheck only — backend + agent..." + cd backend && golangci-lint run --config ../.golangci-fast.yml --enable-only staticcheck ./... + cd agent && golangci-lint run --config ../.golangci-fast.yml --enable-only staticcheck ./... lint-docker: @echo "Running Hadolint..." @@ -191,7 +193,7 @@ test-race: cd backend && go test -race -v ./... check-module-coverage: - @echo "Running module-specific coverage checks (backend + frontend)" + @echo "Running module-specific coverage checks (backend + agent)" @bash scripts/check-module-coverage.sh benchmark: diff --git a/agent/cert/cert_test.go b/agent/cert/cert_test.go new file mode 100644 index 000000000..8e2da327d --- /dev/null +++ b/agent/cert/cert_test.go @@ -0,0 +1,103 @@ +package cert_test + +import ( + "crypto/tls" + "crypto/x509" + "strings" + "testing" + + "github.com/Wikid82/charon/agent/cert" +) + +// TestLoadOrGenerate exercises LoadOrGenerate's genuine logic (ECDSA key +// generation, self-signed cert templating, PEM encoding) — previously +// untested (0% coverage), unlike agent/main.go's CLI bootstrap code, which +// is excluded from the coverage gate rather than tested directly (see +// scripts/agent-test-coverage.sh's EXCLUDE_PACKAGES). +func TestLoadOrGenerate(t *testing.T) { + const agentID = "test-agent-12345" + + cfg, err := cert.LoadOrGenerate(agentID) + if err != nil { + t.Fatalf("LoadOrGenerate returned error: %v", err) + } + if cfg == nil { + t.Fatal("LoadOrGenerate returned nil config") + } + + if len(cfg.Certificates) != 1 { + t.Fatalf("expected exactly one certificate, got %d", len(cfg.Certificates)) + } + + if cfg.MinVersion != tls.VersionTLS13 { + t.Fatalf("expected MinVersion TLS 1.3 (%d), got %d", tls.VersionTLS13, cfg.MinVersion) + } + + tlsCert := cfg.Certificates[0] + if len(tlsCert.Certificate) == 0 { + t.Fatal("expected at least one DER-encoded certificate in the chain") + } + + leaf, err := x509.ParseCertificate(tlsCert.Certificate[0]) + if err != nil { + t.Fatalf("failed to parse leaf certificate DER bytes: %v", err) + } + + if !strings.Contains(leaf.Subject.CommonName, agentID) { + t.Fatalf("expected CommonName to contain agentID %q, got %q", agentID, leaf.Subject.CommonName) + } +} + +// TestLoadOrGenerate_UniquePerCall confirms each call produces a fresh +// certificate (in-memory generation, no reuse/caching), matching the +// documented "generated in memory on every call" behavior. +func TestLoadOrGenerate_UniquePerCall(t *testing.T) { + cfgOne, err := cert.LoadOrGenerate("agent-a") + if err != nil { + t.Fatalf("LoadOrGenerate returned error: %v", err) + } + cfgTwo, err := cert.LoadOrGenerate("agent-a") + if err != nil { + t.Fatalf("LoadOrGenerate returned error: %v", err) + } + + leafOne, err := x509.ParseCertificate(cfgOne.Certificates[0].Certificate[0]) + if err != nil { + t.Fatalf("failed to parse first leaf certificate: %v", err) + } + leafTwo, err := x509.ParseCertificate(cfgTwo.Certificates[0].Certificate[0]) + if err != nil { + t.Fatalf("failed to parse second leaf certificate: %v", err) + } + + if leafOne.SerialNumber.Cmp(leafTwo.SerialNumber) == 0 { + t.Fatal("expected distinct serial numbers across independent LoadOrGenerate calls") + } +} + +// TestLoadOrGenerate_DifferentAgentIDsProduceDifferentCommonNames confirms +// the agentID parameter is actually threaded through into the certificate, +// not a hardcoded/ignored value. +func TestLoadOrGenerate_DifferentAgentIDsProduceDifferentCommonNames(t *testing.T) { + cfgOne, err := cert.LoadOrGenerate("agent-one") + if err != nil { + t.Fatalf("LoadOrGenerate returned error: %v", err) + } + cfgTwo, err := cert.LoadOrGenerate("agent-two") + if err != nil { + t.Fatalf("LoadOrGenerate returned error: %v", err) + } + + leafOne, err := x509.ParseCertificate(cfgOne.Certificates[0].Certificate[0]) + if err != nil { + t.Fatalf("failed to parse first leaf certificate: %v", err) + } + leafTwo, err := x509.ParseCertificate(cfgTwo.Certificates[0].Certificate[0]) + if err != nil { + t.Fatalf("failed to parse second leaf certificate: %v", err) + } + + if leafOne.Subject.CommonName == leafTwo.Subject.CommonName { + t.Fatalf("expected distinct CommonNames for distinct agentIDs, both were %q", leafOne.Subject.CommonName) + } +} diff --git a/agent/leash/leash.go b/agent/leash/leash.go index f0b65fe5d..908740fff 100644 --- a/agent/leash/leash.go +++ b/agent/leash/leash.go @@ -46,13 +46,18 @@ type Config struct { } // Leash manages the reverse WebSocket tunnel to the Charon server. +// +// Leash intentionally holds no *muzzle.Filter field: the filter is +// connection-scoped, not process-scoped (see connect), so a mid-connection +// write-mode toggle on the operator's side can't retroactively change an +// already-negotiated session. Each call to connect constructs a fresh +// Filter and closes over it for the lifetime of that one connection. type Leash struct { serverURL string authKey string agentID string dockerSock string log *logrus.Logger - filter *muzzle.Filter initialDelay time.Duration } @@ -68,7 +73,6 @@ func New(cfg Config) *Leash { agentID: cfg.AgentID, dockerSock: cfg.DockerSock, log: cfg.Log, - filter: muzzle.New(), initialDelay: d, } } @@ -119,7 +123,7 @@ func (l *Leash) connect(ctx context.Context) error { l.log.WithField("server_url", l.serverURL).Info("leash: connecting to server") - wsConn, _, err := dialer.DialContext(ctx, l.serverURL, http.Header{ + wsConn, resp, err := dialer.DialContext(ctx, l.serverURL, http.Header{ "Authorization": {"Bearer " + l.authKey}, "X-Orthrus-Version": {"1.0.0"}, "X-Orthrus-Name": {l.agentID}, @@ -128,6 +132,14 @@ func (l *Leash) connect(ctx context.Context) error { return fmt.Errorf("leash: websocket dial: %w", err) } + // X-Orthrus-Write-Enabled is delivered atomically in the 101 Switching + // Protocols response that completes the handshake (server.go sets it via + // wsUpgrader.Upgrade's responseHeader parameter). A missing header or + // any value other than the literal string "true" is treated as false — + // fail closed, matching this package's existing "unknown stream type, + // closing" posture elsewhere in this file. + writeEnabled := resp != nil && resp.Header.Get("X-Orthrus-Write-Enabled") == "true" + cfg := yamux.DefaultConfig() cfg.LogOutput = io.Discard @@ -136,10 +148,18 @@ func (l *Leash) connect(ctx context.Context) error { _ = wsConn.Close() return fmt.Errorf("leash: yamux client: %w", err) } - defer session.Close() + defer func() { _ = session.Close() }() l.log.Info("leash: connected, accepting proxy streams") + // filter is scoped to this one connection: constructed fresh per + // successful dial with the writeEnabled value negotiated above, closed + // over by every stream this connection accepts, and discarded on + // reconnect. This is what makes a write-mode toggle take effect only on + // the agent's next reconnect, matching the backend's per-AgentSession + // Muzzle scoping. + filter := muzzle.New(writeEnabled) + hbCtx, hbCancel := context.WithCancel(ctx) defer hbCancel() go newHeartbeat(session, l.log).Run(hbCtx) @@ -149,13 +169,13 @@ func (l *Leash) connect(ctx context.Context) error { if err != nil { return fmt.Errorf("leash: accept stream: %w", err) } - go l.handleStream(stream) + go l.handleStream(stream, filter) } } // handleStream reads the leading type byte from a yamux stream and dispatches accordingly. -func (l *Leash) handleStream(stream *yamux.Stream) { - defer stream.Close() +func (l *Leash) handleStream(stream *yamux.Stream, filter *muzzle.Filter) { + defer func() { _ = stream.Close() }() typeBuf := make([]byte, 1) if _, err := io.ReadFull(stream, typeBuf); err != nil { @@ -165,7 +185,7 @@ func (l *Leash) handleStream(stream *yamux.Stream) { switch typeBuf[0] { case streamTypeDocker: - l.handleDockerStream(stream) + l.handleDockerStream(stream, filter) case streamTypePortForward: l.handlePortForward(stream) default: @@ -173,9 +193,10 @@ func (l *Leash) handleStream(stream *yamux.Stream) { } } -// handleDockerStream proxies the stream to the local Docker socket through the Muzzle filter. -func (l *Leash) handleDockerStream(stream *yamux.Stream) { - if err := l.filter.ServeProxy(l.dockerSock, stream, stream); err != nil { +// handleDockerStream proxies the stream to the local Docker socket through +// the connection-scoped Muzzle filter negotiated in connect. +func (l *Leash) handleDockerStream(stream *yamux.Stream, filter *muzzle.Filter) { + if err := filter.ServeProxy(l.dockerSock, stream, stream); err != nil { l.log.WithField("error", sanitizeLogField(err.Error())).Debug("leash: docker proxy stream closed") } } @@ -208,7 +229,7 @@ func (l *Leash) handlePortForward(stream *yamux.Stream) { l.log.WithError(err).WithField("target", sanitizeLogField(targetAddr)).Error("leash: port forward: dial failed") return } - defer conn.Close() + defer func() { _ = conn.Close() }() proxyBidirectional(stream, conn) } diff --git a/agent/leash/leash_test.go b/agent/leash/leash_test.go index bc6633db9..d91db518a 100644 --- a/agent/leash/leash_test.go +++ b/agent/leash/leash_test.go @@ -3,12 +3,15 @@ package leash_test import ( "context" "io" + "net" "net/http" "net/http/httptest" + "path/filepath" "testing" "time" "github.com/gorilla/websocket" + "github.com/hashicorp/yamux" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,6 +19,34 @@ import ( "github.com/Wikid82/charon/agent/leash" ) +// streamTypeDocker and streamTypePortForward mirror the unexported marker +// bytes leash.go's handleStream switches on (streamTypeDocker = 0x01, +// streamTypePortForward = 0x02). They can't be imported (leash_test is an +// external test package and the constants are unexported), so the literal +// values are duplicated here deliberately, matching the wire protocol +// leash.go documents in its own handleStream/handlePortForward comments. +const ( + streamTypeDocker = byte(0x01) + streamTypePortForward = byte(0x02) +) + +// newYamuxServerSession wraps a server-side (already-upgraded) WebSocket +// connection as a net.Conn via leash.NewWSNetConn (the same helper +// TestWsNetConn_ReadWrite above already exercises) and starts a yamux +// server session on it. This is the test-harness-side counterpart to +// connect()'s own yamux.Client(NewWSNetConn(wsConn), cfg) call, needed so +// the test can open streams that the agent's AcceptStream loop will +// receive. +func newYamuxServerSession(t *testing.T, wsConn *websocket.Conn) *yamux.Session { + t.Helper() + netConn := leash.NewWSNetConn(wsConn) + cfg := yamux.DefaultConfig() + cfg.LogOutput = io.Discard + session, err := yamux.Server(netConn, cfg) + require.NoError(t, err) + return session +} + var testUpgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } @@ -29,7 +60,7 @@ func TestLeash_Reconnect(t *testing.T) { connectCount++ conn, err := testUpgrader.Upgrade(w, r, nil) require.NoError(t, err) - conn.Close() + _ = conn.Close() })) defer srv.Close() @@ -61,7 +92,7 @@ func TestWsNetConn_ReadWrite(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := testUpgrader.Upgrade(w, r, nil) require.NoError(t, err) - defer conn.Close() + defer func() { _ = conn.Close() }() _, msg, err := conn.ReadMessage() require.NoError(t, err) @@ -72,7 +103,7 @@ func TestWsNetConn_ReadWrite(t *testing.T) { dialer := websocket.Dialer{} wsConn, _, err := dialer.Dial("ws"+srv.URL[4:], nil) require.NoError(t, err) - defer wsConn.Close() + defer func() { _ = wsConn.Close() }() netConn := leash.NewWSNetConn(wsConn) @@ -86,3 +117,167 @@ func TestWsNetConn_ReadWrite(t *testing.T) { require.NoError(t, err) assert.Equal(t, sent, buf) } + +// TestLeash_Connect_DockerStreamDispatchesThroughFilter proves the full +// write-mode wiring chain actually executes for a real accepted stream: +// connect()'s AcceptStream loop -> handleStream's streamTypeDocker dispatch +// -> handleDockerStream -> the connection-scoped filter.ServeProxy call -> +// net.Dial(dockerSock). None of this had ever been exercised by a test +// before (F.5/F.6 of docs/plans/current_spec.md's coverage follow-up): +// muzzle's own tests call Filter.ServeProxy directly, never through Leash's +// dispatch, so this is the only place that dispatch wiring is proven live. +// +// The test server plays the Charon-server role: it upgrades the WebSocket, +// opens a yamux *server* session on it (the counterpart to connect()'s own +// yamux.Client call), opens one stream, and writes the streamTypeDocker +// marker byte followed by a minimal valid raw HTTP request. The real agent +// side runs via leash.New(...).Run(ctx) in a goroutine, configured with +// DockerSock pointing at a fake unix listener that just accepts and holds +// the connection. Success is that fake listener's Accept() returning within +// a bounded timeout - proof a real stream was dispatched all the way +// through to a real Dial of the configured Docker socket path. +func TestLeash_Connect_DockerStreamDispatchesThroughFilter(t *testing.T) { + dockerSockPath := filepath.Join(t.TempDir(), "docker.sock") + dockerLn, err := net.Listen("unix", dockerSockPath) + require.NoError(t, err) + defer func() { _ = dockerLn.Close() }() + + dockerAccepted := make(chan net.Conn, 1) + go func() { + conn, acceptErr := dockerLn.Accept() + if acceptErr == nil { + dockerAccepted <- conn + } + }() + + // serverDone gates the test server handler's return (and therefore its + // deferred session/stream/WebSocket closes) until the test has either + // observed the docker-side accept or given up, so the underlying + // transport isn't torn down before the agent has a chance to read the + // already-written stream data and act on it. + serverDone := make(chan struct{}) + defer close(serverDone) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, upgradeErr := testUpgrader.Upgrade(w, r, nil) + require.NoError(t, upgradeErr) + defer func() { _ = wsConn.Close() }() + + session := newYamuxServerSession(t, wsConn) + defer func() { _ = session.Close() }() + + stream, openErr := session.OpenStream() + require.NoError(t, openErr) + defer func() { _ = stream.Close() }() + + payload := append([]byte{streamTypeDocker}, []byte("GET /containers/json HTTP/1.1\r\nHost: localhost\r\n\r\n")...) + _, writeErr := stream.Write(payload) + require.NoError(t, writeErr) + + <-serverDone + })) + defer srv.Close() + + wsURL := "ws" + srv.URL[4:] + "/ws" + + log := logrus.New() + log.SetOutput(io.Discard) + + l := leash.New(leash.Config{ + ServerURL: wsURL, + AuthKey: "ch_orthrus_test", + AgentID: "test-agent", + DockerSock: dockerSockPath, + Log: log, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = l.Run(ctx) }() + + select { + case conn := <-dockerAccepted: + _ = conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the docker stream to dial the fake docker socket") + } +} + +// TestLeash_Connect_PortForwardStreamDialsTarget proves handlePortForward's +// successful-dial path (and its deferred conn.Close(), leash.go line 232) +// actually runs for a real accepted stream, the port-forward counterpart to +// the docker-stream test above. +// +// Same harness: the test server opens a stream and writes the +// streamTypePortForward marker byte followed by a 2-byte big-endian address +// length and the address bytes of a fake TCP target listener. Success is +// that target listener's Accept() returning within a bounded timeout, +// proving handleStream's streamTypePortForward case dispatched into +// handlePortForward and it reached net.Dial("tcp", targetAddr) rather than +// one of its early-return error paths (invalid address length, dial +// failure) that are the only parts any pre-existing test exercised. +func TestLeash_Connect_PortForwardStreamDialsTarget(t *testing.T) { + targetLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = targetLn.Close() }() + + targetAccepted := make(chan net.Conn, 1) + go func() { + conn, acceptErr := targetLn.Accept() + if acceptErr == nil { + targetAccepted <- conn + } + }() + + serverDone := make(chan struct{}) + defer close(serverDone) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, upgradeErr := testUpgrader.Upgrade(w, r, nil) + require.NoError(t, upgradeErr) + defer func() { _ = wsConn.Close() }() + + session := newYamuxServerSession(t, wsConn) + defer func() { _ = session.Close() }() + + stream, openErr := session.OpenStream() + require.NoError(t, openErr) + defer func() { _ = stream.Close() }() + + addr := targetLn.Addr().String() + require.LessOrEqual(t, len(addr), 255, "target address must fit handlePortForward's 1-byte length encoding") + lenBuf := []byte{byte(len(addr) >> 8), byte(len(addr))} + + payload := append([]byte{streamTypePortForward}, lenBuf...) + payload = append(payload, []byte(addr)...) + _, writeErr := stream.Write(payload) + require.NoError(t, writeErr) + + <-serverDone + })) + defer srv.Close() + + wsURL := "ws" + srv.URL[4:] + "/ws" + + log := logrus.New() + log.SetOutput(io.Discard) + + l := leash.New(leash.Config{ + ServerURL: wsURL, + AuthKey: "ch_orthrus_test", + AgentID: "test-agent", + DockerSock: "/var/run/docker.sock", + Log: log, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = l.Run(ctx) }() + + select { + case conn := <-targetAccepted: + _ = conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the port-forward stream to dial the fake target listener") + } +} diff --git a/agent/muzzle/muzzle.go b/agent/muzzle/muzzle.go index 9929cc0cc..552fc1c55 100644 --- a/agent/muzzle/muzzle.go +++ b/agent/muzzle/muzzle.go @@ -7,6 +7,8 @@ package muzzle import ( "bufio" + "bytes" + "encoding/json" "fmt" "io" "net" @@ -18,75 +20,83 @@ import ( const forbiddenResponse = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" -// allowedPatterns enumerates the Docker API paths that agents may access. -// Matching uses path.Match after stripping query parameters; each pattern -// uses `*` to match any single path segment (never crosses a slash). +// versionPrefixRe strips a leading Docker API version segment (e.g. +// "/v1.47") from a path. Used by normalizeDockerPath, which every allowlist +// check below is matched against — so a single normalization pass covers +// both the unversioned form (e.g. Dockhand sends GET /containers/json) and +// the versioned form (e.g. the canonical Docker CLI sends GET +// /v1.47/containers/json) with one set of unversioned-only allowlist +// entries, instead of duplicating every entry into an unversioned and a +// "/v*/..." form. +// +// Must stay byte-identical to backend/internal/orthrus/muzzle.go's +// versionPrefixRe declaration — scripts/ci/check_muzzle_allowlist_parity.go +// structurally compares the two regex source strings. +var versionPrefixRe = regexp.MustCompile(`^/v\d+\.\d+`) + +// normalizeDockerPath strips a Docker API version prefix (e.g. "/v1.47") +// using versionPrefixRe, THEN runs path.Clean — matching +// backend/internal/orthrus/muzzle.go's ServeHTTP order exactly (see that +// file's identically-named helper), so both filters reach the same +// allow/deny decision on the same raw input in isolation, not only when +// backend happens to run first in the real request pipeline (GH #1160). // -// Both versioned (/v*/...) and unversioned (/...) forms are listed because -// Docker clients such as Dockhand send unversioned requests (e.g. GET /containers/json) -// while the canonical Docker CLI sends versioned requests (e.g. GET /v1.47/containers/json). -var allowedPatterns = []string{ - "/_ping", // no version prefix (Docker < 1.24 / direct health check) - "/v*/_ping", // versioned ping for Docker client health checks - - // Container listing and inspection — unversioned (RC8 fix) + versioned - "/containers/json", - "/v*/containers/json", +// Stripping first, before path.Clean has resolved any ".." segments, means +// versionPrefixRe is evaluated against the path exactly as received: a +// traversal-disguised version prefix (e.g. "/foo/../v1.44/images/x/json") +// is not mistaken for a real one, since the regex is anchored to the start +// of the raw string, not the cleaned one. +func normalizeDockerPath(reqPath string) string { + stripped := versionPrefixRe.ReplaceAllString(reqPath, "") + return path.Clean("/" + strings.TrimLeft(stripped, "/")) +} + +// allowedDockerPaths is the set of Docker API paths that are safe to expose +// to agents, matched by exact string equality against the +// normalizeDockerPath-normalized path. Mirrors +// backend/internal/orthrus/muzzle.go's allowedDockerPaths declaration 1:1 — +// scripts/ci/check_muzzle_allowlist_parity.go structurally compares the two. +var allowedDockerPaths = map[string]struct{}{ + "/_ping": {}, + "/containers/json": {}, + "/images/json": {}, + "/info": {}, + "/version": {}, + "/events": {}, + "/volumes": {}, + "/networks": {}, + "/system/df": {}, +} + +// allowedDockerPatterns covers dynamic-segment paths such as +// /containers/{id}/json, /volumes/{name}, and /networks/{id}. Matching uses +// path.Match against the normalizeDockerPath-normalized path. Mirrors +// backend/internal/orthrus/muzzle.go's allowedDockerPatterns declaration +// 1:1. +var allowedDockerPatterns = []string{ "/containers/*/json", - "/v*/containers/*/json", "/containers/*/logs", - "/v*/containers/*/logs", "/containers/*/stats", - "/v*/containers/*/stats", "/containers/*/top", - "/v*/containers/*/top", - - // Daemon info — unversioned + versioned - "/info", - "/v*/info", - "/images/json", - "/v*/images/json", - "/version", - "/v*/version", - "/events", - "/v*/events", - - // Volumes — unversioned + versioned - "/volumes", - "/v*/volumes", "/volumes/*", - "/v*/volumes/*", - - // Networks — unversioned + versioned - "/networks", - "/v*/networks", "/networks/*", - "/v*/networks/*", - - // System disk usage — unversioned + versioned - "/system/df", - "/v*/system/df", } -// versionPrefixRe strips a leading Docker API version segment (e.g. -// "/v1.47") from a path before the imageDistributionPatterns prefix/suffix -// check below, so a single comparison covers both the unversioned and -// versioned forms of those two entries. It is used only for that check; -// every other entry in allowedPatterns keeps its literal "/v*/..." form -// matched via path.Match. -var versionPrefixRe = regexp.MustCompile(`^/v\d+\.\d+`) - -// imageDistributionPatterns covers per-image inspect (/images/{name}/json) -// and the registry digest check (/distribution/{name}/json). These are -// matched by prefix/suffix string comparison rather than path.Match, -// because Go's path.Match "*" does not cross "/", and real-world Docker -// image references are frequently namespaced with multiple "/"-separated -// segments (e.g. "ghcr.io/org/repo", "lscr.io/linuxserver/prowlarr"). A -// path.Match pattern like "/images/*/json" only matches single-segment -// names (e.g. "nginx") and would silently reject the overwhelming majority -// of real-world images — the same bug already found and fixed once in +// allowedDockerPrefixSuffixPatterns covers per-image inspect +// (/images/{name}/json) and the registry digest check +// (/distribution/{name}/json), matched against the +// normalizeDockerPath-normalized path. These are matched by prefix/suffix +// string comparison rather than path.Match, because Go's path.Match "*" +// does not cross "/", and real-world Docker image references are +// frequently namespaced with multiple "/"-separated segments (e.g. +// "ghcr.io/org/repo", "lscr.io/linuxserver/prowlarr"). A path.Match pattern +// like "/images/*/json" only matches single-segment names (e.g. "nginx") +// and would silently reject the overwhelming majority of real-world images +// — the same bug already found and fixed once in // backend/internal/orthrus/muzzle.go (commits 98a68b67 and b71cbd62), which -// this mirrors on the remote-agent side of the tunnel. +// this mirrors on the remote-agent side of the tunnel. Mirrors +// backend/internal/orthrus/muzzle.go's allowedDockerPrefixSuffixPatterns +// declaration 1:1. // // Both entries are GET-only like every other allowlist entry: the method // check in Allow runs unconditionally before this check, so POST/PUT/DELETE @@ -100,7 +110,7 @@ var versionPrefixRe = regexp.MustCompile(`^/v\d+\.\d+`) // segment rather than literally "json" — /images/{name}/history, // /images/{name}/get, and /images/{name}/changes all fail the "/json" // suffix check and remain blocked. -var imageDistributionPatterns = []struct { +var allowedDockerPrefixSuffixPatterns = []struct { prefix string suffix string }{ @@ -108,38 +118,289 @@ var imageDistributionPatterns = []struct { {prefix: "/distribution/", suffix: "/json"}, // registry digest check — read-only } +// allowedWriteExactPaths lists the fixed-path write endpoints permitted +// when a connection has negotiated write mode (Filter.writeEnabled), keyed +// on the version-stripped path. /containers/create is handled as its own +// special case in allowWrite (body-validated); /images/create takes its +// parameters via query string and has no body to validate. This MUST stay +// in exact policy agreement with allowedWriteExactPaths/allowedWritePatterns +// in backend/internal/orthrus/muzzle.go — the shared test corpus +// (backend/internal/orthrus/testdata/muzzle_corpus.json) exists specifically +// to catch drift between the two independently-maintained copies. +var allowedWriteExactPaths = map[string]struct{}{ + "/containers/create": {}, + "/images/create": {}, +} + +// allowedWritePatterns lists the dynamic-segment write endpoints permitted +// when write mode is on, matched against the normalizeDockerPath-normalized +// path — the version prefix is stripped once, up front, so no separate +// "/v*/..." entry is needed here. Mirrors +// backend/internal/orthrus/muzzle.go's allowedWritePatterns declaration +// 1:1. +// +// /containers/*/rename takes the new container name via a "?name=" query +// parameter, not a request body, so — like start/stop/restart — it needs no +// body-validation function; Allow only ever sees normalizedPath, never the +// query string. +var allowedWritePatterns = []struct { + method string + pattern string +}{ + {method: http.MethodPost, pattern: "/containers/*/start"}, + {method: http.MethodPost, pattern: "/containers/*/stop"}, + {method: http.MethodPost, pattern: "/containers/*/restart"}, + {method: http.MethodPost, pattern: "/containers/*/rename"}, + {method: http.MethodDelete, pattern: "/containers/*"}, +} + +// maxContainerCreateBodyBytes bounds the size of a /containers/create body +// accepted for validation. Must stay numerically identical to the backend's +// copy (backend/internal/orthrus/muzzle.go) — the shared test corpus +// includes a boundary-size case to catch drift. +const maxContainerCreateBodyBytes = 64 * 1024 + +// hostConfigAllowedKeys, mountEntry, and the two value-level validators +// below are an exact policy mirror of backend/internal/orthrus/muzzle.go's +// copies. See that file's doc comments for the full rationale (allowlist +// over denylist for fail-closed behavior against unknown future fields; the +// VolumeOptions.DriverConfig check closing the local-driver +// bind-mount-via-volume bypass). Duplicated here, not shared via import, +// because agent/ is a separate Go module built as a minimal standalone +// binary and does not import backend/ packages. +// See backend/internal/orthrus/muzzle.go's copy of this declaration for the +// full rationale on which keys are safe to allow vs. deliberately excluded +// (host-escape, host-filesystem, or ambiguous namespace/cgroup-placement +// primitives) — duplicated here only as enforcement logic, not as +// documentation, since agent/ is a separate Go module and cannot import +// backend/ packages. +var hostConfigAllowedKeys = map[string]struct{}{ + "PortBindings": {}, + "RestartPolicy": {}, + "Memory": {}, + "MemorySwap": {}, + "NanoCpus": {}, + "CpuShares": {}, + "Mounts": {}, + "Dns": {}, + "DnsSearch": {}, + "DnsOptions": {}, + "ExtraHosts": {}, + "LogConfig": {}, + "AutoRemove": {}, + "ReadonlyRootfs": {}, + "Init": {}, + "NetworkMode": {}, + "CapDrop": {}, + "UsernsMode": {}, + "ContainerIDFile": {}, + "CpuPeriod": {}, + "CpuQuota": {}, + "CpuRealtimePeriod": {}, + "CpuRealtimeRuntime": {}, + "CpusetCpus": {}, + "CpusetMems": {}, + "BlkioWeight": {}, + "BlkioWeightDevice": {}, + "BlkioDeviceReadBps": {}, + "BlkioDeviceWriteBps": {}, + "BlkioDeviceReadIOps": {}, + "BlkioDeviceWriteIOps": {}, + "KernelMemory": {}, + "KernelMemoryTCP": {}, + "MemoryReservation": {}, + "MemorySwappiness": {}, + "OomKillDisable": {}, + "OomScoreAdj": {}, + "PidsLimit": {}, + "Ulimits": {}, + "StorageOpt": {}, + "ShmSize": {}, + "Tmpfs": {}, + "MaskedPaths": {}, + "ReadonlyPaths": {}, + "ConsoleSize": {}, + "Annotations": {}, + "Links": {}, + "PublishAllPorts": {}, +} + +type mountEntry struct { + Type string `json:"Type"` + VolumeOptions json.RawMessage `json:"VolumeOptions"` +} + +func validateNetworkModeValue(raw json.RawMessage) bool { + var mode string + if err := json.Unmarshal(raw, &mode); err != nil { + return false + } + if mode == "host" { + return false + } + if strings.HasPrefix(mode, "container:") { + return false + } + return true +} + +// validateUsernsModeValue mirrors backend/internal/orthrus/muzzle.go's copy: +// rejects only "host" (opts out of Docker's user-namespace remapping), +// accepts every other value including the empty string. +func validateUsernsModeValue(raw json.RawMessage) bool { + var mode string + if err := json.Unmarshal(raw, &mode); err != nil { + return false + } + return mode != "host" +} + +// validateContainerIDFileValue mirrors backend/internal/orthrus/muzzle.go's +// copy: accepts only the empty string, rejecting any non-empty host path +// (the daemon would create a file there before the container starts). +func validateContainerIDFileValue(raw json.RawMessage) bool { + var cidFile string + if err := json.Unmarshal(raw, &cidFile); err != nil { + return false + } + return cidFile == "" +} + +func validateMountsValue(raw json.RawMessage) bool { + var mounts []mountEntry + if err := json.Unmarshal(raw, &mounts); err != nil { + return false + } + for _, mnt := range mounts { + if mnt.Type != "volume" && mnt.Type != "tmpfs" { + return false + } + if len(mnt.VolumeOptions) == 0 { + continue + } + var volumeOptions map[string]json.RawMessage + if err := json.Unmarshal(mnt.VolumeOptions, &volumeOptions); err != nil { + return false + } + if _, hasDriverConfig := volumeOptions["DriverConfig"]; hasDriverConfig { + return false + } + } + return true +} + +// validateContainerCreateBody validates a /containers/create request body +// against hostConfigAllowedKeys plus the NetworkMode/Mounts value-level +// checks. Unlike the backend's copy, this function does not re-buffer +// req.Body itself — ServeProxy already reads the full body into bodyBytes +// once, up front, for every request (see the doc comment there), so there +// is nothing left to re-buffer by the time this function runs. +func validateContainerCreateBody(bodyBytes []byte) (ok bool, reason string) { + if len(bodyBytes) == 0 { + return true, "" + } + + var top map[string]json.RawMessage + if err := json.Unmarshal(bodyBytes, &top); err != nil { + return false, "malformed request body" + } + + hostConfigRaw, hasHostConfig := top["HostConfig"] + if !hasHostConfig { + return true, "" + } + + var hostConfig map[string]json.RawMessage + if err := json.Unmarshal(hostConfigRaw, &hostConfig); err != nil { + return false, "malformed request body" + } + + for key, rawValue := range hostConfig { + if _, allowed := hostConfigAllowedKeys[key]; !allowed { + return false, "disallowed HostConfig field: " + key + } + switch key { + case "NetworkMode": + if !validateNetworkModeValue(rawValue) { + return false, "disallowed HostConfig field: NetworkMode" + } + case "Mounts": + if !validateMountsValue(rawValue) { + return false, "disallowed HostConfig field: Mounts" + } + case "UsernsMode": + if !validateUsernsModeValue(rawValue) { + return false, "disallowed HostConfig field: UsernsMode" + } + case "ContainerIDFile": + if !validateContainerIDFileValue(rawValue) { + return false, "disallowed HostConfig field: ContainerIDFile" + } + } + } + + return true, "" +} + // Filter is an HTTP allowlist filter for Docker socket proxy streams. -type Filter struct{} +// +// Filter is connection-scoped, not process-scoped: a fresh Filter is +// constructed for each successful agent connection (see leash.go's connect +// function), carrying the writeEnabled value negotiated for that specific +// session via the X-Orthrus-Write-Enabled handshake header. This mirrors the +// backend's per-AgentSession Muzzle scoping exactly, so a mid-connection DB +// toggle on the operator's side cannot retroactively change an +// already-negotiated session — the change only takes effect on the agent's +// next reconnect. +type Filter struct { + writeEnabled bool +} -// New returns a new Muzzle filter. -func New() *Filter { - return &Filter{} +// New returns a new Muzzle filter. writeEnabled governs whether the optional +// write-endpoint allowlist is consulted for this connection; false (the +// default for any caller not yet passing the negotiated handshake value) +// preserves today's unconditional read-only behavior. +func New(writeEnabled bool) *Filter { + return &Filter{writeEnabled: writeEnabled} } -// Allow returns true if method+reqPath is on the allowlist. -// Only GET is permitted, except HEAD which is allowed on /_ping and /v*/_ping -// (Docker SDK connectivity check). -func (f *Filter) Allow(method, reqPath string) bool { +// Allow returns true if method+reqPath (+body, for the one body-validated +// write endpoint) is on the allowlist, after normalizeDockerPath has +// stripped any version prefix and cleaned the path. Only GET is permitted +// for read traffic, except HEAD which is allowed on /_ping (Docker SDK +// connectivity check, matched against the normalized path so a versioned +// request like /v1.47/_ping is also accepted); POST/DELETE are additionally +// permitted for the fixed write-endpoint set when f.writeEnabled is true. +// +// body is only consulted for the one body-validated case (POST to +// /containers/create, versioned or not); every other allowlist branch +// ignores it entirely, so passing nil for read-only calls is safe. +func (f *Filter) Allow(method, reqPath string, body []byte) bool { + normalizedPath := normalizeDockerPath(reqPath) + // HEAD is permitted only for /_ping (Docker SDK connectivity check). if strings.EqualFold(method, http.MethodHead) { - cleanPath := path.Clean(reqPath) - for _, p := range []string{"/_ping", "/v*/_ping"} { - if matched, _ := path.Match(p, cleanPath); matched { - return true - } + return normalizedPath == "/_ping" + } + + if f.writeEnabled && (strings.EqualFold(method, http.MethodPost) || strings.EqualFold(method, http.MethodDelete)) { + if f.allowWrite(method, normalizedPath, body) { + return true } - return false + // Not on the write allowlist even with write mode on — fall through + // to the same false every other disallowed request gets below. } if !strings.EqualFold(method, http.MethodGet) { return false } - // path.Clean normalises redundant separators and removes trailing slashes. - cleanPath := path.Clean(reqPath) + if _, ok := allowedDockerPaths[normalizedPath]; ok { + return true + } - for _, pattern := range allowedPatterns { - matched, err := path.Match(pattern, cleanPath) + for _, pattern := range allowedDockerPatterns { + matched, err := path.Match(pattern, normalizedPath) if err == nil && matched { return true } @@ -147,10 +408,10 @@ func (f *Filter) Allow(method, reqPath string) bool { // Check image/distribution inspect paths, which may contain namespaced // references with multiple "/"-separated segments (see doc comment on - // imageDistributionPatterns for why path.Match is not used here). - unversioned := versionPrefixRe.ReplaceAllString(cleanPath, "") - for _, idp := range imageDistributionPatterns { - if strings.HasPrefix(unversioned, idp.prefix) && strings.HasSuffix(unversioned, idp.suffix) { + // allowedDockerPrefixSuffixPatterns for why path.Match is not used + // here). + for _, ps := range allowedDockerPrefixSuffixPatterns { + if strings.HasPrefix(normalizedPath, ps.prefix) && strings.HasSuffix(normalizedPath, ps.suffix) { return true } } @@ -158,9 +419,54 @@ func (f *Filter) Allow(method, reqPath string) bool { return false } +// allowWrite checks method+normalizedPath against allowedWriteExactPaths +// and allowedWritePatterns, applying validateContainerCreateBody +// specifically for /containers/create. Only called when f.writeEnabled is +// true. +// +// normalizedPath must already be the result of normalizeDockerPath, applied +// once by the caller (Allow). Previously this method separately re-derived +// an "unversioned" value for its exact-path branch only, while its pattern +// branch matched the raw (non-version-stripped) cleanPath directly — a +// second, independent instance of the GH #1160 normalization-order +// divergence, reaching a write endpoint. Both branches now share the same +// pre-normalized path. +func (f *Filter) allowWrite(method, normalizedPath string, body []byte) bool { + if strings.EqualFold(method, http.MethodPost) { + if _, ok := allowedWriteExactPaths[normalizedPath]; ok { + if normalizedPath == "/containers/create" { + valid, _ := validateContainerCreateBody(body) + return valid + } + return true + } + } + + for _, p := range allowedWritePatterns { + if !strings.EqualFold(method, p.method) { + continue + } + if matched, err := path.Match(p.pattern, normalizedPath); err == nil && matched { + return true + } + } + return false +} + // ServeProxy reads the HTTP request from r, checks Allow(), then dials the Docker // unix socket at dst and proxies the full HTTP transaction. If the request is // blocked, a 403 response is written to w and an error is returned. +// +// Every request's body is read and re-buffered unconditionally, not only +// for /containers/create: reading req.Body to obtain bytes for Allow +// consumes the underlying io.ReadCloser exactly once, and req.Write(conn) +// below would forward an empty/already-drained body unless the bytes are +// wrapped back into a fresh reader first. GET requests present req.Body as +// nil or an already-empty, immediately-io.EOF reader (standard net/http +// behavior for bodyless requests), so this is a cheap no-op for the +// overwhelming majority of traffic — keeping this step unconditional avoids +// duplicating the "is this /containers/create" check that already has to +// live inside Allow/allowWrite. func (f *Filter) ServeProxy(dst string, r io.Reader, w io.Writer) error { bufr := bufio.NewReader(r) @@ -169,7 +475,24 @@ func (f *Filter) ServeProxy(dst string, r io.Reader, w io.Writer) error { return fmt.Errorf("muzzle: read request: %w", err) } - if !f.Allow(req.Method, req.URL.Path) { + var bodyBytes []byte + if req.Body != nil { + limited := io.LimitReader(req.Body, maxContainerCreateBodyBytes+1) + bodyBytes, err = io.ReadAll(limited) + _ = req.Body.Close() + if err != nil { + return fmt.Errorf("muzzle: read body: %w", err) + } + if len(bodyBytes) > maxContainerCreateBodyBytes { + _, _ = io.WriteString(w, forbiddenResponse) + return fmt.Errorf("muzzle: blocked %s %s: body too large", req.Method, req.URL.Path) + } + // Re-buffer: req.Write below must still forward the original body intact. + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + } + + if !f.Allow(req.Method, req.URL.Path, bodyBytes) { // Fail closed: write 403 and abort the stream. _, _ = io.WriteString(w, forbiddenResponse) return fmt.Errorf("muzzle: blocked %s %s", req.Method, req.URL.Path) @@ -179,15 +502,15 @@ func (f *Filter) ServeProxy(dst string, r io.Reader, w io.Writer) error { if err != nil { return fmt.Errorf("muzzle: dial docker socket: %w", err) } - defer conn.Close() + defer func() { _ = conn.Close() }() // Ensure Docker closes the socket after the response so ServeProxy can // terminate cleanly instead of waiting on an idle keep-alive connection. req.Close = true // Forward the full request (headers + body) to the Docker socket. - if err := req.Write(conn); err != nil { - return fmt.Errorf("muzzle: forward request to docker: %w", err) + if writeErr := req.Write(conn); writeErr != nil { + return fmt.Errorf("muzzle: forward request to docker: %w", writeErr) } // Stream the response back to the caller. diff --git a/agent/muzzle/muzzle_test.go b/agent/muzzle/muzzle_test.go index c03ec71cb..84ea1711c 100644 --- a/agent/muzzle/muzzle_test.go +++ b/agent/muzzle/muzzle_test.go @@ -3,11 +3,13 @@ package muzzle_test import ( "bufio" "bytes" + "encoding/json" "errors" "fmt" "io" "net" "net/http" + "os" "path/filepath" "strings" "sync" @@ -87,7 +89,7 @@ func startUnixHTTPServer(t *testing.T, handler func(net.Conn)) (string, func()) } func TestFilter_Allow(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) tests := []struct { method string @@ -159,6 +161,23 @@ func TestFilter_Allow(t *testing.T) { // --- Path traversal: path.Clean normalises before matching --- {"GET", "/v1.47/../containers/json", true}, // resolves to /containers/json — allowed {"GET", "/containers/../../etc/passwd", false}, // resolves to /etc/passwd — blocked + {"GET", "/v1.44//containers/json", true}, // double slash after a legitimate version prefix + + // --- GH #1160 regression: normalizeDockerPath strips the version + // prefix BEFORE path.Clean runs, so a version prefix only revealed by + // resolving ".." segments must NOT be treated as a version prefix + // (divergence row 1) — the un-normalized "/foo/../v1.44/..." is not + // on the allowlist even after Clean resolves it to + // "/v1.44/images/x/json", since versionPrefixRe already ran against + // the raw, traversal-disguised path and found no match there. + {"GET", "/foo/../v1.44/images/x/json", false}, + + // --- GH #1160 regression: versionPrefixRe requires a numeric + // major.minor segment (^/v\d+\.\d+); a "v"-prefixed segment that + // isn't numeric must not be accepted as a version prefix (divergence + // row 2, read path; row 2 HEAD variant) --- + {"GET", "/vFOO/containers/json", false}, + {"HEAD", "/vBOGUS/_ping", false}, // --- Allowed: single-segment image/distribution inspect (prefix/suffix match) --- {"GET", "/images/alpine/json", true}, @@ -184,7 +203,7 @@ func TestFilter_Allow(t *testing.T) { for _, tt := range tests { t.Run(tt.method+" "+tt.reqPath, func(t *testing.T) { - got := f.Allow(tt.method, tt.reqPath) + got := f.Allow(tt.method, tt.reqPath, nil) assert.Equal(t, tt.allowed, got) }) } @@ -199,7 +218,7 @@ func TestFilter_Allow(t *testing.T) { // like "nginx". Uses prefix/suffix matching instead of path.Match, whose // "*" does not cross "/". func TestFilter_Allow_NamespacedImagePaths(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) refs := []string{ "ghcr.io/org/repo", @@ -212,12 +231,12 @@ func TestFilter_Allow_NamespacedImagePaths(t *testing.T) { for _, ref := range refs { p := prefix + ref + "/json" t.Run(p, func(t *testing.T) { - assert.True(t, f.Allow("GET", p)) + assert.True(t, f.Allow("GET", p, nil)) }) vp := "/v1.44" + prefix + ref + "/json" t.Run(vp, func(t *testing.T) { - assert.True(t, f.Allow("GET", vp)) + assert.True(t, f.Allow("GET", vp, nil)) }) } } @@ -228,7 +247,7 @@ func TestFilter_Allow_NamespacedImagePaths(t *testing.T) { // match) still rejects writes against namespaced image/distribution paths // now that they pass the allowlist's path check. func TestFilter_Allow_NamespacedImagePaths_NonGETBlocked(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) paths := []string{ "/images/ghcr.io/org/repo/json", @@ -240,14 +259,14 @@ func TestFilter_Allow_NamespacedImagePaths_NonGETBlocked(t *testing.T) { for _, p := range paths { for _, method := range []string{"POST", "PUT", "DELETE", "PATCH"} { t.Run(method+" "+p, func(t *testing.T) { - assert.False(t, f.Allow(method, p)) + assert.False(t, f.Allow(method, p, nil)) }) } } } func TestFilter_ServeProxy_Blocked_POST(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) reqStr := "POST /v1.41/containers/create HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" var buf bytes.Buffer @@ -258,7 +277,7 @@ func TestFilter_ServeProxy_Blocked_POST(t *testing.T) { } func TestFilter_ServeProxy_Blocked_DELETE(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) reqStr := "DELETE /v1.41/containers/abc HTTP/1.1\r\nHost: localhost\r\n\r\n" var buf bytes.Buffer @@ -269,7 +288,7 @@ func TestFilter_ServeProxy_Blocked_DELETE(t *testing.T) { } func TestFilter_ServeProxy_Blocked_PUT(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) reqStr := "PUT /v1.41/networks/abc HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" var buf bytes.Buffer @@ -280,7 +299,7 @@ func TestFilter_ServeProxy_Blocked_PUT(t *testing.T) { } func TestFilter_ServeProxy_Blocked_UnversionedPost(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) reqStr := "POST /containers/create HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n" var buf bytes.Buffer @@ -291,13 +310,13 @@ func TestFilter_ServeProxy_Blocked_UnversionedPost(t *testing.T) { } func TestServeProxy_ConnectionCloseSetOnRequest(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) reqSeen := make(chan *http.Request, 1) serverErr := make(chan error, 1) sockPath, cleanup := startUnixHTTPServer(t, func(conn net.Conn) { - defer conn.Close() + defer func() { _ = conn.Close() }() req, err := http.ReadRequest(bufio.NewReader(conn)) if err != nil { @@ -331,12 +350,12 @@ func TestServeProxy_ConnectionCloseSetOnRequest(t *testing.T) { } func TestServeProxy_CompletesAfterDockerResponse(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) serverErr := make(chan error, 1) body := `{"status":"ok"}` sockPath, cleanup := startUnixHTTPServer(t, func(conn net.Conn) { - defer conn.Close() + defer func() { _ = conn.Close() }() _, err := http.ReadRequest(bufio.NewReader(conn)) if err != nil { @@ -375,13 +394,13 @@ func TestServeProxy_CompletesAfterDockerResponse(t *testing.T) { } func TestServeProxy_StreamingResponseTerminatesOnWriterClose(t *testing.T) { - f := muzzle.New() + f := muzzle.New(false) serverWriteErr := make(chan error, 1) serverErr := make(chan error, 1) sockPath, cleanup := startUnixHTTPServer(t, func(conn net.Conn) { - defer conn.Close() + defer func() { _ = conn.Close() }() _, err := http.ReadRequest(bufio.NewReader(conn)) if err != nil { @@ -441,3 +460,407 @@ func TestServeProxy_StreamingResponseTerminatesOnWriterClose(t *testing.T) { default: } } + +// errReader is an io.Reader that always fails, used to force a mid-body +// read error deterministically instead of relying on a real, flaky I/O +// fault. +type errReader struct { + err error +} + +func (r errReader) Read([]byte) (int, error) { + return 0, r.err +} + +// TestFilter_ServeProxy_BodyReadError_ReturnsError targets muzzle.go's +// io.ReadAll(limited) failure branch in ServeProxy: the request line and +// headers parse successfully (they fit entirely in bufio.Reader's first +// fill, so http.ReadRequest never touches the failing sub-reader), but the +// declared Content-Length body is backed by an io.MultiReader whose second +// segment always errors, so the read is guaranteed to fail only once +// ServeProxy actually starts consuming the body. +func TestFilter_ServeProxy_BodyReadError_ReturnsError(t *testing.T) { + f := muzzle.New(false) + + header := "POST /containers/create HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\n" + boom := errors.New("boom") + reqReader := io.MultiReader(strings.NewReader(header), errReader{err: boom}) + + var buf bytes.Buffer + err := f.ServeProxy("/tmp/nonexistent.sock", reqReader, &buf) + + require.Error(t, err) + assert.Contains(t, err.Error(), "read body") +} + +// TestFilter_ServeProxy_BodyTooLarge_Returns403AndError targets muzzle.go's +// maxContainerCreateBodyBytes size-limit branch. The size check runs before +// Allow is consulted, so the method/path here don't need to be on the +// allowlist for the case to exercise the intended branch. +func TestFilter_ServeProxy_BodyTooLarge_Returns403AndError(t *testing.T) { + f := muzzle.New(false) + + const maxContainerCreateBodyBytes = 64 * 1024 + oversized := bytes.Repeat([]byte("a"), maxContainerCreateBodyBytes+1) + reqStr := fmt.Sprintf("GET /containers/json HTTP/1.1\r\nHost: localhost\r\nContent-Length: %d\r\n\r\n%s", len(oversized), oversized) + + var buf bytes.Buffer + err := f.ServeProxy("/tmp/nonexistent.sock", strings.NewReader(reqStr), &buf) + + require.Error(t, err) + assert.Contains(t, err.Error(), "body too large") + assert.Contains(t, buf.String(), "403") +} + +// TestFilter_ServeProxy_DockerWriteError_ReturnsError targets muzzle.go's +// req.Write(conn) failure branch: ServeProxy successfully dials the fake +// Docker socket, but the write of the forwarded request to it fails. +// +// Getting *specifically* the write to fail (as opposed to the dial, or the +// later response read) is an inherently racy thing to force from outside +// with a real kernel socket, and no amount of server-side cleverness makes +// it deterministic, because ServeProxy dials and writes back-to-back with +// no hook this test can synchronize against: +// +// - Accepting the connection and then abortively closing it (SO_LINGER=0) +// was tried first (Supervisor Correction 2's suggested starting point) +// and found flaky: the request's header write is small enough that the +// kernel routinely buffers it locally and returns success from +// req.Write's underlying write(2) before the peer's close has +// propagated, so the error surfaces later instead, on the response +// read - not on the write itself. +// - Closing the *listener* without ever accepting - so the connection +// ServeProxy just dialed is torn down while still sitting unaccepted in +// the backlog - reliably produces a write failure with the right +// message *when the race lands in that window*, but measured directly +// (a bare goroutine closing the listener, racing unsynchronized against +// ServeProxy's own dial+write, the same shape this test needs), it only +// lands in that window on ~40-50% of attempts under `-race`; the rest +// either close before the dial completes (a *dial* error, wrong branch) +// or after the write already succeeded (a *read* error, wrong branch). +// +// So this test retries with a fresh socket/listener each time, accepting +// only the specific error this line actually produces and discarding any +// other outcome as an inconclusive attempt. Measured per-attempt success +// rate is roughly 40-50%, but attempts within one process are not fully +// independent (occasional short losing streaks were observed) - a +// retryLimit of 25 was empirically observed to exhaust in about 1 in 30 +// full `go test` runs under `-race`, so retryLimit=200 is used instead; +// with that limit, 100+ single-binary `-count=N` runs and 40+ separate +// freshly-exec'd process runs (thousands of attempts total) produced zero +// exhaustions. This is the same bounded-retry technique Go's own standard +// library networking tests use for OS-timing-dependent behavior; it is a +// deliberate, documented trade-off, not an oversight. +func TestFilter_ServeProxy_DockerWriteError_ReturnsError(t *testing.T) { + f := muzzle.New(false) + + const retryLimit = 200 + reqStr := "GET /containers/json HTTP/1.1\r\nHost: localhost\r\n\r\n" + + for attempt := 1; attempt <= retryLimit; attempt++ { + sockPath := filepath.Join(t.TempDir(), fmt.Sprintf("docker-%d.sock", attempt)) + ln, err := net.Listen("unix", sockPath) + require.NoError(t, err) + + // Deliberately never call ln.Accept(). Closing the listener while + // ServeProxy's own net.Dial connection may still be sitting + // unaccepted in the backlog is what can make the subsequent write + // fail - see the determinism discussion above for why this only + // sometimes lands on the write specifically. + go func() { + _ = ln.Close() + }() + + var buf bytes.Buffer + serveErr := f.ServeProxy(sockPath, strings.NewReader(reqStr), &buf) + + if serveErr != nil && strings.Contains(serveErr.Error(), "forward request to docker") { + return + } + } + + t.Fatalf("did not observe a docker-write error within %d attempts (target branch: muzzle.go ServeProxy's req.Write(conn) failure path)", retryLimit) +} + +// --- Write-mode tests (Section 3.3.4/3.4.1 of the Orthrus write-mode spec) --- +// +// These mirror backend/internal/orthrus/muzzle_test.go's write-mode tests +// exactly in intent (though this package's Allow(method, path, body) is +// called directly rather than through the httptest-based ServeHTTP the +// backend uses, since Filter has no http.Handler interface of its own). + +func TestFilter_Allow_WriteEndpoints_BlockedWhenWriteDisabled(t *testing.T) { + f := muzzle.New(false) + + cases := []struct { + method string + path string + }{ + {"POST", "/containers/create"}, + {"POST", "/images/create"}, + {"POST", "/containers/abc123/start"}, + {"POST", "/containers/abc123/stop"}, + {"POST", "/containers/abc123/restart"}, + {"POST", "/containers/abc123/rename"}, + {"DELETE", "/containers/abc123"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + assert.False(t, f.Allow(tc.method, tc.path, nil)) + }) + } +} + +func TestFilter_Allow_WriteEndpoints_AllowedWhenWriteEnabled(t *testing.T) { + f := muzzle.New(true) + + cases := []struct { + method string + path string + }{ + {"POST", "/images/create"}, + {"POST", "/containers/abc123/start"}, + {"POST", "/containers/abc123/stop"}, + {"POST", "/containers/abc123/restart"}, + {"POST", "/containers/abc123/rename"}, + {"DELETE", "/containers/abc123"}, + {"POST", "/v1.44/containers/abc123/start"}, + {"DELETE", "/v1.44/containers/abc123"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + assert.True(t, f.Allow(tc.method, tc.path, nil)) + }) + } +} + +// TestFilter_Allow_ContainerRename_QueryParamNameAllowed proves the +// Dockhand image-update rename step end-to-end on the agent side: Docker's +// rename endpoint takes the new container name via a "?name=" query +// parameter, not a request body (unlike /containers/create), and a real +// Docker-generated container ID is a single path segment (no slashes) — +// exactly what allowedWritePatterns's "/containers/*/rename" path.Match +// pattern assumes. Allow receives req.URL.Path (query string already split +// off by the caller, mirroring ServeProxy's real usage), so the query +// string itself is irrelevant to the match — passing it here just documents +// that a realistic rename call site (path plus query) is what this +// allowlist entry exists to unblock. +func TestFilter_Allow_ContainerRename_QueryParamNameAllowed(t *testing.T) { + f := muzzle.New(true) + + containerID := "3f4d9e2a1b6c8f0d7e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e" + assert.True(t, f.Allow("POST", "/containers/"+containerID+"/rename", nil)) + assert.True(t, f.Allow("POST", "/v1.44/containers/"+containerID+"/rename", nil)) +} + +// TestFilter_Allow_WriteMode_FakeVersionPrefixBlocked is the write-path +// variant of the GH #1160 regression above (divergence row 3): a +// non-numeric fake version prefix must not be accepted by allowWrite's +// pattern branch either. Before this fix, allowWrite's pattern branch +// matched the raw (non-version-stripped) path directly instead of the +// same normalized path its exact-path branch used, so "/vFOO/..." was +// wrongly treated as reaching "/containers/*/start" via the loose +// path.Match "v*" wildcard. +func TestFilter_Allow_WriteMode_FakeVersionPrefixBlocked(t *testing.T) { + f := muzzle.New(true) + + cases := []struct { + method string + path string + }{ + {"POST", "/vFOO/containers/abc/start"}, + {"POST", "/vFOO/containers/create"}, + {"DELETE", "/vFOO/containers/abc"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + assert.False(t, f.Allow(tc.method, tc.path, nil)) + }) + } +} + +// TestFilter_Allow_WriteMode_NonWriteEndpointsStillBlocked confirms that, +// even with write mode on, endpoints outside the fixed seven-operation list +// (create, images/create, start, stop, restart, rename, delete) — e.g. +// exec, image delete, build, prune, auth, commit, Swarm/service — remain +// permanently blocked. Section 7, Explicit Out-of-Scope. +func TestFilter_Allow_WriteMode_NonWriteEndpointsStillBlocked(t *testing.T) { + f := muzzle.New(true) + + cases := []struct { + method string + path string + }{ + {"POST", "/containers/abc123/exec"}, + {"POST", "/exec/xyz/start"}, + {"DELETE", "/images/nginx"}, + {"POST", "/build"}, + {"POST", "/system/prune"}, + {"POST", "/auth"}, + {"POST", "/commit"}, + {"POST", "/networks/create"}, + {"DELETE", "/networks/mynet"}, + {"POST", "/volumes/create"}, + {"DELETE", "/volumes/myvol"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + assert.False(t, f.Allow(tc.method, tc.path, nil)) + }) + } +} + +func TestFilter_Allow_ContainersCreate_SafeBodyAllowed(t *testing.T) { + f := muzzle.New(true) + + body := []byte(`{"Image":"nginx:latest","HostConfig":{"PortBindings":{"80/tcp":[{"HostPort":"8080"}]},"RestartPolicy":{"Name":"unless-stopped"},"NetworkMode":"my-bridge"}}`) + assert.True(t, f.Allow("POST", "/containers/create", body)) + assert.True(t, f.Allow("POST", "/v1.44/containers/create", body)) +} + +// TestFilter_Allow_ContainersCreate_SafeBodiesAllowed_CapDropAndUsernsMode +// mirrors backend/internal/orthrus/muzzle_test.go's copy of the same name. +func TestFilter_Allow_ContainersCreate_SafeBodiesAllowed_CapDropAndUsernsMode(t *testing.T) { + f := muzzle.New(true) + + cases := []struct { + name string + body string + }{ + {"CapDrop", `{"Image":"nginx","HostConfig":{"CapDrop":["ALL"]}}`}, + {"UsernsMode empty (inherit daemon default)", `{"Image":"nginx","HostConfig":{"UsernsMode":""}}`}, + {"UsernsMode named remap", `{"Image":"nginx","HostConfig":{"UsernsMode":"default"}}`}, + {"ContainerIDFile empty", `{"Image":"nginx","HostConfig":{"ContainerIDFile":""}}`}, + { + "CPU/memory/IO/pid resource-limit family", + `{"Image":"nginx","HostConfig":{"CpuPeriod":100000,"CpuQuota":50000,"CpuRealtimePeriod":1000000,"CpuRealtimeRuntime":950000,"CpusetCpus":"0-1","CpusetMems":"0","BlkioWeight":500,"KernelMemory":0,"KernelMemoryTCP":0,"MemoryReservation":1048576,"MemorySwappiness":60,"OomKillDisable":false,"OomScoreAdj":0,"PidsLimit":100,"Ulimits":[{"Name":"nofile","Soft":1024,"Hard":2048}],"StorageOpt":{"size":"10G"},"ShmSize":67108864}}`, + }, + {"Tmpfs (container-internal only)", `{"Image":"nginx","HostConfig":{"Tmpfs":{"/tmp":"rw,noexec,nosuid,size=100m"}}}`}, + {"MaskedPaths/ReadonlyPaths (restriction-only)", `{"Image":"nginx","HostConfig":{"MaskedPaths":["/proc/kcore"],"ReadonlyPaths":["/proc/sys"]}}`}, + {"ConsoleSize/Annotations (cosmetic)", `{"Image":"nginx","HostConfig":{"ConsoleSize":[24,80],"Annotations":{"note":"x"}}}`}, + {"Links/PublishAllPorts/DnsOptions (networking convenience)", `{"Image":"nginx","HostConfig":{"Links":["db:db"],"PublishAllPorts":false,"DnsOptions":["timeout:1"]}}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, f.Allow("POST", "/containers/create", []byte(tc.body))) + }) + } +} + +// TestFilter_Allow_ContainersCreate_DangerousBodiesRejected mirrors +// backend/internal/orthrus/muzzle_test.go's TestMuzzle_ContainersCreate_DangerousBodiesRejected +// case-for-case, including the local-driver bind-mount-via-volume bypass +// (VolumeOptions.DriverConfig) that both filters must independently reject. +func TestFilter_Allow_ContainersCreate_DangerousBodiesRejected(t *testing.T) { + f := muzzle.New(true) + + cases := []struct { + name string + body string + }{ + {"Privileged", `{"Image":"nginx","HostConfig":{"Privileged":true}}`}, + {"CapAdd", `{"Image":"nginx","HostConfig":{"CapAdd":["SYS_ADMIN"]}}`}, + {"legacy Binds", `{"Image":"nginx","HostConfig":{"Binds":["/:/host"]}}`}, + {"NetworkMode host", `{"Image":"nginx","HostConfig":{"NetworkMode":"host"}}`}, + {"NetworkMode container:*", `{"Image":"nginx","HostConfig":{"NetworkMode":"container:abc"}}`}, + {"UsernsMode host", `{"Image":"nginx","HostConfig":{"UsernsMode":"host"}}`}, + {"ContainerIDFile non-empty (arbitrary host file creation)", `{"Image":"nginx","HostConfig":{"ContainerIDFile":"/etc/cron.d/pwn"}}`}, + {"Runtime", `{"Image":"nginx","HostConfig":{"Runtime":"custom-runtime"}}`}, + {"VolumeDriver", `{"Image":"nginx","HostConfig":{"VolumeDriver":"custom-driver"}}`}, + {"VolumesFrom", `{"Image":"nginx","HostConfig":{"VolumesFrom":["other-container"]}}`}, + {"DeviceRequests", `{"Image":"nginx","HostConfig":{"DeviceRequests":[{"Driver":"nvidia","Count":-1}]}}`}, + {"bind-type Mounts", `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"bind","Source":"/etc","Target":"/x"}]}}`}, + { + "local-driver bind-mount-via-volume bypass (VolumeOptions.DriverConfig)", + `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"volume","Source":"fake","Target":"/etc","VolumeOptions":{"DriverConfig":{"Name":"local","Options":{"type":"none","device":"/etc","o":"bind"}}}}]}}`, + }, + {"non-empty Devices", `{"Image":"nginx","HostConfig":{"Devices":[{"PathOnHost":"/dev/sda","PathInContainer":"/dev/sda"}]}}`}, + {"non-empty Sysctls", `{"Image":"nginx","HostConfig":{"Sysctls":{"net.ipv4.ip_forward":"1"}}}`}, + {"unrecognized future HostConfig key", `{"Image":"nginx","HostConfig":{"SomeFutureField":true}}`}, + {"malformed JSON", `{"Image": not valid json`}, + // The four cases below each isolate one still-untested fail-closed + // unmarshal-error branch, distinct from "malformed JSON" above + // (which only reaches the outer top-level unmarshal) and distinct + // from the DriverConfig-bypass case above (which has a *valid* + // VolumeOptions object). + {"malformed NetworkMode value (non-string JSON)", `{"Image":"nginx","HostConfig":{"NetworkMode":12345}}`}, + {"malformed Mounts value (not a JSON array)", `{"Image":"nginx","HostConfig":{"Mounts":"not-an-array"}}`}, + {"malformed Mounts VolumeOptions (invalid JSON object)", `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":"not-an-object"}]}}`}, + {"malformed HostConfig itself (not a JSON object)", `{"Image":"nginx","HostConfig":"not-an-object"}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.False(t, f.Allow("POST", "/containers/create", []byte(tc.body))) + }) + } +} + +// TestFilter_Allow_ContainersCreate_EmptyBodyAllowed locks in +// validateContainerCreateBody's one deliberately *permissive* branch: an +// empty /containers/create body is allowed through rather than rejected as +// malformed. Given its own named test, not folded into the "safe body +// allowed" table, precisely because it documents an intentional +// security-relevant default rather than an incidental pass-through. +func TestFilter_Allow_ContainersCreate_EmptyBodyAllowed(t *testing.T) { + f := muzzle.New(true) + + assert.True(t, f.Allow("POST", "/containers/create", nil)) + assert.True(t, f.Allow("POST", "/containers/create", []byte{})) +} + +// --- Shared anti-drift corpus (Section 3.3.5 of the Orthrus write-mode spec) --- +// +// Loads the exact same testdata/muzzle_corpus.json file that +// backend/internal/orthrus/muzzle_test.go's TestMuzzle_SharedCorpus loads, +// via a relative path across the module boundary — agent/ is a separate Go +// module but plain file I/O in a test isn't constrained by Go import +// resolution, so a single JSON fixture can be the source of truth for both +// independently-maintained allowlist implementations without duplicating +// the data itself (only the enforcement logic is duplicated, by design — +// see the doc comments on hostConfigAllowedKeys etc. in muzzle.go). + +type muzzleCorpusCase struct { + Description string `json:"description"` + Method string `json:"method"` + Path string `json:"path"` + Body json.RawMessage `json:"body,omitempty"` + AgentWriteEnabled bool `json:"agent_write_enabled"` + WantAllowed bool `json:"want_allowed"` +} + +func loadMuzzleCorpus(t *testing.T) []muzzleCorpusCase { + t.Helper() + data, err := os.ReadFile("../../backend/internal/orthrus/testdata/muzzle_corpus.json") + require.NoError(t, err) + var cases []muzzleCorpusCase + require.NoError(t, json.Unmarshal(data, &cases)) + require.NotEmpty(t, cases) + return cases +} + +func TestFilter_SharedCorpus(t *testing.T) { + cases := loadMuzzleCorpus(t) + for _, tc := range cases { + t.Run(tc.Description, func(t *testing.T) { + f := muzzle.New(tc.AgentWriteEnabled) + + // The corpus's Path field may include a query string (e.g. + // "/images/create?fromImage=nginx&tag=latest"), which Allow + // expects to have already been split off by the caller (mirrors + // how ServeProxy passes req.URL.Path, not req.URL.RequestURI()). + p := tc.Path + if idx := strings.IndexByte(p, '?'); idx >= 0 { + p = p[:idx] + } + + got := f.Allow(tc.Method, p, tc.Body) + assert.Equal(t, tc.WantAllowed, got) + }) + } +} diff --git a/agent/protocol/message_test.go b/agent/protocol/message_test.go new file mode 100644 index 000000000..acef94018 --- /dev/null +++ b/agent/protocol/message_test.go @@ -0,0 +1,61 @@ +package protocol_test + +import ( + "testing" + + "github.com/Wikid82/charon/agent/protocol" +) + +// TestMessageTypeConstants guards the documented, distinct byte values of +// the MessageType constants against an accidental future change. protocol +// has no behavioral logic to test (pure constant/type declarations), so +// this is added mainly so `go test ./...` doesn't report "[no test files]" +// for a package now in-scope for the agent coverage gate (Section 3.5 of +// the Orthrus spec), and to catch a wire-protocol-breaking constant-value +// change: these bytes are the first byte on every yamux stream and must +// stay stable across agent/backend versions. +func TestMessageTypeConstants(t *testing.T) { + tests := []struct { + name string + got protocol.MessageType + want protocol.MessageType + }{ + {"TypePing", protocol.TypePing, 0x00}, + {"TypePong", protocol.TypePong, 0x01}, + {"TypePortForward", protocol.TypePortForward, 0x02}, + {"TypeDockerSocket", protocol.TypeDockerSocket, 0x03}, + {"TypeError", protocol.TypeError, 0x04}, + } + + seen := make(map[protocol.MessageType]string, len(tests)) + for _, tc := range tests { + if tc.got != tc.want { + t.Errorf("%s = 0x%02x, want 0x%02x", tc.name, tc.got, tc.want) + } + if existing, dup := seen[tc.got]; dup { + t.Errorf("%s shares byte value 0x%02x with %s -- MessageType values must be distinct", tc.name, tc.got, existing) + } + seen[tc.got] = tc.name + } +} + +// TestMessage_FieldAssignment confirms Message's fields round-trip as a +// plain struct (no custom marshaling logic to bypass) -- the minimal +// behavior this pure-data type has. +func TestMessage_FieldAssignment(t *testing.T) { + msg := protocol.Message{ + Type: protocol.TypeDockerSocket, + StreamID: 42, + Payload: []byte("hello"), + } + + if msg.Type != protocol.TypeDockerSocket { + t.Errorf("Type = 0x%02x, want 0x%02x", msg.Type, protocol.TypeDockerSocket) + } + if msg.StreamID != 42 { + t.Errorf("StreamID = %d, want 42", msg.StreamID) + } + if string(msg.Payload) != "hello" { + t.Errorf("Payload = %q, want %q", string(msg.Payload), "hello") + } +} diff --git a/backend/cmd/localpatchreport/main.go b/backend/cmd/localpatchreport/main.go index e183b8ea3..0a42d4c89 100644 --- a/backend/cmd/localpatchreport/main.go +++ b/backend/cmd/localpatchreport/main.go @@ -17,12 +17,14 @@ type thresholdJSON struct { Overall float64 `json:"overall_patch_coverage_min"` Backend float64 `json:"backend_patch_coverage_min"` Frontend float64 `json:"frontend_patch_coverage_min"` + Agent float64 `json:"agent_patch_coverage_min"` } type thresholdSourcesJSON struct { Overall string `json:"overall"` Backend string `json:"backend"` Frontend string `json:"frontend"` + Agent string `json:"agent"` } type artifactsJSON struct { @@ -39,6 +41,7 @@ type reportJSON struct { Overall patchreport.ScopeCoverage `json:"overall"` Backend patchreport.ScopeCoverage `json:"backend"` Frontend patchreport.ScopeCoverage `json:"frontend"` + Agent patchreport.ScopeCoverage `json:"agent"` FilesNeedingCoverage []patchreport.FileCoverageDetail `json:"files_needing_coverage,omitempty"` Warnings []string `json:"warnings,omitempty"` Artifacts artifactsJSON `json:"artifacts"` @@ -49,8 +52,10 @@ func main() { baselineFlag := flag.String("baseline", "origin/development...HEAD", "Git diff baseline") backendCoverageFlag := flag.String("backend-coverage", "backend/coverage.txt", "Backend Go coverage profile") frontendCoverageFlag := flag.String("frontend-coverage", "frontend/coverage/lcov.info", "Frontend LCOV coverage report") + agentCoverageFlag := flag.String("agent-coverage", "agent/coverage.txt", "Agent Go coverage profile") jsonOutFlag := flag.String("json-out", "test-results/local-patch-report.json", "Path to JSON output report") mdOutFlag := flag.String("md-out", "test-results/local-patch-report.md", "Path to markdown output report") + advisoryFlag := flag.Bool("advisory", false, "Exit 0 even if any coverage scope is below threshold (advisory-only mode). Default is strict: non-zero exit on any below-threshold scope, per CLAUDE.md's Definition of Done Step 2.") flag.Parse() repoRoot, err := filepath.Abs(*repoRootFlag) @@ -61,6 +66,7 @@ func main() { backendCoveragePath := resolvePath(repoRoot, *backendCoverageFlag) frontendCoveragePath := resolvePath(repoRoot, *frontendCoverageFlag) + agentCoveragePath := resolvePath(repoRoot, *agentCoverageFlag) jsonOutPath := resolvePath(repoRoot, *jsonOutFlag) mdOutPath := resolvePath(repoRoot, *mdOutFlag) @@ -74,6 +80,11 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + err = assertFileExists(agentCoveragePath, "agent coverage file") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } diffContent, err := gitDiff(repoRoot, *baselineFlag) if err != nil { @@ -81,13 +92,13 @@ func main() { os.Exit(1) } - backendChanged, frontendChanged, err := patchreport.ParseUnifiedDiffChangedLines(diffContent) + backendChanged, frontendChanged, agentChanged, err := patchreport.ParseUnifiedDiffChangedLines(diffContent) if err != nil { fmt.Fprintf(os.Stderr, "error parsing changed lines from diff: %v\n", err) os.Exit(1) } - backendCoverage, err := patchreport.ParseGoCoverageProfile(backendCoveragePath) + backendCoverage, err := patchreport.ParseGoCoverageProfile(backendCoveragePath, "backend") if err != nil { fmt.Fprintf(os.Stderr, "error parsing backend coverage: %v\n", err) os.Exit(1) @@ -97,26 +108,36 @@ func main() { fmt.Fprintf(os.Stderr, "error parsing frontend coverage: %v\n", err) os.Exit(1) } + agentCoverage, err := patchreport.ParseGoCoverageProfile(agentCoveragePath, "agent") + if err != nil { + fmt.Fprintf(os.Stderr, "error parsing agent coverage: %v\n", err) + os.Exit(1) + } overallThreshold := patchreport.ResolveThreshold("CHARON_OVERALL_PATCH_COVERAGE_MIN", 90, nil) backendThreshold := patchreport.ResolveThreshold("CHARON_BACKEND_PATCH_COVERAGE_MIN", 85, nil) frontendThreshold := patchreport.ResolveThreshold("CHARON_FRONTEND_PATCH_COVERAGE_MIN", 85, nil) + agentThreshold := patchreport.ResolveThreshold("CHARON_AGENT_PATCH_COVERAGE_MIN", 85, nil) backendScope := patchreport.ComputeScopeCoverage(backendChanged, backendCoverage) frontendScope := patchreport.ComputeScopeCoverage(frontendChanged, frontendCoverage) - overallScope := patchreport.MergeScopeCoverage(backendScope, frontendScope) + agentScope := patchreport.ComputeScopeCoverage(agentChanged, agentCoverage) + overallScope := patchreport.MergeScopeCoverage(backendScope, frontendScope, agentScope) backendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(backendChanged, backendCoverage, backendThreshold.Value) frontendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(frontendChanged, frontendCoverage, frontendThreshold.Value) - filesNeedingCoverage := patchreport.MergeFileCoverageDetails(backendFilesNeedingCoverage, frontendFilesNeedingCoverage) + agentFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(agentChanged, agentCoverage, agentThreshold.Value) + filesNeedingCoverage := patchreport.MergeFileCoverageDetails(backendFilesNeedingCoverage, frontendFilesNeedingCoverage, agentFilesNeedingCoverage) backendScope = patchreport.ApplyStatus(backendScope, backendThreshold.Value) frontendScope = patchreport.ApplyStatus(frontendScope, frontendThreshold.Value) + agentScope = patchreport.ApplyStatus(agentScope, agentThreshold.Value) overallScope = patchreport.ApplyStatus(overallScope, overallThreshold.Value) warnings := patchreport.SortedWarnings([]string{ overallThreshold.Warning, backendThreshold.Warning, frontendThreshold.Warning, + agentThreshold.Warning, }) if overallScope.Status == "warn" { warnings = append(warnings, fmt.Sprintf("Overall patch coverage %.1f%% is below threshold %.1f%%", overallScope.PatchCoveragePct, overallThreshold.Value)) @@ -127,24 +148,35 @@ func main() { if frontendScope.Status == "warn" { warnings = append(warnings, fmt.Sprintf("Frontend patch coverage %.1f%% is below threshold %.1f%%", frontendScope.PatchCoveragePct, frontendThreshold.Value)) } + if agentScope.Status == "warn" { + warnings = append(warnings, fmt.Sprintf("Agent patch coverage %.1f%% is below threshold %.1f%%", agentScope.PatchCoveragePct, agentThreshold.Value)) + } + + mode := "strict" + if *advisoryFlag { + mode = "advisory" + } report := reportJSON{ Baseline: *baselineFlag, GeneratedAt: time.Now().UTC().Format(time.RFC3339), - Mode: "warn", + Mode: mode, Thresholds: thresholdJSON{ Overall: overallThreshold.Value, Backend: backendThreshold.Value, Frontend: frontendThreshold.Value, + Agent: agentThreshold.Value, }, ThresholdSources: thresholdSourcesJSON{ Overall: overallThreshold.Source, Backend: backendThreshold.Source, Frontend: frontendThreshold.Source, + Agent: agentThreshold.Source, }, Overall: overallScope, Backend: backendScope, Frontend: frontendScope, + Agent: agentScope, FilesNeedingCoverage: filesNeedingCoverage, Warnings: warnings, Artifacts: artifactsJSON{ @@ -166,7 +198,7 @@ func main() { fmt.Fprintf(os.Stderr, "error writing json report: %v\n", err) os.Exit(1) } - if err := writeMarkdown(mdOutPath, report, relOrAbs(repoRoot, backendCoveragePath), relOrAbs(repoRoot, frontendCoveragePath)); err != nil { + if err := writeMarkdown(mdOutPath, report, relOrAbs(repoRoot, backendCoveragePath), relOrAbs(repoRoot, frontendCoveragePath), relOrAbs(repoRoot, agentCoveragePath)); err != nil { fmt.Fprintf(os.Stderr, "error writing markdown report: %v\n", err) os.Exit(1) } @@ -177,6 +209,16 @@ func main() { for _, warning := range warnings { fmt.Printf("WARN: %s\n", warning) } + + // Strict-mode enforcement runs last, after both artifacts are written to + // disk and the WARN diagnostic lines above are printed to stdout, so a + // failing gate still leaves a developer a full report explaining exactly + // which scope(s)/threshold(s) failed (Supervisor Correction 1) in + // addition to this stderr message and the artifacts on disk. + if !*advisoryFlag && patchreport.HasWarnStatus(overallScope, backendScope, frontendScope, agentScope) { + fmt.Fprintln(os.Stderr, "Local patch coverage below threshold in strict mode (use -advisory to bypass); see report for details.") + os.Exit(1) + } } func resolvePath(repoRoot, configured string) string { @@ -226,7 +268,7 @@ func writeJSON(path string, report reportJSON) error { return nil } -func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontendCoveragePath string) error { +func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontendCoveragePath, agentCoveragePath string) error { var builder strings.Builder builder.WriteString("# Local Patch Coverage Report\n\n") builder.WriteString("## Metadata\n\n") @@ -236,14 +278,16 @@ func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontend builder.WriteString("## Inputs\n\n") fmt.Fprintf(&builder, "- Backend coverage: `%s`\n", backendCoveragePath) - fmt.Fprintf(&builder, "- Frontend coverage: `%s`\n\n", frontendCoveragePath) + fmt.Fprintf(&builder, "- Frontend coverage: `%s`\n", frontendCoveragePath) + fmt.Fprintf(&builder, "- Agent coverage: `%s`\n\n", agentCoveragePath) builder.WriteString("## Resolved Thresholds\n\n") builder.WriteString("| Scope | Minimum (%) | Source |\n") builder.WriteString("|---|---:|---|\n") fmt.Fprintf(&builder, "| Overall | %.1f | %s |\n", report.Thresholds.Overall, report.ThresholdSources.Overall) fmt.Fprintf(&builder, "| Backend | %.1f | %s |\n", report.Thresholds.Backend, report.ThresholdSources.Backend) - fmt.Fprintf(&builder, "| Frontend | %.1f | %s |\n\n", report.Thresholds.Frontend, report.ThresholdSources.Frontend) + fmt.Fprintf(&builder, "| Frontend | %.1f | %s |\n", report.Thresholds.Frontend, report.ThresholdSources.Frontend) + fmt.Fprintf(&builder, "| Agent | %.1f | %s |\n\n", report.Thresholds.Agent, report.ThresholdSources.Agent) builder.WriteString("## Coverage Summary\n\n") builder.WriteString("| Scope | Changed Lines | Covered Lines | Patch Coverage (%) | Status |\n") @@ -251,6 +295,7 @@ func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontend builder.WriteString(scopeRow("Overall", report.Overall)) builder.WriteString(scopeRow("Backend", report.Backend)) builder.WriteString(scopeRow("Frontend", report.Frontend)) + builder.WriteString(scopeRow("Agent", report.Agent)) builder.WriteString("\n") if len(report.FilesNeedingCoverage) > 0 { diff --git a/backend/cmd/localpatchreport/main_test.go b/backend/cmd/localpatchreport/main_test.go index 58fcdbd9d..56d68446b 100644 --- a/backend/cmd/localpatchreport/main_test.go +++ b/backend/cmd/localpatchreport/main_test.go @@ -73,7 +73,7 @@ func TestMain_SuccessWritesReports(t *testing.T) { if err := json.Unmarshal(reportBytes, &report); err != nil { t.Fatalf("unmarshal report: %v", err) } - if report.Mode != "warn" { + if report.Mode != "strict" { t.Fatalf("unexpected mode: %s", report.Mode) } if report.Artifacts.JSON == "" || report.Artifacts.Markdown == "" { @@ -278,7 +278,7 @@ func TestGitDiffAndWriters(t *testing.T) { } markdownPath := filepath.Join(t.TempDir(), "report.md") - err = writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info") + err = writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info", "agent/coverage.txt") if err != nil { t.Fatalf("writeMarkdown should succeed: %v", err) } @@ -343,6 +343,7 @@ func createGitRepoWithCoverageInputs(t *testing.T) string { filepath.Join(repoRoot, "frontend", "src"), filepath.Join(repoRoot, "frontend", "coverage"), filepath.Join(repoRoot, "backend"), + filepath.Join(repoRoot, "agent", "muzzle"), } for _, path := range paths { if err := os.MkdirAll(path, 0o750); err != nil { @@ -356,6 +357,9 @@ func createGitRepoWithCoverageInputs(t *testing.T) string { if err := os.WriteFile(filepath.Join(repoRoot, "frontend", "src", "sample.ts"), []byte("export const sample = 1;\n"), 0o600); err != nil { t.Fatalf("write frontend sample: %v", err) } + if err := os.WriteFile(filepath.Join(repoRoot, "agent", "muzzle", "sample.go"), []byte("package muzzle\nvar Sample = 1\n"), 0o600); err != nil { + t.Fatalf("write agent sample: %v", err) + } backendCoverage := "mode: atomic\nbackend/internal/sample.go:1.1,2.20 1 1\n" if err := os.WriteFile(filepath.Join(repoRoot, "backend", "coverage.txt"), []byte(backendCoverage), 0o600); err != nil { @@ -367,6 +371,11 @@ func createGitRepoWithCoverageInputs(t *testing.T) string { t.Fatalf("write frontend coverage: %v", err) } + agentCoverage := "mode: atomic\nagent/muzzle/sample.go:1.1,2.20 1 1\n" + if err := os.WriteFile(filepath.Join(repoRoot, "agent", "coverage.txt"), []byte(agentCoverage), 0o600); err != nil { + t.Fatalf("write agent coverage: %v", err) + } + mustRunCommand(t, repoRoot, "add", ".") mustRunCommand(t, repoRoot, "commit", "-m", "initial commit") @@ -407,7 +416,7 @@ func TestWriteMarkdownReturnsErrorWhenPathIsDirectory(t *testing.T) { Warnings: nil, Artifacts: artifactsJSON{Markdown: "a", JSON: "b"}, } - if err := writeMarkdown(dir, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err == nil { + if err := writeMarkdown(dir, report, "backend/coverage.txt", "frontend/coverage/lcov.info", "agent/coverage.txt"); err == nil { t.Fatal("expected writeMarkdown to fail when target is a directory") } } @@ -476,16 +485,156 @@ func TestMain_PrintsWarningsWhenThresholdsNotMet(t *testing.T) { result := runMainSubprocess(t, "-repo-root", repoRoot, "-baseline", "HEAD", + "-advisory", ) if result.exitCode != 0 { - t.Fatalf("expected success with warnings, got exit=%d stderr=%s", result.exitCode, result.stderr) + t.Fatalf("expected success with warnings in advisory mode, got exit=%d stderr=%s", result.exitCode, result.stderr) } if !strings.Contains(result.stdout, "WARN: Overall patch coverage") { t.Fatalf("expected WARN output, stdout=%s", result.stdout) } } +// belowThresholdRepo builds a repo whose backend and frontend changes are +// entirely uncovered, guaranteeing every scope's Status is "warn" so tests +// can exercise strict/advisory exit-code behavior deterministically. +func belowThresholdRepo(t *testing.T) string { + t.Helper() + repoRoot := createGitRepoWithCoverageInputs(t) + + if err := os.WriteFile(filepath.Join(repoRoot, "backend", "internal", "sample.go"), []byte("package internal\nvar Sample = 2\n"), 0o600); err != nil { + t.Fatalf("update backend sample: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "frontend", "src", "sample.ts"), []byte("export const sample = 2;\n"), 0o600); err != nil { + t.Fatalf("update frontend sample: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "backend", "coverage.txt"), []byte("mode: atomic\nbackend/internal/sample.go:1.1,2.20 1 0\n"), 0o600); err != nil { + t.Fatalf("write backend uncovered coverage: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "frontend", "coverage", "lcov.info"), []byte("TN:\nSF:frontend/src/sample.ts\nDA:1,0\nend_of_record\n"), 0o600); err != nil { + t.Fatalf("write frontend uncovered coverage: %v", err) + } + + return repoRoot +} + +// TestMain_StrictModeExitsNonZeroWhenAnyScopeBelowThreshold is the core +// regression guard for this follow-up: the local tool must exit non-zero +// by default when any scope is below threshold (F.4/R14), and must do so +// only after the JSON/markdown artifacts and the existing WARN diagnostic +// lines have already been written/printed (Supervisor Correction 1), so a +// failing gate still leaves a full, readable report on disk and in stdout. +func TestMain_StrictModeExitsNonZeroWhenAnyScopeBelowThreshold(t *testing.T) { + repoRoot := belowThresholdRepo(t) + jsonOut := filepath.Join(repoRoot, "test-results", "strict.json") + mdOut := filepath.Join(repoRoot, "test-results", "strict.md") + + result := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD", + "-json-out", jsonOut, + "-md-out", mdOut, + ) + + if result.exitCode == 0 { + t.Fatalf("expected non-zero exit code in strict mode when a scope is below threshold, stdout=%s stderr=%s", result.stdout, result.stderr) + } + if !strings.Contains(result.stderr, "Local patch coverage below threshold in strict mode") { + t.Fatalf("expected strict-mode stderr message, stderr=%s", result.stderr) + } + if !strings.Contains(result.stdout, "WARN: Overall patch coverage") { + t.Fatalf("expected WARN diagnostic lines to still print before exiting, stdout=%s", result.stdout) + } + if _, err := os.Stat(jsonOut); err != nil { + t.Fatalf("expected json report to be written before strict exit: %v", err) + } + if _, err := os.Stat(mdOut); err != nil { + t.Fatalf("expected markdown report to be written before strict exit: %v", err) + } +} + +// TestMain_AdvisoryModeExitsZeroWhenScopeBelowThreshold proves the -advisory +// opt-out (F.4/R15) restores the old advisory behavior: exit 0 regardless of +// scope status, with the artifacts still written. +func TestMain_AdvisoryModeExitsZeroWhenScopeBelowThreshold(t *testing.T) { + repoRoot := belowThresholdRepo(t) + jsonOut := filepath.Join(repoRoot, "test-results", "advisory.json") + + result := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD", + "-json-out", jsonOut, + "-advisory", + ) + + if result.exitCode != 0 { + t.Fatalf("expected exit 0 in advisory mode even when a scope is below threshold, stderr=%s", result.stderr) + } + + body, err := os.ReadFile(jsonOut) + if err != nil { + t.Fatalf("read json output: %v", err) + } + var report reportJSON + if err := json.Unmarshal(body, &report); err != nil { + t.Fatalf("unmarshal json: %v", err) + } + if report.Mode != "advisory" { + t.Fatalf("expected mode=advisory in report, got %q", report.Mode) + } +} + +// TestMain_ReportModeFieldReflectsStrictOrAdvisory proves report.Mode is no +// longer the hardcoded literal "warn" (F.1's re-confirmed finding) but truly +// reflects which mode produced the artifact. +func TestMain_ReportModeFieldReflectsStrictOrAdvisory(t *testing.T) { + repoRoot := createGitRepoWithCoverageInputs(t) + + strictJSONOut := filepath.Join(repoRoot, "test-results", "strict-mode.json") + strictResult := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD...HEAD", + "-json-out", strictJSONOut, + ) + if strictResult.exitCode != 0 { + t.Fatalf("expected success for passing scopes in strict mode: %s", strictResult.stderr) + } + strictBody, err := os.ReadFile(strictJSONOut) + if err != nil { + t.Fatalf("read strict json: %v", err) + } + var strictReport reportJSON + if unmarshalErr := json.Unmarshal(strictBody, &strictReport); unmarshalErr != nil { + t.Fatalf("unmarshal strict json: %v", unmarshalErr) + } + if strictReport.Mode != "strict" { + t.Fatalf("expected mode=strict by default, got %q", strictReport.Mode) + } + + advisoryJSONOut := filepath.Join(repoRoot, "test-results", "advisory-mode.json") + advisoryResult := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD...HEAD", + "-json-out", advisoryJSONOut, + "-advisory", + ) + if advisoryResult.exitCode != 0 { + t.Fatalf("expected success for passing scopes in advisory mode: %s", advisoryResult.stderr) + } + advisoryBody, err := os.ReadFile(advisoryJSONOut) + if err != nil { + t.Fatalf("read advisory json: %v", err) + } + var advisoryReport reportJSON + if err := json.Unmarshal(advisoryBody, &advisoryReport); err != nil { + t.Fatalf("unmarshal advisory json: %v", err) + } + if advisoryReport.Mode != "advisory" { + t.Fatalf("expected mode=advisory when -advisory is passed, got %q", advisoryReport.Mode) + } +} + func TestRelOrAbsConvertsSlashes(t *testing.T) { repoRoot := t.TempDir() targetPath := filepath.Join(repoRoot, "reports", "file.json") @@ -547,6 +696,30 @@ func TestMain_FailsWhenFrontendCoverageIsMissing(t *testing.T) { } } +// TestMain_FailsWhenAgentCoverageIsMissing is the agent-module analogue of +// TestMain_FailsWhenBackendCoverageIsMissing/TestMain_FailsWhenFrontendCoverageIsMissing, +// added alongside the new -agent-coverage flag (Section 3.4.1 of the Orthrus +// spec) so the missing-input error path has the same test coverage backend +// and frontend already had. +func TestMain_FailsWhenAgentCoverageIsMissing(t *testing.T) { + repoRoot := createGitRepoWithCoverageInputs(t) + if err := os.Remove(filepath.Join(repoRoot, "agent", "coverage.txt")); err != nil { + t.Fatalf("remove agent coverage: %v", err) + } + + result := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD...HEAD", + ) + + if result.exitCode == 0 { + t.Fatalf("expected non-zero exit code for missing agent coverage") + } + if !strings.Contains(result.stderr, "missing agent coverage file") { + t.Fatalf("expected missing agent coverage error, stderr=%s", result.stderr) + } +} + func TestMain_FailsWhenRepoRootInvalid(t *testing.T) { nonexistentPath := filepath.Join(t.TempDir(), "missing", "repo") @@ -596,7 +769,7 @@ func TestWriteMarkdownIncludesArtifactsSection(t *testing.T) { } path := filepath.Join(t.TempDir(), "report.md") - if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil { + if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info", "agent/coverage.txt"); err != nil { t.Fatalf("writeMarkdown: %v", err) } @@ -809,6 +982,7 @@ func TestMainWithFileNeedingCoverageIncludesMarkdownTable(t *testing.T) { "-repo-root", repoRoot, "-baseline", "HEAD", "-md-out", mdOut, + "-advisory", ) if result.exitCode != 0 { t.Fatalf("expected success: stderr=%s", result.stderr) @@ -856,7 +1030,7 @@ func TestWriteMarkdownWithoutWarningsOrFiles(t *testing.T) { } path := filepath.Join(t.TempDir(), "report.md") - if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil { + if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info", "agent/coverage.txt"); err != nil { t.Fatalf("writeMarkdown failed: %v", err) } @@ -897,7 +1071,7 @@ func TestMainProducesExpectedJSONSchemaFields(t *testing.T) { if err := json.Unmarshal(body, &raw); err != nil { t.Fatalf("unmarshal raw json: %v", err) } - required := []string{"baseline", "generated_at", "mode", "thresholds", "threshold_sources", "overall", "backend", "frontend", "artifacts"} + required := []string{"baseline", "generated_at", "mode", "thresholds", "threshold_sources", "overall", "backend", "frontend", "agent", "artifacts"} for _, key := range required { if _, ok := raw[key]; !ok { t.Fatalf("missing required key %q in report json", key) @@ -1008,9 +1182,10 @@ func TestMainOutputsWarnLinesWhenAnyScopeWarns(t *testing.T) { result := runMainSubprocess(t, "-repo-root", repoRoot, "-baseline", "HEAD", + "-advisory", ) if result.exitCode != 0 { - t.Fatalf("expected success with warnings: stderr=%s", result.stderr) + t.Fatalf("expected success with warnings in advisory mode: stderr=%s", result.stderr) } if !strings.Contains(result.stdout, "WARN:") { t.Fatalf("expected warning lines in stdout: %s", result.stdout) @@ -1041,7 +1216,7 @@ func TestWriteMarkdownContainsSummaryTable(t *testing.T) { } path := filepath.Join(t.TempDir(), "summary.md") - if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil { + if err := writeMarkdown(path, report, "backend/coverage.txt", "frontend/coverage/lcov.info", "agent/coverage.txt"); err != nil { t.Fatalf("write markdown: %v", err) } body, err := os.ReadFile(path) @@ -1146,7 +1321,7 @@ func TestMain_BackendCoverageWithInvalidRowsStillSucceeds(t *testing.T) { } } -func TestMainOutputMentionsModeWarn(t *testing.T) { +func TestMainOutputMentionsModeStrict(t *testing.T) { repoRoot := createGitRepoWithCoverageInputs(t) result := runMainSubprocess(t, "-repo-root", repoRoot, @@ -1155,7 +1330,7 @@ func TestMainOutputMentionsModeWarn(t *testing.T) { if result.exitCode != 0 { t.Fatalf("expected success: %s", result.stderr) } - if !strings.Contains(result.stdout, "mode=warn") { + if !strings.Contains(result.stdout, "mode=strict") { t.Fatalf("expected mode in stdout: %s", result.stdout) } } @@ -1247,6 +1422,7 @@ func TestMain_WithChangedFilesProducesFilesNeedingCoverageInJSON(t *testing.T) { "-repo-root", repoRoot, "-baseline", "HEAD", "-json-out", jsonOut, + "-advisory", ) if result.exitCode != 0 { t.Fatalf("expected success: %s", result.stderr) @@ -1265,6 +1441,58 @@ func TestMain_WithChangedFilesProducesFilesNeedingCoverageInJSON(t *testing.T) { } } +// TestMain_AgentChangedLinesAppearInReport is the agent-module analogue of +// TestMain_WithChangedFilesProducesFilesNeedingCoverageInJSON: it proves the +// generalized patchreport.ParseUnifiedDiffChangedLines/ParseGoCoverageProfile +// plumbing (Section 3.4.1 of the Orthrus spec) actually attributes changed +// agent/** lines to the Agent report scope end-to-end through the compiled +// binary, not just that the new -agent-coverage flag is accepted. This is +// the regression guard for the "agent coverage silently reports 0%" bug the +// moduleDir generalization was written to close. +func TestMain_AgentChangedLinesAppearInReport(t *testing.T) { + repoRoot := createGitRepoWithCoverageInputs(t) + if err := os.WriteFile(filepath.Join(repoRoot, "agent", "muzzle", "sample.go"), []byte("package muzzle\nvar Sample = 42\n"), 0o600); err != nil { + t.Fatalf("update agent file: %v", err) + } + if err := os.WriteFile(filepath.Join(repoRoot, "agent", "coverage.txt"), []byte("mode: atomic\nagent/muzzle/sample.go:1.1,2.20 1 0\n"), 0o600); err != nil { + t.Fatalf("write agent coverage: %v", err) + } + + jsonOut := filepath.Join(repoRoot, "test-results", "agent-coverage-gaps.json") + result := runMainSubprocess(t, + "-repo-root", repoRoot, + "-baseline", "HEAD", + "-json-out", jsonOut, + "-advisory", + ) + if result.exitCode != 0 { + t.Fatalf("expected success: %s", result.stderr) + } + + body, err := os.ReadFile(jsonOut) + if err != nil { + t.Fatalf("read json output: %v", err) + } + var report reportJSON + if err := json.Unmarshal(body, &report); err != nil { + t.Fatalf("unmarshal json: %v", err) + } + if report.Agent.ChangedLines == 0 { + t.Fatalf("expected agent scope to report nonzero changed lines, got: %+v", report.Agent) + } + + foundAgentFile := false + for _, fileDetail := range report.FilesNeedingCoverage { + if fileDetail.Path == "agent/muzzle/sample.go" { + foundAgentFile = true + break + } + } + if !foundAgentFile { + t.Fatalf("expected agent/muzzle/sample.go in files_needing_coverage, got: %+v", report.FilesNeedingCoverage) + } +} + func TestMain_FailsWhenMarkdownPathParentIsDirectoryFileConflict(t *testing.T) { repoRoot := createGitRepoWithCoverageInputs(t) conflict := filepath.Join(repoRoot, "conflict") @@ -1336,7 +1564,7 @@ func TestMain_ReportContainsCoverageScopes(t *testing.T) { if err != nil { t.Fatalf("read json: %v", err) } - for _, key := range []string{"\"overall\"", "\"backend\"", "\"frontend\""} { + for _, key := range []string{"\"overall\"", "\"backend\"", "\"frontend\"", "\"agent\""} { if !strings.Contains(string(body), key) { t.Fatalf("expected %s in json: %s", key, string(body)) } @@ -1380,8 +1608,8 @@ func TestMain_ReportIncludesMode(t *testing.T) { if err != nil { t.Fatalf("read json: %v", err) } - if !strings.Contains(string(body), "\"mode\": \"warn\"") { - t.Fatalf("expected warn mode in json: %s", string(body)) + if !strings.Contains(string(body), "\"mode\": \"strict\"") { + t.Fatalf("expected strict mode in json: %s", string(body)) } } @@ -1416,12 +1644,18 @@ func TestMain_FailsWhenGitRepoNotInitialized(t *testing.T) { if err := os.MkdirAll(filepath.Join(repoRoot, "frontend", "coverage"), 0o750); err != nil { t.Fatalf("mkdir frontend: %v", err) } + if err := os.MkdirAll(filepath.Join(repoRoot, "agent"), 0o750); err != nil { + t.Fatalf("mkdir agent: %v", err) + } if err := os.WriteFile(filepath.Join(repoRoot, "backend", "coverage.txt"), []byte("mode: atomic\nbackend/internal/sample.go:1.1,1.2 1 1\n"), 0o600); err != nil { t.Fatalf("write backend coverage: %v", err) } if err := os.WriteFile(filepath.Join(repoRoot, "frontend", "coverage", "lcov.info"), []byte("TN:\nSF:frontend/src/sample.ts\nDA:1,1\nend_of_record\n"), 0o600); err != nil { t.Fatalf("write frontend lcov: %v", err) } + if err := os.WriteFile(filepath.Join(repoRoot, "agent", "coverage.txt"), []byte("mode: atomic\nagent/muzzle/sample.go:1.1,1.2 1 1\n"), 0o600); err != nil { + t.Fatalf("write agent coverage: %v", err) + } result := runMainSubprocess(t, "-repo-root", repoRoot, @@ -1449,9 +1683,10 @@ func TestMain_WritesWarningsToJSONWhenPresent(t *testing.T) { "-repo-root", repoRoot, "-baseline", "HEAD", "-json-out", jsonOut, + "-advisory", ) if result.exitCode != 0 { - t.Fatalf("expected success with warnings: %s", result.stderr) + t.Fatalf("expected success with warnings in advisory mode: %s", result.stderr) } body, err := os.ReadFile(jsonOut) if err != nil { diff --git a/backend/internal/api/handlers/orthrus_handler.go b/backend/internal/api/handlers/orthrus_handler.go index 0f84b53c3..1514c5c95 100644 --- a/backend/internal/api/handlers/orthrus_handler.go +++ b/backend/internal/api/handlers/orthrus_handler.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/orthrus" "github.com/Wikid82/charon/backend/internal/services" ) @@ -22,13 +23,21 @@ type orthrusProxyStatusResolver interface { // OrthrusHandler handles REST requests for Orthrus agent management. type OrthrusHandler struct { - svc *services.OrthrusService - proxyResolver orthrusProxyStatusResolver + svc *services.OrthrusService + proxyResolver orthrusProxyStatusResolver + securityService *services.SecurityService } // NewOrthrusHandler creates an OrthrusHandler backed by the given service. -func NewOrthrusHandler(orthrsuSvc *services.OrthrusService) *OrthrusHandler { - return &OrthrusHandler{svc: orthrsuSvc} +// securityService is used to emit an audit entry whenever an operator +// toggles an agent's write-mode flag (see Patch) — a separate, handler-level +// concern from the per-request write-path audit entries Muzzle emits +// directly via the AuditLogger interface (see muzzle.go), following this +// codebase's existing convention of logging admin-initiated audit events at +// the handler layer rather than inside the service (compare +// security_handler.go, crowdsec_handler.go). +func NewOrthrusHandler(orthrsuSvc *services.OrthrusService, securityService *services.SecurityService) *OrthrusHandler { + return &OrthrusHandler{svc: orthrsuSvc, securityService: securityService} } // SetProxyResolver wires a live OrthrusServer so that GetProxyStatus can @@ -107,9 +116,14 @@ type patchAgentRequest struct { DeviceID *string `json:"device_id"` ResolvedAddress *string `json:"resolved_address"` ExternalProxyPort *int `json:"external_proxy_port"` + WriteEnabled *bool `json:"write_enabled"` } -// Patch applies a partial update to an Orthrus agent. +// Patch applies a partial update to an Orthrus agent. If WriteEnabled is +// present in the request, this is the one field on this endpoint whose +// change is itself security-relevant enough to warrant its own audit entry +// (distinct from, and in addition to, the per-write-request audit entries +// Muzzle emits directly for actual proxied traffic — see muzzle.go). func (h *OrthrusHandler) Patch(c *gin.Context) { uuid := c.Param("uuid") var req patchAgentRequest @@ -117,7 +131,7 @@ func (h *OrthrusHandler) Patch(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - agent, err := h.svc.Patch(uuid, req.Name, req.HecateTunnelUUID, req.DeviceID, req.ResolvedAddress, req.ExternalProxyPort) + agent, err := h.svc.Patch(uuid, req.Name, req.HecateTunnelUUID, req.DeviceID, req.ResolvedAddress, req.ExternalProxyPort, req.WriteEnabled) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) @@ -126,6 +140,21 @@ func (h *OrthrusHandler) Patch(c *gin.Context) { } return } + + if req.WriteEnabled != nil && h.securityService != nil { + action := "orthrus_write_disabled" + if *req.WriteEnabled { + action = "orthrus_write_enabled" + } + _ = h.securityService.LogAudit(&models.SecurityAudit{ + Actor: actorFromContext(c), + Action: action, + EventCategory: "orthrus_write", + ResourceUUID: uuid, + Details: fmt.Sprintf(`{"agent_name":%q}`, agent.Name), + }) + } + c.JSON(http.StatusOK, agent) } @@ -206,14 +235,16 @@ func (h *OrthrusHandler) GetProxyStatus(c *gin.Context) { return } resp := gin.H{ - "agent_uuid": agent.UUID, - "agent_online": false, - "configured_port": agent.ExternalProxyPort, - "active": false, - "active_port": 0, - "bind_address": "", - "connection_string": "", - "error": "", + "agent_uuid": agent.UUID, + "agent_online": false, + "configured_port": agent.ExternalProxyPort, + "configured_write_enabled": agent.WriteEnabled, + "active_write_enabled": false, + "active": false, + "active_port": 0, + "bind_address": "", + "connection_string": "", + "error": "", } if h.proxyResolver != nil { if status, ok := h.proxyResolver.GetExternalProxyStatus(uuid); ok { @@ -221,6 +252,7 @@ func (h *OrthrusHandler) GetProxyStatus(c *gin.Context) { resp["active"] = status.Active resp["active_port"] = status.ActivePort resp["bind_address"] = status.BoundAddress + resp["active_write_enabled"] = status.WriteEnabled if status.Active && status.ActivePort > 0 { resp["connection_string"] = fmt.Sprintf("tcp://%s:%d", resolveExternalProxyHost(c), status.ActivePort) } diff --git a/backend/internal/api/handlers/orthrus_handler_test.go b/backend/internal/api/handlers/orthrus_handler_test.go index 9b033e8c8..15e5bf698 100644 --- a/backend/internal/api/handlers/orthrus_handler_test.go +++ b/backend/internal/api/handlers/orthrus_handler_test.go @@ -27,7 +27,7 @@ func openOrthrusTestDB(t *testing.T) *gorm.DB { Logger: glogger.Default.LogMode(glogger.Silent), }) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&models.OrthrusAgent{})) + require.NoError(t, db.AutoMigrate(&models.OrthrusAgent{}, &models.SecurityAudit{})) t.Cleanup(func() { sqlDB, _ := db.DB() if sqlDB != nil { @@ -51,7 +51,31 @@ func newOrthrusTestSetup(t *testing.T) (*OrthrusHandler, *gorm.DB) { t.Cleanup(srv.Stop) svc := services.NewOrthrusService(db, srv) - return NewOrthrusHandler(svc), db + securityService := services.NewSecurityService(db) + t.Cleanup(securityService.Close) + return NewOrthrusHandler(svc, securityService), db +} + +// newOrthrusTestSetupWithSecurity is a variant of newOrthrusTestSetup that +// also returns the *services.SecurityService, for tests that need to +// Flush() and query the audit trail produced by write_enabled toggles. +func newOrthrusTestSetupWithSecurity(t *testing.T) (*OrthrusHandler, *services.SecurityService) { + t.Helper() + dir := t.TempDir() + caPath := filepath.Join(dir, "ca") + require.NoError(t, os.MkdirAll(caPath, 0o700)) + + db := openOrthrusTestDB(t) + ca, err := orthrus.NewInternalCA(caPath) + require.NoError(t, err) + srv, err := orthrus.NewOrthrusServer(db, ca) + require.NoError(t, err) + t.Cleanup(srv.Stop) + + svc := services.NewOrthrusService(db, srv) + securityService := services.NewSecurityService(db) + t.Cleanup(securityService.Close) + return NewOrthrusHandler(svc, securityService), securityService } func TestOrthrusHandler_List_Empty(t *testing.T) { @@ -619,6 +643,141 @@ func TestOrthrusHandler_PatchAgent_ExternalProxyPort_Invalid(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestOrthrusHandler_PatchAgent_WriteEnabled_EmitsAuditEntry(t *testing.T) { + h, securityService := newOrthrusTestSetupWithSecurity(t) + + wProv := httptest.NewRecorder() + cProv, _ := gin.CreateTestContext(wProv) + cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents", + bytes.NewBufferString(`{"name":"write-mode-agent"}`)) + cProv.Request.Header.Set("Content-Type", "application/json") + h.Provision(cProv) + require.Equal(t, http.StatusCreated, wProv.Code) + var provisioned map[string]any + require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned)) + agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string) + + body, _ := json.Marshal(map[string]bool{"write_enabled": true}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPatch, "/management/orthrus/agents/"+agentUUID, + bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "uuid", Value: agentUUID}} + h.Patch(c) + + assert.Equal(t, http.StatusOK, w.Code) + var resp models.OrthrusAgent + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.True(t, resp.WriteEnabled) + + securityService.Flush() + entries, total, err := securityService.ListAuditLogs(services.AuditLogFilter{ + EventCategory: "orthrus_write", + ResourceUUID: agentUUID, + }, 1, 10) + require.NoError(t, err) + require.EqualValues(t, 1, total) + assert.Equal(t, "orthrus_write_enabled", entries[0].Action) + assert.Contains(t, entries[0].Details, "write-mode-agent") +} + +func TestOrthrusHandler_PatchAgent_WriteDisabled_EmitsAuditEntry(t *testing.T) { + h, securityService := newOrthrusTestSetupWithSecurity(t) + + wProv := httptest.NewRecorder() + cProv, _ := gin.CreateTestContext(wProv) + cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents", + bytes.NewBufferString(`{"name":"write-mode-agent-2"}`)) + cProv.Request.Header.Set("Content-Type", "application/json") + h.Provision(cProv) + require.Equal(t, http.StatusCreated, wProv.Code) + var provisioned map[string]any + require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned)) + agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string) + + body, _ := json.Marshal(map[string]bool{"write_enabled": false}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPatch, "/management/orthrus/agents/"+agentUUID, + bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "uuid", Value: agentUUID}} + h.Patch(c) + + assert.Equal(t, http.StatusOK, w.Code) + + securityService.Flush() + entries, total, err := securityService.ListAuditLogs(services.AuditLogFilter{ + EventCategory: "orthrus_write", + ResourceUUID: agentUUID, + }, 1, 10) + require.NoError(t, err) + require.EqualValues(t, 1, total) + assert.Equal(t, "orthrus_write_disabled", entries[0].Action) +} + +func TestOrthrusHandler_PatchAgent_NoWriteEnabledField_NoAuditEntry(t *testing.T) { + h, securityService := newOrthrusTestSetupWithSecurity(t) + + wProv := httptest.NewRecorder() + cProv, _ := gin.CreateTestContext(wProv) + cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents", + bytes.NewBufferString(`{"name":"no-audit-agent"}`)) + cProv.Request.Header.Set("Content-Type", "application/json") + h.Provision(cProv) + require.Equal(t, http.StatusCreated, wProv.Code) + var provisioned map[string]any + require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned)) + agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string) + + body, _ := json.Marshal(map[string]string{"name": "renamed"}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPatch, "/management/orthrus/agents/"+agentUUID, + bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "uuid", Value: agentUUID}} + h.Patch(c) + + assert.Equal(t, http.StatusOK, w.Code) + + securityService.Flush() + _, total, err := securityService.ListAuditLogs(services.AuditLogFilter{ + EventCategory: "orthrus_write", + ResourceUUID: agentUUID, + }, 1, 10) + require.NoError(t, err) + assert.EqualValues(t, 0, total, "a Patch call that doesn't touch write_enabled must not emit an orthrus_write audit entry") +} + +func TestOrthrusHandler_GetProxyStatus_IncludesWriteEnabledFields(t *testing.T) { + h, _ := newOrthrusTestSetup(t) + + wProv := httptest.NewRecorder() + cProv, _ := gin.CreateTestContext(wProv) + cProv.Request = httptest.NewRequest(http.MethodPost, "/management/orthrus/agents", + bytes.NewBufferString(`{"name":"proxy-status-write-agent"}`)) + cProv.Request.Header.Set("Content-Type", "application/json") + h.Provision(cProv) + require.Equal(t, http.StatusCreated, wProv.Code) + var provisioned map[string]any + require.NoError(t, json.Unmarshal(wProv.Body.Bytes(), &provisioned)) + agentUUID := provisioned["agent"].(map[string]any)["uuid"].(string) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/management/orthrus/agents/"+agentUUID+"/proxy-status", http.NoBody) + c.Params = gin.Params{{Key: "uuid", Value: agentUUID}} + h.GetProxyStatus(c) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, false, resp["configured_write_enabled"]) + assert.Equal(t, false, resp["active_write_enabled"]) +} + func TestOrthrusHandler_GetProxyStatus_NilResolver(t *testing.T) { h, _ := newOrthrusTestSetup(t) diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3255c01fd..b56064547 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -556,11 +556,17 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg hecateHandler := handlers.NewHecateHandler(hecateSvc) hecateHandler.RegisterRoutes(management) - orthrusHandler := handlers.NewOrthrusHandler(orthrsuSvc) + orthrusHandler := handlers.NewOrthrusHandler(orthrsuSvc, securityService) orthrusHandler.RegisterRoutes(management) if orthrusServer != nil { orthrusHandler.SetProxyResolver(orthrusServer) + // Wires write-path audit logging (Section 3.3.7 of the + // Orthrus write-mode spec) — securityService satisfies + // orthrus.AuditLogger structurally; orthrus never imports + // services directly (see AuditLogger's doc comment in + // muzzle.go for why that import direction is a cycle). + orthrusServer.SetAuditLogger(securityService) } hecateWSHandler := handlers.NewHecateWSHandler(hecateSvc, wsTracker) diff --git a/backend/internal/models/orthrus_agent.go b/backend/internal/models/orthrus_agent.go index 5154c5456..434511292 100644 --- a/backend/internal/models/orthrus_agent.go +++ b/backend/internal/models/orthrus_agent.go @@ -47,6 +47,13 @@ type OrthrusAgent struct { // 0 = disabled. Valid values: 1024–65535. ExternalProxyPort int `json:"external_proxy_port" gorm:"default:0"` + // WriteEnabled opts this agent into a narrow, fixed set of Docker write endpoints + // (image pull, container start/stop/restart/create/remove) in addition to the + // unconditional read-only allowlist. false = read-only (default). Enforced + // independently by both backend/internal/orthrus/muzzle.go and agent/muzzle/muzzle.go. + // Takes effect on the agent's next reconnect (see AgentSession handshake). + WriteEnabled bool `json:"write_enabled" gorm:"default:false"` + LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` LastSeen *time.Time `json:"last_seen,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/orthrus/muzzle.go b/backend/internal/orthrus/muzzle.go index 4d941f4ae..e4edf9975 100644 --- a/backend/internal/orthrus/muzzle.go +++ b/backend/internal/orthrus/muzzle.go @@ -1,15 +1,30 @@ package orthrus import ( + "bytes" + "encoding/json" + "io" "net/http" "path" "regexp" "strings" "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/util" + "golang.org/x/time/rate" ) +// AuditLogger is the narrow interface Muzzle depends on for write-path audit +// logging. Satisfied by *services.SecurityService at the call site in +// session.go, where the concrete type is available. Muzzle cannot import +// services directly: services/orthrus_service.go already imports +// "github.com/Wikid82/charon/backend/internal/orthrus", so an orthrus → +// services import would create a cycle. +type AuditLogger interface { + LogAudit(a *models.SecurityAudit) error +} + // sanitizePath strips newlines and carriage returns from a path string to // prevent log injection (CWE-117). func sanitizePath(p string) string { @@ -91,27 +106,469 @@ var allowedDockerPrefixSuffixPatterns = []struct { {prefix: "/distribution/", suffix: "/json"}, // registry digest check — read-only, used by update-checker tools } +// allowedWriteExactPaths lists the fixed-path write endpoints permitted when +// a session has negotiated write mode. Only consulted for POST requests, and +// only when Muzzle.writeEnabled is true — see the write-mode gate at the top +// of ServeHTTP. /containers/create additionally requires its body to pass +// validateContainerCreateBody; /images/create takes its parameters via query +// string (fromImage=, tag=) and has no body to validate. +var allowedWriteExactPaths = map[string]struct{}{ + "/containers/create": {}, + "/images/create": {}, +} + +// allowedWritePatterns lists the dynamic-segment write endpoints permitted +// when write mode is on: start/stop/restart/rename an existing container, +// or remove one outright. Each entry pairs the exact HTTP method required +// with a path.Match pattern — unlike allowedDockerPrefixSuffixPatterns, +// these patterns operate on Docker-generated container IDs (a single path +// segment, never namespaced), so path.Match's "no cross-slash" behavior is +// the correct, sufficient tool here. +// +// /containers/*/rename takes the new container name via a "?name=" query +// parameter, not a request body (unlike /containers/create), so — like +// start/stop/restart — it needs no body-validation function; the query +// string is not even visible here, since ServeHTTP/Allow match against +// r.URL.Path, not RequestURI. This is Dockhand's standard update pattern: +// rename the old container out of the way (e.g. to "_old") before +// bringing up the new one under the original name. +var allowedWritePatterns = []struct { + method string + pattern string +}{ + {method: http.MethodPost, pattern: "/containers/*/start"}, + {method: http.MethodPost, pattern: "/containers/*/stop"}, + {method: http.MethodPost, pattern: "/containers/*/restart"}, + {method: http.MethodPost, pattern: "/containers/*/rename"}, + {method: http.MethodDelete, pattern: "/containers/*"}, +} + +// maxContainerCreateBodyBytes bounds the size of a /containers/create +// request body accepted for validation. Generously larger than any +// realistic container-create body (typically a few KB even with a long +// env/label list) — bounds worst-case JSON-parse cost. This constant must +// stay numerically identical to agent/muzzle's copy; the shared test corpus +// (Section 3.3.5 of the write-mode spec) includes a boundary-size case to +// catch drift between the two. +const maxContainerCreateBodyBytes = 64 * 1024 + +// hostConfigAllowedKeys is the exhaustive allowlist of HostConfig top-level +// keys considered safe for a same-host container recreate. A key NOT in +// this set causes the whole /containers/create request to be rejected — +// this is what gives fail-closed behavior against Docker Engine API fields +// this list's authors don't know about yet, rather than trying to enumerate +// every dangerous key by name. The keys deliberately kept OUT of this set, +// and why, are grouped below rather than left to guesswork: +// +// - Privileged, CapAdd, Devices, DeviceCgroupRules, DeviceRequests (GPU/device +// passthrough), SecurityOpt (SELinux/AppArmor/seccomp override), Sysctls, +// Runtime (arbitrary OCI runtime selection): direct host-escape or +// isolation-bypass primitives. +// - Binds, VolumeDriver (a top-level echo of the same local-driver +// bind-mount-via-volume bypass validateMountsValue's DriverConfig check +// closes for Mounts), VolumesFrom (inherits another container's mounts, +// including any host binds granted to it outside this muzzle's control): +// host-filesystem-access primitives. +// - PidMode, IpcMode, UTSMode, CgroupnsMode, Cgroup, CgroupParent, GroupAdd: +// namespace/cgroup-placement fields whose risk depends on what the named +// namespace, cgroup, or host GID actually grants — not blanket-safe the +// way a resource *limit* is, so excluded along with the rest of this +// group rather than guessed at. +// - Ulimits: NOT excluded as dangerous — see the resource-limit group +// below. Listed here only because earlier revisions of this comment +// mis-grouped it as excluded; it is allowed. +// +// Every key actually in the map below is safe because it either (a) only +// limits/throttles a resource the container already has, never grants new +// access; (b) only restricts the container's own view of itself further +// (MaskedPaths, ReadonlyPaths); (c) is cosmetic/metadata with no runtime +// effect (ConsoleSize, Annotations); or (d) operates entirely inside the +// container's own namespaces (Tmpfs, ShmSize — unlike a bind mount, these +// never touch the host filesystem). Grouped by kind, not alphabetically: +// +// CPU/memory/IO/pid resource limits (Resources, embedded flat into +// HostConfig's JSON — CpuShares, NanoCpus, Memory, MemorySwap already +// present above): CpuPeriod, CpuQuota, CpuRealtimePeriod, +// CpuRealtimeRuntime, CpusetCpus, CpusetMems, BlkioWeight, +// BlkioWeightDevice, BlkioDeviceReadBps, BlkioDeviceWriteBps, +// BlkioDeviceReadIOps, BlkioDeviceWriteIOps, KernelMemory, +// KernelMemoryTCP, MemoryReservation, MemorySwappiness, OomKillDisable, +// OomScoreAdj, PidsLimit, Ulimits, StorageOpt, ShmSize. +// Container-internal-only mounts: Tmpfs (mirrors the already-allowed +// Type:"tmpfs" Mounts entries — never a host path). +// Restriction-only (narrow the container's own view, cannot grant host +// access): MaskedPaths, ReadonlyPaths. +// Cosmetic/metadata, no runtime security effect: ConsoleSize, Annotations. +// Networking convenience, same risk class as the already-allowed +// ExtraHosts/DnsSearch (hostname/port aliasing, not host filesystem or +// capability access): Links, PublishAllPorts, DnsOptions. +// +// NetworkMode, Mounts, UsernsMode, and ContainerIDFile additionally receive +// a value-level check (see validateNetworkModeValue, validateMountsValue, +// validateUsernsModeValue, validateContainerIDFileValue) beyond simple key +// presence: each is operationally necessary and so cannot be +// blanket-excluded the way the fields above are, but each can still express +// a host-escape (NetworkMode, UsernsMode), host-filesystem (Mounts), or +// host-file-creation (ContainerIDFile — the daemon creates a file at this +// path on the host before the container even starts) primitive through its +// value rather than its mere presence. +var hostConfigAllowedKeys = map[string]struct{}{ + "PortBindings": {}, + "RestartPolicy": {}, + "Memory": {}, + "MemorySwap": {}, + "NanoCpus": {}, + "CpuShares": {}, + "Mounts": {}, + "Dns": {}, + "DnsSearch": {}, + "DnsOptions": {}, + "ExtraHosts": {}, + "LogConfig": {}, + "AutoRemove": {}, + "ReadonlyRootfs": {}, + "Init": {}, + "NetworkMode": {}, + "CapDrop": {}, + "UsernsMode": {}, + "ContainerIDFile": {}, + "CpuPeriod": {}, + "CpuQuota": {}, + "CpuRealtimePeriod": {}, + "CpuRealtimeRuntime": {}, + "CpusetCpus": {}, + "CpusetMems": {}, + "BlkioWeight": {}, + "BlkioWeightDevice": {}, + "BlkioDeviceReadBps": {}, + "BlkioDeviceWriteBps": {}, + "BlkioDeviceReadIOps": {}, + "BlkioDeviceWriteIOps": {}, + "KernelMemory": {}, + "KernelMemoryTCP": {}, + "MemoryReservation": {}, + "MemorySwappiness": {}, + "OomKillDisable": {}, + "OomScoreAdj": {}, + "PidsLimit": {}, + "Ulimits": {}, + "StorageOpt": {}, + "ShmSize": {}, + "Tmpfs": {}, + "MaskedPaths": {}, + "ReadonlyPaths": {}, + "ConsoleSize": {}, + "Annotations": {}, + "Links": {}, + "PublishAllPorts": {}, +} + +// mountEntry is the subset of Docker's Mount struct this validator inspects. +type mountEntry struct { + Type string `json:"Type"` + VolumeOptions json.RawMessage `json:"VolumeOptions"` +} + +// validateNetworkModeValue rejects "host" networking and container-mode +// networking ("container:", which grants access to another container's +// network namespace) — the two NetworkMode values that carry the same class +// of host-escape risk as the blanket-excluded HostConfig keys. Every other +// value (a bridge network name, "bridge", "none", "default") is accepted, +// since NetworkMode is a normal, operationally required field for any +// container recreate that isn't on the default bridge network. +func validateNetworkModeValue(raw json.RawMessage) bool { + var mode string + if err := json.Unmarshal(raw, &mode); err != nil { + return false + } + if mode == "host" { + return false + } + if strings.HasPrefix(mode, "container:") { + return false + } + return true +} + +// validateUsernsModeValue rejects only the value "host", which opts a +// container out of Docker's user-namespace remapping and thereby carries the +// same class of host-escape risk as the blanket-excluded HostConfig keys. +// Every other value — the empty string (inherit the daemon's userns-remap +// configuration, the common case for a same-host recreate) or any other +// named mode — is accepted, mirroring validateNetworkModeValue's shape. +func validateUsernsModeValue(raw json.RawMessage) bool { + var mode string + if err := json.Unmarshal(raw, &mode); err != nil { + return false + } + return mode != "host" +} + +// validateContainerIDFileValue accepts only the empty string. A non-empty +// ContainerIDFile has the Docker daemon create a new file, containing the +// new container's ID, at an operator-chosen path on the HOST filesystem +// (opened O_EXCL, so it cannot clobber an existing file, but it can still +// create one anywhere the daemon's own filesystem permissions allow) — +// before the container itself even starts, so none of the container's own +// isolation applies. That is a real host-filesystem-write primitive, unlike +// every other field in hostConfigAllowedKeys, so it gets the narrowest +// possible value-level check rather than a blanket allow: the empty string +// (the overwhelming common case — the field is rarely set explicitly, and a +// same-host recreate of such a container legitimately echoes it back empty) +// is accepted, and every non-empty value is rejected. +func validateContainerIDFileValue(raw json.RawMessage) bool { + var cidFile string + if err := json.Unmarshal(raw, &cidFile); err != nil { + return false + } + return cidFile == "" +} + +// validateMountsValue rejects any bind-type mount and any mount entry whose +// VolumeOptions carries a DriverConfig key at all, regardless of Type. +// +// The DriverConfig check closes a documented bypass of the Type check +// alone: Docker's default "local" volume driver accepts +// {"type":"none","device":"/host/path","o":"bind"} inside +// VolumeOptions.DriverConfig.Options, which makes a Type:"volume" mount +// behave exactly like an arbitrary host bind mount — fully equivalent in +// effect to the Type:"bind" mount rejected below, but reached through a +// field one level deeper. No attempt is made to selectively allow safe +// DriverConfig.Options key/value pairs: any DriverConfig presence at all is +// rejected, matching this validator's hard-reject-over-silent-strip +// philosophy. +func validateMountsValue(raw json.RawMessage) bool { + var mounts []mountEntry + if err := json.Unmarshal(raw, &mounts); err != nil { + return false + } + for _, mnt := range mounts { + if mnt.Type != "volume" && mnt.Type != "tmpfs" { + // Rejects "bind" and any unrecognized/future mount type. The + // legacy HostConfig.Binds field is rejected separately, simply + // by not appearing in hostConfigAllowedKeys. + return false + } + if len(mnt.VolumeOptions) == 0 { + continue + } + var volumeOptions map[string]json.RawMessage + if err := json.Unmarshal(mnt.VolumeOptions, &volumeOptions); err != nil { + return false + } + if _, hasDriverConfig := volumeOptions["DriverConfig"]; hasDriverConfig { + return false + } + } + return true +} + +// validateContainerCreateBody reads, size-caps, and validates a +// /containers/create request body against hostConfigAllowedKeys plus the +// NetworkMode/Mounts value-level checks, then re-buffers r.Body so the +// downstream reverse proxy can still forward the original bytes intact — +// reading r.Body consumes it exactly once, so it must be replaced with a +// fresh reader over the same bytes before this function returns, regardless +// of outcome. +// +// Returns (true, "") if the body is safe to forward. Returns (false, reason) +// if the request should be rejected, with reason suitable for both the audit +// log and the HTTP error response. +func validateContainerCreateBody(r *http.Request) (ok bool, reason string) { + if r.Body == nil { + return true, "" + } + + limited := io.LimitReader(r.Body, maxContainerCreateBodyBytes+1) + bodyBytes, err := io.ReadAll(limited) + _ = r.Body.Close() + if err != nil { + r.Body = http.NoBody + return false, "malformed request body" + } + if len(bodyBytes) > maxContainerCreateBodyBytes { + // Re-buffer the (oversized, rejected) bytes anyway: forwarding never + // happens on this path, but leaving r.Body in a consumed state would + // be surprising for any future caller of this function. + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + r.ContentLength = int64(len(bodyBytes)) + return false, "request body too large" + } + + // Re-buffer before any further validation so every return path below — + // success or rejection — leaves r.Body forwarding-ready. + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + r.ContentLength = int64(len(bodyBytes)) + + if len(bodyBytes) == 0 { + return true, "" + } + + var top map[string]json.RawMessage + if err := json.Unmarshal(bodyBytes, &top); err != nil { + return false, "malformed request body" + } + + hostConfigRaw, hasHostConfig := top["HostConfig"] + if !hasHostConfig { + return true, "" + } + + var hostConfig map[string]json.RawMessage + if err := json.Unmarshal(hostConfigRaw, &hostConfig); err != nil { + return false, "malformed request body" + } + + for key, rawValue := range hostConfig { + if _, allowed := hostConfigAllowedKeys[key]; !allowed { + return false, "disallowed HostConfig field: " + key + } + switch key { + case "NetworkMode": + if !validateNetworkModeValue(rawValue) { + return false, "disallowed HostConfig field: NetworkMode" + } + case "Mounts": + if !validateMountsValue(rawValue) { + return false, "disallowed HostConfig field: Mounts" + } + case "UsernsMode": + if !validateUsernsModeValue(rawValue) { + return false, "disallowed HostConfig field: UsernsMode" + } + case "ContainerIDFile": + if !validateContainerIDFileValue(rawValue) { + return false, "disallowed HostConfig field: ContainerIDFile" + } + } + } + + return true, "" +} + +// writeAuditDetails marshals a small flat field set into the JSON string +// stored in a SecurityAudit's Details column. Marshal failure (unreachable +// for the string-only maps this is called with) degrades to an empty JSON +// object rather than losing the audit entry entirely. +func writeAuditDetails(fields map[string]string) string { + b, err := json.Marshal(fields) + if err != nil { + return "{}" + } + return string(b) +} + +// logAudit is a nil-safe wrapper around m.auditLogger.LogAudit. auditLogger +// is nil for read-only sessions and in most tests — a no-op in that case, +// not an error, since read-only traffic was never audited before this +// feature and isn't in scope for it now (see Section 3.3.7 of the +// write-mode spec). +func (m *Muzzle) logAudit(action, details string) { + if m.auditLogger == nil { + return + } + // Actor identifies the agent, not a Charon user — there is no + // authenticated operator in this request path, only a third-party tool + // on the other end of the tunnel. agentUUID (not agent name) is used + // here because it's what NewMuzzle actually receives; ResourceUUID + // carries the same value for querying, and a UUID is a more stable + // identifier for an audit trail than a renamable display name anyway. + _ = m.auditLogger.LogAudit(&models.SecurityAudit{ + Actor: "orthrus-agent:" + m.agentUUID, + Action: action, + EventCategory: "orthrus_write", + ResourceUUID: m.agentUUID, + Details: details, + }) +} + +func (m *Muzzle) auditAllowed(r *http.Request) { + m.logAudit("orthrus_write_allowed", writeAuditDetails(map[string]string{ + "method": r.Method, + "path": sanitizePath(r.URL.Path), + })) +} + +func (m *Muzzle) auditBlocked(r *http.Request, reason string) { + m.logAudit("orthrus_write_blocked", writeAuditDetails(map[string]string{ + "method": r.Method, + "path": sanitizePath(r.URL.Path), + "reason": reason, + })) +} + +func (m *Muzzle) auditRateLimited(r *http.Request) { + m.logAudit("orthrus_write_rate_limited", writeAuditDetails(map[string]string{ + "method": r.Method, + "path": sanitizePath(r.URL.Path), + })) +} + // Muzzle is an http.Handler wrapper that restricts Docker socket access -// to a curated allowlist of read-only, non-destructive endpoints. +// to a curated allowlist of read-only, non-destructive endpoints, plus an +// optional narrow set of write endpoints when writeEnabled is true. type Muzzle struct { next http.Handler + // writeEnabled is fixed at construction time (one Muzzle per AgentSession, + // per external-proxy start) — never re-read from the DB per-request. See + // NewMuzzle doc comment for why. + writeEnabled bool + // writeLimiter bounds write-request throughput; nil unless writeEnabled. + writeLimiter *rate.Limiter + // auditLogger records every write attempt (allowed or blocked); nil is + // tolerated (no-op) so tests and read-only sessions don't need one. + auditLogger AuditLogger + // agentUUID identifies the session this Muzzle guards, for audit entries. + agentUUID string } // NewMuzzle wraps handler with the Docker socket allowlist filter. -func NewMuzzle(next http.Handler) *Muzzle { - return &Muzzle{next: next} +// +// writeEnabled, writeLimiter, auditLogger, and agentUUID govern the optional +// write-endpoint allowlist (see Section 3.3.3 of the Orthrus write-mode +// spec). writeEnabled is captured once, at construction time, and never +// re-checked against the database on a per-request basis — StartExternalProxy +// constructs a new Muzzle per AgentSession using the value negotiated at +// connect time, so toggling the DB flag only takes effect on the agent's next +// reconnect. Re-reading it per-request would both reintroduce a TOCTOU-like +// inconsistency with that reconnect-to-apply guarantee and add a DB +// round-trip to the hot proxy path. +func NewMuzzle(next http.Handler, writeEnabled bool, writeLimiter *rate.Limiter, auditLogger AuditLogger, agentUUID string) *Muzzle { + return &Muzzle{ + next: next, + writeEnabled: writeEnabled, + writeLimiter: writeLimiter, + auditLogger: auditLogger, + agentUUID: agentUUID, + } +} + +// normalizeDockerPath strips a Docker API version prefix (e.g. "/v1.47") +// using versionPrefixRe, THEN runs path.Clean — in that order. Stripping +// first means the version-prefix match runs against the raw, uncleaned +// path, so a traversal-disguised prefix (e.g. "/foo/../v1.44/...") is not +// mistaken for a real one: versionPrefixRe is anchored to the start of the +// string as given, before ".." segments have been resolved away. Normalize +// away any "." or ".." segments only after that check so that traversal-style +// paths such as /containers/../json cannot match patterns like +// /containers/*/json. path.Clean always returns a rooted result when given a +// rooted input; the explicit "/" prefix guards against an empty stripped +// value. +// +// agent/muzzle/muzzle.go has an identically-named helper that must apply +// versionPrefixRe (same regex source) and path.Clean in this exact order — +// see that file's doc comment and scripts/ci/check_muzzle_allowlist_parity.go, +// which structurally compares the two files' versionPrefixRe declarations. +func normalizeDockerPath(rawPath string) string { + stripped := versionPrefixRe.ReplaceAllString(rawPath, "") + return path.Clean("/" + strings.TrimLeft(stripped, "/")) } // ServeHTTP implements http.Handler. Only GET requests to allowlisted paths // are forwarded; HEAD is also permitted for /_ping (Docker client health checks). // All other methods or paths receive 403 Forbidden. func (m *Muzzle) ServeHTTP(w http.ResponseWriter, r *http.Request) { - rawPath := versionPrefixRe.ReplaceAllString(r.URL.Path, "") - // Normalize away any "." or ".." segments before any allowlist check so that - // traversal-style paths such as /containers/../json cannot match patterns like - // /containers/*/json. path.Clean always returns a rooted result when given a - // rooted input; the explicit "/" prefix guards against an empty rawPath value. - stripped := path.Clean("/" + strings.TrimLeft(rawPath, "/")) + stripped := normalizeDockerPath(r.URL.Path) // HEAD /_ping is permitted alongside GET for Docker client health checks. if r.Method == http.MethodHead && stripped == "/_ping" { @@ -119,6 +576,20 @@ func (m *Muzzle) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Write-endpoint handling must run before the unconditional GET-only + // check below, since POST/DELETE requests would otherwise be rejected + // before ever reaching the write allowlist. Only consulted when this + // session negotiated write mode (m.writeEnabled, fixed at construction + // time — see NewMuzzle) — every other session's POST/DELETE traffic + // falls straight through, unaffected, to the same 403 it always got. + if m.writeEnabled && (r.Method == http.MethodPost || r.Method == http.MethodDelete) { + if m.tryServeWrite(w, r, stripped) { + return + } + // Not on the write allowlist even with write mode on — fall through + // to the same "Forbidden" response every other disallowed request gets. + } + if r.Method != http.MethodGet { logger.Log().WithField("method", util.SanitizeForLog(r.Method)).WithField("path", sanitizePath(r.URL.Path)). Warn("orthrus: muzzle blocked non-GET Docker request") @@ -154,3 +625,50 @@ func (m *Muzzle) ServeHTTP(w http.ResponseWriter, r *http.Request) { Warn("orthrus: muzzle blocked disallowed Docker path") http.Error(w, "Forbidden", http.StatusForbidden) } + +// tryServeWrite handles a POST/DELETE request when this session has write +// mode enabled. Returns true if it fully handled the request — forwarded, +// rate-limited, or rejected with a write-specific reason and audit entry — +// false if the method/path combination isn't on the write allowlist at all, +// in which case the caller (ServeHTTP) falls through to the same generic +// 403 every other disallowed request receives. +func (m *Muzzle) tryServeWrite(w http.ResponseWriter, r *http.Request, stripped string) bool { + if r.Method == http.MethodPost { + if _, ok := allowedWriteExactPaths[stripped]; ok { + if stripped == "/containers/create" { + if valid, reason := validateContainerCreateBody(r); !valid { + m.auditBlocked(r, reason) + http.Error(w, "Forbidden: "+reason, http.StatusForbidden) + return true + } + } + return m.forwardWrite(w, r) + } + } + + for _, p := range allowedWritePatterns { + if r.Method != p.method { + continue + } + if matched, err := path.Match(p.pattern, stripped); err == nil && matched { + return m.forwardWrite(w, r) + } + } + + return false +} + +// forwardWrite applies the write-path rate limiter, audits the outcome, and +// forwards the request on success. Always returns true (the caller uses the +// return value only to distinguish "on the write allowlist" from "not"; a +// rate-limited request is still a request tryServeWrite fully handled). +func (m *Muzzle) forwardWrite(w http.ResponseWriter, r *http.Request) bool { + if m.writeLimiter != nil && !m.writeLimiter.Allow() { + m.auditRateLimited(r) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return true + } + m.auditAllowed(r) + m.next.ServeHTTP(w, r) + return true +} diff --git a/backend/internal/orthrus/muzzle_test.go b/backend/internal/orthrus/muzzle_test.go index e5c682e4c..29c720575 100644 --- a/backend/internal/orthrus/muzzle_test.go +++ b/backend/internal/orthrus/muzzle_test.go @@ -1,11 +1,20 @@ package orthrus import ( + "bytes" + "encoding/json" "net/http" "net/http/httptest" + "os" + "sync" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" + + "github.com/Wikid82/charon/backend/internal/models" ) func passthroughHandler() http.Handler { @@ -27,7 +36,7 @@ func TestMuzzle_AllowlistedGET_Passthrough(t *testing.T) { "/system/df", } - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, path := range allowed { t.Run(path, func(t *testing.T) { @@ -52,7 +61,7 @@ func TestMuzzle_VersionPrefixStripped_Passthrough(t *testing.T) { "/v1.47/system/df", } - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, path := range paths { t.Run(path, func(t *testing.T) { @@ -64,8 +73,63 @@ func TestMuzzle_VersionPrefixStripped_Passthrough(t *testing.T) { } } +// TestNormalizeDockerPath exercises normalizeDockerPath directly (not only +// through ServeHTTP/the shared corpus) for fast local iteration on the two +// GH #1160 divergence rows that motivated extracting this helper: a +// traversal-disguised version prefix, and a non-numeric fake version prefix. +func TestNormalizeDockerPath(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "legitimate version prefix stripped", + in: "/v1.44/containers/json", + want: "/containers/json", + }, + { + // Divergence row 1 (GH #1160's own example): the version prefix + // is only revealed after traversal resolution. versionPrefixRe + // must NOT match here, since it runs against the raw path before + // path.Clean has resolved "/foo/..". + name: "traversal-disguised version prefix is not treated as a version prefix", + in: "/foo/../v1.44/images/x/json", + want: "/v1.44/images/x/json", + }, + { + // Divergence row 2: non-numeric "version" segment must not match + // the numeric-anchored regex. + name: "non-numeric fake version prefix is left intact", + in: "/vFOO/containers/json", + want: "/vFOO/containers/json", + }, + { + name: "traversal escaping above root clamps to root", + in: "/containers/../../etc/passwd", + want: "/etc/passwd", + }, + { + name: "double slash after legitimate version prefix collapses", + in: "/v1.44//containers/json", + want: "/containers/json", + }, + { + name: "version-prefixed traversal resolving to an allowed path", + in: "/v1.47/../containers/json", + want: "/containers/json", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, normalizeDockerPath(tc.in)) + }) + } +} + func TestMuzzle_POST_Blocked(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") paths := []string{ "/containers/create", @@ -84,7 +148,7 @@ func TestMuzzle_POST_Blocked(t *testing.T) { } func TestMuzzle_DELETE_Blocked(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") req := httptest.NewRequest(http.MethodDelete, "/containers/abc123", http.NoBody) rr := httptest.NewRecorder() m.ServeHTTP(rr, req) @@ -92,7 +156,7 @@ func TestMuzzle_DELETE_Blocked(t *testing.T) { } func TestMuzzle_HEAD_Ping_Passthrough(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, path := range []string{"/_ping", "/v1.44/_ping"} { t.Run(path, func(t *testing.T) { @@ -105,7 +169,7 @@ func TestMuzzle_HEAD_Ping_Passthrough(t *testing.T) { } func TestMuzzle_HEAD_NonPing_Blocked(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") req := httptest.NewRequest(http.MethodHead, "/containers/json", http.NoBody) rr := httptest.NewRecorder() m.ServeHTTP(rr, req) @@ -133,7 +197,7 @@ func TestMuzzle_DynamicPaths_Passthrough(t *testing.T) { "/v1.44/distribution/alpine/json", } - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, p := range paths { t.Run(p, func(t *testing.T) { @@ -159,7 +223,7 @@ func TestMuzzle_UnknownPath_Blocked(t *testing.T) { "/distribution/create", } - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, path := range paths { t.Run(path, func(t *testing.T) { @@ -185,7 +249,7 @@ func TestMuzzle_NamespacedImagePaths_Passthrough(t *testing.T) { "registry.example.com/team/project/image", } - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") for _, prefix := range []string{"/images/", "/distribution/"} { for _, ref := range refs { @@ -213,7 +277,7 @@ func TestMuzzle_NamespacedImagePaths_Passthrough(t *testing.T) { // ServeHTTP) still rejects writes against namespaced image paths now that // they pass the allowlist's path check. func TestMuzzle_NamespacedImagePaths_NonGET_Blocked(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") paths := []string{ "/images/ghcr.io/org/repo/json", @@ -235,7 +299,7 @@ func TestMuzzle_NamespacedImagePaths_NonGET_Blocked(t *testing.T) { // write path: POST to either is still rejected, even though method-checking // already happens unconditionally before any path match in ServeHTTP. func TestMuzzle_ImageAndDistributionEndpoints_POSTBlocked(t *testing.T) { - m := NewMuzzle(passthroughHandler()) + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") paths := []string{ "/images/alpine/json", @@ -251,3 +315,362 @@ func TestMuzzle_ImageAndDistributionEndpoints_POSTBlocked(t *testing.T) { }) } } + +// --- Write-mode tests (Section 3.3.3/3.3.4 of the Orthrus write-mode spec) --- + +func TestMuzzle_WriteEndpoints_BlockedWhenWriteDisabled(t *testing.T) { + m := NewMuzzle(passthroughHandler(), false, nil, nil, "") + + cases := []struct { + method string + path string + }{ + {http.MethodPost, "/containers/create"}, + {http.MethodPost, "/images/create"}, + {http.MethodPost, "/containers/abc123/start"}, + {http.MethodPost, "/containers/abc123/stop"}, + {http.MethodPost, "/containers/abc123/restart"}, + {http.MethodPost, "/containers/abc123/rename"}, + {http.MethodDelete, "/containers/abc123"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, http.NoBody) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + } +} + +func TestMuzzle_WriteEndpoints_AllowedWhenWriteEnabled(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + cases := []struct { + method string + path string + }{ + {http.MethodPost, "/images/create"}, + {http.MethodPost, "/containers/abc123/start"}, + {http.MethodPost, "/containers/abc123/stop"}, + {http.MethodPost, "/containers/abc123/restart"}, + {http.MethodPost, "/containers/abc123/rename"}, + {http.MethodDelete, "/containers/abc123"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, http.NoBody) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + } +} + +// TestMuzzle_ContainerRename_QueryParamNameAllowed proves the Dockhand +// image-update rename step end-to-end: Docker's rename endpoint takes the +// new container name via a "?name=" query parameter, not a request body +// (unlike /containers/create), and a real Docker-generated container ID is +// a single path segment (no slashes) — exactly what +// allowedWritePatterns's "/containers/*/rename" path.Match pattern assumes. +// This is Dockhand's standard update pattern: rename the old container out +// of the way (e.g. to "_old") before bringing up the new one under +// the original name — the flow that was failing with a 403 before this fix +// (rename was missing from the write allowlist). +func TestMuzzle_ContainerRename_QueryParamNameAllowed(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + // A real Docker-generated container ID: 64 hex characters, single path + // segment. + containerID := "3f4d9e2a1b6c8f0d7e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e" + req := httptest.NewRequest(http.MethodPost, "/containers/"+containerID+"/rename?name=myapp_old", http.NoBody) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestMuzzle_WriteMode_NonWriteEndpointsStillBlocked(t *testing.T) { + // Even with write mode on, endpoints outside the fixed seven-operation + // list (create, images/create, start, stop, restart, rename, delete) + // — e.g. exec, image delete, build, prune, auth, commit, Swarm/service — + // must remain permanently blocked. Section 7, Explicit Out-of-Scope. + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + cases := []struct { + method string + path string + }{ + {http.MethodPost, "/containers/abc123/exec"}, + {http.MethodPost, "/exec/xyz/start"}, + {http.MethodDelete, "/images/nginx"}, + {http.MethodPost, "/build"}, + {http.MethodPost, "/system/prune"}, + {http.MethodPost, "/auth"}, + {http.MethodPost, "/commit"}, + {http.MethodPost, "/networks/create"}, + {http.MethodDelete, "/networks/mynet"}, + {http.MethodPost, "/volumes/create"}, + {http.MethodDelete, "/volumes/myvol"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, http.NoBody) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + } +} + +func TestMuzzle_ContainersCreate_SafeBodyAllowed(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + body := `{"Image":"nginx:latest","HostConfig":{"PortBindings":{"80/tcp":[{"HostPort":"8080"}]},"RestartPolicy":{"Name":"unless-stopped"},"NetworkMode":"my-bridge"}}` + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader([]byte(body))) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) +} + +// TestMuzzle_ContainersCreate_SafeBodiesAllowed_CapDropAndUsernsMode covers +// two HostConfig fields a Dockhand-style recreate legitimately sends that a +// blanket exhaustive allowlist previously rejected outright: CapDrop (which +// only ever reduces privilege, unlike CapAdd) and a non-"host" UsernsMode +// value (the daemon-userns-remap-inherited case, not the escape-relevant +// one). +func TestMuzzle_ContainersCreate_SafeBodiesAllowed_CapDropAndUsernsMode(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + + cases := []struct { + name string + body string + }{ + {"CapDrop", `{"Image":"nginx","HostConfig":{"CapDrop":["ALL"]}}`}, + {"UsernsMode empty (inherit daemon default)", `{"Image":"nginx","HostConfig":{"UsernsMode":""}}`}, + {"UsernsMode named remap", `{"Image":"nginx","HostConfig":{"UsernsMode":"default"}}`}, + {"ContainerIDFile empty", `{"Image":"nginx","HostConfig":{"ContainerIDFile":""}}`}, + { + "CPU/memory/IO/pid resource-limit family", + `{"Image":"nginx","HostConfig":{"CpuPeriod":100000,"CpuQuota":50000,"CpuRealtimePeriod":1000000,"CpuRealtimeRuntime":950000,"CpusetCpus":"0-1","CpusetMems":"0","BlkioWeight":500,"KernelMemory":0,"KernelMemoryTCP":0,"MemoryReservation":1048576,"MemorySwappiness":60,"OomKillDisable":false,"OomScoreAdj":0,"PidsLimit":100,"Ulimits":[{"Name":"nofile","Soft":1024,"Hard":2048}],"StorageOpt":{"size":"10G"},"ShmSize":67108864}}`, + }, + {"Tmpfs (container-internal only)", `{"Image":"nginx","HostConfig":{"Tmpfs":{"/tmp":"rw,noexec,nosuid,size=100m"}}}`}, + {"MaskedPaths/ReadonlyPaths (restriction-only)", `{"Image":"nginx","HostConfig":{"MaskedPaths":["/proc/kcore"],"ReadonlyPaths":["/proc/sys"]}}`}, + {"ConsoleSize/Annotations (cosmetic)", `{"Image":"nginx","HostConfig":{"ConsoleSize":[24,80],"Annotations":{"note":"x"}}}`}, + {"Links/PublishAllPorts/DnsOptions (networking convenience)", `{"Image":"nginx","HostConfig":{"Links":["db:db"],"PublishAllPorts":false,"DnsOptions":["timeout:1"]}}`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader([]byte(tc.body))) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + } +} + +func TestMuzzle_ContainersCreate_DangerousBodiesRejected(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + + cases := []struct { + name string + body string + }{ + {"Privileged", `{"Image":"nginx","HostConfig":{"Privileged":true}}`}, + {"CapAdd", `{"Image":"nginx","HostConfig":{"CapAdd":["SYS_ADMIN"]}}`}, + {"legacy Binds", `{"Image":"nginx","HostConfig":{"Binds":["/:/host"]}}`}, + {"NetworkMode host", `{"Image":"nginx","HostConfig":{"NetworkMode":"host"}}`}, + {"NetworkMode container:*", `{"Image":"nginx","HostConfig":{"NetworkMode":"container:abc"}}`}, + {"UsernsMode host", `{"Image":"nginx","HostConfig":{"UsernsMode":"host"}}`}, + {"ContainerIDFile non-empty (arbitrary host file creation)", `{"Image":"nginx","HostConfig":{"ContainerIDFile":"/etc/cron.d/pwn"}}`}, + {"Runtime", `{"Image":"nginx","HostConfig":{"Runtime":"custom-runtime"}}`}, + {"VolumeDriver", `{"Image":"nginx","HostConfig":{"VolumeDriver":"custom-driver"}}`}, + {"VolumesFrom", `{"Image":"nginx","HostConfig":{"VolumesFrom":["other-container"]}}`}, + {"DeviceRequests", `{"Image":"nginx","HostConfig":{"DeviceRequests":[{"Driver":"nvidia","Count":-1}]}}`}, + {"bind-type Mounts", `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"bind","Source":"/etc","Target":"/x"}]}}`}, + { + "local-driver bind-mount-via-volume bypass (VolumeOptions.DriverConfig)", + `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"volume","Source":"fake","Target":"/etc","VolumeOptions":{"DriverConfig":{"Name":"local","Options":{"type":"none","device":"/etc","o":"bind"}}}}]}}`, + }, + {"non-empty Devices", `{"Image":"nginx","HostConfig":{"Devices":[{"PathOnHost":"/dev/sda","PathInContainer":"/dev/sda"}]}}`}, + {"non-empty Sysctls", `{"Image":"nginx","HostConfig":{"Sysctls":{"net.ipv4.ip_forward":"1"}}}`}, + {"unrecognized future HostConfig key", `{"Image":"nginx","HostConfig":{"SomeFutureField":true}}`}, + {"malformed JSON", `{"Image": not valid json`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader([]byte(tc.body))) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + }) + } +} + +func TestMuzzle_ContainersCreate_BodyTooLarge(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + oversized := bytes.Repeat([]byte("a"), maxContainerCreateBodyBytes+1) + body := `{"Image":"nginx","Labels":{"padding":"` + string(oversized) + `"}}` + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader([]byte(body))) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) +} + +func TestMuzzle_WriteEndpoints_RateLimited(t *testing.T) { + // Burst of 1, refill effectively never within the test's lifetime — the + // first write request consumes the only token; the second must 429. + writeLimiter := rate.NewLimiter(rate.Every(time.Hour), 1) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + req1 := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + rr1 := httptest.NewRecorder() + m.ServeHTTP(rr1, req1) + assert.Equal(t, http.StatusOK, rr1.Code) + + req2 := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + rr2 := httptest.NewRecorder() + m.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusTooManyRequests, rr2.Code) +} + +// mockAuditLogger records every SecurityAudit entry passed to LogAudit, for +// assertion in tests. Safe for concurrent use. +type mockAuditLogger struct { + mu sync.Mutex + entries []*models.SecurityAudit +} + +func (m *mockAuditLogger) LogAudit(a *models.SecurityAudit) error { + m.mu.Lock() + defer m.mu.Unlock() + m.entries = append(m.entries, a) + return nil +} + +func (m *mockAuditLogger) all() []*models.SecurityAudit { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*models.SecurityAudit, len(m.entries)) + copy(out, m.entries) + return out +} + +func TestMuzzle_AuditLog_AllowedWriteRecorded(t *testing.T) { + logger := &mockAuditLogger{} + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, logger, "agent-uuid-1") + + req := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + + entries := logger.all() + require.Len(t, entries, 1) + assert.Equal(t, "orthrus_write_allowed", entries[0].Action) + assert.Equal(t, "orthrus_write", entries[0].EventCategory) + assert.Equal(t, "agent-uuid-1", entries[0].ResourceUUID) + assert.Contains(t, entries[0].Actor, "agent-uuid-1") +} + +func TestMuzzle_AuditLog_BlockedWriteRecorded(t *testing.T) { + logger := &mockAuditLogger{} + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, logger, "agent-uuid-2") + + body := `{"Image":"nginx","HostConfig":{"Privileged":true}}` + req := httptest.NewRequest(http.MethodPost, "/containers/create", bytes.NewReader([]byte(body))) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + + entries := logger.all() + require.Len(t, entries, 1) + assert.Equal(t, "orthrus_write_blocked", entries[0].Action) + assert.Contains(t, entries[0].Details, "Privileged") +} + +func TestMuzzle_AuditLog_RateLimitedRecorded(t *testing.T) { + logger := &mockAuditLogger{} + writeLimiter := rate.NewLimiter(rate.Every(time.Hour), 1) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, logger, "agent-uuid-3") + + req1 := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + m.ServeHTTP(httptest.NewRecorder(), req1) + req2 := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + m.ServeHTTP(httptest.NewRecorder(), req2) + + entries := logger.all() + require.Len(t, entries, 2) + assert.Equal(t, "orthrus_write_allowed", entries[0].Action) + assert.Equal(t, "orthrus_write_rate_limited", entries[1].Action) +} + +func TestMuzzle_AuditLog_NilLoggerIsNoop(t *testing.T) { + writeLimiter := rate.NewLimiter(rate.Inf, 100) + m := NewMuzzle(passthroughHandler(), true, writeLimiter, nil, "agent-uuid") + + req := httptest.NewRequest(http.MethodPost, "/containers/abc/start", http.NoBody) + rr := httptest.NewRecorder() + assert.NotPanics(t, func() { m.ServeHTTP(rr, req) }) + assert.Equal(t, http.StatusOK, rr.Code) +} + +// --- Shared anti-drift corpus (Section 3.3.5 of the Orthrus write-mode spec) --- +// +// muzzle_corpus.json is the single source of truth this test and +// agent/muzzle/muzzle_test.go's TestFilter_SharedCorpus both load, so the +// backend and agent allowlist implementations are proven to agree on every +// case in it — the concrete mitigation for the drift class that let the +// image/distribution read-only fix land in this package without the +// agent-side copy (fixed later, commits 98a68b67 / eabf358d) once already. + +type muzzleCorpusCase struct { + Description string `json:"description"` + Method string `json:"method"` + Path string `json:"path"` + Body json.RawMessage `json:"body,omitempty"` + AgentWriteEnabled bool `json:"agent_write_enabled"` + WantAllowed bool `json:"want_allowed"` +} + +func loadMuzzleCorpus(t *testing.T) []muzzleCorpusCase { + t.Helper() + data, err := os.ReadFile("testdata/muzzle_corpus.json") + require.NoError(t, err) + var cases []muzzleCorpusCase + require.NoError(t, json.Unmarshal(data, &cases)) + require.NotEmpty(t, cases) + return cases +} + +func TestMuzzle_SharedCorpus(t *testing.T) { + cases := loadMuzzleCorpus(t) + for _, tc := range cases { + t.Run(tc.Description, func(t *testing.T) { + var writeLimiter *rate.Limiter + if tc.AgentWriteEnabled { + writeLimiter = rate.NewLimiter(rate.Inf, 1000) + } + m := NewMuzzle(passthroughHandler(), tc.AgentWriteEnabled, writeLimiter, nil, "corpus-test-agent") + + req := httptest.NewRequest(tc.Method, tc.Path, bytes.NewReader(tc.Body)) + rr := httptest.NewRecorder() + m.ServeHTTP(rr, req) + + allowed := rr.Code == http.StatusOK + assert.Equal(t, tc.WantAllowed, allowed, "got status %d", rr.Code) + }) + } +} diff --git a/backend/internal/orthrus/server.go b/backend/internal/orthrus/server.go index 0ac63599d..6e6f46544 100644 --- a/backend/internal/orthrus/server.go +++ b/backend/internal/orthrus/server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strconv" "strings" "sync" "time" @@ -33,6 +34,11 @@ type OrthrusServer struct { ctx context.Context cancel context.CancelFunc wg sync.WaitGroup + // auditLogger records write-path audit entries for every AgentSession + // this server creates. nil until SetAuditLogger is called (e.g. at + // startup in routes.go, once the concrete *services.SecurityService is + // available) — a nil auditLogger is a safe no-op throughout Muzzle. + auditLogger AuditLogger } // NewOrthrusServer creates an OrthrusServer with the given database and internal CA. @@ -47,6 +53,15 @@ func NewOrthrusServer(db *gorm.DB, ca *InternalCA) (*OrthrusServer, error) { }, nil } +// SetAuditLogger wires the write-path audit logger used by every +// AgentSession this server creates from this point forward. Called once at +// startup (routes.go) with the concrete *services.SecurityService, which +// satisfies the AuditLogger interface structurally — orthrus never imports +// services directly (see the AuditLogger doc comment in muzzle.go for why). +func (s *OrthrusServer) SetAuditLogger(al AuditLogger) { + s.auditLogger = al +} + // Stop cancels the server context, closes all active agent sessions, and waits // for all background goroutines to exit. Closing sessions explicitly ensures // yamux's internal goroutines terminate promptly regardless of the underlying @@ -78,13 +93,26 @@ func (s *OrthrusServer) HandleWebSocket(c *gin.Context) { return } - conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + // X-Orthrus-Write-Enabled is delivered atomically as part of the 101 + // Switching Protocols response that completes the handshake — the agent + // reads it off the *http.Response returned by its dial call (see + // leash.go:connect) and uses it to construct a connection-scoped + // muzzle.Filter for the life of this one connection. This is Option A + // from Section 3.3.1 of the write-mode spec: no new yamux control-stream + // type is introduced, avoiding a third hand-maintained stream-type + // constant on top of the two (streamTypeDocker, streamTypePortForward) + // that already have to be kept in sync between this package and + // agent/leash/leash.go. + respHeader := http.Header{} + respHeader.Set("X-Orthrus-Write-Enabled", strconv.FormatBool(agent.WriteEnabled)) + + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, respHeader) if err != nil { logger.Log().WithError(err).Error("orthrus: WebSocket upgrade failed") return } - session, err := NewAgentSession(agent.UUID, agent.Name, conn) + session, err := NewAgentSession(agent.UUID, agent.Name, agent.WriteEnabled, s.auditLogger, conn) if err != nil { logger.Log().WithError(err).Error("orthrus: create agent session failed") _ = conn.Close() diff --git a/backend/internal/orthrus/server_coverage_test.go b/backend/internal/orthrus/server_coverage_test.go index 89f4697f2..386d4bd24 100644 --- a/backend/internal/orthrus/server_coverage_test.go +++ b/backend/internal/orthrus/server_coverage_test.go @@ -25,7 +25,7 @@ func TestOrthrusServer_GetSession_KnownUUID(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("sess-uuid", "sess-agent", serverConn) + sess, err := NewAgentSession("sess-uuid", "sess-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -44,7 +44,7 @@ func TestOrthrusServer_GetProxyAddr_SessionExists_NoProxy(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("no-proxy-uuid", "agent", serverConn) + sess, err := NewAgentSession("no-proxy-uuid", "agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -63,7 +63,7 @@ func TestOrthrusServer_GetProxyAddr_SessionExists_WithProxy(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("with-proxy-uuid", "agent", serverConn) + sess, err := NewAgentSession("with-proxy-uuid", "agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -86,7 +86,7 @@ func TestOrthrusServer_DisconnectAgent_WithSession(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("disc-uuid", "disc-agent", serverConn) + sess, err := NewAgentSession("disc-uuid", "disc-agent", false, nil, serverConn) require.NoError(t, err) srv.sessions.Store("disc-uuid", sess) @@ -194,7 +194,7 @@ func TestOrthrusServer_WatchHeartbeat_ClosedSession_ExitsAndMarksOffline(t *test serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("wh-uuid", "wh-agent", serverConn) + sess, err := NewAgentSession("wh-uuid", "wh-agent", false, nil, serverConn) require.NoError(t, err) // Close session immediately so IsAlive() returns false on first tick. @@ -394,7 +394,7 @@ func TestHandleWebSocket_DisplacesExistingSession(t *testing.T) { // prior connection that has not yet been cleaned up. oldConn, oldCleanup := testWSPair(t) defer oldCleanup() - oldSess, err := NewAgentSession("displace-uuid", "displace-agent", oldConn) + oldSess, err := NewAgentSession("displace-uuid", "displace-agent", false, nil, oldConn) require.NoError(t, err) srv.sessions.Store("displace-uuid", oldSess) diff --git a/backend/internal/orthrus/server_external_proxy_test.go b/backend/internal/orthrus/server_external_proxy_test.go index 401581352..c10dc2854 100644 --- a/backend/internal/orthrus/server_external_proxy_test.go +++ b/backend/internal/orthrus/server_external_proxy_test.go @@ -81,7 +81,7 @@ func TestOrthrusServer_DisconnectAgent_ClosesExternalProxy(t *testing.T) { serverConn, wsCleanup := testWSPair(t) defer wsCleanup() - sess, err := NewAgentSession("ext-srv02-uuid", "ext-srv02-agent", serverConn) + sess, err := NewAgentSession("ext-srv02-uuid", "ext-srv02-agent", false, nil, serverConn) require.NoError(t, err) require.NoError(t, sess.StartDockerProxy()) diff --git a/backend/internal/orthrus/server_proxy_test.go b/backend/internal/orthrus/server_proxy_test.go index 98e47a8b3..bfca9a5ad 100644 --- a/backend/internal/orthrus/server_proxy_test.go +++ b/backend/internal/orthrus/server_proxy_test.go @@ -71,7 +71,7 @@ func TestOrthrusServer_WatchHeartbeat_ClosesProxyListener(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("proxy-s2-uuid", "proxy-s2-agent", serverConn) + sess, err := NewAgentSession("proxy-s2-uuid", "proxy-s2-agent", false, nil, serverConn) require.NoError(t, err) require.NoError(t, sess.StartDockerProxy()) diff --git a/backend/internal/orthrus/server_test.go b/backend/internal/orthrus/server_test.go index d6de28b5a..6ed2184c8 100644 --- a/backend/internal/orthrus/server_test.go +++ b/backend/internal/orthrus/server_test.go @@ -217,7 +217,7 @@ func TestWatchHeartbeat_StaleGoroutine_DoesNotEvictNewSession(t *testing.T) { // goroutine is still running after a newer session has replaced it. conn1, done1 := testWSPair(t) defer done1() - sess1, err := NewAgentSession(agentUUID, "race-agent", conn1) + sess1, err := NewAgentSession(agentUUID, "race-agent", false, nil, conn1) require.NoError(t, err) require.NoError(t, sess1.Close()) require.False(t, sess1.IsAlive()) @@ -225,7 +225,7 @@ func TestWatchHeartbeat_StaleGoroutine_DoesNotEvictNewSession(t *testing.T) { // sess2: alive — represents the current (newer) reconnect stored in the map. conn2, done2 := testWSPair(t) defer done2() - sess2, err := NewAgentSession(agentUUID, "race-agent", conn2) + sess2, err := NewAgentSession(agentUUID, "race-agent", false, nil, conn2) require.NoError(t, err) t.Cleanup(func() { _ = sess2.Close() }) srv.sessions.Store(agentUUID, sess2) @@ -277,7 +277,7 @@ func TestWatchHeartbeat_CurrentSession_MarksOfflineAndEvictsFromMap(t *testing.T conn, wsCleanup := testWSPair(t) defer wsCleanup() - sess, err := NewAgentSession(agentUUID, "current-agent", conn) + sess, err := NewAgentSession(agentUUID, "current-agent", false, nil, conn) require.NoError(t, err) require.NoError(t, sess.Close()) require.False(t, sess.IsAlive()) diff --git a/backend/internal/orthrus/session.go b/backend/internal/orthrus/session.go index 183d1911a..f4bb1fd55 100644 --- a/backend/internal/orthrus/session.go +++ b/backend/internal/orthrus/session.go @@ -15,6 +15,21 @@ import ( "github.com/Wikid82/charon/backend/internal/logger" "github.com/gorilla/websocket" "github.com/hashicorp/yamux" + "golang.org/x/time/rate" +) + +// writeLimiterEvery and writeLimiterBurst configure the token-bucket rate +// limiter applied to write-path traffic on any write-enabled session +// (Section 3.3.6 of the write-mode spec). Steady-state 0.5 req/s (30/min), +// burst of 5: a single compliant update cycle (pull → stop → remove → +// create → start) is exactly 5 write calls, so the burst allows one full +// update to proceed immediately, while the steady-state rate bounds +// sustained container churn well below anything a legitimate periodic +// update-checker would ever need. Fixed constants for v1 — no DB-backed +// per-agent tuning (see Section 7, Explicit Out-of-Scope). +const ( + writeLimiterEvery = 2 * time.Second + writeLimiterBurst = 5 ) // streamTypeDocker identifies Docker socket proxy streams opened toward the agent. @@ -101,6 +116,7 @@ type ExternalProxyStatus struct { ActivePort int `json:"active_port"` // actual bound port (0 if not active) BoundAddress string `json:"bind_address"` // e.g. "0.0.0.0:9999" Active bool `json:"active"` + WriteEnabled bool `json:"write_enabled"` // negotiated value for this live session Error string `json:"error,omitempty"` // last start error, if any } @@ -118,11 +134,22 @@ type AgentSession struct { extListener net.Listener // nil until StartExternalProxy succeeds extProxyPort int // port passed to StartExternalProxy; 0 if never started extErr error // last error from StartExternalProxy - mu sync.Mutex + // writeEnabled is the write-mode value negotiated at connect time (from + // OrthrusAgent.WriteEnabled at the moment HandleWebSocket ran). Fixed for + // the life of this session, exactly like extProxyPort — a DB toggle only + // takes effect on the agent's next reconnect. + writeEnabled bool + // writeLimiter bounds write-request throughput for this session; nil + // unless writeEnabled (lazily constructed in NewAgentSession). + writeLimiter *rate.Limiter + // auditLogger records write-path audit entries; nil is a safe no-op + // (see Muzzle.logAudit), used by tests and read-only sessions. + auditLogger AuditLogger + mu sync.Mutex } // NewAgentSession wraps the WebSocket connection in a Yamux server session. -func NewAgentSession(agentUUID, agentName string, conn *websocket.Conn) (*AgentSession, error) { +func NewAgentSession(agentUUID, agentName string, writeEnabled bool, auditLogger AuditLogger, conn *websocket.Conn) (*AgentSession, error) { cfg := yamux.DefaultConfig() cfg.LogOutput = io.Discard @@ -133,12 +160,20 @@ func NewAgentSession(agentUUID, agentName string, conn *websocket.Conn) (*AgentS _, cancel := context.WithCancel(context.Background()) + var writeLimiter *rate.Limiter + if writeEnabled { + writeLimiter = rate.NewLimiter(rate.Every(writeLimiterEvery), writeLimiterBurst) + } + return &AgentSession{ - agentUUID: agentUUID, - agentName: agentName, - conn: conn, - session: session, - cancel: cancel, + agentUUID: agentUUID, + agentName: agentName, + conn: conn, + session: session, + writeLimiter: writeLimiter, + auditLogger: auditLogger, + cancel: cancel, + writeEnabled: writeEnabled, }, nil } @@ -303,7 +338,7 @@ func (s *AgentSession) StartExternalProxy(port int) error { } srv := &http.Server{ - Handler: NewMuzzle(rp), + Handler: NewMuzzle(rp, s.writeEnabled, s.writeLimiter, s.auditLogger, s.agentUUID), ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 0, } @@ -353,6 +388,7 @@ func (s *AgentSession) GetExternalProxyStatus() ExternalProxyStatus { ActivePort: activePort, BoundAddress: boundAddr, Active: active, + WriteEnabled: s.writeEnabled, Error: errStr, } } diff --git a/backend/internal/orthrus/session_coverage_test.go b/backend/internal/orthrus/session_coverage_test.go index 776aaad3c..d267d1b7a 100644 --- a/backend/internal/orthrus/session_coverage_test.go +++ b/backend/internal/orthrus/session_coverage_test.go @@ -72,7 +72,7 @@ func TestAgentSession_GetProxyAddr_WithPort(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("port-uuid", "port-agent", serverConn) + sess, err := NewAgentSession("port-uuid", "port-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -105,7 +105,7 @@ func TestGetExternalProxyStatus_ErrorFieldPopulated(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("ext-err-uuid", "ext-err-agent", serverConn) + sess, err := NewAgentSession("ext-err-uuid", "ext-err-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() diff --git a/backend/internal/orthrus/session_external_proxy_test.go b/backend/internal/orthrus/session_external_proxy_test.go index 3f330fe6a..febb02fba 100644 --- a/backend/internal/orthrus/session_external_proxy_test.go +++ b/backend/internal/orthrus/session_external_proxy_test.go @@ -38,7 +38,7 @@ func sessionWithLoopback(t *testing.T) (sess *AgentSession, clientYamux *yamux.S cy, err := yamux.Client(newWSNetConn(clientConn), cfg) require.NoError(t, err) - s, err := NewAgentSession("ext-test-uuid", "ext-test-agent", serverConn) + s, err := NewAgentSession("ext-test-uuid", "ext-test-agent", false, nil, serverConn) require.NoError(t, err) require.NoError(t, s.StartDockerProxy()) @@ -89,7 +89,7 @@ func TestStartExternalProxy_ZeroPortIsNoOp(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("ext01-uuid", "ext01-agent", serverConn) + sess, err := NewAgentSession("ext01-uuid", "ext01-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -103,7 +103,7 @@ func TestStartExternalProxy_LoopbackNotStarted(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("ext02-uuid", "ext02-agent", serverConn) + sess, err := NewAgentSession("ext02-uuid", "ext02-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() diff --git a/backend/internal/orthrus/session_proxy_test.go b/backend/internal/orthrus/session_proxy_test.go index 1544746d1..ce30972bf 100644 --- a/backend/internal/orthrus/session_proxy_test.go +++ b/backend/internal/orthrus/session_proxy_test.go @@ -49,7 +49,7 @@ func TestStartDockerProxy_SetsProxyAddr(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("u1-uuid", "u1-agent", serverConn) + sess, err := NewAgentSession("u1-uuid", "u1-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -64,7 +64,7 @@ func TestStartDockerProxy_Idempotent(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("u2-uuid", "u2-agent", serverConn) + sess, err := NewAgentSession("u2-uuid", "u2-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -84,7 +84,7 @@ func TestStartDockerProxy_AcceptsAndForwards(t *testing.T) { serverConn, clientConn, done := testWSPairBoth(t) defer done() - sess, err := NewAgentSession("u3-uuid", "u3-agent", serverConn) + sess, err := NewAgentSession("u3-uuid", "u3-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -139,7 +139,7 @@ func TestClose_StopsProxyListener(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("u4-uuid", "u4-agent", serverConn) + sess, err := NewAgentSession("u4-uuid", "u4-agent", false, nil, serverConn) require.NoError(t, err) require.NoError(t, sess.StartDockerProxy()) @@ -161,7 +161,7 @@ func TestStartDockerProxy_AfterClose(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("u5-uuid", "u5-agent", serverConn) + sess, err := NewAgentSession("u5-uuid", "u5-agent", false, nil, serverConn) require.NoError(t, err) require.NoError(t, sess.Close()) @@ -185,7 +185,7 @@ func TestAgentSession_runProxyListener_NonErrClosedError_Returns(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("rpl-uuid", "rpl-agent", serverConn) + sess, err := NewAgentSession("rpl-uuid", "rpl-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -208,7 +208,7 @@ func TestAgentSession_proxyConn_ClosedSession_ReturnsQuietly(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("pc-closed-uuid", "pc-agent", serverConn) + sess, err := NewAgentSession("pc-closed-uuid", "pc-agent", false, nil, serverConn) require.NoError(t, err) // Close the yamux session so session.Open() returns an error. diff --git a/backend/internal/orthrus/session_test.go b/backend/internal/orthrus/session_test.go index 1e87bb458..2829f37f5 100644 --- a/backend/internal/orthrus/session_test.go +++ b/backend/internal/orthrus/session_test.go @@ -50,7 +50,7 @@ func TestNewAgentSession_IsAlive(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("uuid-1", "agent-1", serverConn) + sess, err := NewAgentSession("uuid-1", "agent-1", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -61,7 +61,7 @@ func TestAgentSession_GetProxyAddr_NoPort(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("uuid-1", "agent-1", serverConn) + sess, err := NewAgentSession("uuid-1", "agent-1", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() @@ -72,7 +72,7 @@ func TestAgentSession_Close_SetsNotAlive(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("uuid-1", "agent-1", serverConn) + sess, err := NewAgentSession("uuid-1", "agent-1", false, nil, serverConn) require.NoError(t, err) require.NoError(t, sess.Close()) @@ -83,7 +83,7 @@ func TestStartExternalProxy_TransportDisablesKeepAlives(t *testing.T) { serverConn, done := testWSPair(t) defer done() - sess, err := NewAgentSession("keepalive-uuid", "keepalive-agent", serverConn) + sess, err := NewAgentSession("keepalive-uuid", "keepalive-agent", false, nil, serverConn) require.NoError(t, err) defer func() { _ = sess.Close() }() diff --git a/backend/internal/orthrus/testdata/muzzle_corpus.json b/backend/internal/orthrus/testdata/muzzle_corpus.json new file mode 100644 index 000000000..16107debb --- /dev/null +++ b/backend/internal/orthrus/testdata/muzzle_corpus.json @@ -0,0 +1,421 @@ +[ + { + "description": "read-only allowlist regression: GET /_ping always allowed", + "method": "GET", + "path": "/_ping", + "agent_write_enabled": false, + "want_allowed": true + }, + { + "description": "read-only allowlist regression: GET /containers/json always allowed", + "method": "GET", + "path": "/containers/json", + "agent_write_enabled": false, + "want_allowed": true + }, + { + "description": "read-only allowlist regression: GET /containers/json still allowed with write mode on", + "method": "GET", + "path": "/containers/json", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "read-only allowlist regression: namespaced image inspect allowed", + "method": "GET", + "path": "/images/ghcr.io/org/repo/json", + "agent_write_enabled": false, + "want_allowed": true + }, + { + "description": "read-only allowlist regression: /exec never on any allowlist", + "method": "GET", + "path": "/exec/abc123/start", + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "write endpoint blocked when write mode off: POST /containers/create", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx:latest" }, + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "write endpoint allowed when write mode on: POST /containers/create, safe body", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx:latest", "Env": ["FOO=bar"] }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, safe HostConfig", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { + "PortBindings": { "80/tcp": [{ "HostPort": "8080" }] }, + "RestartPolicy": { "Name": "unless-stopped" }, + "NetworkMode": "my-bridge-network" + } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, safe volume mount", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { + "Mounts": [{ "Type": "volume", "Source": "data", "Target": "/data" }] + } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, CapDrop (reduces privilege, never grants host escape)", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { "CapDrop": ["ALL"] } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, UsernsMode empty (inherit daemon userns-remap default)", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { "UsernsMode": "" } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, ContainerIDFile empty", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { "ContainerIDFile": "" } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, CPU/memory/IO/pid resource-limit fields", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { + "CpuPeriod": 100000, + "CpuQuota": 50000, + "CpusetCpus": "0-1", + "MemoryReservation": 1048576, + "PidsLimit": 100, + "Ulimits": [{ "Name": "nofile", "Soft": 1024, "Hard": 2048 }], + "ShmSize": 67108864 + } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed: POST /containers/create, Tmpfs (container-internal only, not a host path)", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx:latest", + "HostConfig": { "Tmpfs": { "/tmp": "rw,noexec,nosuid,size=100m" } } + }, + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint blocked when write mode off: POST /images/create", + "method": "POST", + "path": "/images/create?fromImage=nginx&tag=latest", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "write endpoint allowed when write mode on: POST /images/create", + "method": "POST", + "path": "/images/create?fromImage=nginx&tag=latest", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed when write mode on: POST /containers/{id}/start", + "method": "POST", + "path": "/containers/abc123/start", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint blocked when write mode off: POST /containers/{id}/start", + "method": "POST", + "path": "/containers/abc123/start", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "write endpoint allowed when write mode on: POST /containers/{id}/stop", + "method": "POST", + "path": "/containers/abc123/stop", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed when write mode on: POST /containers/{id}/restart", + "method": "POST", + "path": "/containers/abc123/restart", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint allowed when write mode on: POST /containers/{id}/rename", + "method": "POST", + "path": "/containers/abc123/rename?name=myapp_old", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint blocked when write mode off: POST /containers/{id}/rename", + "method": "POST", + "path": "/containers/abc123/rename?name=myapp_old", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "write endpoint allowed when write mode on: DELETE /containers/{id}", + "method": "DELETE", + "path": "/containers/abc123", + "agent_write_enabled": true, + "want_allowed": true + }, + { + "description": "write endpoint blocked when write mode off: DELETE /containers/{id}", + "method": "DELETE", + "path": "/containers/abc123", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "never allowed under any configuration: /containers/{id}/exec", + "method": "POST", + "path": "/containers/abc123/exec", + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "never allowed under any configuration: image delete", + "method": "DELETE", + "path": "/images/nginx", + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "never allowed under any configuration: system prune", + "method": "POST", + "path": "/system/prune", + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: Privileged", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "Privileged": true } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: CapAdd", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "CapAdd": ["SYS_ADMIN"] } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: legacy Binds", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "Binds": ["/:/host"] } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: NetworkMode host", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "NetworkMode": "host" } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: NetworkMode container:", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "NetworkMode": "container:abc123" } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: UsernsMode host", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "UsernsMode": "host" } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: ContainerIDFile non-empty (arbitrary host file creation)", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "ContainerIDFile": "/etc/cron.d/pwn" } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: Runtime (arbitrary OCI runtime selection)", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "Runtime": "custom-runtime" } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: VolumesFrom (inherits another container's host mounts)", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "VolumesFrom": ["other-container"] } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: bind-type Mounts entry", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx", + "HostConfig": { + "Mounts": [{ "Type": "bind", "Source": "/etc", "Target": "/host-etc" }] + } + }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: local-driver bind-mount-via-volume bypass (VolumeOptions.DriverConfig) — the specific bypass found and closed during Supervisor review", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx", + "HostConfig": { + "Mounts": [ + { + "Type": "volume", + "Source": "fake-volume", + "Target": "/host-etc", + "VolumeOptions": { + "DriverConfig": { + "Name": "local", + "Options": { "type": "none", "device": "/etc", "o": "bind" } + } + } + } + ] + } + }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: non-empty Devices", + "method": "POST", + "path": "/containers/create", + "body": { + "Image": "nginx", + "HostConfig": { "Devices": [{ "PathOnHost": "/dev/sda", "PathInContainer": "/dev/sda" }] } + }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: non-empty Sysctls", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "Sysctls": { "net.ipv4.ip_forward": "1" } } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "dangerous body rejected even with write mode on: unrecognized future HostConfig key (fail-closed against unknown fields)", + "method": "POST", + "path": "/containers/create", + "body": { "Image": "nginx", "HostConfig": { "SomeFutureDockerAPIField": true } }, + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "traversal-disguised version prefix on namespaced image inspect (GH #1160's own example)", + "method": "GET", + "path": "/foo/../v1.44/images/x/json", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "non-numeric fake version prefix on a plain read path", + "method": "GET", + "path": "/vFOO/containers/json", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "non-numeric fake version prefix on /_ping via HEAD", + "method": "HEAD", + "path": "/vBOGUS/_ping", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "non-numeric fake version prefix reaching a write endpoint", + "method": "POST", + "path": "/vFOO/containers/abc/start", + "agent_write_enabled": true, + "want_allowed": false + }, + { + "description": "version-prefixed traversal that legitimately resolves to an allowed path", + "method": "GET", + "path": "/v1.47/../containers/json", + "agent_write_enabled": false, + "want_allowed": true + }, + { + "description": "traversal escaping to a disallowed path", + "method": "GET", + "path": "/containers/../../etc/passwd", + "agent_write_enabled": false, + "want_allowed": false + }, + { + "description": "double-slash with legitimate version prefix", + "method": "GET", + "path": "/v1.44//containers/json", + "agent_write_enabled": false, + "want_allowed": true + } +] diff --git a/backend/internal/patchreport/patchreport.go b/backend/internal/patchreport/patchreport.go index eec0e4301..a1b74877d 100644 --- a/backend/internal/patchreport/patchreport.go +++ b/backend/internal/patchreport/patchreport.go @@ -79,9 +79,10 @@ func ResolveThreshold(envName string, defaultValue float64, lookup func(string) return ThresholdResolution{Value: value, Source: "env"} } -func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, error) { +func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, FileLineSet, error) { backendChanged := make(FileLineSet) frontendChanged := make(FileLineSet) + agentChanged := make(FileLineSet) var currentFile string currentScope := "" @@ -103,12 +104,16 @@ func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, } newFile = strings.TrimPrefix(newFile, "b/") newFile = normalizeRepoPath(newFile) - if strings.HasPrefix(newFile, "backend/") { + switch { + case strings.HasPrefix(newFile, "backend/"): currentFile = newFile currentScope = "backend" - } else if strings.HasPrefix(newFile, "frontend/") { + case strings.HasPrefix(newFile, "frontend/"): currentFile = newFile currentScope = "frontend" + case strings.HasPrefix(newFile, "agent/"): + currentFile = newFile + currentScope = "agent" } continue } @@ -116,7 +121,7 @@ func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, if matches := hunkPattern.FindStringSubmatch(line); matches != nil { startLine, err := strconv.Atoi(matches[1]) if err != nil { - return nil, nil, fmt.Errorf("parse hunk start line: %w", err) + return nil, nil, nil, fmt.Errorf("parse hunk start line: %w", err) } currentNewLine = startLine inHunk = true @@ -137,6 +142,8 @@ func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, addLine(backendChanged, currentFile, currentNewLine) case "frontend": addLine(frontendChanged, currentFile, currentNewLine) + case "agent": + addLine(agentChanged, currentFile, currentNewLine) } currentNewLine++ case '-': @@ -148,13 +155,13 @@ func ParseUnifiedDiffChangedLines(diffContent string) (FileLineSet, FileLineSet, } if err := scanner.Err(); err != nil { - return nil, nil, fmt.Errorf("scan diff content: %w", err) + return nil, nil, nil, fmt.Errorf("scan diff content: %w", err) } - return backendChanged, frontendChanged, nil + return backendChanged, frontendChanged, agentChanged, nil } -func ParseGoCoverageProfile(profilePath string) (data CoverageData, err error) { +func ParseGoCoverageProfile(profilePath, moduleDir string) (data CoverageData, err error) { validatedPath, err := validateReadablePath(profilePath) if err != nil { return CoverageData{}, fmt.Errorf("validate go coverage profile path: %w", err) @@ -205,7 +212,7 @@ func ParseGoCoverageProfile(profilePath string) (data CoverageData, err error) { continue } - normalizedFile := normalizeGoCoveragePath(filePart) + normalizedFile := normalizeGoCoveragePath(filePart, moduleDir) if normalizedFile == "" { continue } @@ -351,6 +358,19 @@ func ApplyStatus(scope ScopeCoverage, minThreshold float64) ScopeCoverage { return scope } +// HasWarnStatus reports whether any of the given scopes is below its +// threshold (Status == "warn"). Used by cmd/localpatchreport's strict mode +// to decide the process exit code — kept in this package, not main.go, +// so it is unit-testable independent of os.Exit and CLI flag parsing. +func HasWarnStatus(scopes ...ScopeCoverage) bool { + for _, scope := range scopes { + if scope.Status == "warn" { + return true + } + } + return false +} + func ComputeFilesNeedingCoverage(changedLines FileLineSet, coverage CoverageData, minThreshold float64) []FileCoverageDetail { details := make([]FileCoverageDetail, 0, len(changedLines)) @@ -466,23 +486,44 @@ func normalizeRepoPath(input string) string { return cleaned } -func normalizeGoCoveragePath(input string) string { +// moduleFallbackPrefixes maps a module directory (as passed to +// normalizeGoCoveragePath and ParseGoCoverageProfile) to the set of +// repo-relative top-level package prefixes that module's own coverage +// profile lines may appear under when the profile's file path doesn't +// contain "//" at all (e.g. a coverage tool that already +// stripped the module prefix). Each module has a different top-level +// package layout, so this is not a single shared list: backend has +// cmd/internal/pkg/api/integration/tools, while agent/'s own top-level +// packages are cert/leash/muzzle/protocol. +var moduleFallbackPrefixes = map[string][]string{ + "backend": {"cmd/", "internal/", "pkg/", "api/", "integration/", "tools/"}, + "agent": {"cert/", "leash/", "muzzle/", "protocol/"}, +} + +// normalizeGoCoveragePath converts a Go coverage profile's file path +// (typically a full import path such as +// "github.com/Wikid82/charon//internal/service.go") into a +// repo-relative path (e.g. "/internal/service.go"), so it can be +// intersected with diff-parsed changed-line paths, which are always +// repo-relative. moduleDir is the module's repo-relative directory name +// ("backend" or "agent"). +func normalizeGoCoveragePath(input, moduleDir string) string { cleaned := normalizeRepoPath(input) if cleaned == "" { return "" } - if strings.HasPrefix(cleaned, "backend/") { + modulePrefix := moduleDir + "/" + if strings.HasPrefix(cleaned, modulePrefix) { return cleaned } - if idx := strings.Index(cleaned, "/backend/"); idx >= 0 { + if idx := strings.Index(cleaned, "/"+modulePrefix); idx >= 0 { return cleaned[idx+1:] } - repoRelativePrefixes := []string{"cmd/", "internal/", "pkg/", "api/", "integration/", "tools/"} - for _, prefix := range repoRelativePrefixes { + for _, prefix := range moduleFallbackPrefixes[moduleDir] { if strings.HasPrefix(cleaned, prefix) { - return "backend/" + cleaned + return modulePrefix + cleaned } } diff --git a/backend/internal/patchreport/patchreport_test.go b/backend/internal/patchreport/patchreport_test.go index 0aa5e80f4..e1c3e6736 100644 --- a/backend/internal/patchreport/patchreport_test.go +++ b/backend/internal/patchreport/patchreport_test.go @@ -117,15 +117,23 @@ index 3333333..4444444 100644 @@ -20,0 +21,2 @@ export default function App() { +new frontend line +another frontend line +diff --git a/agent/muzzle/muzzle.go b/agent/muzzle/muzzle.go +index 5555555..6666666 100644 +--- a/agent/muzzle/muzzle.go ++++ b/agent/muzzle/muzzle.go +@@ -30,0 +31,2 @@ func Allow() { ++new agent line ++another agent line ` - backendChanged, frontendChanged, err := ParseUnifiedDiffChangedLines(diff) + backendChanged, frontendChanged, agentChanged, err := ParseUnifiedDiffChangedLines(diff) if err != nil { t.Fatalf("ParseUnifiedDiffChangedLines returned error: %v", err) } assertHasLines(t, backendChanged, "backend/internal/app.go", []int{11, 12}) assertHasLines(t, frontendChanged, "frontend/src/App.tsx", []int{21, 22}) + assertHasLines(t, agentChanged, "agent/muzzle/muzzle.go", []int{31, 32}) } func TestParseUnifiedDiffChangedLines_InvalidHunkStartReturnsError(t *testing.T) { @@ -139,12 +147,12 @@ index 1111111..2222222 100644 +line ` - backendChanged, frontendChanged, err := ParseUnifiedDiffChangedLines(diff) + backendChanged, frontendChanged, agentChanged, err := ParseUnifiedDiffChangedLines(diff) if err != nil { t.Fatalf("expected graceful handling for invalid hunk, got error: %v", err) } - if len(backendChanged) != 0 || len(frontendChanged) != 0 { - t.Fatalf("expected no changed lines for invalid hunk, got backend=%v frontend=%v", backendChanged, frontendChanged) + if len(backendChanged) != 0 || len(frontendChanged) != 0 || len(agentChanged) != 0 { + t.Fatalf("expected no changed lines for invalid hunk, got backend=%v frontend=%v agent=%v", backendChanged, frontendChanged, agentChanged) } } @@ -162,7 +170,7 @@ github.com/Wikid82/charon/backend/internal/service.go:12.1,12.20 1 1 t.Fatalf("failed to write temp coverage file: %v", err) } - coverage, err := ParseGoCoverageProfile(coverageFile) + coverage, err := ParseGoCoverageProfile(coverageFile, "backend") if err != nil { t.Fatalf("ParseGoCoverageProfile returned error: %v", err) } @@ -183,6 +191,49 @@ github.com/Wikid82/charon/backend/internal/service.go:12.1,12.20 1 1 } } +// TestAgentChangedLineCoverageComputation is the agent-module analogue of +// TestBackendChangedLineCoverageComputation: it exercises +// ParseGoCoverageProfile(..., "agent") end-to-end against a coverage +// profile whose file paths use agent/'s own module import path +// (github.com/Wikid82/charon/agent/...), confirming the generalized +// normalizeGoCoveragePath correctly attributes them to a repo-relative +// "agent/..." path rather than silently producing 0% coverage for every +// agent file (the exact bug this generalization closes). +func TestAgentChangedLineCoverageComputation(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + coverageFile := filepath.Join(tempDir, "coverage.txt") + coverageContent := `mode: atomic +github.com/Wikid82/charon/agent/muzzle/muzzle.go:10.1,10.20 1 1 +github.com/Wikid82/charon/agent/muzzle/muzzle.go:11.1,11.20 1 0 +github.com/Wikid82/charon/agent/muzzle/muzzle.go:12.1,12.20 1 1 +` + if err := os.WriteFile(coverageFile, []byte(coverageContent), 0o600); err != nil { + t.Fatalf("failed to write temp coverage file: %v", err) + } + + coverage, err := ParseGoCoverageProfile(coverageFile, "agent") + if err != nil { + t.Fatalf("ParseGoCoverageProfile returned error: %v", err) + } + + changed := FileLineSet{ + "agent/muzzle/muzzle.go": {10: {}, 11: {}, 15: {}}, + } + + scope := ComputeScopeCoverage(changed, coverage) + if scope.ChangedLines != 2 { + t.Fatalf("changed lines mismatch: got %d want 2", scope.ChangedLines) + } + if scope.CoveredLines != 1 { + t.Fatalf("covered lines mismatch: got %d want 1", scope.CoveredLines) + } + if scope.PatchCoveragePct != 50.0 { + t.Fatalf("coverage pct mismatch: got %.1f want 50.0", scope.PatchCoveragePct) + } +} + func TestFrontendChangedLineCoverageComputationFromLCOV(t *testing.T) { t.Parallel() @@ -239,7 +290,7 @@ func TestParseUnifiedDiffChangedLines_AllowsLongLines(t *testing.T) { "+" + longLine, }, "\n") - backendChanged, _, err := ParseUnifiedDiffChangedLines(diff) + backendChanged, _, _, err := ParseUnifiedDiffChangedLines(diff) if err != nil { t.Fatalf("ParseUnifiedDiffChangedLines returned error for long line: %v", err) } @@ -259,7 +310,7 @@ func TestParseGoCoverageProfile_AllowsLongLines(t *testing.T) { t.Fatalf("failed to write temp coverage file: %v", err) } - _, err := ParseGoCoverageProfile(coverageFile) + _, err := ParseGoCoverageProfile(coverageFile, "backend") if err != nil { t.Fatalf("ParseGoCoverageProfile returned error for long line: %v", err) } @@ -466,14 +517,33 @@ func TestSortedWarnings_FiltersBlanksAndSorts(t *testing.T) { func TestNormalizePathsAndRanges(t *testing.T) { t.Parallel() - if got := normalizeGoCoveragePath("internal/service.go"); got != "backend/internal/service.go" { + if got := normalizeGoCoveragePath("internal/service.go", "backend"); got != "backend/internal/service.go" { t.Fatalf("unexpected normalized go path: %s", got) } - if got := normalizeGoCoveragePath("/tmp/work/backend/internal/service.go"); got != "backend/internal/service.go" { + if got := normalizeGoCoveragePath("/tmp/work/backend/internal/service.go", "backend"); got != "backend/internal/service.go" { t.Fatalf("unexpected backend extraction path: %s", got) } + // agent/'s own top-level package set (cert/, leash/, muzzle/, protocol/) + // differs from backend's fallback prefix list, so both the + // "//" substring-extraction path and the module-specific + // fallback-prefix path are exercised for "agent" too. + if got := normalizeGoCoveragePath("muzzle/muzzle.go", "agent"); got != "agent/muzzle/muzzle.go" { + t.Fatalf("unexpected normalized agent path: %s", got) + } + + if got := normalizeGoCoveragePath("/tmp/work/agent/muzzle/muzzle.go", "agent"); got != "agent/muzzle/muzzle.go" { + t.Fatalf("unexpected agent extraction path: %s", got) + } + + // A backend-shaped fallback prefix must NOT be treated as an agent path + // when normalizing for the agent module (proves the fallback list is + // genuinely per-module, not still secretly shared). + if got := normalizeGoCoveragePath("internal/service.go", "agent"); got != "internal/service.go" { + t.Fatalf("expected backend-only fallback prefix to be left unmodified for agent module, got: %s", got) + } + frontend := normalizeFrontendCoveragePaths("/tmp/work/frontend/src/App.tsx") if len(frontend) == 0 { t.Fatal("expected frontend normalized paths") @@ -507,7 +577,7 @@ func TestScopeCoverageMergeAndStatus(t *testing.T) { func TestParseCoverageProfiles_InvalidPath(t *testing.T) { t.Parallel() - _, err := ParseGoCoverageProfile(" ") + _, err := ParseGoCoverageProfile(" ", "backend") if err == nil { t.Fatal("expected go profile path validation error") } @@ -537,3 +607,34 @@ func TestAddLine_IgnoresInvalidInputs(t *testing.T) { t.Fatalf("expected no entries for invalid addLine input, got %#v", set) } } + +func TestHasWarnStatus_TrueWhenAnyScopeWarn(t *testing.T) { + t.Parallel() + + scopes := []ScopeCoverage{ + {Status: "pass"}, + {Status: "warn"}, + {Status: "pass"}, + } + + if !HasWarnStatus(scopes...) { + t.Fatal("expected HasWarnStatus to report true when any scope is warn") + } +} + +func TestHasWarnStatus_FalseWhenAllScopesPass(t *testing.T) { + t.Parallel() + + scopes := []ScopeCoverage{ + {Status: "pass"}, + {Status: "pass"}, + } + + if HasWarnStatus(scopes...) { + t.Fatal("expected HasWarnStatus to report false when all scopes pass") + } + + if HasWarnStatus() { + t.Fatal("expected HasWarnStatus to report false for no scopes") + } +} diff --git a/backend/internal/services/orthrus_service.go b/backend/internal/services/orthrus_service.go index 118a3903d..a00bdc479 100644 --- a/backend/internal/services/orthrus_service.go +++ b/backend/internal/services/orthrus_service.go @@ -81,7 +81,15 @@ func (s *OrthrusService) Get(uuid string) (*models.OrthrusAgent, error) { } // Patch applies a partial update to an agent. Only non-nil fields are written. -func (s *OrthrusService) Patch(uuid string, name, hecateTunnelUUID, deviceID, resolvedAddress *string, externalProxyPort *int) (*models.OrthrusAgent, error) { +// +// writeEnabled has no range validation (unlike externalProxyPort) since a +// bool has no invalid values; the audit entry for this specific field is +// emitted by the caller (OrthrusHandler.Patch), not here, matching this +// codebase's existing convention of logging admin-initiated audit events at +// the handler layer (see e.g. security_handler.go, crowdsec_handler.go) +// rather than growing OrthrusService's dependencies to include +// *services.SecurityService and a *gin.Context-derived actor. +func (s *OrthrusService) Patch(uuid string, name, hecateTunnelUUID, deviceID, resolvedAddress *string, externalProxyPort *int, writeEnabled *bool) (*models.OrthrusAgent, error) { updates := map[string]interface{}{} if name != nil { trimmed := strings.TrimSpace(*name) @@ -106,6 +114,9 @@ func (s *OrthrusService) Patch(uuid string, name, hecateTunnelUUID, deviceID, re } updates["external_proxy_port"] = port } + if writeEnabled != nil { + updates["write_enabled"] = *writeEnabled + } if len(updates) == 0 { return s.Get(uuid) } @@ -117,7 +128,7 @@ func (s *OrthrusService) Patch(uuid string, name, hecateTunnelUUID, deviceID, re // Rename updates the display name of an agent (backward-compat wrapper around Patch). func (s *OrthrusService) Rename(uuid, newName string) (*models.OrthrusAgent, error) { - return s.Patch(uuid, &newName, nil, nil, nil, nil) + return s.Patch(uuid, &newName, nil, nil, nil, nil, nil) } // Delete removes an agent from the database (does not revoke/disconnect first). diff --git a/backend/internal/services/orthrus_service_test.go b/backend/internal/services/orthrus_service_test.go index f7879eaa5..4c8efa116 100644 --- a/backend/internal/services/orthrus_service_test.go +++ b/backend/internal/services/orthrus_service_test.go @@ -289,7 +289,7 @@ func TestOrthrusService_Patch_NameOnly(t *testing.T) { require.NoError(t, err) newName := "updated-name" - got, err := svc.Patch(agent.UUID, &newName, nil, nil, nil, nil) + got, err := svc.Patch(agent.UUID, &newName, nil, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "updated-name", got.Name) @@ -307,7 +307,7 @@ func TestOrthrusService_Patch_ProviderFields(t *testing.T) { deviceID := "device-id-456" resolved := "10.0.0.1" - got, err := svc.Patch(agent.UUID, nil, &tunnelUUID, &deviceID, &resolved, nil) + got, err := svc.Patch(agent.UUID, nil, &tunnelUUID, &deviceID, &resolved, nil, nil) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "provider-agent", got.Name) @@ -327,7 +327,7 @@ func TestOrthrusService_Patch_BlankName(t *testing.T) { require.NoError(t, err) blank := " " - _, err = svc.Patch(agent.UUID, &blank, nil, nil, nil, nil) + _, err = svc.Patch(agent.UUID, &blank, nil, nil, nil, nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "cannot be blank") } @@ -339,7 +339,7 @@ func TestOrthrusService_Patch_EmptyUpdate(t *testing.T) { agent, _, err := svc.Provision("no-change-agent") require.NoError(t, err) - got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, nil) + got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, nil, nil) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, "no-change-agent", got.Name) @@ -351,7 +351,7 @@ func TestOrthrusService_Patch_UnknownUUID(t *testing.T) { svc := services.NewOrthrusService(db, setupOrthrusServer(t, db)) newName := "irrelevant" - _, err := svc.Patch("00000000-0000-0000-0000-000000000000", &newName, nil, nil, nil, nil) + _, err := svc.Patch("00000000-0000-0000-0000-000000000000", &newName, nil, nil, nil, nil, nil) require.Error(t, err) assert.ErrorIs(t, err, gorm.ErrRecordNotFound) } @@ -378,7 +378,7 @@ func TestOrthrusService_Patch_ExternalProxyPort_Zero(t *testing.T) { require.NoError(t, err) port := 0 - got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, &port) + got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, &port, nil) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 0, got.ExternalProxyPort) @@ -392,7 +392,7 @@ func TestOrthrusService_Patch_ExternalProxyPort_Valid(t *testing.T) { require.NoError(t, err) port := 2375 - got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, &port) + got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, &port, nil) require.NoError(t, err) require.NotNil(t, got) assert.Equal(t, 2375, got.ExternalProxyPort) @@ -406,7 +406,7 @@ func TestOrthrusService_Patch_ExternalProxyPort_TooLow(t *testing.T) { require.NoError(t, err) port := 1023 - _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port) + _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port, nil) require.Error(t, err) assert.Contains(t, err.Error(), "external_proxy_port") } @@ -419,7 +419,7 @@ func TestOrthrusService_Patch_ExternalProxyPort_TooHigh(t *testing.T) { require.NoError(t, err) port := 70000 - _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port) + _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port, nil) require.Error(t, err) assert.Contains(t, err.Error(), "external_proxy_port") } @@ -432,7 +432,67 @@ func TestOrthrusService_Patch_ExternalProxyPort_Negative(t *testing.T) { require.NoError(t, err) port := -1 - _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port) + _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, &port, nil) require.Error(t, err) assert.Contains(t, err.Error(), "external_proxy_port") } + +func TestOrthrusService_Patch_WriteEnabled_DefaultsFalse(t *testing.T) { + db := setupOrthrusTestDB(t) + svc := services.NewOrthrusService(db, setupOrthrusServer(t, db)) + + agent, _, err := svc.Provision("write-mode-default") + require.NoError(t, err) + assert.False(t, agent.WriteEnabled) +} + +func TestOrthrusService_Patch_WriteEnabled_True(t *testing.T) { + db := setupOrthrusTestDB(t) + svc := services.NewOrthrusService(db, setupOrthrusServer(t, db)) + + agent, _, err := svc.Provision("write-mode-enable") + require.NoError(t, err) + + enabled := true + got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, nil, &enabled) + require.NoError(t, err) + require.NotNil(t, got) + assert.True(t, got.WriteEnabled) +} + +func TestOrthrusService_Patch_WriteEnabled_BackToFalse(t *testing.T) { + db := setupOrthrusTestDB(t) + svc := services.NewOrthrusService(db, setupOrthrusServer(t, db)) + + agent, _, err := svc.Provision("write-mode-disable") + require.NoError(t, err) + + enabled := true + _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, nil, &enabled) + require.NoError(t, err) + + disabled := false + got, err := svc.Patch(agent.UUID, nil, nil, nil, nil, nil, &disabled) + require.NoError(t, err) + require.NotNil(t, got) + assert.False(t, got.WriteEnabled) +} + +func TestOrthrusService_Patch_WriteEnabled_NilLeavesUnchanged(t *testing.T) { + db := setupOrthrusTestDB(t) + svc := services.NewOrthrusService(db, setupOrthrusServer(t, db)) + + agent, _, err := svc.Provision("write-mode-untouched") + require.NoError(t, err) + + enabled := true + _, err = svc.Patch(agent.UUID, nil, nil, nil, nil, nil, &enabled) + require.NoError(t, err) + + newName := "renamed-agent" + got, err := svc.Patch(agent.UUID, &newName, nil, nil, nil, nil, nil) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "renamed-agent", got.Name) + assert.True(t, got.WriteEnabled, "write_enabled must be untouched by an unrelated Patch call") +} diff --git a/codecov.yml b/codecov.yml index 8a10cd677..1bf484e4e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -113,6 +113,7 @@ ignore: # Main entry points (bootstrap code only) - "backend/cmd/api/**" + - "agent/main.go" # Infrastructure packages (logging, metrics, tracing) # These are thin wrappers around external libraries with no business logic diff --git a/docs/features/orthrus.md b/docs/features/orthrus.md index b2a78e057..9e486b48c 100644 --- a/docs/features/orthrus.md +++ b/docs/features/orthrus.md @@ -31,7 +31,7 @@ Charon normally needs to reach your server directly, so this is a problem. **Disconnections are handled automatically** — if the network hiccups, the agent reconnects on its own with no action required from you. -> **Note:** Orthrus is read-only. It can list containers, images, and networks — but it cannot start, stop, delete, or modify anything on your remote machine. This is by design and cannot be changed. +> **Note:** Orthrus is **read-only by default**. It can list containers, images, and networks — but out of the box it cannot start, stop, delete, or modify anything on your remote machine, and that default can never be loosened by accident. If you want a specific agent to be able to do more — for example, letting an update-checker tool apply an update it's found — you can explicitly opt that one agent into a narrow, audited set of write operations. See [What Orthrus Can (and Cannot) Do](#what-orthrus-can-and-cannot-do) below. --- @@ -87,21 +87,54 @@ That's it. You can now use this agent when [adding a Remote Server](../guides/re ## What Orthrus Can (and Cannot) Do -Orthrus only ever lets Charon **read** information from your remote Docker. It cannot touch anything. +**By default, every agent is strictly read-only**, and that default can't be loosened by accident — there's no setting that weakens it globally. Orthrus only ever lets Charon **read** information from your remote Docker unless you take the deliberate, per-agent step described below. -**It CAN:** +**Every agent, always, CAN:** - List running containers and their details - List images, networks, and volumes - Stream container logs (for display in Charon) - Report Docker system info -**It CANNOT:** +**A default (read-only) agent CANNOT:** - Start, stop, restart, or delete containers - Create or remove networks or volumes - Pull images - Run commands inside containers -This restriction is enforced at every single request — there is no way to turn it off. +### Opting an agent into write access + +If you're using the [External Docker Proxy](#external-docker-proxy-advanced) to let a third-party tool (like an update-checker) talk to an agent's Docker API, you can optionally, explicitly grant that one agent a **narrow, fixed set** of write operations: + +- Pull a new image +- Start a container +- Stop a container +- Restart a container +- Remove a container +- Create (recreate) a container + +This is exactly enough for a tool to run a full "pull the new image, swap the container over" update cycle — nothing more. **Regardless of this setting, the following are never permitted, for any agent, under any configuration:** + +- Running commands inside a container (exec / shell access) +- Creating or removing networks or volumes +- Deleting images +- Building images +- Anything Docker Swarm or service-related + +**Turning it on:** + +1. Go to **Remote Agents** +2. Click the **shield icon** next to the agent you want +3. Toggle **write access** on +4. Type the agent's exact current name to confirm — this typed confirmation is required specifically so this can't be flipped on by accident. Turning it back off never requires typing anything. +5. Click **Save** + +The change takes effect the next time that agent reconnects (the same way changing the External Proxy port does) — if the agent is already connected, Charon will tell you a reconnect is needed. + +**Every write attempt is logged**, whether it succeeds or gets blocked, so you always have a record of what a connected tool actually did. Find this under **Audit Logs**, filtered to that agent. + +One limitation worth knowing: if a container is attached to **more than one** Docker network, this write-access flow can recreate it on its primary network but won't re-attach the extra ones automatically — you'd need to do that manually, or through Charon's own Docker management screens, after the recreate. + +This restriction — read-only by default, write access only when you explicitly and knowingly turn it on for one agent at a time — is enforced independently, twice, at every single request: once by Charon and once by the agent itself. There's no way to weaken the read-only default globally, and no way to grant any operation outside the fixed list above, no matter what. --- @@ -126,7 +159,7 @@ tcp://: `` is your Charon instance's own address, as reachable from wherever the third-party tool runs — Charon fills this in for you automatically, so you don't need to look it up or type it yourself. `` is the number you chose in Step 3 above. -**Still strictly read-only.** Just like the rest of Orthrus, there is no way to turn this restriction off. Through this port, a tool can: +**Read-only by default, same as the rest of Orthrus** — and that default can't be loosened by accident. Through this port, a tool can always: - List containers, images, networks, and volumes - Read Docker system info, version, and live events @@ -134,7 +167,9 @@ tcp://: - Look up details about a specific image (image inspect) - Check the registry for a newer version of an image (registry digest check) -One note on that last item: "read-only" means the proxy can't change anything on your Docker host — it can't start, stop, or modify a single thing. But the registry digest check does cause your agent's Docker daemon to reach out to the image's registry (e.g. Docker Hub) to check for updates. That outbound check is expected and is exactly what makes update-checker tools work — it just isn't touching your host, which is the guarantee that matters here. +One note on that last item: read-only here means the proxy can't change anything on your Docker host by itself — it can't start, stop, or modify a single thing unless you've explicitly turned on write access (below). But the registry digest check does cause your agent's Docker daemon to reach out to the image's registry (e.g. Docker Hub) to check for updates. That outbound check is expected and is exactly what makes update-checker tools work — it just isn't touching your host, which is the guarantee that matters here. + +**Want the tool to be able to apply an update it finds, not just detect one?** See [Opting an agent into write access](#opting-an-agent-into-write-access) above — it's the same idea, just for a fixed, audited set of write operations layered on top of this same proxy. --- diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 052265071..6d71cf39e 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,1971 +1,811 @@ -# Async Backup/Restore Jobs — NS_BINDING_ABORTED Remediation (PR #1136) +# Feature Spec: Orthrus Muzzle Normalization Parity + Agent CI Enforcement (GH #1160 + #1161) -**Branch:** `feature/backuprestore` (existing PR #1136 — this plan adds commits to that same PR; it does **not** open a new PR, per CLAUDE.md "one feature = one PR") -**Supersedes for planning purposes:** the previous `docs/plans/current_spec.md` (Restore-Reliability Remediation — C1/H1/H2/M4/M5, dated 2026-07-14 — already implemented; its fixes are visible in code as the `H1 defensive addition` / `C1 fix` comments cited below). This document's `spec §3.x` references continue that same numbering (the original Issue #32 Phase 2 spec) since that's what the codebase's inline comments already cite. -**Trigger:** Bug report — clicking "Create Backup" produces `NS_BINDING_ABORTED` in Firefox DevTools (client-aborted XHR, 0 bytes transferred, no server error). - ---- - -## Table of Contents - -1. [Introduction](#1-introduction) -2. [Research Findings](#2-research-findings) -3. [Technical Specifications](#3-technical-specifications) -4. [Implementation Plan](#4-implementation-plan) -5. [Acceptance Criteria](#5-acceptance-criteria) -6. [Commit Slicing Strategy](#6-commit-slicing-strategy) -7. [Ignore-File & Repo Hygiene Review](#7-ignore-file--repo-hygiene-review) -8. [Deferred Findings (Explicitly Out of Scope)](#8-deferred-findings-explicitly-out-of-scope) -9. [Addendum: Backup Download 401 Over Plain-HTTP/Tailscale Access (Same PR)](#9-addendum-backup-download-401-over-plain-httptailscale-access-same-pr) -10. [Addendum: OAuth Callback Redirect Path Mismatch (Same PR)](#10-addendum-oauth-callback-redirect-path-mismatch-same-pr) -11. [Addendum: Stale LastTestStatus Persists Through OAuth Connect (Same PR)](#11-addendum-stale-lastteststatus-persists-through-oauth-connect-same-pr) -12. [Addendum: Backup Encryption UX Gaps — Manual Save Button, Create-Dialog Smart Defaults, Scheduled-Backup Encryption (Same PR)](#12-addendum-backup-encryption-ux-gaps--manual-save-button-create-dialog-smart-defaults-scheduled-backup-encryption-same-pr) -13. [Addendum: Trusted-Proxy Header Hardening for Cookie Security Decisions (Same PR)](#13-addendum-trusted-proxy-header-hardening-for-cookie-security-decisions-same-pr) +**Type**: Bug fix / CI hardening (single feature, single PR — companion issues sharing one root defect class) +**Branch**: `feature/orthrus` (current working branch; no worktree, no new branch, per `CLAUDE.md`) +**Status**: DRAFT — pending Supervisor review +**Author**: `planning` agent +**Research verified against**: repository HEAD on `feature/orthrus`, 2026-07-20 (5 local, unpushed write-mode commits already applied: `a4be39e2`..`d2fa3154`) --- ## 1. Introduction -`POST /api/v1/backups` (and, as this plan will show, `POST /api/v1/backups/:filename/restore`) runs its entire archive-creation (or restore) pipeline **synchronously inside the HTTP request/response cycle**, while holding a global mutex. On a host with a large `data/` directory or slow disk, that pipeline legitimately exceeds the frontend's 30-second axios timeout. The browser then aborts the still-in-flight XHR client-side — Firefox reports this as `NS_BINDING_ABORTED`, 0 bytes transferred — while the Go handler keeps working, unaware the client is gone, and its eventual response is discarded. +### 1.1 Objectives -**Goal:** make backup creation and restore return to the browser almost immediately (job accepted), moving the actual archive/restore work into a tracked background goroutine that the frontend polls for status — eliminating the client-timeout race entirely, for data directories of any size, rather than papering over it with a bigger timeout number. +Fix two GitHub issues that describe the same underlying defect class — two independently hand-maintained Docker API allowlist filters (`backend/internal/orthrus/muzzle.go` and `agent/muzzle/muzzle.go`) that are supposed to enforce identical read-only/write policy but are not provably kept in sync: -This is **not** a symptom patch. Raising `client.ts`'s 30s timeout would still fail for a big-enough data directory or slow-enough disk; it would also leave the tab spinning for minutes with no feedback, and does nothing for the equally-synchronous restore path. The fix must remove the requirement that a single HTTP request stay open for the full duration of the operation. - -**Revision note (post-review):** production log evidence surfaced a second, independent, source-level bug during review — Charon opens the live SQLite database file through **two different SQLite engine implementations concurrently** (§2.5), which is a plausible self-inflicted cause of the "database disk image is malformed" corruption seen in a real scheduled-backup failure. §2 below now documents **two separate confirmed root causes**, both fixed by this plan: (A) the original unbounded-synchronous-HTTP-request race (§2.1-§2.4, fixed by the async-job architecture) and (B) a dual-SQLite-driver concurrency hazard that can corrupt the database and silently fail backups (§2.5, fixed by driver standardization + a fail-fast integrity pre-check). Neither subsumes the other — both are real, both are fixed here. - ---- +1. **GH #1160** — the two filters normalize request paths (version-prefix stripping vs. `path.Clean`) in a different order, so a crafted input can be classified differently by each filter *in isolation*. +2. **GH #1161** — the two allowlists are hand-copied with no structural guard against drift, and `agent/` (a separate Go module) has materially weaker CI enforcement than `backend/` (no staticcheck/lint, no coverage gate, and until the write-mode work landed, no CI-run tests at all). -## 2. Research Findings +Both issues were filed against the same discovery: a real production incident where a fix landed in the backend copy and shipped/redeployed twice before anyone noticed the agent copy still rejected the same paths (commits `98a68b67`, `b71cbd62`, `eabf358d`). They are treated as **one feature / one PR** per `CLAUDE.md`'s "One Feature = One PR" rule, because fixing #1160 without also closing the drift-detection gap in #1161 leaves the exact same class of silent divergence free to recur on the next allowlist edit. -### 2.1 Confirmed root cause +### 1.2 Why this plan differs from the issues' literal suggestions -The bug report's own working hypothesis is **confirmed**: the client-visible `NS_BINDING_ABORTED` is caused by axios's client-side 30s timeout firing while the Go backend is still synchronously executing a **multi-step, unbounded-duration** pipeline, not by a hang, deadlock, or symlink loop. +Both issues explicitly invite evaluation rather than blind implementation of their suggested fix ("evaluate, don't blindly follow if a better approach exists"). Research against the **current** state of both files (post write-mode commits, not the pre-write-mode version the issues were originally filed against) changed the recommended scope in two material ways, detailed in Section 2: -| # | Evidence | File:Line | -|---|----------|-----------| -| 1 | Global axios timeout is `30000` ms, applied to every request via the shared instance; no per-call override exists for `POST /backups` | `frontend/src/api/client.ts:10`; `frontend/src/api/backups.ts:56-61` (`createBackup` calls `client.post('/backups', options)` with no `{timeout}` override) | -| 2 | `BackupHandler.Create` calls `h.service.CreateBackupWithOptions(...)` and blocks on its return before writing any JSON | `backend/internal/api/handlers/backup_handler.go:142-154` | -| 3 | `CreateBackupWithOptions` → `createBackupLocked` runs, **in order, on the request goroutine, holding `s.mu`**: (a) `os.Stat` + disk-space check, (b) `writeV2Archive`, which itself calls **`createSQLiteSnapshot`** — a `VACUUM INTO` snapshot of the *live* database via a **second, independent SQLite connection** (§2.5) — before walking `caddy/**` and `crowdsec/**` into the zip via `filepath.Walk`, (c) optional age/scrypt encryption of the whole archive, (d) `sha256File` — a second full sequential read of the finished archive, (e) a GORM insert. **Correction from an earlier pass of this research:** the initial read of `createBackupLocked` cited `writeV2Archive` as going straight into the directory walk; it was incomplete — `writeV2Archive` (backup_service.go:695-749) calls `createSQLiteSnapshot(dbPath)` at line 708, *before* any zip-writing begins, and that function (lines 216-245) is where the `VACUUM INTO` actually executes. | `backend/internal/services/backup_service.go:559-669` (`CreateBackupWithOptions`, `createBackupLocked`), `:695-749` (`writeV2Archive`), `:708` (call site), `:216-245` (`createSQLiteSnapshot`, `VACUUM INTO ?`), `:776-794` (`sha256File`) | -| 4 | None of steps (b)-(d) are bounded by a context, deadline, or size cap — total wall-clock time scales linearly with `data/` size and disk speed | Same as above; no `context.Context` parameter exists anywhere in `createBackupLocked`'s call chain | +- The "shared test fixture" GH #1160 asks for **already exists** (`backend/internal/orthrus/testdata/muzzle_corpus.json`, consumed by both `TestMuzzle_SharedCorpus` and `TestFilter_SharedCorpus`), built during the write-mode work. The remaining gap is corpus **content** (no traversal/edge-case entries), not infrastructure. +- The literal fix ("swap two lines in agent to strip-before-clean") is insufficient: agent's *read-path* matching doesn't use a strip-then-match model at all — it duplicates every pattern into an unversioned and a `"/v*/..."` form, matched via `path.Match`. The `v*` wildcard is **more permissive** than backend's numeric-anchored `^/v\d+\.\d+` regex, which is a second, independently exploitable divergence beyond the one the issue names. Section 2.3 has concrete inputs proving this. -### 2.2 Each hypothesis explicitly ruled on +--- -| Hypothesis | Verdict | Evidence | -|---|---|---| -| axios's own 30s timeout elapses and aborts the request | **CONFIRMED — primary cause** | §2.1 above. | -| Component unmounts and an AbortController fires | **RULED OUT** | No `AbortController`/`signal` usage exists anywhere in `frontend/src/api/backups.ts`, `frontend/src/hooks/useBackups.ts`, or `frontend/src/api/client.ts` (grepped, none found). TanStack Query v5's default mutation behavior does **not** cancel in-flight mutations on unmount (only queries are unmount-aborted via their internal `AbortSignal`); mutations run to completion in the background regardless of component lifecycle. The Create dialog also stays mounted for the duration (`Backups.tsx` doesn't unmount `` while `createMutation.isPending`). | -| Tab navigates away mid-request (native form submit) | **RULED OUT** | The Create button (`frontend/src/pages/Backups.tsx:286-293`) is a plain ` -)} -``` +### F.1 Root Cause — Re-Verified Against Current Code (`docs/reports/qa_report.md` Finding 1, confirmed still accurate) -Effect per target type/state: -| Target | `OAUTH_TYPES.has(type)` | `oauth_status` | Test button | +| # | Root cause | Confirmed at | Status | |---|---|---|---| -| s3, sftp, webdav (any) | false | `''` | shown (unchanged) | -| dropbox / google_drive | true | `not_connected` | hidden (was shown — this is the fix) | -| dropbox / google_drive | true | `revoked` | hidden (was shown — same fix, same rationale as §11.1's `ErrOAuthRevoked` note: a revoked target also can't be meaningfully probed by the button today since `uploaderFor` would hit the identical `ErrOAuthNotConnected` check — `secrets.OAuthAccessToken` is cleared by nothing yet, but `Connect`/`Reconnect` is the correct next action regardless) | -| dropbox / google_drive | true | `connected` | shown (unchanged) | - -Note: no backend `OAuthStatus` transition to `"revoked"` currently exists anywhere in the codebase (`grep -rn '"revoked"' backend/internal --include=*.go` outside tests returns nothing under `services/`) — the frontend's `revoked` badge/copy is forward-defined but not yet reachable from live data. Out of scope here; not touched by this fix, noted only so the `revoked` row in the table above is understood as "currently unreachable in production, matches the reachable `not_connected` case identically once it is." - -### 11.4 TDD test plan - -**Backend — `backend/internal/services/backup_remote_service_test.go`** (the file already containing `TestBackupRemoteService_TestConnection_RecordsOutcome`, lines 227-255, which stays as-is and continues to serve as the existing control proving the `testErr := uploader.Test(ctx)` branch still records `"failed"` on a genuine post-connection failure — no changes needed to that test). Add two new tests immediately after it: +| 1 | `scripts/local-patch-report.sh`'s baseline resolution (lines 66-80) tries `origin/main` first, `origin/development` only as a fallback that a second, unrelated ref (`main`) gets priority over. Since both `origin/main` and `origin/development` exist locally, it always resolves to `origin/main...HEAD`, never matching PR #1166's actual GitHub base. | `scripts/local-patch-report.sh:66-80` | Confirmed unchanged | +| 2 | `backend/internal/patchreport/patchreport.go`'s `ApplyStatus` (lines 353-359) sets `Status = "warn"` below threshold but nothing downstream reads it to fail. `backend/cmd/localpatchreport/main.go` computes and prints `WARN:` lines (142-152) but always returns from `main()` normally (implicit exit 0). `scripts/local-patch-report.sh` only checks that the JSON/MD artifacts are non-empty (lines 124-133) and exits 0 if so — coverage content is never inspected. | `patchreport.go:353-359`, `main.go` (no `os.Exit(1)` on any `Status=="warn"` path), `local-patch-report.sh:124-134` | Confirmed unchanged | +| 3 | `codecov.yml`'s patch check (`coverage.status.patch.default`) is `informational: true` (lines 12-17) — Codecov's own PR comment cannot hard-block the merge on patch coverage. `CLAUDE.md`'s DoD Step 6 nonetheless calls backend/agent coverage "MANDATORY — Non-negotiable," a stricter internal bar than Codecov enforces, which only the local tool was ever positioned to enforce — and doesn't. | `codecov.yml:12-17` | Confirmed unchanged | +| 4 (new, found during this follow-up's research — not in the original QA report) | `backend/cmd/localpatchreport/main.go`'s own `--baseline` flag **already defaults to** `"origin/development...HEAD"` (line 52) — the *correct* value. The bug is entirely in the shell wrapper: `scripts/local-patch-report.sh` always computes an explicit `$BASELINE` (root cause #1) and always passes it via `--baseline "$BASELINE"` (line 116), so the Go tool's already-correct default is unconditionally overridden on every invocation. Fixing `main.go`'s default would have no effect; the shell script is the only file that needs the baseline-selection fix. | `main.go:52`, `local-patch-report.sh:116` | New finding | -1. `TestBackupRemoteService_Test_OAuthNotConnected_DoesNotRecordFailedStatus` — the bug-reproduction case. `uploaderFactory` returns `(nil, remotestorage.ErrOAuthNotConnected)`; target seeded with `LastTestStatus: "never"` (the actual `Create`-time default, `backup_remote_service.go:240`) and `OAuthStatus: "not_connected"`. Call `Test`, assert `errors.Is(err, remotestorage.ErrOAuthNotConnected)`, then reload the row and assert `LastTestStatus` is still `"never"` (not `"failed"`), `LastTestAt` is still `nil`, and `LastError` is still empty — proving no write happened. -2. `TestBackupRemoteService_Test_UploaderForGenuineError_StillRecordsFailedStatus` — the regression guard that this fix doesn't over-broaden the skip. `uploaderFactory` returns `(nil, assertAnError)` (the file's existing generic sentinel, `backup_remote_service_test.go:277-281`) — a stand-in for a genuine `uploaderFor` config/setup error (§11.1's "other errors" list), deliberately *not* `ErrOAuthNotConnected`. Target can be a plain `sftp` target (`uploaderFor` errors aren't OAuth-specific). Call `Test`, assert the error is returned and `!errors.Is(err, remotestorage.ErrOAuthNotConnected)`, then reload and assert `LastTestStatus == "failed"`, `LastTestAt` is non-nil, and `LastError` contains `"boom"` — proving the skip is scoped to the one sentinel, not to "any `uploaderFor` error." +**Also re-confirmed**: `report.Mode` in `backend/cmd/localpatchreport/main.go` is hardcoded to the literal string `"warn"` (line 157) regardless of outcome — the QA report's characterization of this as misleading is accurate and is fixed as part of F.4 below (the field now reflects the tool's actual enforcement mode). -Both reuse `newRemoteServiceTestDB`, `NewBackupRemoteService`, and the `uploaderFactory` override seam already established in this file — no new test scaffolding required. +### F.2 Branching Model Confirmation -**Frontend — `frontend/src/components/backups/__tests__/RemoteTargetsCard.test.tsx`** (already has the `describe('OAuth status badge and connect/reconnect/disconnect buttons', ...)` block, lines 303-340, with `dropboxNotConnected`, `dropboxConnected`, and `gdriveRevoked` fixtures already defined at lines 91-129 — reuse them, no new fixtures needed). Add three new `it` blocks inside that `describe`: +`gh pr list --state merged --limit 15 --json number,baseRefName,headRefName` shows **13 of 13** non-promotion merged PRs target `development` (renovate bumps, bot commits, feature PRs); only the two `nightly` PRs (#1164, #1146) target `main`. `git log --merges` corroborates: every non-nightly merge is `development`-bound. This matches `CLAUDE.md`'s documented model (feature branches → `development` → periodic promotion PRs → `main`) exactly and is the basis for F.3's fallback-order change: **`development` is the default integration branch; `main` is only ever a target for the scheduled nightly-promotion PR.** -1. `it('hides the Test button for a not_connected Dropbox target', ...)` — render with `targets = [dropboxNotConnected]`, find its row, assert `within(row).queryByTestId('backup-remote-target-test-btn')` is `not.toBeInTheDocument()`. -2. `it('hides the Test button for a revoked Google Drive target', ...)` — same, using `gdriveRevoked`. -3. `it('shows the Test button for a connected Dropbox target', ...)` — same, using `dropboxConnected`, assert `getByTestId('backup-remote-target-test-btn')` **is** present. +### F.3 Baseline Resolution Fix (Design) -Non-OAuth types (s3/sftp) are already implicitly covered without any new test: the existing tests at lines 184, 200, and 261 already `click(within(...).getByTestId('backup-remote-target-test-btn'))` on the `nas` (sftp) and `b2` (s3) fixture rows — those would already fail today if the new gating condition accidentally hid the button for non-OAuth types, since `getByTestId` throws when the element is absent. No changes needed to those three tests; they serve as the non-OAuth regression guard for free. +Three-tier resolution, replacing `scripts/local-patch-report.sh` lines 66-80: -### 11.5 GORM security scan +**Tier 1 — explicit override (unchanged)**: `$CHARON_PATCH_BASELINE`, if set, wins outright — no change to this precedence, it's already correct and is the documented manual-override escape hatch. -**Not required.** This fix touches no `backend/internal/models/**` file, adds no field, and changes no GORM query shape — `LastTestStatus`, `LastError`, `LastTestAt`, and `OAuthStatus` all already exist on `RemoteStorageTarget` (`backend/internal/models/remote_storage_target.go:42-55`, all present before this change). The fix is confined to a control-flow branch inside one existing service method plus a JSX conditional-render change. Per CLAUDE.md §1.5, skip `./scripts/scan-gorm-security.sh --check`. +**Tier 2 — `gh`-derived real PR base (new)**: when `CHARON_PATCH_BASELINE` is unset, attempt to ask GitHub what the *actual* open PR's base branch is, so the local number is computed against the exact same ref Codecov uses: -### 11.6 Backfill note - -No backend data-migration/backfill is in scope. For the user's own already-affected live row, the user has already been told that clicking Test again post-connection will overwrite the stale `"failed"` status with a fresh, correct outcome — no code needs to reach back and correct historical rows. - -### 11.7 Commit Slicing Strategy (this addendum only) - -**Decision:** same PR (#1136), two ordered commits appended after §10's B1, split backend/frontend since each is independently testable in isolation — same precedent as §9.6's A1 (backend) / A2 (frontend-adjacent) split, and consistent with CLAUDE.md's suggested commit sequence (backend before frontend). Both commits are small enough that further splitting (e.g. separating the two new backend tests from the fix itself) would add process overhead without improving reviewability. - -| # | Commit | Scope | Files | Depends on | Validation gate | -|---|---|---|---|---|---| -| C1 | `fix: don't record failed test status for unattempted OAuth-precondition probes` | Backend | `backend/internal/services/backup_remote_service.go` (`Test`, §11.2), `backend/internal/services/backup_remote_service_test.go` (2 new tests, §11.4) | None — independent of §1-10 | `go test ./backend/internal/services/... -run TestBackupRemoteService_Test -v` (scoped to this test-name prefix only — do **not** run the full backend `-race` suite; concurrent heavy test jobs are running elsewhere on this machine right now) | -| C2 | `fix: hide remote-target Test button until OAuth target is connected` | Frontend | `frontend/src/components/backups/RemoteTargetsCard.tsx` (§11.3), `frontend/src/components/backups/__tests__/RemoteTargetsCard.test.tsx` (3 new tests, §11.4) | C1 (same bug, reviewed together; no code dependency — C2 would still build/pass standalone if reordered) | `cd frontend && npx vitest run src/components/backups/__tests__/RemoteTargetsCard.test.tsx` (scoped to this single test file only — do **not** run the full frontend coverage suite or Playwright; both are under heavy concurrent load elsewhere right now) | - -**Rollback/contingency:** each commit reverts independently — C1 is a pure backend control-flow narrowing (removing it just restores the old unconditional-record behavior, no data loss), C2 is a pure render-condition change (removing it just restores the always-visible button). Neither commit alters stored data or API contracts, so either can be reverted alone without touching the other. - -### 9.2 Issue 2 — `tests/uptime-orthrus.spec.ts:135-163`, "non-Orthrus monitor at same IP is checked independently" - -## 12. Addendum: Backup Encryption UX Gaps — Manual Save Button, Create-Dialog Smart Defaults, Scheduled-Backup Encryption (Same PR) - -**Status:** three related findings from a UX/security audit of the encryption feature landed in §9-11, same PR #1136 per CLAUDE.md's "one feature = one PR." Larger than §9-11 (touches the `POST /backups` API contract and passphrase decryption — a security-sensitive code path), but still a bug-fix addendum, not a new design. - -**Items:** -- **A** — `BackupEncryptionCard` has no save button of its own; encryption changes only persist if the user separately clicks the unrelated Schedule card's button. -- **B** — the manual Create Backup dialog always defaults encryption off and has no remote-upload toggle at all, forcing needless re-entry of an already-stored passphrase and offering no way to skip a per-backup remote upload. -- **C** — scheduled (cron-triggered) backups never apply the `backup.encryption_enabled` setting at all — turning on "Enable backup encryption" currently has zero effect on the backups actually produced by the schedule. - -### 12.1 Item A — BackupEncryptionCard needs its own Save button - -**Root cause (verified):** `frontend/src/components/backups/BackupEncryptionCard.tsx` (78 lines total) has no `Button`/save action anywhere in its JSX; its own doc comment (lines 12-16) states it "has no save button of its own" and shares `form.save()` with `BackupScheduleCard`. `BackupScheduleCard.tsx`'s `handleSave` (lines 37-42) calls `form.save({...})`, and its `` inside `BackupEncryptionCard`'s `CardContent`, in a `
` wrapper matching `BackupScheduleCard`'s existing footer layout (lines 144-146). The button's own `handleSave` closure is identical in shape to `BackupScheduleCard`'s (calls `form.save({ onSuccess: () => toast.success(t('backups.encryption.saveSuccess')), onError: (error) => toast.error(error.message) })`) — a distinct i18n success-toast key (`backups.encryption.saveSuccess`) rather than reusing `backups.schedule.saveSuccess`, since the two cards represent conceptually distinct settings groups to the user even though they share one PUT. - -**`BackupScheduleCard` keeps its own Save button too** (per the user's explicit call, confirmed correct against the hook): `useBackupSettingsForm()` models one `BackupSettings` REST resource behind one `PUT /api/v1/backups/settings`, and `form.save()` always submits the *entire* current form state (`useBackups.ts:312-320`, the `updateMutation.mutate({schedule_enabled, schedule_cron, retention_count, remote_retention_count, encryption_enabled, ...})` payload) regardless of which button triggered it — so two buttons hitting the same shared mutation is safe and requires no fork. This does mean clicking "Save Schedule" also persists any pending encryption-field edits and vice versa; that is already true today (§12's premise is that this silent cross-save is the *problem* for the encryption side, not a new side effect introduced by adding a second button) — the fix here is *making that cross-save also reachable and discoverable from the Encryption card*, not eliminating the shared-resource model. - -**Required i18n additions** (`frontend/src/locales/en/translation.json`, inside the existing `backups.encryption` block, `~lines 1009-1017`): -```json -"save": "Save Encryption Settings", -"saveSuccess": "Encryption settings updated" +if command -v gh >/dev/null 2>&1; then + GH_BASE_REF="$(timeout 5s gh pr view --json baseRefName -q .baseRefName 2>/dev/null || true)" + if [[ -n "$GH_BASE_REF" ]] && git -C "$ROOT_DIR" rev-parse --verify --quiet "origin/${GH_BASE_REF}^{commit}" >/dev/null; then + BASELINE="origin/${GH_BASE_REF}...HEAD" + fi +fi ``` -**New `data-testid`:** `backup-encryption-save-btn` (mirrors `BackupScheduleCard`'s convention — that card's button currently has no explicit `data-testid` either, relying on `getByText`/role queries in its tests; keep the same convention, i.e. no `data-testid` required, but querying by accessible name `t('backups.encryption.save')` in tests. Add one only if the Playwright/Vitest test authoring makes it clearly more robust — matches this file's own existing precedent of testid-only-where-ambiguous, e.g. `backup-encryption-toggle`, `backup-encryption-passphrase-input`). - -#### 12.1.1 TDD test plan (Item A) - -`frontend/src/components/backups/__tests__/BackupEncryptionCard.test.tsx` (existing `makeForm()` helper at lines 8-36 already includes `save: vi.fn()`, `saveDisabled: false`, `isSaving: false` — reused as-is, no helper changes needed): -1. `it('renders a Save button that calls form.save on click')` — render with default `makeForm()`, click the save button (query by role/name `backups.encryption.save`), assert `form.save` was called once. -2. `it('disables the Save button when the form reports saveDisabled')` — render with `makeForm({ saveDisabled: true })` (mirrors `BackupScheduleCard.test.tsx:95-97`'s existing pattern for the same prop), assert the button `toBeDisabled()`. -3. `it('shows a loading state on the Save button while isSaving is true')` — render with `makeForm({ isSaving: true })`, assert the button reflects `isLoading` (existing `Button` component's loading-state assertion pattern — check how `BackupScheduleCard.test.tsx` asserts `isLoading` today, e.g. a spinner testid or `aria-busy`, and reuse that exact assertion). -4. `it('shows a success toast when save succeeds')` — render with `makeForm({ save: vi.fn((cb) => cb?.onSuccess?.()) })` (mirrors `BackupScheduleCard.test.tsx:151-152`'s existing pattern), click save, assert `toast.success` was called with the encryption-specific message. - -No backend changes for Item A — `PUT /api/v1/backups/settings` already accepts and persists encryption fields; this is a pure frontend affordance fix. - -### 12.2 Item B — Create Backup dialog: smart encryption default + remote-upload toggle - -#### 12.2.1 Current behavior (verified against current source) +**Tier 3 — static heuristic fallback (changed)**: only reached if `gh` is absent, unauthenticated, times out, or no PR is open for the current branch (all folded into the same `|| true` / empty-`GH_BASE_REF` path above — no separate error handling needed, it degrades silently to Tier 3). Per F.2, the preference order flips to put `development` first: -- `frontend/src/pages/Backups.tsx`, `handleOpenCreateDialog` (lines 64-68): unconditionally resets `createEncrypt` to `false` and `createPassphrase` to `''` every time the dialog opens, ignoring `settingsForm.encryptionPassphraseSet` (already available in-component via `const settingsForm = useBackupSettingsForm()`, line 54). -- `handleCreateConfirm` (lines 70-78): `createMutation.mutate(createEncrypt ? { encrypt: true, passphrase: createPassphrase } : undefined, {...})` — always sends whatever is in `createPassphrase`, and the Create button is `disabled={createEncrypt && !createPassphrase}` (line 304) — meaning a user with a stored passphrase is *hard-blocked* from creating an encrypted backup without retyping it. -- No remote-upload control exists anywhere in the dialog JSX (lines 263-309) — not hidden, not defaulted, simply absent. -- Backend: `createBackupLockedWithProgress` (`backend/internal/services/backup_service.go:899-902`) `if opts.Encrypt && strings.TrimSpace(opts.Passphrase) == "" { return nil, fmt.Errorf("passphrase is required to encrypt backup") }` — confirmed, no fallback to any stored value. `StartCreateBackupJob` (`backup_service.go:675-681`) performs the identical check synchronously before spawning the job goroutine, so today a manual create with `Encrypt:true, Passphrase:""` fails fast with a 500 before any job row is even created. -- `CreateBackupWithOptions` (`backup_service.go:641-665`) fires `s.remoteUploadHook` unconditionally at line 656 (`if s.remoteUploadHook != nil && record != nil { ... go func(){ s.remoteUploadHook(...) }() }`) whenever a record was produced — confirmed no existing gate of any kind. `BackupRemoteService.TriggerUpload` (`backup_remote_service.go:518-533`) is the hook implementation and fans out to every `enabled=true` `RemoteStorageTarget` row unconditionally. -- **Corrected call-graph finding (post-review):** the manual Create-dialog's actual request path — `Create` handler → `StartCreateBackupJob` (`backup_service.go:675-713`) → its spawned goroutine → `runCreateBackupJob` (`:720-745`) → `createBackupLockedWithProgress` (`:724`) — **never calls `CreateBackupWithOptions` at all**. `remoteUploadHook` has exactly one call site in the entire codebase (`grep -rn "remoteUploadHook(" backend/internal/services/*.go` outside tests: one hit, `backup_service.go:660`, inside `CreateBackupWithOptions`), which only `CreateBackup()`'s legacy synchronous wrapper (`:626-632`, used by `RunScheduledBackup`) and any hypothetical direct caller actually reach. **Net effect: manual dialog-triggered backups are never remote-uploaded via this hook today, regardless of any setting** — an independent latent gap from item B's original ask, surfaced by tracing this call graph, and one the design below now also closes as a byproduct of wiring the gate into the path that's actually executed. -- **Confirmed: no decrypt call site for the stored passphrase exists anywhere in the codebase today.** `grep -rn "SettingKeyBackupEncryptionPassphraseEnc"` finds only the constant definition, the `hasPassphrase` presence-check in `GetSettings` (`backup_settings.go:177,185` — used only to compute the boolean `encryption_passphrase_set` field, never to decrypt), and the write path in `UpdateSettings` (`backup_settings.go:222,267` — `s.encryption.Encrypt(...)` only). The exact decrypt primitive to reuse is `(*crypto.EncryptionService).Decrypt(ciphertextB64 string) ([]byte, error)` (`backend/internal/crypto/encryption.go:78`) — the same method three *other* services already call for their own encrypted-at-rest secrets: `backup_remote_service.go:335` (`s.encryption.Decrypt(target.SecretsEncrypted)`), `certificate_service.go:775`, `credential_service.go:422`, `dns_provider_service.go:547`. `BackupService` already holds the required key material as `s.encryption *crypto.EncryptionService` (field declared `backup_service.go:161`, set in `NewBackupService`, `backup_service.go:350`) — no new key/dependency needed, this is a straight reuse of an existing field + an existing method on a sibling type, applied to a settings key nothing has decrypted before. -- `frontend/src/hooks/useRemoteTargets.ts:25` `useRemoteTargets()` (`frontend/src/api/backups.ts:309-` `RemoteTarget` interface has `enabled: boolean`, line 313) — same hook `RemoteTargetsCard.tsx` already calls; confirmed usable from `Backups.tsx` for computing "at least one enabled target." - -#### 12.2.2 API contract change - -**`backend/internal/api/handlers/backup_handler.go:124-127`**, `createBackupRequest`: -```go -type createBackupRequest struct { - Encrypt bool `json:"encrypt"` - Passphrase string `json:"passphrase"` - UploadToRemote *bool `json:"upload_to_remote,omitempty"` -} -``` -`*bool` matches this codebase's established "nil = leave/default, non-nil = explicit override" convention for optional boolean request fields (`backup_handler.go:532` `ScheduleEnabled *bool`, `:536` `EncryptionEnabled *bool`; `backup_remote_handler.go:61` `Enabled *bool`; `user_handler.go:714`, `credential_service.go:45`, `dns_provider_service.go:60-61`, `models/proxy_host.go:54` `EnableStandardHeaders *bool` with the exact "nil → default true" semantics being proposed here). `Create` (handler, `~line 167-181`) threads it through unchanged: -```go -job, err := h.service.StartCreateBackupJob(services.BackupOptions{ - Type: "manual", - Encrypt: req.Encrypt, - Passphrase: req.Passphrase, - UploadToRemote: req.UploadToRemote, -}, audit) ``` - -**`backend/internal/services/backup_service.go:608-619`**, `BackupOptions`: -```go -type BackupOptions struct { - Type string - Encrypt bool - Passphrase string - // UploadToRemote controls whether CreateBackupWithOptions fires the - // remote-upload hook for this backup. nil means "default to the - // pre-existing unconditional-upload behavior" (true) — preserves every - // existing caller (CreateBackup's "manual" wrapper, RunScheduledBackup, - // RestoreBackupSafe's S1 pre_restore step) unchanged without needing to - // touch them (spec §12.2.2). - UploadToRemote *bool -} +if [[ -z "$BASELINE" ]]; then + if git -C "$ROOT_DIR" rev-parse --verify --quiet "origin/development^{commit}" >/dev/null; then + BASELINE="origin/development...HEAD" + elif git -C "$ROOT_DIR" rev-parse --verify --quiet "development^{commit}" >/dev/null; then + BASELINE="development...HEAD" + elif git -C "$ROOT_DIR" rev-parse --verify --quiet "origin/main^{commit}" >/dev/null; then + BASELINE="origin/main...HEAD" + elif git -C "$ROOT_DIR" rev-parse --verify --quiet "main^{commit}" >/dev/null; then + BASELINE="main...HEAD" + else + BASELINE="origin/development...HEAD" + fi +fi ``` -**Nil-safe default semantics — verified against every call site:** - -| Caller | Sets `UploadToRemote`? | Resulting behavior | Verified unchanged? | -|---|---|---|---| -| `CreateBackup()` legacy wrapper (`backup_service.go:626-632`, `BackupOptions{Type: "manual"}`) | No (nil) | Defaults true → upload fires (existing behavior) | Yes — used by `RunScheduledBackup` pre-fix and the certificate handler's cron-adjacent seam; both keep firing uploads today | -| `RestoreBackupSafe` S1 (`backup_restore_safe.go:278`, `s.createBackupLocked(BackupOptions{Type: "pre_restore"})`) | No (nil) — and moot regardless | **No behavior change possible**: this call goes through `createBackupLocked` directly, never through `CreateBackupWithOptions` (the only function that reads `remoteUploadHook`/`UploadToRemote` at all, line 656) — pre_restore backups have never been remote-uploaded, before or after this change. Confirmed by tracing every call site of `remoteUploadHook`/`s.uploadWG` — only reachable from `CreateBackupWithOptions`. | -| `RunScheduledBackup` (item C, §12.3) | No (nil), unless explicitly decided otherwise in C | Defaults true → upload fires (matches today's actual behavior for scheduled backups, which are already uploaded unconditionally) | Yes — reaches `CreateBackupWithOptions` | -| New manual-create toggle (item B, §12.2.3) | **Yes**, always explicit (`true` or `false` from the dialog toggle state) | User-controlled — but see corrected call-graph below: this must be read from inside `runCreateBackupJob`, not `CreateBackupWithOptions`, since the manual-create request path never reaches the latter | New behavior, by design — **and** a fix to the pre-existing "manual creates never upload at all" gap (§12.2.1) | -| Existing/legacy test callers constructing `BackupOptions{...}` with no `UploadToRemote` field | No (nil, Go zero value for a pointer) | Defaults true — identical to pre-change behavior | Yes — Go struct literals with named fields are unaffected by new struct fields; no test needs to change to keep compiling | +| Edge case | Behavior | +|---|---| +| `gh` not installed | `command -v gh` fails → Tier 2 skipped entirely → Tier 3 | +| `gh` installed, not authenticated | `gh pr view` exits non-zero, stderr suppressed, `\|\| true` prevents `set -e` abort → `GH_BASE_REF` empty → Tier 3 | +| No PR open for current branch | Same as above (`gh pr view` errors "no pull requests found") → Tier 3 | +| `gh` hangs (network issue) | `timeout 5s` bounds the wait → non-zero exit → Tier 3 | +| `gh` returns a `baseRefName` whose `origin/` isn't fetched locally | The `rev-parse --verify` guard in Tier 2 rejects it, falls through to Tier 3 rather than later failing the "baseline base ref not available locally" check at line 105-108 | -**Corrected design (post-review): a shared helper, gated at both of the two places that can actually produce a successful record.** §12.2.1's corrected call-graph finding means gating only `CreateBackupWithOptions:656` would be a no-op for the manual-create path — that function is never on the call stack for `POST /api/v1/backups`. `runCreateBackupJob` cannot simply call `CreateBackupWithOptions` itself to get the gate for free: `StartCreateBackupJob` already holds `s.mu` for the goroutine's entire duration (`:686` `TryLock()`, unlocked via the goroutine's own `defer s.mu.Unlock()` wrapping `runCreateBackupJob`, `:706-710`), and `CreateBackupWithOptions` itself calls `s.mu.TryLock()` again (`:646`) — a second, nested `TryLock()` on an already-held `sync.Mutex` would simply fail fast with `ErrBackupInProgress` (Go's `sync.Mutex` isn't reentrant), silently turning every async-job backup into a "no upload, but no visible error either" outcome. The fix is a small shared helper, called from both places instead: +This satisfies task requirement 3 exactly: `gh`-exact-match primary, `development`-preferring heuristic fallback, graceful degradation on every named failure mode. -```go -// fireRemoteUploadIfEnabled fires s.remoteUploadHook for record in a -// tracked goroutine, gated on opts.UploadToRemote (spec §12.2.2): nil or -// true fires (preserves the pre-existing unconditional-upload behavior for -// every caller that never sets the field — CreateBackup()'s legacy -// wrapper, RunScheduledBackup), explicit false skips. Shared by -// CreateBackupWithOptions (the synchronous legacy/scheduled path, fired -// after s.mu.Unlock()) and runCreateBackupJob (the async manual-create job -// path — fired here instead, since that path never calls -// CreateBackupWithOptions and, even if it wanted to, cannot: it already -// holds s.mu via StartCreateBackupJob's deferred unlock, and -// CreateBackupWithOptions's own TryLock() would fail fast on the -// already-held, non-reentrant s.mu). The upload itself always runs in a -// new goroutine tracked by s.uploadWG, so calling this while s.mu is still -// held (the runCreateBackupJob case) is safe — the spawned goroutine never -// touches s.mu. -func (s *BackupService) fireRemoteUploadIfEnabled(opts BackupOptions, record *models.BackupRecord) { - if s.remoteUploadHook == nil || record == nil { - return - } - if opts.UploadToRemote != nil && !*opts.UploadToRemote { - return - } - s.uploadWG.Add(1) - go func() { - defer s.uploadWG.Done() - s.remoteUploadHook(s.uploadCtx, record) - }() -} -``` +### F.4 Enforcement Fix (Design) -`CreateBackupWithOptions` (`:641-665`) replaces its inline block with the helper — behavior-preserving for its existing callers (nil `UploadToRemote` → still fires unconditionally): -```go -func (s *BackupService) CreateBackupWithOptions(opts BackupOptions) (*models.BackupRecord, error) { - if opts.Type == "" { - opts.Type = "manual" - } - if !s.mu.TryLock() { - return nil, ErrBackupInProgress - } - record, err := s.createBackupLocked(opts) - s.mu.Unlock() - - if err != nil { - return nil, err - } - - s.fireRemoteUploadIfEnabled(opts, record) - - return record, nil -} -``` +**Decision**: default to **strict** (non-zero exit on any scope below threshold), with an explicit `-advisory` opt-out flag for legitimate mid-feature use — per the task's own steer and `CLAUDE.md` DoD Step 2's "MANDATORY" language. Exit-code logic lives in the Go tool (`backend/cmd/localpatchreport/main.go`), not the shell script, since `main.go` already owns every `ScopeCoverage.Status` computation; the shell script only needs to propagate whatever exit code the Go tool returns. -`runCreateBackupJob` (`:720-745`) — the actual manual-create path — gets the equivalent call added on its success branch, right where the job's `record` is known good: -```go -func (s *BackupService) runCreateBackupJob(job *models.BackupJob, opts BackupOptions, audit RequestAuditInfo) { - s.markBackupJobRunning(job) - progress := s.newBackupJobProgressFunc(job) - - record, err := s.createBackupLockedWithProgress(opts, progress) - - updates := map[string]interface{}{"finished_at": timePtr(time.Now())} - if err != nil { - logger.Log().WithError(err).WithField("job_uuid", job.UUID).Error("async backup job failed") - updates["status"] = "failed" - updates["error_message"] = err.Error() - updates["error_code"] = backupErrorCode(err) - s.auditBackupJobPermissionFailure("backup_create_failed", err, audit) - } else { - updates["status"] = "completed" - updates["filename"] = record.Filename - updates["result_uuid"] = record.UUID - s.fireRemoteUploadIfEnabled(opts, record) - } - - s.finishBackupJob(job, updates) -} -``` +**New exported function**, `backend/internal/patchreport/patchreport.go` (alongside the existing `ApplyStatus`/`MergeScopeCoverage`): -**Passphrase-reuse resolution** — new private helper, `backup_service.go` (near `BackupOptions`): ```go -// resolveBackupPassphrase fills opts.Passphrase from the stored, encrypted -// backup.encryption_passphrase_enc setting when the caller requested -// encryption without supplying one explicitly (spec §12.2 — the Create -// dialog's "use the already-configured passphrase" default). Reuses the -// exact decrypt primitive backup_remote_service.go:335 already uses for -// stored target secrets. No-op (opts returned unmodified) when Encrypt is -// false, Passphrase is already non-empty, or no passphrase is stored — -// callers still get the existing "passphrase is required" error in that -// last case, from the unchanged check that already exists at the call -// site (backup_service.go:900, :679-681). -func (s *BackupService) resolveBackupPassphrase(opts BackupOptions) (BackupOptions, error) { - if !opts.Encrypt || strings.TrimSpace(opts.Passphrase) != "" { - return opts, nil - } - if s.db == nil || s.encryption == nil { - return opts, nil - } - stored, ok := readBackupSettingString(s.db, SettingKeyBackupEncryptionPassphraseEnc) - if !ok || strings.TrimSpace(stored) == "" { - return opts, nil - } - plaintext, err := s.encryption.Decrypt(stored) - if err != nil { - return opts, fmt.Errorf("decrypt stored backup passphrase: %w", err) - } - opts.Passphrase = string(plaintext) - return opts, nil +// HasWarnStatus reports whether any of the given scopes is below its +// threshold (Status == "warn"). Used by cmd/localpatchreport's strict mode +// to decide the process exit code — kept in this package, not main.go, +// so it is unit-testable independent of os.Exit and CLI flag parsing. +func HasWarnStatus(scopes ...ScopeCoverage) bool { + for _, scope := range scopes { + if scope.Status == "warn" { + return true + } + } + return false } ``` -Called at the top of `StartCreateBackupJob` (`backup_service.go:675-681`), **after** the existing `s.db == nil` guard (moved earlier so the helper always has a non-nil `s.db` to read) and **before** the existing `opts.Encrypt && Passphrase == ""` rejection check, so a resolved stored passphrase satisfies that check instead of tripping it: -```go -func (s *BackupService) StartCreateBackupJob(opts BackupOptions, audit RequestAuditInfo) (*models.BackupJob, error) { - if opts.Type == "" { - opts.Type = "manual" - } - if s.db == nil { - return nil, fmt.Errorf("backup job tracking requires a database connection") - } - opts, err := s.resolveBackupPassphrase(opts) - if err != nil { - return nil, err - } - if opts.Encrypt && strings.TrimSpace(opts.Passphrase) == "" { - return nil, fmt.Errorf("passphrase is required to encrypt backup") - } - if !s.mu.TryLock() { - return nil, ErrBackupInProgress - } - ... -``` -`createBackupLockedWithProgress`'s own identical check (`backup_service.go:900`) is left completely unchanged — it remains a correct defense-in-depth guard for the one remaining direct caller of `CreateBackupWithOptions`/`createBackupLocked` with `Encrypt:true` (test code), and is already satisfied by the time `StartCreateBackupJob`'s goroutine reaches it in the real request path. - -#### 12.2.3 Frontend changes -`frontend/src/api/backups.ts:38-41`, `CreateBackupOptions`: -```ts -export interface CreateBackupOptions { - encrypt?: boolean - passphrase?: string - upload_to_remote?: boolean -} -``` - -`frontend/src/pages/Backups.tsx`: -- Add `import { useRemoteTargets } from '../hooks/useRemoteTargets'` and `const { data: remoteTargets } = useRemoteTargets()` near the existing `settingsForm`/`dbHealth` reads (line ~54-55). -- New state: `const [createUploadToRemote, setCreateUploadToRemote] = useState(true)`. -- `handleOpenCreateDialog` (lines 64-68) becomes: - ```ts - const handleOpenCreateDialog = () => { - const hasStoredPassphrase = settingsForm.encryptionPassphraseSet - setCreateEncrypt(hasStoredPassphrase) - setCreatePassphrase('') - setCreateUploadToRemote((remoteTargets ?? []).some((t) => t.enabled)) - setCreateDialogOpen(true) - } - ``` - Defaults encryption **on** exactly when a passphrase is already stored (matching `BackupEncryptionCard`'s own `encryptionPassphraseSet` distinction, §12.1) and leaves it fully user-toggleable off via the existing `Switch`. Defaults remote upload on exactly when at least one enabled target exists. -- Passphrase input becomes conditional on *not having* a stored passphrase, mirroring `BackupEncryptionCard`'s `passphraseChangeLabel`/`passphraseKeepCurrent` pattern (`BackupEncryptionCard.tsx:59-68`): - ```tsx - {createEncrypt && !settingsForm.encryptionPassphraseSet && ( - setCreatePassphrase(e.target.value)} - autoComplete="new-password" - /> - )} - {createEncrypt && settingsForm.encryptionPassphraseSet && ( -

- {t('backups.encryption.passphraseSet')} -

- )} - ``` -- New remote-upload toggle, hidden entirely (not just disabled) when there are zero enabled targets: - ```tsx - {(remoteTargets ?? []).some((t) => t.enabled) && ( -
- - -
- )} - ``` -- `handleCreateConfirm` (lines 70-78): - ```ts - const handleCreateConfirm = () => { - const hasTargets = (remoteTargets ?? []).some((t) => t.enabled) - createMutation.mutate( - createEncrypt - ? { - encrypt: true, - ...(settingsForm.encryptionPassphraseSet ? {} : { passphrase: createPassphrase }), - ...(hasTargets ? { upload_to_remote: createUploadToRemote } : {}), - } - : hasTargets - ? { upload_to_remote: createUploadToRemote } - : undefined, - { onSuccess: ..., onError: ... } - ) +**`backend/cmd/localpatchreport/main.go` changes**: +- New flag: `advisoryFlag := flag.Bool("advisory", false, "Exit 0 even if any coverage scope is below threshold (advisory-only mode). Default is strict: non-zero exit on any below-threshold scope, per CLAUDE.md's Definition of Done Step 2.")` +- `report.Mode` (currently hardcoded `"warn"` at line 157) becomes `"strict"` or `"advisory"` based on `*advisoryFlag`, so the JSON/markdown artifacts themselves truthfully describe the mode that produced them (fixes the QA report's flagged "always says warn" defect as a side effect). +- After `writeJSON`/`writeMarkdown` succeed (after line 198, before the function returns) — artifacts must be written and confirmed on disk *before* any exit-1, so a failing gate still leaves a full report for the developer to read: + ```go + if !*advisoryFlag && patchreport.HasWarnStatus(overallScope, backendScope, frontendScope, agentScope) { + fmt.Fprintln(os.Stderr, "Local patch coverage below threshold in strict mode (use -advisory to bypass); see report for details.") + os.Exit(1) } ``` -- Create-button disabled condition (line 304) narrows from `createEncrypt && !createPassphrase` to `createEncrypt && !settingsForm.encryptionPassphraseSet && !createPassphrase` — a stored passphrase no longer blocks the button. - -**New i18n key** (`frontend/src/locales/en/translation.json`, alongside `encryptThisBackup`, `~line 977`): `"uploadToRemote": "Upload to remote storage"`. - -#### 12.2.4 TDD test plan (Item B) - -**Backend — `backend/internal/services/backup_service_test.go` / a new `backup_service_options_test.go`** (new field threading + passphrase reuse + conditional upload; `TestRestoreBackupSafe_WrongPassphrase_Rejected`-style `record.Encrypted`/filename-suffix assertions, `backup_service_v2_hardening_test.go:136-138`, are the established pattern to reuse for the encryption-success assertions): -1. `TestStartCreateBackupJob_UsesStoredPassphraseWhenEncryptRequestedWithoutOne` — seed `SettingKeyBackupEncryptionPassphraseEnc` via `UpdateSettings` (real encrypt round-trip, not a raw DB write, so the test also proves the stored value this reads is genuinely the encrypted-at-rest column), call `StartCreateBackupJob(BackupOptions{Encrypt: true}, audit)` (no `Passphrase`), wait for job completion (existing async-job test-polling helper — find it in `backup_service_async_create_test.go`), assert `job.Status == "completed"` and the resulting `BackupRecord.Encrypted == true`. -2. `TestStartCreateBackupJob_EncryptWithNoPassphraseAndNoneStored_Fails` — regression guard: no stored passphrase, `BackupOptions{Encrypt: true}`, assert the existing synchronous rejection still fires (`err` non-nil, no job created) — proves `resolveBackupPassphrase`'s no-op path still lets the pre-existing guard do its job. -3. `TestCreateBackupWithOptions_UploadToRemoteFalse_SkipsHook` — set a `remoteUploadHook` spy (existing seam, `SetRemoteUploadHook`), call `CreateBackupWithOptions(BackupOptions{Type: "manual", UploadToRemote: boolPtr(false)})` directly (the synchronous legacy/scheduled path), assert the spy was never invoked (use `s.uploadWG.Wait()` — already exported behavior via `Stop()`, or a small sync channel — before asserting, to avoid a race against the (absent) goroutine). Covers `fireRemoteUploadIfEnabled`'s gate as exercised from `CreateBackupWithOptions`. -4. `TestCreateBackupWithOptions_UploadToRemoteNil_FiresHookAsBefore` — same setup, `BackupOptions{Type: "manual"}` (nil), assert the spy **was** invoked — the explicit regression guard for the "nil means unchanged" contract on the `CreateBackupWithOptions` call site. -5. **`TestStartCreateBackupJob_UploadToRemoteFalse_SkipsHook`** — the test that actually covers the bug fix (§12.2.1's corrected call-graph finding): spy on `remoteUploadHook`, call `StartCreateBackupJob(BackupOptions{UploadToRemote: boolPtr(false)}, audit)` (the real manual-create request path), wait for the job to reach `"completed"` and for `s.uploadWG.Wait()`, assert the spy was never invoked. -6. **`TestStartCreateBackupJob_UploadToRemoteNil_FiresHook`** — same setup, `BackupOptions{}` (nil `UploadToRemote`), assert the spy **was** invoked. This is the regression test proving the previously-latent "manual creates never upload" gap (§12.2.1) is actually closed — before this fix, this exact scenario would have failed (spy never called) even though nothing about `UploadToRemote` was involved; it's the async-job path calling `CreateBackupWithOptions` at all that was missing. -7. **`TestStartCreateBackupJob_CorruptedStoredPassphrase_ReturnsError`** (non-blocking finding #3 from review) — seed `SettingKeyBackupEncryptionPassphraseEnc` directly with an unparseable/corrupted ciphertext string (bypassing `UpdateSettings`'s normal `Encrypt` call, simulating bit-rot or a key-rotation mismatch), call `StartCreateBackupJob(BackupOptions{Encrypt: true}, audit)` (no `Passphrase`), assert the call returns a non-nil error (from `resolveBackupPassphrase`'s `s.encryption.Decrypt` failure path, `backup_service.go`'s new helper) and no job is created — proves the decrypt-failure path surfaces as a real synchronous error rather than silently falling through to "passphrase is required." - -**Backend — `backend/internal/api/handlers/backup_handler_test.go`**: extend the existing `Create` handler tests with one asserting `UploadToRemote` decodes correctly from `{"upload_to_remote": false}` JSON into `services.BackupOptions.UploadToRemote` (a `*bool` pointing at `false`, not a nil-vs-false ambiguity) — same pattern already used for `EncryptionEnabled *bool` decode tests on `updateBackupSettingsRequest`, if any exist; otherwise a minimal `httptest` round-trip against a mocked `BackupService.StartCreateBackupJob` capturing the `opts` argument. - -**Frontend — `frontend/src/pages/__tests__/Backups.test.tsx`**: the `useBackupSettingsForm` mock (currently a fixed object returned from a single `vi.mock` factory, lines 23-58) must become configurable per-test (mirror the existing `createIsPending`/`createJob` mutable-variable pattern already used in this same file, lines 15-16, 29) so tests can vary `encryptionPassphraseSet`. Add a parallel mock for `useRemoteTargets` (new import into `Backups.tsx`, currently unmocked in this file since it isn't imported yet) using the same mutable-variable pattern. -1. `it('defaults the encrypt toggle on and hides the passphrase input when a passphrase is already stored')` — set mock `encryptionPassphraseSet = true`, open the create dialog, assert `backup-create-encrypt-toggle` is checked and `backup-create-passphrase-input` is absent while `backup-create-passphrase-set-indicator` is present. -2. `it('still defaults the encrypt toggle off when no passphrase is stored (existing behavior)')` — the existing test at line 172 (`'opens the create backup dialog and submits an unencrypted backup by default'`) already covers this with the default mock (`encryptionPassphraseSet: false`); confirm it still passes unmodified — it is the regression guard for this case, not a new test. -3. `it('submits without a passphrase field when confirming an encrypted backup with a stored passphrase')` — `encryptionPassphraseSet = true`, open dialog, confirm, assert `mockCreateMutate` was called with `{ encrypt: true, upload_to_remote: ... }` and no `passphrase` key at all (not `passphrase: ''`). -4. `it('shows and defaults on the upload-to-remote toggle when an enabled remote target exists')` — mock `useRemoteTargets` returning one `{ enabled: true, ... }` target, open dialog, assert `backup-create-upload-toggle` present and checked. -5. `it('hides the upload-to-remote toggle when there are no enabled remote targets')` — mock returns `[]` or all-disabled targets, open dialog, assert `backup-create-upload-toggle` absent. -6. `it('omits upload_to_remote from the payload when there are no enabled remote targets')` — same setup as (5), confirm, assert the mutate payload has no `upload_to_remote` key. - -### 12.3 Item C — Scheduled (cron) backups never apply the encryption setting - -#### 12.3.1 Root cause (confirmed) - -`backend/internal/services/backup_service.go:384` (`s.Cron.AddFunc(scheduleCron, s.RunScheduledBackup)`) and `:438` (`Reschedule`, same target) wire cron directly to `RunScheduledBackup` (`:446-470`). That function's actual backup-creation call, `:458` `createBackup()`, resolves via the injectable-for-testing indirection at `:448-451`: -```go -createBackup := s.CreateBackup -if s.createBackup != nil { - createBackup = s.createBackup -} -``` -— defaulting (`s.createBackup` is set to `s.CreateBackup` in `NewBackupService`, `:354`) to `CreateBackup()` (`:626-632`), which is hardcoded to `s.CreateBackupWithOptions(BackupOptions{Type: "manual"})` — **no `Encrypt`/`Passphrase` field at all**, always `false`/`""`. `SettingKeyBackupEncryptionEnabled` and `SettingKeyBackupEncryptionPassphraseEnc` (`backup_settings.go:32,35`) are read only by `GetSettings` (serves the settings-card GET) and written only by `UpdateSettings` (serves the settings-card PUT) — neither is ever consulted anywhere in the `RunScheduledBackup`/`CreateBackup` call chain. Confirmed by exhaustive grep: `SettingKeyBackupEncryptionEnabled` has exactly 3 references in non-test `.go` files (the constant declaration, one read in `GetSettings`, one write in `UpdateSettings`) — none inside `backup_service.go`'s scheduling code. - -**Net effect:** toggling "Enable backup encryption" on in the UI has zero effect on any backup actually produced by the cron schedule. It only affects backups created through the manual Create-dialog path today (and even there, only when the user manually re-toggles + re-types per §12.2's Item B). - -#### 12.3.2 Fix - -`RunScheduledBackup` must resolve the encryption settings and call `CreateBackupWithOptions` (or, better, `StartCreateBackupJob`-equivalent synchronous path — see note below) with `Encrypt`/`Passphrase` populated, instead of the bare no-args `createBackup()`. - -**Design constraint:** the `s.createBackup func() (string, error)` injectable-for-testing field (`backup_service.go:147`) is stubbed directly by two existing tests (`backup_service_test.go:495-499`, `TestRunScheduledBackup_CleanupFails`; `backup_service_rehydrate_test.go:317-321`, `TestBackupService_RunScheduledBackup_CreateBackupAndCleanupHooks`) to avoid touching disk. Changing `RunScheduledBackup` to resolve options itself and call a *different* function means these two stubs would go dead (never invoked) unless the field they patch is repurposed to match. **Resolution:** repurpose the field's signature from `func() (string, error)` to `func(BackupOptions) (*models.BackupRecord, error)` — matching `CreateBackupWithOptions`'s own signature exactly, defaulted in `NewBackupService` (`:354`) to `s.CreateBackupWithOptions` instead of `s.CreateBackup`. Both of the two existing test call sites are updated in the same commit (they already construct trivial closures; only the signature and their two `service.createBackup = func...` lines change, not their assertions on `createCalled`/`createCalls`/`cleanupCalled`). - -```go -type BackupService struct { - ... - createBackupOpts func(BackupOptions) (*models.BackupRecord, error) // was: createBackup func() (string, error) - cleanupOld func(int) (int, error) - ... -} -``` -```go -// in NewBackupService: -s.createBackupOpts = s.CreateBackupWithOptions -``` -```go -// resolveScheduledBackupOptions builds the BackupOptions for a -// cron-triggered backup, applying the persisted encryption setting (spec -// §12.3) — previously RunScheduledBackup always created unencrypted -// backups regardless of the "Enable backup encryption" setting. Reuses -// resolveBackupPassphrase (§12.2.2) for the identical stored-passphrase -// decrypt path the manual-create flow now also uses; no new decrypt logic. -func (s *BackupService) resolveScheduledBackupOptions() (BackupOptions, error) { - opts := BackupOptions{Type: "scheduled"} // was "manual" — see §12.3.3, folded into this same fix per Supervisor review - if s.db == nil { - return opts, nil - } - opts.Encrypt = readBackupSettingBool(s.db, SettingKeyBackupEncryptionEnabled, false) - if !opts.Encrypt { - return opts, nil - } - return s.resolveBackupPassphrase(opts) -} -``` -```go -func (s *BackupService) RunScheduledBackup() { - logger.Log().Info("Starting scheduled backup") - createBackupOpts := s.CreateBackupWithOptions - if s.createBackupOpts != nil { - createBackupOpts = s.createBackupOpts - } - - cleanupOld := s.CleanupOldBackups - if s.cleanupOld != nil { - cleanupOld = s.cleanupOld - } - - opts, err := s.resolveScheduledBackupOptions() - if err != nil { - logger.Log().WithError(err).Error("Scheduled backup: failed to resolve encryption settings") - return - } - - if record, err := createBackupOpts(opts); err != nil { - logger.Log().WithError(err).Error("Scheduled backup failed") - } else { - logger.Log().WithField("backup", record.Filename).Info("Scheduled backup created") - if deleted, err := cleanupOld(DefaultBackupRetention); err != nil { - logger.Log().WithError(err).Warn("Failed to cleanup old backups") - } else if deleted > 0 { - logger.Log().WithField("deleted_count", deleted).Info("Cleaned up old backups") - } - } -} -``` -If `opts.Encrypt` is true but no passphrase is stored, `resolveBackupPassphrase` returns `opts` unmodified (empty `Passphrase`) rather than erroring — the downstream `createBackupLockedWithProgress` check (`:900`) then fails the backup with the existing `"passphrase is required to encrypt backup"` error, logged exactly like any other scheduled-backup failure today (no new failure mode, just a more specific error message than an unencrypted backup silently going out). - -`UploadToRemote` is deliberately left unset (nil) in `resolveScheduledBackupOptions` — defaults to `true` per §12.2.2's table, preserving today's actual behavior (scheduled backups are already uploaded to all enabled remote targets unconditionally; item C does not change that). -#### 12.3.3 Side question: `Type: "manual"` vs `Type: "scheduled"` — investigated, resolved (fold into F1) +**`scripts/local-patch-report.sh` changes**: +- New optional CLI flag `--advisory` (or `CHARON_PATCH_REPORT_ADVISORY=1` env var), forwarded to the Go tool as `--advisory=true`; unset/absent means strict (default). +- The Go-tool invocation (lines 112-122) is restructured so the script can still run its own artifact-existence checks (lines 124-133) even when the tool exits 1, and only then propagates the real exit code — rather than letting `set -e` abort the script mid-way and skip artifact verification: + ```bash + set +e + ( + cd "$ROOT_DIR/backend" + go run ./cmd/localpatchreport \ + --repo-root "$ROOT_DIR" \ + --baseline "$BASELINE" \ + --backend-coverage "$BACKEND_COVERAGE_FILE" \ + --frontend-coverage "$FRONTEND_COVERAGE_FILE" \ + --agent-coverage "$AGENT_COVERAGE_FILE" \ + --json-out "$JSON_OUT" \ + --md-out "$MD_OUT" \ + --advisory="$ADVISORY" + ) + REPORT_STATUS=$? + set -e + ``` + ...followed by the existing non-empty-artifact checks unchanged, and a final `exit "$REPORT_STATUS"` replacing the implicit exit-0 fallthrough at the end of the script. -**Finding:** `backup_manifest.go:23` documents the type taxonomy explicitly in its own field comment — `` BackupType string `json:"backup_type"` // manual|scheduled|pre_restore|uploaded `` — i.e. `"scheduled"` is a first-class, intentionally-designed value in the same enum as `"manual"`, not something added speculatively. `git log -S'BackupOptions{Type: "manual"}'` traces the `CreateBackup()` wrapper's hardcoded `"manual"` back to commit `ff66cb5d` ("v2 backups — validated restore, encryption, schedule, remote storage"), the same commit that introduced the whole v2 type taxonomy including `"scheduled"` — nothing in that commit's message or diff suggests `"manual"` was deliberately chosen for the cron path specifically; it reads as `CreateBackup()` simply being the pre-existing, pre-v2 single entry point that `RunScheduledBackup` already called before scheduling existed, left unchanged when v2's richer `BackupOptions`/type taxonomy was added elsewhere. `frontend/src/pages/Backups.tsx:39-43`'s `BACKUP_TYPE_KEYS` maps `scheduled` to a distinct `t('backups.types.scheduled')` = "Scheduled" badge — currently dead code from the scheduled-backup path's perspective, since no scheduled backup can ever produce a record with `Type: "scheduled"` today (only a manually-crafted `BackupRecord` or a future fix would reach that branch). +**Decision explicitly rejected**: flipping `codecov.yml`'s `coverage.status.patch.default.informational` from `true` to `false` so Codecov itself hard-blocks the PR. Per `CLAUDE.md`'s Governance & Precedence "stricter security requirement wins" rule this is arguable, but it was rejected for this follow-up because it changes CI-blocking behavior **repo-wide, for every PR**, not just this one — a materially larger blast radius than "make the already-mandatory local tool actually enforce what it claims to." The local strict-by-default gate (this section) already satisfies `CLAUDE.md` DoD Step 2's "MANDATORY" requirement without that side effect. Revisit as a separate, explicitly-scoped change if the team wants Codecov itself to hard-block. -**Resolved (post-review): genuine bug, fixed as part of F1.** Supervisor concurred this is a genuine bug — the taxonomy was clearly designed to distinguish them, and no evidence anywhere (spec, tests, comments) indicates scheduled-as-manual was deliberate — and confirmed via grep that no existing test asserts `Type == "manual"` for a scheduled-path record, so correcting it carries no test-breakage risk. `resolveScheduledBackupOptions` (§12.3.2) now seeds `opts := BackupOptions{Type: "scheduled"}` instead of `"manual"`, folded into Commit F1 below since that function is already being touched by this same addendum. **Scope note:** this only affects backups created *after* this fix ships — existing on-disk/DB-recorded scheduled backups remain permanently labeled `"manual"` (no backfill migration is included; consistent with §12's "bug fixes riding the same PR" scope, not a data-correction project). +### F.5 Regenerated Gap Data (Correct `origin/development` Baseline) -#### 12.3.4 TDD test plan (Item C) +Regenerated directly via `backend/cmd/localpatchreport` (bypassing the not-yet-fixed shell script) with `--baseline "origin/development...HEAD"`, fresh `go test -coverprofile` runs for `backend/` and `agent/`: -**`backend/internal/services/backup_service_test.go`** (update the two existing stubs per §12.3.2's signature change) **+ a new test**: -1. Update `TestRunScheduledBackup_CleanupFails` (`:495-499`) and `TestBackupService_RunScheduledBackup_CreateBackupAndCleanupHooks` (`backup_service_rehydrate_test.go:317-321`): change `service.createBackup = func() (string, error) {...}` to `service.createBackupOpts = func(BackupOptions) (*models.BackupRecord, error) {...}`, returning a minimal `&models.BackupRecord{Filename: ...}` instead of a bare string; assertions on `createCalled`/`createCalls`/`cleanupCalled` unchanged. -2. **New** `TestRunScheduledBackup_EncryptionEnabled_ProducesEncryptedBackup` — the core regression test for this bug. Real `BackupService` (not a stubbed `createBackupOpts`, so the actual pipeline runs), seed via `UpdateSettings(BackupSettingsUpdate{EncryptionEnabled: boolPtr(true), EncryptionPassphrase: strPtr("correct-horse-battery-staple")})`, call `service.RunScheduledBackup()`, then list backups and assert (reusing the `record.Encrypted`/`.age`-suffix assertion pattern from `backup_service_v2_hardening_test.go:136-138`) the produced `BackupRecord.Encrypted == true`, its filename ends in `.age`, **and its `Type == "scheduled"`** (§12.3.3's folded-in fix — this test now also covers that correction). -3. **New** `TestRunScheduledBackup_EncryptionEnabledNoStoredPassphrase_FailsWithClearError` — encryption enabled via settings, no passphrase ever set, `RunScheduledBackup()` logs the failure and produces no successful backup (existing "should not panic when backup fails" assertion style from the pre-existing top-of-file test) — proves the missing-passphrase case degrades to a logged failure, not a silent unencrypted backup. -4. **New** `TestRunScheduledBackup_EncryptionDisabled_ProducesUnencryptedBackup` — explicit regression guard: default settings (`encryption_enabled` false), `RunScheduledBackup()`, assert `Encrypted == false` **and `Type == "scheduled"`** (proves the `Type` fix applies regardless of the encryption setting, since `resolveScheduledBackupOptions` sets `Type` unconditionally before branching on `Encrypt`) — the pre-fix, still-correct default path for encryption. -5. **New** `TestRunScheduledBackup_CorruptedStoredPassphrase_LogsErrorNoUnencryptedFallback` (non-blocking finding #3 from review) — encryption enabled via settings, but `SettingKeyBackupEncryptionPassphraseEnc` seeded directly with an unparseable/corrupted ciphertext (bypassing `UpdateSettings`), call `service.RunScheduledBackup()`, assert no backup record is produced (list backups, confirm no new entry) — proves `resolveScheduledBackupOptions`'s propagated decrypt error causes `RunScheduledBackup` to log-and-return (per §12.3.2's `if err != nil { ...; return }` branch) rather than silently falling through to an unencrypted backup. +| Scope | Changed Lines | Covered | Patch Coverage | Threshold | Status | +|---|---:|---:|---:|---:|---| +| Overall | 168 | 142 | 84.5% | 90.0% | **warn** | +| Backend | 34 | 31 | 91.2% | 85.0% | pass | +| Frontend | 0 | 0 | 100.0% | 85.0% | pass | +| Agent | 134 | 111 | 82.8% | 85.0% | **warn** | -### 12.4 GORM security scan applicability +**Important caveat, stated explicitly rather than glossed over**: these agent-scope numbers are numerically identical to the QA report's `origin/main`-baseline numbers (also 134 changed / 111 covered / 82.8%). This is *expected*, not evidence the baseline bug doesn't matter: `agent/muzzle/muzzle.go` and `agent/leash/leash.go`'s changed lines in this diff are entirely new-to-this-branch content that exists on neither `origin/main` nor `origin/development` — for these two specific files, the diff is "whole relevant section is new" under either baseline, so the choice doesn't move their numbers. The baseline bug is still real and still matters (F.1, F.2) for the *overall* number and for any file that has independently changed on `development` since it diverged from `main` — it just doesn't happen to be the reason these two files look bad. -**Not required for §12 as a whole**, per CLAUDE.md §1.5's trigger condition (`backend/internal/models/**` changes, GORM queries, or migrations): -- No new `models` struct or field is introduced by A, B, or C. -- `SettingKeyBackupEncryptionEnabled` and `SettingKeyBackupEncryptionPassphraseEnc` are pre-existing keys (`backup_settings.go:32,35`, present since `ff66cb5d`) — B and C only *add new call sites that read/decrypt* an already-existing, already-encrypted-at-rest column value (`BackupSetting.Value`, via the existing `readBackupSettingString`/`upsertBackupSetting` helpers); no schema, key, or migration change. -- `createBackupRequest.UploadToRemote` and `BackupOptions.UploadToRemote` are plain Go/JSON request-and-options-struct fields, not GORM model fields — no `AutoMigrate` impact. -- No new or modified GORM query (`Where`, `Find`, `Create`, `Model(...).Update`) shape appears anywhere in A/B/C's design — `resolveBackupPassphrase`/`resolveScheduledBackupOptions` call the existing `readBackupSettingString`/`readBackupSettingBool` helpers unchanged. +**Also documented as a known residual limitation, not something F.3/F.4 claim to fix**: local numbers (82.8%/86.1%/63.2%) do not exactly match Codecov's reported numbers (76.92%/81.72%/36.36%). This is very likely because Go's standard coverage profile format records only per-statement-block hit counts ("was this block executed at least once"), while Codecov's "partial" bucket (8 partials on `muzzle.go` alone) reflects branch-level analysis — a line with an `if`/`&&` where only one branch was exercised. `ComputeScopeCoverage` in `patchreport.go` treats any line with `count > 0` as fully covered, which is systematically more generous than Codecov's hit/partial/miss model. Closing this gap exactly would require branch-level coverage instrumentation Go's standard tooling doesn't produce; out of scope for this follow-up. The baseline and enforcement fixes (F.3/F.4) make the local tool *directionally trustworthy and actually blocking* — they do not promise bit-for-bit parity with Codecov's percentage. -Skip `./scripts/scan-gorm-security.sh --check` for this addendum's commits, per the same reasoning as §11.5. +**Files needing coverage** (from the regenerated report): -### 12.5 Commit Slicing Strategy (this addendum only) +| Path | Patch Coverage | Uncovered Changed Lines | +|---|---:|---| +| `agent/leash/leash.go` | 63.2% | 172, 177-178, 188, 198-199, 232 | +| `agent/muzzle/muzzle.go` | 86.1% | 191-192, 205-206, 216-217, 233-234, 248-249, 409-410, 412-414, 438 | +| `backend/cmd/localpatchreport/main.go` | 91.2% | 112-114 (pre-existing gap in the tool's own agent-coverage-missing error path; not touched by this follow-up's own new code, left as-is) | -**Decision:** same PR (#1136), four ordered commits appended after §11's C2, backend-before-frontend per CLAUDE.md's suggested sequence, with the two backend items (B and C) sharing a foundation (`resolveBackupPassphrase`) split into their own commits since each has an independently meaningful test suite and either could, in principle, be reverted alone without the other breaking (C's `resolveScheduledBackupOptions` calls B's `resolveBackupPassphrase`, so D1 must land before F1 — captured as a dependency below, not a reason to merge them into one commit). +**What each uncovered line actually represents** — corrects one detail of the QA report's characterization: -| # | Commit | Scope | Files | Depends on | Validation gate | -|---|---|---|---|---|---| -| D1 | `feat: thread UploadToRemote option and stored-passphrase reuse through backup creation` | Backend | `backend/internal/api/handlers/backup_handler.go` (`createBackupRequest`, `Create`, §12.2.2), `backend/internal/services/backup_service.go` (`BackupOptions.UploadToRemote`, `resolveBackupPassphrase`, `fireRemoteUploadIfEnabled` shared helper, `CreateBackupWithOptions`, `runCreateBackupJob`, §12.2.2 — corrected call-graph fix), new/extended `backend/internal/services/backup_service_options_test.go` and `backend/internal/api/handlers/backup_handler_test.go` (§12.2.4 tests 1-7) | None — independent of §1-11 | `go test ./backend/internal/services/... -run 'TestStartCreateBackupJob|TestCreateBackupWithOptions_UploadToRemote' -v && go test ./backend/internal/api/handlers/... -run TestBackupHandler_Create -v` (scoped to these test-name prefixes only — do **not** run the full backend `-race` suite; concurrent heavy test jobs are running elsewhere on this machine right now) | -| D2 | `feat: smart defaults for encryption and remote-upload in the Create Backup dialog` | Frontend | `frontend/src/api/backups.ts` (`CreateBackupOptions.upload_to_remote`), `frontend/src/pages/Backups.tsx` (§12.2.3), `frontend/src/locales/en/translation.json` (`backups.uploadToRemote`), `frontend/src/pages/__tests__/Backups.test.tsx` (mock refactor + §12.2.4 tests 1-6) | D1 (frontend payload shape must match the now-live backend contract; UI would 400/silently-ignore against an unpatched backend otherwise) | `cd frontend && npx vitest run src/pages/__tests__/Backups.test.tsx` (scoped to this single test file only — do **not** run the full frontend coverage suite or Playwright; both are under heavy concurrent load elsewhere right now) | -| E1 | `feat: add Save button to BackupEncryptionCard` | Frontend | `frontend/src/components/backups/BackupEncryptionCard.tsx` (§12.1), `frontend/src/locales/en/translation.json` (`backups.encryption.save`/`saveSuccess`), `frontend/src/components/backups/__tests__/BackupEncryptionCard.test.tsx` (§12.1.1) | None — independent of D1/D2, could be reordered first | `cd frontend && npx vitest run src/components/backups/__tests__/BackupEncryptionCard.test.tsx` (scoped to this single test file only) | -| F1 | `fix: apply the encryption setting to scheduled/cron backups, correct their Type to "scheduled"` | Backend | `backend/internal/services/backup_service.go` (`createBackupOpts` field rename + `resolveScheduledBackupOptions` incl. the `Type: "scheduled"` correction §12.3.3 + `RunScheduledBackup`, §12.3.2), `backend/internal/services/backup_service_test.go` and `backend/internal/services/backup_service_rehydrate_test.go` (2 existing stub updates, §12.3.4 test 1), new tests (§12.3.4 tests 2-5, including the `Type == "scheduled"` assertions and the corrupted-passphrase test) | D1 (`resolveScheduledBackupOptions` calls `resolveBackupPassphrase`, introduced in D1) | `go test ./backend/internal/services/... -run TestRunScheduledBackup -v && go test ./backend/internal/services/... -run TestBackupService_RunScheduledBackup -v` (scoped to these two test-name prefixes only — do **not** run the full backend `-race` suite; concurrent heavy test jobs are running elsewhere on this machine right now) | +`agent/muzzle/muzzle.go`: -**Rollback/contingency:** D2 and E1 are pure frontend render/state changes — revert either independently with no data-model impact. D1 is additive at the Go type level (a new pointer field, two new private helpers) plus one behavior-restoring line added to `runCreateBackupJob`'s success branch (`s.fireRemoteUploadIfEnabled(opts, record)`) — reverting D1 restores the pre-existing passphrase-required rejection and, notably, also reverts manual dialog-triggered backups back to *never* remote-uploading (§12.2.1's corrected finding: that was already true before this PR, so reverting D1 is a true rollback to `main`'s behavior, not a new regression). No persisted-data implication either way (no new column, no migration to roll back). F1 depends on D1's `resolveBackupPassphrase` helper but is otherwise isolated to `RunScheduledBackup`/`resolveScheduledBackupOptions`; reverting F1 alone (leaving D1 in place) restores today's "scheduled backups ignore the encryption setting, and are always labeled `manual`" behavior — safe, since that is the pre-PR status quo, not a regression relative to `main`. The `Type: "manual"`→`"scheduled"` correction (§12.3.3) is folded into F1's scope and reverts along with it as a single unit; it is not independently revertible without reverting all of F1. +| Lines | Function | Branch | Fail-closed or permissive? | +|---|---|---|---| +| 191-192 | `validateNetworkModeValue` | `json.Unmarshal(raw, &mode)` fails (e.g. `NetworkMode` is a JSON number, not a string) → `return false` | Fail-closed | +| 205-206 | `validateMountsValue` | `json.Unmarshal(raw, &mounts)` fails (e.g. `Mounts` is a JSON string, not an array) → `return false` | Fail-closed | +| 216-217 | `validateMountsValue` | per-mount `json.Unmarshal(mnt.VolumeOptions, &volumeOptions)` fails (malformed `VolumeOptions` object) → `return false` | Fail-closed | +| **233-234** | `validateContainerCreateBody` | `len(bodyBytes) == 0` → **`return true, ""`** | **Permissive** — an empty `/containers/create` body is intentionally allowed through | +| 248-249 | `validateContainerCreateBody` | `json.Unmarshal(hostConfigRaw, &hostConfig)` fails (`HostConfig` present but not a JSON object) → `return false, "malformed request body"` | Fail-closed | +| 409-410 | `ServeProxy` | `io.ReadAll(limited)` fails while reading the request body → `return fmt.Errorf(...)` | Fail-closed (aborts the stream) | +| 412-414 | `ServeProxy` | body exceeds `maxContainerCreateBodyBytes` (64KiB) → writes `forbiddenResponse`, returns error | Fail-closed | +| 438 | `ServeProxy` | `req.Write(conn)` fails forwarding to the real Docker socket → `return fmt.Errorf(...)` | Fail-closed (I/O fault path, not a policy branch) | -- **Route-mock timing (ruled out)**: `setupUptimePage()` (`tests/uptime-orthrus.spec.ts:48-71`) registers both `page.route(UPTIME_MONITOR_HISTORY_API, ...)` and `page.route(UPTIME_MONITORS_API, ...)` and `await`s both calls before `await page.goto('/uptime')` (lines 53-69). Playwright deterministically intercepts all matching requests issued after `page.goto()` once `route()` has resolved — there is no race window here. This matches the coordinator's own skepticism of the race theory. -- **Route pattern (ruled out)**: `frontend/src/api/uptime.ts:35-36` (`getMonitors`) calls `client.get('/uptime/monitors')` with no query string, which exactly matches the mock pattern `**/api/v1/uptime/monitors` (`tests/uptime-orthrus.spec.ts:5`). No mismatch. -- **Stale/leaked real data (ruled out)**: if real, unmocked data from a long-lived container were leaking through, the three *single*-monitor tests earlier in the same file (lines 78-133), which use unqualified `page.getByTestId('monitor-card')`/`getByTestId('status-badge')` locators with `.toBeVisible()` (a strict-mode-sensitive assertion that throws if more than one match exists), would also fail whenever extra real monitor cards were present. They do not fail — only the two-monitor test does. This rules out data leakage as the cause. -- **True root cause — component sort order, confirmed by reading `frontend/src/pages/Uptime.tsx` in full**: - - Line 559-565: `sortedMonitors` sorts the entire monitor list **alphabetically by name**, case-insensitively — `[...monitors].sort((a, b) => (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()))`. This is a deliberate, commented feature ("Sort monitors alphabetically by name"), not a bug. - - Lines 567-569: `sortedMonitors` is then split into three category buckets by `proxy_host_id` / `remote_server_id` (`UptimeMonitor` interface, `frontend/src/api/uptime.ts:7-8`): `proxyHostMonitors`, `remoteServerMonitors`, `otherMonitors`. - - The test's two mock monitors — `MOCK_ORTHRUS_MONITOR_BASE` (name `"Remote Server (Orthrus)"`) and `MOCK_TCP_MONITOR_SAME_IP` (name `"Dockhand Service"`) — **both omit `proxy_host_id` and `remote_server_id`** (`tests/uptime-orthrus.spec.ts:11-35`), so both land in the same `otherMonitors` bucket and render in the same grid (`Uptime.tsx:632-641`). - - Within that bucket they are sorted alphabetically: `"Dockhand Service"` (`D`) sorts **before** `"Remote Server (Orthrus)"` (`R`). So `page.getByTestId('monitor-card').first()` is the **TCP** monitor card, and `.nth(1)` is the **Orthrus** monitor card — the exact inverse of what the test's positional assertions (lines 151-162) assume. - - This also explains QA's own observation that `cards.toHaveCount(2)` (line 146-149) passes: sorting changes order, not count, so that assertion was never informative about the actual defect. - - The name `"Dockhand Service"` in the test fixture is coincidental/red-herring-adjacent to QA's "real Dockhand Service monitor" theory — it is the test's **own mock data**, not a leaked real monitor; QA misread which object was showing up first. -- **Classification**: this is a genuine, order-dependent Playwright assertion bug colliding with an intentional, pre-existing, correctly-functioning component behavior (alphabetical sort). It is **not** a race, **not** a route-mock scoping bug, and **not** an app-level defect — `Uptime.tsx`'s sort is working exactly as designed and does not need to change. +The QA report's claim that "most of the gap... are fail-closed error-return branches" is **still substantially accurate** (7 of 8 ranges) but **not complete**: line 233-234 is the one *permissive* branch in the set — the empty-body pass-through for `/containers/create` — and deserves its own explicit positive-outcome test (F.6) precisely because an untested permissive branch is exactly the kind of thing that should not be taken on faith, unlike the fail-closed branches where "untested" at least means "cannot be coerced into an unsafe outcome." -**Conclusion: test bug. Owner: Playwright Dev.** No change to `frontend/src/pages/Uptime.tsx` is warranted or in scope. +`agent/leash/leash.go` — every uncovered line is part of the write-mode connection-scoped `*muzzle.Filter` dispatch wiring added by the earlier `a4be39e2` commit, and **none of it has ever been exercised by a test**, not even indirectly: -**Exact fix spec** — in `tests/uptime-orthrus.spec.ts`, replace the two `test.step` blocks at lines 151-162 (leave the `cards.toHaveCount(2)` step at lines 146-149 unchanged) with content-scoped locators instead of positional (`.first()`/`.nth(1)`) ones: +| Lines | Location | What's uncovered | +|---|---|---| +| 172 | `connect`'s `AcceptStream` loop | `go l.handleStream(stream, filter)` — the real per-stream dispatch call is never reached because the only existing test (`TestLeash_Reconnect`) has the fake server close the connection immediately, before any stream is ever opened | +| 177-178 | `handleStream` | function signature + `defer func() { _ = stream.Close() }()` — the function itself is never called by any test (it's unexported; the only test file, `leash_test.go`, is external `package leash_test`, and no test drives a stream through `connect()` far enough to invoke it) | +| 188 | `handleStream`'s `switch` | `l.handleDockerStream(stream, filter)` case dispatch | +| 198-199 | `handleDockerStream` | function signature + `filter.ServeProxy(l.dockerSock, stream, stream)` — the connection-scoped filter is never actually invoked from the dispatch path in any test (muzzle's own tests construct a `*Filter` directly and call `.Allow`/`.ServeProxy` on it, never through `Leash`'s wiring) | +| 232 | `handlePortForward` | `defer func() { _ = conn.Close() }()` — only reached on a *successful* TCP dial; existing coverage of `handlePortForward` (if any, outside the diff) only exercises the early-return error paths (invalid address length, dial failure), never a real successful dial | -```ts -await test.step('verify each monitor card shows its own independent status badge', async () => { - const orthrusCard = page.getByTestId('monitor-card').filter({ hasText: 'Remote Server (Orthrus)' }); - const tcpCard = page.getByTestId('monitor-card').filter({ hasText: 'Dockhand Service' }); - await expect(orthrusCard.getByTestId('status-badge')).toHaveAttribute('data-status', 'up'); - await expect(tcpCard.getByTestId('status-badge')).toHaveAttribute('data-status', 'up'); -}); +This is a meaningful, non-cosmetic gap: it is the wiring that proves `connect()`'s per-connection `muzzle.New(writeEnabled)` filter (line 161) actually reaches `ServeProxy` for real traffic — exactly the security-relevant behavior this whole follow-up chain (#1160/#1161) depends on, not incidental scaffolding. -await test.step('verify Orthrus monitor card shows ORTHRUS type and TCP monitor card shows TCP type', async () => { - const orthrusCard = page.getByTestId('monitor-card').filter({ hasText: 'Remote Server (Orthrus)' }); - const tcpCard = page.getByTestId('monitor-card').filter({ hasText: 'Dockhand Service' }); - await expect(orthrusCard).toContainText('ORTHRUS'); - await expect(tcpCard).toContainText('TCP'); -}); -``` +### F.6 Test Specifications to Close the Gap (TDD — meaningful tests, not padding) -This makes the assertions correct regardless of `Uptime.tsx`'s (intentional, unchanged) alphabetical sort order, and regardless of any future re-ordering of the mock array or additional monitors being added to the fixture. +**`agent/muzzle/muzzle_test.go`** (package `muzzle_test`, external — all reachable through the existing `Filter.Allow`/`ServeProxy` surface, no white-box access needed): -**Suggested commit message**: `test(uptime): assert Orthrus/TCP monitor cards by name, not render position` +1. Extend the existing table in `TestFilter_Allow_ContainersCreate_DangerousBodiesRejected` (currently ends around line 605) with 4 new cases, each isolating exactly one of the four still-untested fail-closed branches (distinct from the existing `"malformed JSON"` case, which only reaches the *outer* top-level unmarshal failure, a different, already-covered line): + - `"malformed NetworkMode value (non-string JSON)"` — body `{"Image":"nginx","HostConfig":{"NetworkMode":12345}}` → targets lines 191-192. + - `"malformed Mounts value (not a JSON array)"` — body `{"Image":"nginx","HostConfig":{"Mounts":"not-an-array"}}` → targets lines 205-206. + - `"malformed Mounts VolumeOptions (invalid JSON object)"` — body `{"Image":"nginx","HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":"not-an-object"}]}}` → targets lines 216-217 (distinct from the existing `DriverConfig`-bypass case, which has a *valid* `VolumeOptions` object). + - `"malformed HostConfig itself (not a JSON object)"` — body `{"Image":"nginx","HostConfig":"not-an-object"}` → targets lines 248-249 (distinct from the existing top-level `"malformed JSON"` case). + All four assert `f.Allow("POST", "/containers/create", []byte(tc.body))` is `false`, same pattern as every existing row in that table. +2. New test `TestFilter_Allow_ContainersCreate_EmptyBodyAllowed` — `f := muzzle.New(true)`; asserts `f.Allow("POST", "/containers/create", nil)` and `f.Allow("POST", "/containers/create", []byte{})` are both `true`. Targets lines 233-234, the one *permissive* branch identified in F.5 — given its own named test (not folded into the "safe body allowed" table) specifically because it documents and locks in an intentional security-relevant default, not an incidental pass-through. +3. New test `TestFilter_ServeProxy_BodyReadError_ReturnsError` — feeds `ServeProxy` an `io.Reader` that yields a well-formed HTTP request line + headers followed by a body source that returns a read error mid-stream (e.g. `io.MultiReader(validHeaderBytes, errReader{err: errors.New("boom")})`); asserts a non-nil error mentioning `"read body"` is returned and no panic occurs. Targets lines 409-410. +4. New test `TestFilter_ServeProxy_BodyTooLarge_Returns403AndError` — sends a request (any allowed or disallowed method/path is fine, since the size check runs before `Allow` is consulted) with a body of `maxContainerCreateBodyBytes + 1` bytes; asserts `forbiddenResponse` was written to the `io.Writer` and the returned error mentions `"body too large"`. Targets lines 412-414. +5. New test `TestFilter_ServeProxy_DockerWriteError_ReturnsError` — uses a real `net.Listen("unix", tmpSock)` fake Docker socket whose accept handler closes the accepted connection immediately (before `ServeProxy`'s `req.Write(conn)` runs), for an otherwise-allowed request (e.g. `GET /containers/json`); asserts a non-nil error mentioning `"forward request to docker"` is returned. Targets line 438. -## 13. Addendum: Trusted-Proxy Header Hardening for Cookie Security Decisions (Same PR) +**`agent/leash/leash_test.go`** (package `leash_test`, external — extends the existing WebSocket-upgrade test harness already used by `TestLeash_Reconnect`; deliberately *not* a new white-box/internal test file, because driving the dispatch through the real exported `Run`/`Config` surface exercises the actual wiring end-to-end, which is the behavior that matters here, not merely the unexported functions in isolation): -**Status:** security-hardening fix for a gap QA identified and explicitly risk-accepted (non-blocking) during review of the §9 auth-cookie fix (`6e4d2c0e`) — see `docs/reports/qa_report.md`, "Security Deep-Dive — Auth Cookie Secure-Flag Fix" → "Adversarial angle: header-spoofing to force a false `Secure` downgrade". QA's own recommendation at the time: *"scope `isLocalRequest`'s header trust to only apply when the request's actual remote-socket address (`c.Request.RemoteAddr`, not headers) is itself local/private, rather than trusting `X-Forwarded-Host`/`Origin`/`Referer` unconditionally."* This addendum designs and specs exactly that fix. Same PR #1136 per CLAUDE.md's "one feature = one PR" — a bug-fix/hardening item riding along on an already-mid-flight branch, not a new architecture. +1. New test `TestLeash_Connect_DockerStreamDispatchesThroughFilter` — test WS server upgrades the connection, wraps it in a server-side `yamux.Server` session, opens one stream, writes the `streamTypeDocker` marker byte followed by a minimal valid raw HTTP request (`"GET /containers/json HTTP/1.1\r\nHost: x\r\n\r\n"`). The agent side is a real `leash.New(Config{DockerSock: , ...})` running via `Run(ctx)` in a goroutine. `DockerSock` points at a `net.Listen("unix", ...)` fake listener that accepts and immediately closes each connection. Assertion: the fake listener's `Accept()` returns a connection within a bounded timeout, proving the full chain — `connect()`'s `AcceptStream` loop (line 172) → `handleStream` (177-178) → `handleDockerStream` (188, 198-199) → the connection-scoped `filter.ServeProxy` → `net.Dial(dockerSock)` — actually executed for a real accepted stream, not just that the individual functions don't panic in isolation. +2. New test `TestLeash_Connect_PortForwardStreamDialsTarget` — same harness, but the opened stream writes the `streamTypePortForward` marker byte followed by a 2-byte big-endian length + address bytes encoding a `net.Listen("tcp", "127.0.0.1:0")` fake target listener's address. Assertion: the fake target listener's `Accept()` returns a connection within a bounded timeout, proving `handlePortForward` reached its successful-dial path and the deferred `conn.Close()` (line 232) executes, in addition to `handleStream`'s `streamTypePortForward` case. -### 13.1 Confirmed vulnerability (verified against current source) +Both new leash tests are integration-style through the package's exported surface (`New`, `Config`, `Run`) — they need no unexported access and therefore add zero white-box test surface to maintain. -`backend/internal/api/handlers/auth_handler.go`: +### F.7 Additional EARS Requirements -- `requestScheme` (lines 37-50) reads `c.GetHeader("X-Forwarded-Proto")` **unconditionally**, before any other fact about the request, and returns its value verbatim (lowercased) if present. -- `isLocalRequest` (lines 118-152) builds a `candidates` list from `c.Request.Host`, `c.Request.URL.Host`, `originHost(Origin)`, `originHost(Referer)` (lines 121-131), and `X-Forwarded-Host` (lines 134-139) — **all** client-controllable, **none** gated by any trust check — and returns `true` if *any* candidate parses as loopback/RFC1918/IPv6-ULA/Tailscale-CGNAT via `isLocalOrPrivateHost` (lines 101-116). -- `setSecureCookie` (lines 169-201) uses `requestScheme` and `isLocalRequest` together to decide `Secure`/`SameSite` on the `auth_token` cookie (all 3 call sites: `Login` line 227, `Refresh` line 291, `Logout`→`clearSecureCookie` line 264→205). -- `backend/internal/server/server.go:19`, `_ = router.SetTrustedProxies(nil)` — Gin's own trusted-proxy mechanism, which governs only `gin.Context.ClientIP()`. It has **zero** effect on the manual `c.GetHeader`/`c.Request.Host` reads above; `requestScheme`/`isLocalRequest` don't call `ClientIP()` at all. +| ID | Requirement | +|---|---| +| R12 | WHEN `scripts/local-patch-report.sh` runs with `CHARON_PATCH_BASELINE` unset and the `gh` CLI is available, authenticated, and an open PR exists for the current branch, THE script SHALL resolve its diff baseline to that PR's exact `baseRefName`, matching Codecov's own comparison base. | +| R13 | WHEN `gh` is unavailable, unauthenticated, times out, or no PR is open for the current branch, THE script SHALL fall back to a static heuristic that prefers `origin/development` over `origin/main`, without erroring out. | +| R14 | THE local patch-coverage tool SHALL, by default (absent an explicit `-advisory`/`--advisory` opt-out), exit with a non-zero status if any computed coverage scope's `Status` is `"warn"`, after having already written both the JSON and markdown report artifacts to disk. | +| R15 | WHEN `-advisory`/`--advisory` is explicitly passed, THE tool SHALL exit 0 regardless of any scope's status, and SHALL record `"advisory"` (not `"strict"`) as the report's `mode` field, so the artifact itself is never misleading about which mode produced it. | +| R16 | THE new `agent/muzzle/muzzle.go` and `agent/leash/leash.go` unit tests added to close this specific Codecov-flagged gap SHALL each assert a distinct, real branch of normalization/validation/dispatch logic — not merely increment a coverage counter — verified by each new test targeting a line range named in F.5's gap table. | -**Net effect:** a client that opens a direct TCP connection to Charon's admin port (no real intermediary) — reachable in Charon's own documented direct-access deployment mode (Tailscale/LAN, no reverse proxy in front of the admin API; see §13.2) — can send `X-Forwarded-Proto: https` and/or `X-Forwarded-Host: ` and influence whether its own `auth_token` cookie gets `Secure: false`. QA's exploitability analysis (re-confirmed here, nothing new): this backend has no CORS middleware (repo-wide grep, none found), so this can only weaken the *attacker's own request's* cookie attributes — it cannot forge or steal another user's cookie. Severity is genuinely low, but the mechanism that's supposed to answer "is a *trusted* TLS-terminating proxy actually in front of me" is not currently trustworthy, which is a real design gap independent of today's low blast radius. +### F.8 Commit Slicing Strategy (Addendum) -### 13.2 Deployment topology (why `X-Forwarded-*` handling exists here at all) +**Decision**: two additional ordered commits, landing after the six already-merged-onto-branch commits from the original plan (actual git history: `6fe7a800`..`260c5ee8`), still within the single `feature/orthrus` / PR #1166. No new branch, no new PR — this is a follow-up fix to an open PR per `CLAUDE.md`'s "One Feature = One PR." -Per `ARCHITECTURE.md` ("Network Architecture" → "Dual-Port Model"): +--- -- **Management Interface (port 8080)** — Charon's own admin UI/API (the auth flow this section is about). Served directly by Gin. Explicitly **no** Cerberus middleware (rate limit/ACL/WAF/CrowdSec) — "Management interface must remain accessible even when security modules are misconfigured" / "Emergency endpoints require unrestricted access." Nothing in Charon's own bundled stack sits in front of this port. -- **Proxy Traffic (ports 80/443)** — Caddy, Charon's own bundled reverse proxy, terminates TLS here for **user-configured hosts only** (`app.example.com → http://localhost:3000`-style entries the admin creates through the UI). Caddy never proxies Charon's own admin API. +#### Commit 7 — Foundation: fix baseline resolution + enforcement in patch-coverage tooling, with tests for the tooling itself -So "behind a trusted reverse proxy" for the admin API specifically means one of two things, both legitimate and both needing to keep working: +**Scope**: F.3 (baseline resolution rewrite) + F.4 (strict-by-default enforcement, `-advisory` flag, `HasWarnStatus`, truthful `report.Mode`). No changes to `agent/muzzle/muzzle.go` or `agent/leash/leash.go` in this commit — this commit only fixes the *measurement and gating* tool, so its own validation gate can run against the still-uncovered agent code and correctly report `warn`/exit 1, proving the fix works before Commit 8 makes it pass for a genuine reason. -1. **Direct access** — Tailscale mesh, LAN IP, or `localhost`, no TLS termination at all. This is the deployment mode §9 of this document exists to support (the original Tailscale/plain-HTTP cookie-drop bug). -2. **A user-operated, externally-configured reverse proxy** — the admin's own nginx/Caddy/Traefik/cloud load balancer, set up outside Charon, terminating TLS and forwarding to Charon's admin port with `X-Forwarded-Proto`/`X-Forwarded-Host`. This is why the header-reading code exists in the first place — but today it's honored from *anyone*, not just an admin-attested reverse proxy. +**Files**: +- `scripts/local-patch-report.sh` (baseline three-tier resolution; `--advisory` passthrough; restructured exit-code propagation) +- `backend/cmd/localpatchreport/main.go` (`-advisory` flag; `report.Mode` reflects real mode; strict-mode `os.Exit(1)` after artifacts are written) +- `backend/cmd/localpatchreport/main_test.go` (new tests using the existing `runMainSubprocess` subprocess-reexec helper (line 305) — the established pattern in this file for testing `os.Exit` paths): `TestMain_StrictModeExitsNonZeroWhenAnyScopeBelowThreshold`, `TestMain_AdvisoryModeExitsZeroWhenScopeBelowThreshold`, `TestMain_ReportModeFieldReflectsStrictOrAdvisory` +- `backend/internal/patchreport/patchreport.go` (new `HasWarnStatus`) +- `backend/internal/patchreport/patchreport_test.go` (new tests: `TestHasWarnStatus_TrueWhenAnyScopeWarn`, `TestHasWarnStatus_FalseWhenAllScopesPass`) +- New `scripts/tests/local-patch-report_baseline.bats` (following the existing `scripts/history-rewrite/tests/*.bats` convention already in this repo) covering: `gh` available + PR open → uses `gh`'s `baseRefName`; `gh` absent → heuristic fallback prefers `origin/development`; `gh` present but errors/times out → same fallback; explicit `CHARON_PATCH_BASELINE` still wins over both. `gh` is stubbed via a fake executable earlier in `PATH`, the standard `bats` technique. -### 13.3 Design: `CHARON_TRUSTED_PROXIES` + peer-gated header trust +**Dependencies**: None — first commit of this follow-up, independent of the agent-code changes in Commit 8. -**New config value** (`backend/internal/config/config.go`), following the exact pattern already used for `SecurityConfig.ManagementCIDRs` (comma-separated CIDR/IP list, parsed via the existing `splitAndTrim` helper, lines 178-186): +**Validation gate**: +- `cd backend && go test ./internal/patchreport/... ./cmd/localpatchreport/... -v` — all green, including the new strict/advisory/`HasWarnStatus` tests. +- `bats scripts/tests/local-patch-report_baseline.bats` — all green. +- Manual proof the fix actually works end-to-end (documented in the PR description, not just asserted): run `bash scripts/local-patch-report.sh` on this branch (still pre-Commit-8, agent coverage still genuinely below threshold) and confirm it now (a) resolves baseline to `origin/development...HEAD`, (b) exits **non-zero**, matching what Codecov actually reported — this is the negative-path proof that closes the exact gap this whole follow-up exists to fix. Then re-run with `--advisory` and confirm exit 0, artifacts still written, `mode: "advisory"` in the JSON. +- `make lint-staticcheck-only`; `cd backend && go build ./...`. -```go -// SecurityConfig holds configuration for optional security services. -type SecurityConfig struct { - ... - // TrustedProxies lists IPs/CIDRs of reverse proxies whose - // X-Forwarded-Proto/X-Forwarded-Host headers are honored for TLS- - // termination detection (setSecureCookie) and whose presence enables - // Gin's own ClientIP() X-Forwarded-For resolution. Empty (default) - // trusts nothing — forwarded headers are ignored entirely and every - // trust decision falls back to the raw TCP peer address - // (c.Request.RemoteAddr), matching Gin's own SetTrustedProxies(nil) - // default. See docs/plans/current_spec.md §13. - TrustedProxies []string -} -``` +--- -`loadSecurityConfig()` gains, mirroring the `ManagementCIDRs` block exactly: +#### Commit 8 — Close the actual coverage gap in `agent/muzzle/muzzle.go` and `agent/leash/leash.go` -```go -trustedProxiesStr := getEnvAny("", "CHARON_TRUSTED_PROXIES") -if trustedProxiesStr != "" { - for _, cidr := range splitAndTrim(trustedProxiesStr, ",") { - if cidr != "" { - cfg.TrustedProxies = append(cfg.TrustedProxies, cidr) - } - } -} -``` +**Scope**: F.6's 5 new muzzle.go test cases/functions and 2 new leash.go integration-style tests. No production-code changes — every line named in F.5's gap table is already-shipped, already-reviewed logic (from the six earlier commits); this commit only adds the tests needed to exercise it. -`CHARON_TRUSTED_PROXIES` — comma-separated list of IPs and/or CIDRs (e.g. `172.20.0.5,10.0.0.0/24`). **Default: unset/empty → trust nobody.** This is a deliberate secure-by-default choice consistent with CLAUDE.md's "Big Picture" (zero-effort security) and with Gin's own current `nil` default — an admin who has genuinely put a reverse proxy in front of Charon's admin port must explicitly say so. +**Files**: `agent/muzzle/muzzle_test.go`, `agent/leash/leash_test.go` -**Why `SecurityConfig`, not a new top-level `Config` field:** `ManagementCIDRs` already establishes the convention that IP-trust-boundary lists live under `Security`; `TrustedProxies` is conceptually the same kind of value (an admin-attested network trust boundary), so it's grouped with it rather than invented as a third pattern. +**Dependencies**: Commit 7 (so this commit's validation gate can use the now-fixed, now-strict `scripts/local-patch-report.sh` against the correct `origin/development` baseline as its own proof of success, rather than eyeballing raw `go test -cover` output). -**Threading `isLocalOrPrivateHost`'s helper reuse — no new IP-matching code.** `backend/internal/security/whitelist.go` already has `IsIPInCIDRList(clientIP, cidrList string) bool` (comma-separated CIDR/IP string, handles bare IPs and CIDRs, canonicalizes IPv4/IPv6 loopback) — used today by `EmergencyBypass`'s `ManagementCIDRs` check. Reuse it directly rather than hand-rolling a second `[]*net.IPNet` parser (DRY, per CLAUDE.md): +**Validation gate**: +- `cd agent && go test ./muzzle/... ./leash/... -v -coverprofile=coverage.txt` — all new and existing tests green. +- `bash scripts/local-patch-report.sh` (now fixed, strict, correct baseline) exits **0**, with the markdown report's Agent row at or above 85% and the Overall row at or above 90% — this is the actual close-out proof for the Codecov-flagged gap, not just "new tests exist." +- `make lint-staticcheck-only` (agent module included, per the original plan's Commit 5 CI-parity work). +- `cd agent && go build ./...`. +- Full regression: `cd backend && go test ./... && cd ../agent && go test ./...` both green. -```go -// isTrustedPeer reports whether the request's actual TCP peer (its raw -// RemoteAddr — never a header) is in the configured trusted-proxy set. Only -// a trusted peer's X-Forwarded-Proto/X-Forwarded-Host are honored by -// requestScheme/isLocalRequest below. Empty trustedProxies => always false -// (trust nobody), matching Gin's SetTrustedProxies(nil) default. -func isTrustedPeer(c *gin.Context, trustedProxies []string) bool { - if len(trustedProxies) == 0 || c.Request == nil { - return false - } - peerIP := normalizeHost(c.Request.RemoteAddr) - if peerIP == "" { - return false - } - return security.IsIPInCIDRList(peerIP, strings.Join(trustedProxies, ",")) -} -``` +--- -(`normalizeHost` already strips the `:port`/`[]` from `RemoteAddr` — reused as-is, no new host-parsing logic.) +**Rollback / contingency for this follow-up as a whole**: both commits are additive (new tests, new flags with safe defaults, a corrected shell fallback order) — no runtime production-code behavior changes anywhere in `agent/muzzle/muzzle.go` or `agent/leash/leash.go` (only their test files change). If Commit 7's stricter default unexpectedly blocks an unrelated in-flight local workflow, the `-advisory`/`--advisory` escape hatch is the documented, intended way to bypass it temporarily — reaching for `--no-verify` on the git hook (a different, unrelated bypass) is not needed and not appropriate here. If a reviewer finds the `gh`-based baseline resolution unreliable in some CI environment, the existing `CHARON_PATCH_BASELINE` env-var override (unchanged, highest precedence, F.3 Tier 1) is the immediate mitigation without touching the script. -**`requestScheme` — gate the one header it reads:** +### F.9 Definition of Done — gate applicability (this follow-up) -```go -func requestScheme(c *gin.Context, trustedProxies []string) string { - if isTrustedPeer(c, trustedProxies) { - if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { - parts := strings.Split(proto, ",") - return strings.ToLower(strings.TrimSpace(parts[0])) - } - } - if c.Request != nil && c.Request.TLS != nil { - return "https" - } - if c.Request != nil && c.Request.URL != nil && c.Request.URL.Scheme != "" { - return strings.ToLower(c.Request.URL.Scheme) - } - return "http" -} -``` +| Gate | Applicable? | Why | +|---|---|---| +| Playwright E2E | No | Zero `frontend/` files touched. | +| GORM Security Scan | No | Zero `backend/internal/models/**`, zero GORM queries/migrations. | +| Local Patch Coverage Preflight | **Yes — this follow-up's own subject** | Commit 7 fixes the tool; Commit 8's validation gate is a real run of the fixed tool. | +| CodeQL Go | Yes | Both commits touch `.go` files. | +| CodeQL JS | No | Zero frontend/TS files touched. | +| Staticcheck | Yes | Both modules (`backend/`, `agent/`). | +| Backend coverage (85%+) | Yes | Commit 7 adds backend tests only; no backend production code removed. | +| Agent coverage | **Yes — this follow-up's own subject** | Commit 8 is the fix. | +| Frontend coverage / type-check | No frontend files touched | Gates trivially pass, still run per standard process. | +| `go build ./...` (backend and agent) | Yes | | -**`isLocalRequest` — gate `X-Forwarded-Host`, drop `Origin`/`Referer` entirely (§13.4), and use the raw peer IP — not the client-suppliable `Host` header — as the sole locality signal when the peer is untrusted:** +--- -```go -func isLocalRequest(c *gin.Context, trustedProxies []string) bool { - if c.Request == nil { - return false - } - - if isTrustedPeer(c, trustedProxies) { - candidates := []string{normalizeHost(c.Request.Host)} - if c.Request.URL != nil { - candidates = append(candidates, normalizeHost(c.Request.URL.Host)) - } - if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" { - for _, part := range strings.Split(forwardedHost, ",") { - candidates = append(candidates, normalizeHost(part)) - } - } - for _, host := range candidates { - if host != "" && isLocalOrPrivateHost(host) { - return true - } - } - return false - } - - // Untrusted peer: Host, X-Forwarded-Host, Origin, and Referer are all - // values the connecting client fully controls and the server cannot - // verify — a raw HTTP client hitting this port directly can set Host to - // anything ("curl -H 'Host: 127.0.0.1' http://public-charon:8080/") just - // as trivially as it can set X-Forwarded-Host. The one fact this server - // can actually trust is who opened the TCP connection. - peerIP := normalizeHost(c.Request.RemoteAddr) - return peerIP != "" && isLocalOrPrivateHost(peerIP) -} -``` +## Addendum: Rename allowlist gap + write-mode restart toast (PR #1166 continuation) -Note this is a **strict improvement**, not just a lateral gating change, for the untrusted-peer path: in the *legitimate* direct-access case (no proxy at all — Tailscale/LAN, §13.2 case 1), the raw TCP peer address and the browser's typed `Host` necessarily coincide (there is no intermediary to make them differ), so switching to `RemoteAddr` loses no legitimate coverage. It does, however, close the Host-header-forgery angle QA flagged, and as a side effect now also correctly recognizes direct access via a private-IP-resolved *custom* LAN hostname (e.g. `http://charon.local:8080` where `charon.local` resolves via `/etc/hosts`/mDNS to `192.168.1.50`) — today's Host-string-only check doesn't recognize `"charon.local"` as local at all (it isn't `"localhost"` or a parseable IP), so that case currently gets `Secure: true` and silently drops the cookie; under the new RemoteAddr-based check it correctly resolves via the peer's real private IP. Not the point of this fix, but worth noting as a incidental correctness win. +**Type**: Two-fix addendum on the same feature/PR (`feature/orthrus`, PR #1166) — additional commits, not a new branch, per `CLAUDE.md`'s "no worktrees" instruction and "One Feature = One PR." +**Status**: DRAFT — pending Supervisor review +**Research verified against**: repository HEAD on `feature/orthrus`, 2026-07-20. Fix 2's frontend research verified directly against current `frontend/src` source (see file/line citations inline below), not assumed. -**`setSecureCookie`/`clearSecureCookie` — thread the same list through:** +### A.1 Fix 1 — `containers/*/rename` missing from both write allowlists (context/traceability only) -```go -func setSecureCookie(c *gin.Context, name, value string, maxAge int, trustedProxies []string) { - scheme := requestScheme(c, trustedProxies) - ... - if isLocalRequest(c, trustedProxies) { - ... -} +No design decisions required here; recorded for PR traceability alongside Fix 2 since both land in the same PR. Backend Dev is implementing this in parallel with this addendum's authoring. -func clearSecureCookie(c *gin.Context, name string, trustedProxies []string) { - setSecureCookie(c, name, "", -1, trustedProxies) -} -``` +- **Change**: add `{method: http.MethodPost, pattern: "/containers/*/rename"}` to `allowedWritePatterns` in both `backend/internal/orthrus/muzzle.go` and `agent/muzzle/muzzle.go` — the same two dual-maintained allowlists documented in Section 2.1/2.2 above (GH #1160/#1161 muzzle-parity work). +- **Rationale**: Docker's rename endpoint (`POST /containers/{id}/rename?name=`) takes the new name via a query parameter with no request body, matching the existing no-body pattern already used for `/start`, `/stop`, and `/restart` — **not** the body-validated `/containers/create` pattern (`maxContainerCreateBodyBytes`, `hostConfigAllowedKeys`, etc. do not apply). +- **Files**: `backend/internal/orthrus/muzzle.go`, `agent/muzzle/muzzle.go`, plus corresponding corpus/test entries in `backend/internal/orthrus/testdata/muzzle_corpus.json` (shared corpus consumed by both `TestMuzzle_SharedCorpus` and `TestFilter_SharedCorpus`, per Section 2.2 above) so parity is asserted, not just implemented. +- **Validation gate**: `cd backend && go test ./internal/orthrus/... && cd ../agent && go test ./muzzle/...`, both green; `scripts/ci/check_muzzle_allowlist_parity.go` (Section 7 item 3) stays clean. -**`AuthHandler` gains the field, set once at construction** (mirrors `EmergencyBypass(managementCIDRs []string, db *gorm.DB)`'s existing "parse/store once, no global mutable state" idiom — deliberately *not* a package-level `var`, since several tests run with `t.Parallel()` and a shared mutable trust list would race): +### A.2 Fix 2 — Proactive "agent needs restart" toast when write mode is turned on for a connected agent -```go -type AuthHandler struct { - authService *services.AuthService - db *gorm.DB - trustedProxies []string -} +#### A.2.1 Toast mechanism (verified via `git show be0b96e7`) -func NewAuthHandler(authService *services.AuthService, trustedProxies []string) *AuthHandler { - return &AuthHandler{authService: authService, trustedProxies: trustedProxies} -} +The codebase's one and only toast mechanism is **`react-hot-toast`**, called directly as `toast.(...)` (not wrapped in a local `useToast()` hook). Precedent from `frontend/src/context/AuthContext.tsx`: -func NewAuthHandlerWithDB(authService *services.AuthService, db *gorm.DB, trustedProxies []string) *AuthHandler { - return &AuthHandler{authService: authService, db: db, trustedProxies: trustedProxies} -} +```ts +import { toast } from 'react-hot-toast'; +... +toast.error('Session expired. Please log in again.', { + id: 'auth-session-expired', + duration: 10000, +}); ``` -`Login`/`Refresh`/`Logout` change their 3 call sites from `setSecureCookie(c, "auth_token", token, 3600*24)` to `setSecureCookie(c, "auth_token", token, 3600*24, h.trustedProxies)` (and `clearSecureCookie(c, "auth_token", h.trustedProxies)` in `Logout`). +The `` mount point already lives in `frontend/src/App.tsx` (confirmed present — no new provider/mount needed). The `id` option is the established dedupe pattern (a toast with a given `id` replaces any currently-showing toast with the same `id` rather than stacking); Fix 2 reuses this with a **per-agent** id (`write-mode-restart-${agent.uuid}`) rather than a single static id, since an operator could plausibly turn write mode on for two different agents in quick succession and both notices are independently relevant. -**Call-site blast radius (grepped, confirmed small):** +Fix 2 uses `toast.success(...)` (not `.error`) — this is a confirmation that the save succeeded plus an actionable follow-up, not a failure state. -| Symbol | Non-test call sites | File:line | -|---|---|---| -| `NewAuthHandlerWithDB` | 1 | `backend/internal/api/routes/routes.go:208` → becomes `handlers.NewAuthHandlerWithDB(authService, db, cfg.Security.TrustedProxies)` (`cfg config.Config` is already an in-scope parameter of `RegisterWithDeps`) | -| `NewAuthHandler` | 0 (unused outside tests today) | — | - -**`SetTrustedProxies` wiring — same list, one trust boundary, not two independently-configured mechanisms:** +#### A.2.2 Connection-status source (verified against `frontend/src/api/orthrus.ts` and `frontend/src/hooks/useOrthrus.ts`) -`backend/internal/server/server.go`'s `NewRouter` gains a third parameter and uses it for Gin's own mechanism instead of the hardcoded `nil`: +`OrthrusAgent` (`frontend/src/api/orthrus.ts:5-24`) already carries the connection-status field the dialog needs: -```go -func NewRouter(frontendDir string, dataDir string, trustedProxies []string) *gin.Engine { - router := gin.Default() - if len(trustedProxies) > 0 { - if err := router.SetTrustedProxies(trustedProxies); err != nil { - // Fail safe: an invalid entry must not silently fall through to - // Gin's "trust everyone" behavior. Log and trust nothing. - logger.Log().WithError(err).Warn("invalid CHARON_TRUSTED_PROXIES entry; trusting no proxies") - _ = router.SetTrustedProxies(nil) - } - } else { - _ = router.SetTrustedProxies(nil) - } - ... +```ts +export type OrthrusStatus = 'online' | 'offline' | 'pending'; +export interface OrthrusAgent { + ... + status: OrthrusStatus; + ... } ``` -`backend/cmd/api/main.go:253` changes from `server.NewRouter(cfg.FrontendDir, filepath.Dir(cfg.DatabasePath))` to `server.NewRouter(cfg.FrontendDir, filepath.Dir(cfg.DatabasePath), cfg.Security.TrustedProxies)`. - -**`ClientIP()` blast-radius check (required by task — grepped, confirmed safe and, in fact, an intentional correctness improvement, not just a side effect to tolerate):** `c.ClientIP()` is called at ~20 non-test sites (`emergency_handler.go`, `user_handler.go`, `permission_helpers.go`, `security_handler.go` ×8, `security_notifications.go`, `encryption_handler.go` ×4, `backup_handler.go`, `system_permissions_handler.go`, `middleware/emergency.go`, `middleware/request_logger.go`, `cerberus/rate_limit.go` ×2, `cerberus/cerberus.go`) — mostly for audit-log `IPAddress` fields and `ManagementCIDRs`/rate-limit keys. **When `CHARON_TRUSTED_PROXIES` is left unset (the default), behavior at every one of these call sites is byte-for-byte unchanged** — `SetTrustedProxies(nil)` continues to be called, so `ClientIP()` continues to return the raw socket peer exactly as it does on `main` today. Only when an admin *opts in* by setting `CHARON_TRUSTED_PROXIES` does `ClientIP()` start honoring `X-Forwarded-For` from that specific trusted set — and that is the **correct** behavior for that scenario: today, an admin who puts a reverse proxy in front of Charon *without* also fixing this gap gets every `ClientIP()`-based audit log entry, rate-limit key, and `ManagementCIDRs` check recording the proxy's own IP for every request, which is arguably already a latent correctness bug for that (currently unsupported) topology. This fix makes `ClientIP()` and the cookie logic share one admin-controlled trust boundary instead of Gin trusting nothing while `auth_handler.go` trusted everything — call this out explicitly in the commit message as an intentional, opt-in behavior change, not a silent one. +`AgentWriteModeDialog.tsx:41` already derives `const isOnline = agent.status === 'online';` from this exact field for an unrelated purpose (gating the `useAgentProxyStatus` query) — Fix 2 reuses the same `status === 'online'` check, not a new field. -### 13.4 `Origin`/`Referer`: investigated, recommendation is removal, not gating +Critically, **the source for the connection-status read at save time should be the PATCH response itself, not the cached agent-list**: `patchAgent` (`frontend/src/api/orthrus.ts:94-97`) is typed `Promise` and returns the full updated agent record from `PATCH /orthrus/agents/{uuid}`, which includes `status`. `usePatchAgent()` (`frontend/src/hooks/useOrthrus.ts:46-55`) is a thin `useMutation` wrapper with no `select`/transform, so its `onSuccess` handler receives that same full `OrthrusAgent` (including `status`) as its first argument. This is fresher than reading `agent.status` off the dialog's `agent` prop (a snapshot from whenever the dialog opened, potentially stale by the time Save is clicked) or than re-reading the `AGENTS_QUERY_KEY` cache (which the mutation's own `onSuccess` invalidates but does not synchronously repopulate before the dialog's callback runs). No new query or field is needed — only reading a field the response already carries. -Per the task's explicit ask: do `Origin`/`Referer` factor into the trust decision today, and if so, do they need gating or removal? +#### A.2.3 Exact change: `frontend/src/components/hecate/AgentWriteModeDialog.tsx` -**Finding:** yes, they factor in today — `isLocalRequest`'s `candidates` list (current lines 128-131) includes `originHost(c.Request.Header.Get("Origin"))` and `originHost(c.Request.Header.Get("Referer"))`; if either resolves to a local/private host, `isLocalRequest` returns `true`, which can flip `Secure` to `false` when `scheme != "https"`. +1. Add `import { toast } from 'react-hot-toast';` to the import block (after the `react-i18next` import, alongside the other named imports). -**Recommendation: remove, not gate.** Unlike `X-Forwarded-Host`/`X-Forwarded-Proto` — which, *when a real trusted reverse proxy is the peer*, at least plausibly carry proxy-verified information about the original request — `Origin` and `Referer` are ordinary browser-set request headers that reflect what page the browser was on. **No reverse proxy, trusted or not, rewrites or verifies them for this purpose**; gating them on peer trust would give them a veneer of verification they can never actually have. A "trusted proxy" configuration says "I trust this peer to tell me the truth about TLS termination and virtual-hosting," not "I trust this peer's opinion of the original browser's `Origin` header" — those are unrelated claims. Gating a signal that is unconditionally meaningless either way adds complexity with no security benefit. **Action: delete both `originHost(...)` candidates from `isLocalRequest` entirely** (both the trusted and untrusted branches, per the code in §13.3 above — neither branch references `Origin`/`Referer` anymore). +2. Modify `handleSave` (currently lines 65-71) to pass the off→on transition flag into the mutation's `onSuccess`, and check the response's `status`: -**Consequence: `originHost` becomes dead code.** Grepped — its only call sites in non-test code are the two just removed (`auth_handler.go:129-130`). Per CLAUDE.md ("CLEAN: Delete dead code immediately"), **delete the `originHost` function** (current lines 67-78) along with its two direct unit-test blocks (`TestHostHelpers`'s `"originHost"` subtest, current lines 372-376, and `TestAuthHandler_HelperFunctions`'s `"originHost returns empty for invalid url"` subtest, current lines 1371-1374) — see §13.6 for the replacement regression tests that pin "Origin/Referer no longer influence this decision" instead. + ```ts + const handleSave = () => { + if (!canSave) return; + const wasTurnedOn = requiresConfirmation; // off→on transition — same predicate + // already computed at line 61 to gate + // the typed-confirmation step; reused + // here rather than re-derived, since + // desiredEnabled/agent.write_enabled + // cannot change between this render + // and this synchronous save call. + patch( + { uuid: agent.uuid, req: { write_enabled: desiredEnabled } }, + { + onSuccess: (updatedAgent) => { + if (wasTurnedOn && updatedAgent.status === 'online') { + toast.success( + t('hecate.writeMode.restartRequiredToast', { name: agent.name }), + { id: `write-mode-restart-${agent.uuid}`, duration: 8000 }, + ); + } + onClose(); + }, + }, + ); + }; + ``` -### 13.5 Truth table (extends §9.2's table with the new "is peer trusted" dimension) + No other function in the file changes. `requiresConfirmation` (line 61) is not renamed or repurposed in meaning — it still means "off→on transition," which is exactly the condition Fix 2 also needs, so this is a reuse, not a new parallel computation of the same boolean. -`scheme` below is `requestScheme(c, trustedProxies)`'s resolved value; `isLocalRequest` is `isLocalRequest(c, trustedProxies)`; "peer trusted" is `isTrustedPeer(c, trustedProxies)` — i.e. whether `c.Request.RemoteAddr` (never a header) matches an entry in `CHARON_TRUSTED_PROXIES`. +3. **Why the check lives here and not in the shared `usePatchAgent()` hook**: `usePatchAgent()` (`frontend/src/hooks/useOrthrus.ts:46-55`) is also called from other write paths on `OrthrusAgent` (e.g. `AgentExternalProxyDialog.tsx` patching `external_proxy_port`, and rename/provider-assignment flows). Putting the toast trigger inside the shared hook would require the hook to inspect *which* field changed and diff against prior state for every caller — fragile and wrong-layered. Keeping the check local to `AgentWriteModeDialog.handleSave`, which already knows both the pre-save value (`agent.write_enabled`) and the intended new value (`desiredEnabled`), structurally guarantees that patches to unrelated fields from other components can never trigger this write-mode-specific toast, without needing any conditional logic in the shared hook. -| Peer trusted? | `X-Forwarded-Proto`/`-Host` | Raw connection facts | `scheme` resolves to | `isLocalRequest` | `Secure` | `SameSite` | Example | -|---|---|---|---|---|---|---|---| -| No (default — nothing configured) | *ignored entirely* | TLS nil, `RemoteAddr` = Tailscale/LAN IP | `http` | `true` (via `RemoteAddr`) | `false` | `Lax` | **§9's original bug repro, direct Tailscale access — must keep working, no proxy configured** | -| No | *ignored entirely* | TLS nil, `RemoteAddr` = public IP | `http` | `false` | `true` (fail-safe) | `Lax` | Charon exposed directly on a public IP over plain HTTP (unsupported/discouraged) — unchanged from §9.2 | -| No | *ignored entirely* | `c.Request.TLS != nil` | `https` | per `RemoteAddr` | `true` | `Strict`/`Lax` | Direct HTTPS to Charon's own Go TLS listener (uncommon but possible config) | -| No | **forged** `X-Forwarded-Proto: https` / `X-Forwarded-Host: 127.0.0.1` from a public, non-proxy peer | TLS nil, `RemoteAddr` = public IP | `http` (header ignored) | `false` (`RemoteAddr` is public; forged `Host` claim ignored) | `true` | `Lax` | **The QA-identified gap, now closed** — forgery no longer has any effect | -| **Yes** (`RemoteAddr` ∈ `CHARON_TRUSTED_PROXIES`) | `X-Forwarded-Proto: https` | (irrelevant — header wins) | `https` | per `Host`/`X-Forwarded-Host` | `true` | `Lax`/`Strict` | **Legitimate reverse-proxy-HTTPS case — behind the admin's own nginx/Caddy/Traefik in front of Charon's admin port** | -| **Yes** | *no forwarded headers sent* | TLS nil | `http` | per raw `Host`/`URL.Host` | per locality | per locality | A configured-trusted peer that simply isn't forwarding anything (e.g. a plain TCP proxy) — falls back to the same raw-fact resolution as the untrusted branch, except `Host`/`X-Forwarded-Host` (if present) are still honored | +4. **i18n key** — add to `frontend/src/locales/en/translation.json`, inside the existing `hecate.writeMode` block (currently ends at line 2009 with `"disableConfirm": "Disable"`), a new key: -`Origin`/`Referer` do not appear in this table — per §13.4 they are removed from the decision entirely, in every row. + ```json + "restartRequiredToast": "\"{{name}}\" is connected — restart the Orthrus agent on its remote machine for the write-mode change to take effect." + ``` -### 13.6 TDD test plan + Add the same key (English text, matching the existing pattern where `reconnectNotice` is left untranslated/English in `es`/`zh`/`fr`/`de` — pre-existing translation debt in this file, not something Fix 2 needs to resolve) to the equivalent `hecate.writeMode` block in `frontend/src/locales/{es,zh,fr,de}/translation.json` for structural key-parity (some locale files likely have a translation-key-completeness test — Frontend Dev should check `frontend/src/__tests__/i18n.test.ts` and satisfy whatever parity check it runs). -**New tests (write red first):** +#### A.2.4 Vitest test cases — `frontend/src/components/hecate/__tests__/AgentWriteModeDialog.test.tsx` -1. `TestIsTrustedPeer` — table-driven: `RemoteAddr` inside a configured CIDR → `true`; outside → `false`; empty `trustedProxies` → always `false` regardless of `RemoteAddr`; malformed `RemoteAddr` (no port, garbage string) → `false`, no panic. **New case (review finding, non-blocking):** `trustedProxies: []string{"not-a-cidr", "10.0.0.0/8"}`, `RemoteAddr` inside `10.0.0.0/8` → assert `true` (the malformed entry doesn't break matching of the valid entries after it) and, as a second sub-case in the same table, `RemoteAddr` a value that would only match if `"not-a-cidr"` were (incorrectly) treated as match-everything → assert `false`, no panic. This pins the already-fail-safe behavior of `security.IsIPInCIDRList` (`whitelist.go:45-48`: a `net.ParseCIDR` error on a malformed entry hits `continue`, silently skipping just that entry rather than erroring out of the whole list or matching everything) as exercised through `isTrustedPeer`, which was previously unverified by any test. -2. `TestRequestScheme_ForgedForwardedProto_IgnoredFromUntrustedPeer` — `RemoteAddr` a public IP not in any trusted list, `X-Forwarded-Proto: https` set, `req.TLS == nil` → assert `requestScheme(c, nil)` (or an explicitly non-matching list) returns `"http"`. Directly proves TDD requirement (a): forgery from an untrusted peer no longer flips scheme. -3. `TestRequestScheme_ForwardedProto_HonoredFromTrustedPeer` — same headers, but `RemoteAddr` set to an address inside `trustedProxies` passed in → assert `"https"`. Proves TDD requirement (c): the legitimate trusted-proxy case still works. -4. `TestIsLocalRequest_UntrustedPeer_IgnoresForwardedHost` — `X-Forwarded-Host: localhost` set, `RemoteAddr` a public IP, `trustedProxies` doesn't include it → assert `false`. Companion of #2 for `isLocalRequest`. -5. `TestIsLocalRequest_TrustedPeer_HonorsForwardedHost` — same headers, `RemoteAddr` inside `trustedProxies` → assert `true`. Companion of #3. -6. `TestIsLocalRequest_UntrustedPeer_UsesRawPeerIPNotHostHeader` — `req.Host = "127.0.0.1:8080"` (forged/mismatched), `RemoteAddr` a public IP, no trusted proxies configured → assert `false` (proves the `Host` header alone, absent a matching real peer address, no longer grants locality — the Host-forgery half of the gap, distinct from the `X-Forwarded-Host` half covered by #4). -7. `TestIsLocalRequest_UntrustedPeer_DirectTailscaleAccess_StillWorks` — `RemoteAddr` set to `100.98.12.109:xxxxx` (the exact §9 bug-report IP), `req.Host` matching, no `X-Forwarded-*`, no trusted proxies configured → assert `true`. **This is the mandatory non-regression proof for TDD requirement (b)** — the direct-access case this whole PR exists to support must not regress. -8. `TestSetSecureCookie_TrustedProxyHTTPS_PublicHost_Secure` — full `setSecureCookie` integration: `RemoteAddr` inside `trustedProxies`, `X-Forwarded-Proto: https`, `X-Forwarded-Host: admin.example.com` (public) → assert `Secure: true`, `SameSite: Strict` (not local, but genuinely HTTPS via a real trusted terminator). -9. `TestSetSecureCookie_UntrustedForgedHeaders_NoDowngrade` — full integration counterpart to #2/#9: `RemoteAddr` public/untrusted, forged `X-Forwarded-Proto: https` + `X-Forwarded-Host: 127.0.0.1`, real connection is plain HTTP from a public peer → assert `Secure: true` (unchanged fail-safe — forgery cannot downgrade it) — this is the single most important new test, directly reproducing and closing the QA-identified adversarial scenario end-to-end. -10. `TestIsLocalRequest_OriginHeaderIgnored` (replaces the deleted `"origin loopback"` subtest of `TestIsLocalRequest`, §13.4) — `Origin: http://127.0.0.1:3000` set, `Host` a public non-local value, no trusted proxy → assert `isLocalRequest` returns `false`, pinning that `Origin` no longer grants locality even when it claims loopback. -11. `TestSetSecureCookie_OriginHeaderIgnored_NoLongerAffectsSecurity` (replaces the repurposed `TestSetSecureCookie_OriginLoopbackForcesInsecure`, §13.4) — `Host: service.internal` (not local), `Origin: http://127.0.0.1:8080` (spoofed loopback), plain HTTP, no trusted proxy → assert `Secure: true` (public fail-safe holds; a spoofed `Origin` cannot downgrade it). +The existing mock at the top of this file (`vi.mock('../../../hooks/useOrthrus', () => ({ usePatchAgent: () => ({ mutate: mockPatch, isPending: false }), ... }))`, lines 8-14) needs `mockPatch` to actually invoke the `onSuccess` callback it's given, since that's now where the toast decision happens. Add a `vi.mock('react-hot-toast', ...)` block (same shape as `frontend/src/context/__tests__/AuthContext.test.tsx:11-16`, but mocking `success` instead of `error`/`dismiss`) and update `mockPatch`'s implementation per-test to call `opts.onSuccess(updatedAgentFixture)` with a controllable `status`. -**Existing tests requiring updates (enumerated by name, per CLAUDE.md's testing rigor — grouped by why):** +New `describe('restart-required toast', ...)` block, four cases: -*(a) Mechanical only — add the new trailing `trustedProxies` parameter (pass `nil`), no assertion or setup change, because the resolved outcome is peer-address-agnostic:* -`TestSetSecureCookie_HTTPS_Strict`, `TestSetSecureCookie_HTTP_Lax`, `TestSetSecureCookie_HTTP_PublicIP_Secure`, `TestClearSecureCookie`, `TestRequestScheme`'s `"tls request"`/`"url scheme fallback"`/`"default http fallback"` subtests, `TestIsLocalRequest`'s `"non local request"` subtest, `TestAuthHandler_HelperFunctions`'s `"requestScheme uses tls..."`/`"requestScheme uses request url scheme..."`/`"requestScheme defaults to http..."`/`"normalizeHost strips brackets and port"` subtests, `TestHostHelpers`'s `"normalizeHost"`/`"isLocalOrPrivateHost"`/`"isLocalOrPrivateHost tailscaleCGNAT boundary"` subtests (these three don't call the changed functions at all — no parameter change needed either). - -*(b) Needs `req.RemoteAddr` added, matching the test's `Host` IP, to keep asserting the same result — because locality for an untrusted peer (the implicit default in every one of these, since none configure a trusted proxy) now comes from `RemoteAddr`, not `Host`:* -`TestSetSecureCookie_HTTP_Loopback_Insecure` (add `req.RemoteAddr = "127.0.0.1:9999"`), `TestSetSecureCookie_HTTP_PrivateIP_Insecure` (`"192.168.1.50:9999"`), `TestSetSecureCookie_HTTP_10Network_Insecure` (`"10.0.0.5:9999"`), `TestSetSecureCookie_HTTP_172Network_Insecure` (`"172.16.0.1:9999"`), `TestSetSecureCookie_HTTP_IPv6ULA_Insecure` (`"[fd12::1]:9999"`), `TestSetSecureCookie_HTTPS_PrivateIP_Secure` (`"192.168.1.50:9999"` — needed to keep `SameSite: Lax`, since that assertion depends on `isLocalRequest` being `true`), and **`TestSetSecureCookie_HTTP_TailscaleCGNAT_Insecure`** (add `req.RemoteAddr = "100.98.12.109:9999"` — this is the direct regression test for the original §9 bug; it must be updated and must still pass, satisfying TDD requirement (b) at the original bug's own test). - -*(c) Needs both `req.RemoteAddr` set to a value inside an explicitly-passed `trustedProxies` list — because these specifically test forwarded-header handling, and under the new model an untrusted peer's headers are simply ignored, so leaving them unmodified would make them pass for the wrong reason (they'd stop exercising forwarded-header logic at all and coincidentally land on the same asserted values via the fail-safe/raw-fact path). Each needs e.g. `req.RemoteAddr = "203.0.113.9:443"` plus `trustedProxies: []string{"203.0.113.9/32"}` passed into the call:* -`TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure`, `TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure`, `TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure`, `TestRequestScheme`'s `"forwarded proto first value wins"` subtest, `TestAuthHandler_HelperFunctions`'s `"requestScheme prefers forwarded proto"` subtest, `TestIsLocalRequest`'s `"forwarded host list includes localhost"` subtest, `TestAuthHandler_HelperFunctions`'s `"isLocalOrPrivateHost and isLocalRequest"` subtest. (Asserted values are unchanged in every case — only the setup changes, to make the test actually exercise the trusted-proxy path it claims to.) - -*(d) Deleted / repurposed (§13.4 — Origin/Referer removed as a signal):* -`TestSetSecureCookie_OriginLoopbackForcesInsecure` → repurpose into new test #11 above (asserted value changes from `Secure: true`-via-a-now-nonexistent-mechanism to `Secure: true`-via-fail-safe — same literal assertion, different, now-accurate, rationale/comment). `TestIsLocalRequest`'s `"origin loopback"` subtest → replaced by new test #10 (assertion flips `true` → `false`). `TestHostHelpers`'s `"originHost"` subtest and `TestAuthHandler_HelperFunctions`'s `"originHost returns empty for invalid url"` subtest → **deleted outright** (function deleted, §13.4). - -*(e) Signature-only passthrough in the handler-level tests* (`TestAuthHandler_Login*`, `TestAuthHandler_Refresh*`, `TestAuthHandler_Logout*`, `TestAuthHandler_VerifyStatus*`, etc.) — none call `setSecureCookie`/`isLocalRequest`/`requestScheme` directly; they go through `NewAuthHandler(WithDB)(...)`, which needs its new `trustedProxies []string` constructor argument (pass `nil`) at its 3 test call sites (`auth_handler_test.go`, `user_integration_test.go`, `additional_coverage_test.go`) — no assertion changes. - -*(f) `server_test.go`* — `TestNewRouter`'s two `NewRouter(...)` calls (lines 23, 75) need the new third `trustedProxies []string` parameter (pass `nil`) — no assertion changes, `SetTrustedProxies(nil)` is still what runs. - -**Frontend:** none — this fix is entirely backend header/cookie logic; no frontend contract changes. - -**E2E (Playwright):** none required — this closes a header-forgery vector against a direct backend connection, which isn't something Playwright's browser-driven flows exercise (a real browser never sends a forged `X-Forwarded-Proto`/`X-Forwarded-Host`). The existing `feature/backuprestore` Playwright coverage of the cookie-fallback download path (§9.4/§9.6 commit A2) is unaffected — it runs same-origin `localhost`, which resolves identically before and after this fix (no trusted proxy configured, `RemoteAddr` is loopback either way). +| # | Scenario | Setup | Assertion | +|---|---|---|---| +| 1 | Turned on while agent connected → fires | `baseAgent` (`write_enabled: false`), toggle switch on, type agent name into confirm input, click Save; `mockPatch` invokes `onSuccess` with `{ ...baseAgent, write_enabled: true, status: 'online' }` | `toast.success` called once, with a message containing `'Test Agent'` (or the mocked `t()` key form per this file's `react-i18next` mock at lines 16-21, i.e. `'hecate.writeMode.restartRequiredToast:Test Agent'`), and `{ id: 'write-mode-restart-agent-1', duration: 8000 }` | +| 2 | Turned on while agent disconnected → does not fire | Same save flow, but `onSuccess` payload has `status: 'offline'` (or `'pending'`) | `toast.success` not called | +| 3 | Turned off → does not fire | `baseAgent` variant with `write_enabled: true` at open; toggle switch off (no confirm text needed, `requiresConfirmation` is false); Save; `onSuccess` payload `{ write_enabled: false, status: 'online' }` | `toast.success` not called | +| 4 | No-op save (already on, saved again unchanged) → does not fire | `baseAgent` variant with `write_enabled: true` at open; do not touch the toggle; Save; `onSuccess` payload `{ write_enabled: true, status: 'online' }` | `toast.success` not called — this is the concrete in-component equivalent of "unrelated field patched": since this dialog only ever sends `write_enabled`, and other components never import this dialog's `handleSave`, structurally no other patch call site can reach this code path (see A.2.3 point 3); this test instead pins the true→true (no transition) boundary of `requiresConfirmation`/`wasTurnedOn`. | -### 13.7 GORM security scan applicability +**Files**: `frontend/src/components/hecate/AgentWriteModeDialog.tsx` (implementation), `frontend/src/components/hecate/__tests__/AgentWriteModeDialog.test.tsx` (tests), `frontend/src/locales/en/translation.json` + 4 other locale files (i18n key). -**Not required.** No `backend/internal/models/**` changes, no GORM queries, no migrations — this fix is confined to `internal/config` (new `SecurityConfig` field + env parsing), `internal/api/handlers/auth_handler.go` (function signatures + trust logic), `internal/api/routes/routes.go` (one constructor call-site update), `internal/server/server.go` (one `NewRouter` parameter), and `internal/cmd/api/main.go` (one call-site update). Per CLAUDE.md §1.5, skip `./scripts/scan-gorm-security.sh --check`. +### A.3 Commit Slicing Strategy (this addendum) -### 13.8 Commit Slicing Strategy (this addendum only) +Both fixes are small and independent of each other (different files, different layers) but ship in the same PR per "One Feature = One PR." Suggested order: -**Decision:** same PR (#1136, `feature/backuprestore`), one additional commit appended after §12's F1 (the branch's current final commit). This is a single, cohesive, mechanically-linked change (config field → handler trust logic → router wiring, all load-bearing on each other) with no meaningful sub-slice — unlike §9's/§12's multi-commit addenda, splitting this further would leave intermediate commits in a broken or misleading state (e.g. a config field with nothing reading it, or gated handler logic with no way to configure trust). +1. **Commit A — Fix 1**: dual-allowlist `rename` entries + shared corpus cases (Backend Dev, already in progress). Validation gate: `cd backend && go test ./internal/orthrus/... && cd ../agent && go test ./muzzle/...`; `scripts/ci/check_muzzle_allowlist_parity.go` clean. +2. **Commit B — Fix 2 tests first**: add the four Vitest cases in A.2.4 against the *not-yet-changed* `AgentWriteModeDialog.tsx` (expected to fail/be skipped, e.g. `it.fails` or written against the target behavior) — establishes the spec before implementation, matching this repo's suggested E2E-first commit ordering philosophy (`CLAUDE.md` "Suggested Commit Sequence") adapted to Vitest since this is unit-level, not E2E. +3. **Commit C — Fix 2 implementation**: the `AgentWriteModeDialog.tsx` change in A.2.3 + i18n keys in A.2.4, turning Commit B's tests green. Validation gate: `cd frontend && npx vitest run src/components/hecate/__tests__/AgentWriteModeDialog.test.tsx`; `npm run type-check`. +4. **Commit D — Hardening**: full `cd frontend && npm run build`, `npx vitest run` (full suite, coverage), `npx playwright test --project=firefox` (scoped to Orthrus/hecate specs at minimum, since Fix 2 changes an existing dialog's save flow that E2E specs already exercise per the prior write-mode commits in this branch's history). -| # | Commit | Scope | Files | Depends on | Validation gate | -|---|---|---|---|---|---| -| G1 | `fix(security): gate X-Forwarded-Proto/X-Forwarded-Host trust behind an explicit CHARON_TRUSTED_PROXIES allowlist, remove Origin/Referer from cookie-security decisions` | Backend | `backend/internal/config/config.go` (`SecurityConfig.TrustedProxies`, `loadSecurityConfig`), `backend/internal/api/handlers/auth_handler.go` (`isTrustedPeer` new, `requestScheme`/`isLocalRequest`/`setSecureCookie`/`clearSecureCookie` signature + logic changes, `originHost` deleted, `AuthHandler.trustedProxies` field + constructor changes), `backend/internal/api/routes/routes.go` (`NewAuthHandlerWithDB` call site), `backend/internal/server/server.go` (`NewRouter` third parameter + conditional `SetTrustedProxies`), `backend/cmd/api/main.go` (`NewRouter` call site), `backend/internal/api/handlers/auth_handler_test.go` (§13.6's full test set — 11 new tests, ~20 existing tests updated per categories (a)-(f)), `backend/internal/server/server_test.go` (2 call-site updates), `backend/internal/config/config_test.go` (new `TestLoadSecurityConfig_TrustedProxies`-style test — **note (review correction): no existing test covers `ManagementCIDRs` env parsing either** (`grep -n ManagementCIDRs config_test.go` returns nothing), so there is no specific precedent to mirror for this parsing test; write it fresh, following the general shape/style of whatever other env-var-parsing tests already exist in that file (e.g. comma-split/whitespace-trim/empty-value cases) rather than searching for a `ManagementCIDRs` test that doesn't exist) | §12's F1 (end of current PR work) | `go test ./backend/internal/api/handlers/... ./backend/internal/config/... ./backend/internal/server/... -v` (scoped to these three packages only — do **not** run the full backend `-race` suite; concurrent heavy jobs may still be running elsewhere on this machine); `make lint-fast`; `lefthook run pre-commit` (confirm CodeQL Go scan stays clean — no new `go/cookie-secure-not-set`-style finding; the existing suppression comment on the `c.SetCookie` call is untouched by this change) | +**Rollback/contingency**: both fixes are additive and behavior-scoped — Fix 1 only widens an allowlist by one already-vetted, no-body-pattern entry; Fix 2 only adds a toast on an already-existing, already-safe code path (no new API calls, no new state persisted). Neither changes existing passing behavior for any case not newly covered. If the per-agent toast `id` proves to cause unexpected duplicate-suppression issues in manual QA, the fallback is a static id (matching the auth-toast precedent) at the cost of only the most-recent agent's notice being visible if two fire in quick succession — a minor UX regression, not a functional break, and reversible in a single line. -**Rollback/contingency:** `git revert` of G1 alone is safe — it only touches function signatures/logic and one new config field/env var; no schema, no migration, no wire-format change, and no other commit in this PR calls any of the changed functions in a way that would break if reverted (G1 lands after all other work). If reverted, the codebase returns exactly to §9's already-shipped behavior (forwarded headers trusted unconditionally) — a known, QA-accepted-as-low-severity state, not a new regression. If CI surfaces an unexpected `ClientIP()`-dependent behavior change at one of the ~20 grepped call sites once `CHARON_TRUSTED_PROXIES` is actually configured in some environment (§13.3's blast-radius note), the contingency is to scope that call site's reliance on `ClientIP()` explicitly rather than reverting G1 wholesale — G1's default (unset env var) behavior is provably identical to today's on every one of those call sites, so this scenario can only arise for an admin who has opted in, and is a configuration-time issue, not a code defect. +### A.4 Definition of Done — gate applicability (this addendum) ---- +| Gate | Applicable? | Why | +|---|---|---| +| Playwright E2E | **Yes** | Fix 2 changes an existing dialog save flow with existing E2E coverage (per branch history: `30cf1c08 feat(orthrus): add write-mode UI...`, `d2fa3154 docs(orthrus): enable write-mode E2E specs...`). Re-run relevant specs; add/adjust if the toast should be asserted at the E2E layer too (Frontend Dev/Playwright Dev to decide scope). | +| GORM Security Scan | No | Zero `backend/internal/models/**`, zero GORM queries/migrations in either fix. | +| Local Patch Coverage Preflight | Yes | Standard gate, both fixes touch tested source. | +| CodeQL Go | Yes | Fix 1 touches `.go` files. | +| CodeQL JS | Yes | Fix 2 touches `.tsx`/`.ts`/`.json` files. | +| Staticcheck | Yes | Fix 1, both modules (`backend/`, `agent/`). | +| Backend coverage (85%+) | Yes | Fix 1 adds backend/agent tests only. | +| Frontend coverage (85%+) | Yes | Fix 2's own subject — new Vitest cases in A.2.4. | +| Type-check (frontend) | Yes | Fix 2 touches `.tsx`. | +| `go build ./...` / `npm run build` | Yes, both | Both fixes touch buildable source. | diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index c57f1cdfe..07bd54b51 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -1,114 +1,119 @@ -> **Note:** the prior report previously at this path (WebDAV/Dropbox/Google Drive remote backup storage, dated 2026-07-13) has been archived to `docs/reports/archive/qa_report_webdav-dropbox-gdrive-backuprestore_2026-07-13.md`. +# QA Report — Orthrus Muzzle Normalization Parity + Agent CI Enforcement (GH #1160 + #1161) -# QA Report — Auth Cookie Secure-Flag Fix + Full `feature/backuprestore` Branch Sweep +**Date:** 2026-07-20 +**Branch:** `feature/orthrus` (unpushed — no upstream configured) +**Scope of this audit:** 7 new commits `6fe7a800`..`6562a64b`, stacked on 5 pre-existing write-mode commits `a4be39e2`..`d2fa3154` +**Correct diff base for the 7 audited commits:** `d2fa3154..HEAD` (verified `6fe7a800~1 == d2fa3154`) +**Pipeline stage:** Final QA/Security audit, after Planning → Supervisor (plan, 2 cycles) → Backend Dev (TDD) → Supervisor (implementation, 2 cycles, both APPROVE) -**Date:** 2026-07-17/18 -**Branch:** `feature/backuprestore` -**Primary scope (as tasked):** 3 commits — `6e4d2c0e` (backend fix), `062855bd` (E2E), `9c16bda8` (docs) -**Extended scope (mid-session addition, per Management):** full branch diff, `a87bdcf0..00fa7eb2` (12 commits total), covering the additional OAuth-redirect, remote-target UI, and backup-encryption/remote-upload work that landed concurrently with this QA pass -**Auditor:** QA & Security Engineer — mechanical/exhaustive Definition of Done verification (Supervisor already reviewed/approved implementation correctness for the primary 3-commit scope; this report covers testing/security gates only) +**Files touched by the 7 audited commits** (28 files, `d2fa3154..HEAD`): +`.github/workflows/codecov-upload.yml`, `.github/workflows/quality-checks.yml`, `.golangci-fast.yml` (moved from `backend/`), `ARCHITECTURE.md`, `CHANGELOG.md`, `Makefile`, `agent/cert/cert_test.go` (new), `agent/leash/leash.go`, `agent/leash/leash_test.go`, `agent/muzzle/muzzle.go`, `agent/muzzle/muzzle_test.go`, `agent/protocol/message_test.go` (new), `backend/cmd/localpatchreport/main.go`, `backend/cmd/localpatchreport/main_test.go`, `backend/internal/orthrus/muzzle.go`, `backend/internal/orthrus/muzzle_test.go`, `backend/internal/orthrus/testdata/muzzle_corpus.json`, `backend/internal/patchreport/patchreport.go`, `backend/internal/patchreport/patchreport_test.go`, `codecov.yml`, `docs/plans/current_spec.md`, `lefthook.yml`, `scripts/agent-test-coverage.sh` (new), `scripts/check-module-coverage.sh` (new), `scripts/ci/check_muzzle_allowlist_parity.go` (new), `scripts/local-patch-report.sh`, `scripts/pre-commit-hooks/golangci-lint-fast.sh`, `scripts/pre-commit-hooks/golangci-lint-full.sh`. ---- - -## Executive Summary - -**Primary scope (3-commit auth-cookie fix): READY TO MERGE. No blockers.** - -**Extended scope (full branch, 12 commits): NOT READY — 2 real, non-flaky E2E regressions found**, caused by two of the newer commits changing frontend UI behavior without updating the pre-existing Playwright specs that assert the old behavior. Both are narrow, mechanical test-locator fixes, not design/security issues, and do not affect the primary auth-cookie fix's own scope. - -This session ran substantially longer than expected because the shared working tree was being concurrently modified by other agents throughout — 8 additional commits landed after this QA task began, which invalidated several early full-suite runs and required them to be re-run against a stabilized HEAD. All results below are from runs confirmed to have executed against a **stable, unchanging `HEAD=00fa7eb2`** (verified via repeated `git rev-parse HEAD` checks bracketing each run). +**Note on an earlier miscitation in this audit's working notes:** an initial `git diff --stat` was run against the wrong base (`30cf1c08`, one commit too early), which incorrectly attributed `docs/features/orthrus.md` and `tests/orthrus-write-mode.spec.ts` changes to the 7 audited commits. Both files actually belong to the prior, already-approved `d2fa3154` write-mode commit. This was caught and corrected before any gate conclusion was finalized; it does not change any finding below. --- -## Definition of Done Checklist (10 items, CLAUDE.md "Task Completion Protocol") +## Gate Results Summary -| # | Item | Status | Evidence | +| # | Gate | Status | Notes | |---|---|---|---| -| 1 | Playwright E2E — full suite | **FAIL (extended scope only)** | Full `tests/tasks/ tests/core/ tests/integration/` (`--project=firefox`) against stable `HEAD=00fa7eb2`: 423 passed, 9 failed (24.9 min). Isolated re-runs (zero contention) confirm: **4 failures are genuine, deterministic regressions** (`backups-encryption.spec.ts` ×1, `backups-remote-targets.spec.ts` ×3) — see "Blocking Findings" below. **5 failures were resource-contention flakes**, confirmed resolved: the 4 `caddy-import-*.spec.ts` failures passed 18/18 in isolation; the 5th (an earlier apparent remote-targets flake) also did not reproduce in isolation. The primary-scope test, `backups-create.spec.ts` ("should download backup file successfully" — the real navigation-triggered download this fix targets), **passes**. | -| 1.5 | GORM Security Scan | **PASS (ran as precaution)** | Not strictly required — primary 3-commit scope touches no `internal/models/**`/GORM queries/migrations (confirmed via `git show 6e4d2c0e --stat`). Extended scope's `backup_service.go` changes call pre-existing, unmodified GORM-backed helpers (`readBackupSettingString`/`readBackupSettingBool` in `backup_settings.go`) but add no new queries. Ran `./scripts/scan-gorm-security.sh --check` anyway: 0 CRITICAL/HIGH/MEDIUM, 2 pre-existing INFO suggestions (unrelated, `user.go`). | -| 2 | Local Patch Coverage Preflight | **PASS** | `bash scripts/local-patch-report.sh` (baseline `origin/main...HEAD`, full branch diff) → **Overall 91.5%** (min 90%), **Backend 90.3%** (min 85%), **Frontend 99.6%** (min 85%). Both artifacts present: `test-results/local-patch-report.md`, `test-results/local-patch-report.json`. | -| 3 | Security Scans — CodeQL Go | **PASS** | Fresh scan (see Tooling Note below for CLI issue/fix): 249/249 compiled Go files extracted (matches `go list` baseline exactly). **0 blocking findings.** 1 non-blocking warning: `go/cookie-secure-not-set` at `internal/api/handlers/auth_handler.go` — this is the expected, single, justified-suppression call site (see Security Deep-Dive below). Rule's own `defaultConfiguration.level` is `warning`, not `error` — non-blocking by policy regardless of suppression-comment recognition. | -| 3 | Security Scans — CodeQL JS/TS | **PASS** | 544/544 files scanned. **0 findings, any severity.** | -| 3 | `codeql-check-findings.sh` | **PASS** | "All CodeQL checks passed" — 0 blocking findings both languages. | -| 3 | `check-codeql-parity.sh` | **PASS** | Workflow triggers + suite pinning (`security-and-quality`) + local/CI alignment confirmed. | -| 3 | Trivy | **INCONCLUSIVE (tooling scope mismatch)** | `trivy fs` against the source tree found 0 language-specific files — this repo's `trivy.yaml` is scoped/commented for scanning the **extracted binary/container image**, not raw source (`node_modules`/lockfiles aren't the intended target of this config). A full multi-stage Docker image build + `trivy image` scan was not performed in this session (would add ~10+ min on top of an already-long session). Recommend relying on CI's `docker-build.yml` Trivy gate (which scans the built image) as authoritative for this PR. `SECURITY.md`'s tracked-vulnerability list (last reviewed 2026-07-16) shows no CRITICAL findings and no new HIGH findings attributable to this branch's dependencies. | -| 4 | Gotify Token Review | **PASS** | No Gotify tokens/query-string tokens present in any test artifact, log, or diff reviewed in this session. Not applicable to this branch's changes (no Gotify-related code touched). | -| 5 | Staticcheck / lint | **PASS** | `make lint-fast` (staticcheck, govet, errcheck, ineffassign, unused via `.golangci-fast.yml`) → **0 issues**, run against stable current HEAD. | -| 6 | Backend coverage | **PASS** | `scripts/go-test-coverage.sh`, final run in complete isolation (0 concurrent processes, confirmed via `ps`/`uptime`): **89.1% line coverage** (gate 87%), **zero test failures** across all packages. (Two earlier attempts run concurrently with the full E2E suite and frontend coverage both hit a reproducible 10-minute timeout in `internal/services` — confirmed via isolated re-runs to be pure resource contention, not a defect: that package passed cleanly and race-free in isolation on 3 separate occasions, ~490s each.) | -| 6 | Frontend coverage | **PASS** | `vitest run --coverage` → **90.59% line coverage** (gate 87%, from `vitest.config.ts`'s `resolvedCoverageThreshold`). One run (concurrent with the other two heavy jobs) showed 7 apparent failures in `ProxyHostForm.test.tsx`; isolated re-run (zero contention) — **60/60 passed**, confirming pure resource-contention flakiness, not a regression. `ProxyHostForm.tsx`/`.test.tsx` are untouched by any of the 12 commits in scope. | -| 7 | Frontend type safety | **PASS** | `npm run type-check` (`tsc --noEmit`) — 0 errors. | -| 8 | Build verification | **PASS** | `go build ./...` — clean. `npm run build` — succeeds (2.45s). Both confirmed against stable current HEAD. | -| 9 | Fixed/new code testing | **PASS (backend), FAIL (E2E, extended scope)** | Backend: `go test ./...` zero failures (both an early full run and the final isolated coverage run). New/changed tests from `6e4d2c0e` confirmed present and passing: 5 flipped `assert.False` assertions, `TestSetSecureCookie_HTTP_TailscaleCGNAT_Insecure`, the CIDR-boundary subtest, `TestAuthHandler_Logout_InvalidatesSessionBeforeClearingCookie`. E2E: see item 1 — 4 genuine failures in extended scope. | -| 10 | Clean-up check | **PASS** | Grepped the full `a87bdcf0..00fa7eb2` diff (all 11 commits) for `fmt.Println(`, `console.log(`, `debugger;` — 0 hits. No commented-out code blocks found in reviewed diffs. | +| 1 | Playwright E2E | **N/A (confirmed)** | Zero `frontend/` or `tests/` paths in the 7-commit diff (`d2fa3154..HEAD`). Skipped per CLAUDE.md's own gate-applicability allowance. | +| 2 | GORM Security Scan | **N/A (confirmed)** | Zero `backend/internal/models/**`, zero GORM queries/migrations in the 7-commit diff. | +| 3 | Local Patch Coverage Preflight | **PASS (artifacts) / WARN (thresholds)** | Artifacts generated; `agent/` scope now genuinely populated (see Finding 1). | +| 4 | Security Scans — CodeQL Go | **PASS** | `lefthook run codeql` → 0 findings in `codeql-results-go.sarif` (empty results array). | +| 4 | Security Scans — CodeQL JS | **PASS** | `lefthook run codeql` → 0 findings in `codeql-results-js.sarif`. Parity check (`4-parity-check`) also passed. | +| 4 | Semgrep (SAST) | **PASS** | Scoped to the 28 files the 7 commits touch: 122 rules, 0 findings. Full-repo `--all-files` sweep also run for completeness (see Verification Method Notes). | +| 4 | Trivy / govulncheck | **PASS** | No new dependencies in this PR (zero `go.mod`/`go.sum`/`Dockerfile` diff); `govulncheck ./...` clean (0 exploitable) in both `backend/` and `agent/`. Full container Trivy scan deferred to normal PR CI (see notes). | +| 5 | Staticcheck (backend + agent) | **PASS** | `make lint-staticcheck-only` → `0 issues` for both modules. | +| 6 | Backend coverage (≥85%) | **PASS** | 89.0% line coverage via `scripts/go-test-coverage.sh` (gate: 87%). | +| 6 | Agent coverage (≥65%, new gate) | **PASS (thin margin — see Finding 2)** | 65.3% line coverage via `scripts/agent-test-coverage.sh` (gate: 65%). Margin is 0.3 points. | +| 7 | Frontend type-check | **PASS (trivial)** | `tsc --noEmit` clean; zero frontend diff. | +| 8 | Build — backend | **PASS** | `cd backend && go build ./...` clean. | +| 8 | Build — agent | **PASS** | `cd agent && go build ./...` clean. | +| 9 | Full test suites | **PASS** | `backend`: all packages `ok`. `agent`: all packages `ok`, including new `agent/cert`, `agent/protocol` tests. | +| 9 | 4 corpus rows red→green | **PASS (independently verified)** | All 4 previously-failing `TestFilter_SharedCorpus` rows now pass; `TestMuzzle_SharedCorpus` (backend) unaffected (unchanged pass). | +| 9 | Parity checker exits 0 | **PASS** | `go run scripts/ci/check_muzzle_allowlist_parity.go` → exit 0, "all 8 paired declarations match." | +| 10 | Security-specific fix review | **PASS** | See "Security Review of the Fix" below — all 3 documented divergences closed; adversarial probing beyond the corpus found no new divergence. | +| 11 | Git state | **PASS** | All 12 audited commits are clean/committed; no upstream configured for `feature/orthrus`; nothing pushed. (The only working-tree change at report time is this report file itself, `docs/reports/qa_report.md`, being authored as this audit's own deliverable — not part of the audited code.) | + +**Overall disposition: READY to hand back to the user for manual review/push**, with two non-blocking findings (below) the user should be aware of before opening the PR — neither is a security defect, and neither requires looping back to Backend Dev unless the user wants the margins widened first. --- -## Blocking Findings (Extended Scope Only — Do Not Affect Primary 3-Commit Auth-Cookie Fix) +## Findings -Both are **stale Playwright E2E assertions**, not application defects — the underlying UI behavior changes are correct and intentional; the pre-existing E2E specs simply weren't updated alongside them. +### Finding 1 — MEDIUM (process/coverage) — Local patch coverage below mandated thresholds -### 1. `tests/tasks/backups-encryption.spec.ts:78` — locator now ambiguous +`bash scripts/local-patch-report.sh` (baseline `origin/main...HEAD`, i.e. the full unmerged feature — all 12 commits, since nothing from this branch has merged yet) reports: -Commit `67ec1681` ("feat: add Save button to BackupEncryptionCard") added a new "Save Encryption Settings" button to the Backups page. The pre-existing test's locator: -```ts -await expect(page.getByRole('button', { name: /save/i })).toBeDisabled(); -``` -now matches **two** elements (strict-mode violation): the pre-existing "Save Schedule" button and the new "Save Encryption Settings" button. `67ec1681` updated `BackupEncryptionCard.tsx` and its Vitest unit test, but never touched this Playwright spec. +| Scope | Changed Lines | Covered | Patch Coverage | Threshold | Status | +|---|---:|---:|---:|---:|---| +| Overall | 409 | 359 | 87.8% | 90.0% | **warn** | +| Backend | 275 | 248 | 90.2% | 85.0% | pass | +| Frontend | 0 | 0 | 100.0% | 85.0% | pass | +| Agent | 134 | 111 | 82.8% | 85.0% | **warn** | -**Fix:** disambiguate the locator (e.g. by `data-testid`, or exact name `{ name: 'Save Encryption Settings', exact: true }`). +Files needing coverage (from `test-results/local-patch-report.md`): -### 2. `tests/tasks/backups-remote-targets.spec.ts` — 3 tests expect a now-hidden button +| Path | Patch Coverage | Uncovered Lines | +|---|---:|---| +| `backend/internal/orthrus/server.go` | 62.5% | 61-63 | +| `agent/leash/leash.go` | 63.2% | 172, 177-178, 188, 198-199, 232 | +| `agent/muzzle/muzzle.go` | 86.1% | 191-192, 205-206, 216-217, 233-234, 248-249, 409-410, 412-414, 438 | +| `backend/internal/orthrus/session.go` | 86.7% | 165-166 | +| `backend/internal/orthrus/muzzle.go` | 89.6% | 195-196, 222-223, 236-237, 258-259, 265-267, 283-284, 298-299, 327-328 | +| `backend/internal/patchreport/patchreport.go` | 90.5% | 124, 158 | +| `backend/cmd/localpatchreport/main.go` | 91.2% | 112-114 | -Commit `297872a6` ("fix: hide remote-target Test button until OAuth target is connected") intentionally hides the "Test Connection" button for not-yet-connected OAuth remote targets. It updated `RemoteTargetsCard.tsx` and its Vitest unit test, but never touched this Playwright spec, which still: -- clicks `getByTestId('backup-remote-target-test-btn')` for not-connected targets → 90s timeout (button doesn't exist in that state) — 2 tests (`should surface the oauth_not_connected error code via the Test button toast path`, `should surface the oauth_revoked error code via the Test button toast path`) -- asserts an ARIA snapshot that includes `- button "Test Connection"` for a not-connected row → snapshot mismatch — 1 test (`should render accessible Connect/Reconnect controls consistently across not_connected/connected/revoked states`) +**Root cause traced (per CLAUDE.md's Root Cause Analysis Protocol, not just the surface warning):** the tool's diff baseline is `origin/main`, which is correct — nothing in this 12-commit feature has merged, so the *whole* feature must clear 90%/85% before merge, not just today's 7 commits. Most of the gap (`server.go`, `session.go`, most of `agent/leash/leash.go`) originates in the earlier 5 write-mode commits, already through 2 rounds of Supervisor review before today. Within the 7 commits under *this* audit specifically, the relevant uncovered lines are the error-return branches of `validateNetworkModeValue`/`validateMountsValue`/`validateContainerCreateBody` in both `muzzle.go` files (malformed-JSON and oversized-body rejection paths) — these are fail-closed-by-default branches (an unmarshal error already returns `false`/reject), so the coverage gap is a test-completeness gap, not a live security gap: the untested lines cannot be coerced into the *permissive* outcome, only the already-safe one. -**Fix:** update these 3 tests' assertions/flows to match the new hide-until-connected behavior (each test's own docstring/context should make the correct new expectation clear from `RemoteTargetsCard.test.tsx`'s already-updated version). +**This is genuinely important to flag** because: +- The local script itself is hardcoded to always report `Mode: "warn"` (`backend/cmd/localpatchreport/main.go:157`) and exits 0 regardless — it will never block a local commit. The actual enforcement point is Codecov's patch-coverage check once this PR is opened against GitHub, which may fail and block merge. +- `agent/leash/leash.go`'s uncovered lines are pre-existing/out-of-scope per the plan's own Section 3.5 ("`agent/leash` ... explicitly not required to increase coverage in this PR"), but the tool still counts them against the *overall* number since those lines technically changed (3 `defer x.Close()` → `defer func() { _ = x.Close() }()` edits made to satisfy the new staticcheck gate) and file line-shifting causes the diff to touch nearby context. -**Verification method:** both findings were reproduced with **zero resource contention** (isolated `npx playwright test` runs, confirmed via `ps`/`uptime` before each run), ruling out flakiness. A third, related failure seen in one full-suite run (`should save config + client secret without a token...`) did **not** reproduce in isolation — noted but not further pursued given time constraints; recommend a quick recheck if it recurs. +**Recommendation:** before opening the PR, either (a) add a handful of targeted unit tests for the listed error-branches in `muzzle.go` (both files) to close the ~3-4 points of gap most directly attributable to this PR's own new code, or (b) accept the current state and rely on Codecov's actual PR-level patch-coverage check, understanding it may require a follow-up commit if it fails there. Not a blocker for handing back to the user — but the user should not be surprised if Codecov flags this on PR open. ---- +### Finding 2 — LOW (process) — Agent coverage gate has almost no margin -## Security Deep-Dive — Auth Cookie Secure-Flag Fix (`6e4d2c0e`) +`scripts/agent-test-coverage.sh` measured 65.3% against a 65% gate (`CHARON_AGENT_MIN_COVERAGE` default) — a 0.3-percentage-point margin. Verified this is a real, non-rigged calibration (not e.g. a 0% floor): the script's arithmetic is identical to backend's coverage gate, and the threshold is explicitly documented in the script as calibrated to "the module's actual aggregate line coverage after this PR's new tests landed," correctly attributing the low aggregate to `agent/leash` sitting at ~44% (pre-existing, explicitly out of scope per the plan). This is real and working as designed, but the margin is thin enough that almost any future commit touching `agent/` without a matching test could flip this gate red. Non-blocking; flagging for awareness only. -Per task instructions, independently re-verified beyond the mechanical checklist: +### Finding 3 — informational — `agent-quality` CI job lint scope differs from `backend-quality` -### Core security claim (re-verified against source, not just spec) -`Secure` is downgraded to `false` **only** when `scheme != "https" && isLocalRequest(c)` — confirmed by direct code read of `setSecureCookie` (`auth_handler.go:169-201`). A public-HTTP host (not local/private/Tailscale) still gets `Secure: true` (fail-safe: cookie silently dropped by the browser rather than transmitted unencrypted) — confirmed via the existing `TestSetSecureCookie_HTTP_PublicIP_Secure` test (host `203.0.113.5`, a public TEST-NET-3 address) and by reading the truth table in `docs/plans/current_spec.md` §9.2, which matches the code exactly. +`backend-quality`'s golangci-lint step runs the **full** linter suite with `continue-on-error: true` (non-blocking/advisory — the actual blocking gate for backend is the separate staticcheck-only lefthook pre-commit hook). `agent-quality`'s equivalent step runs only the **fast/staticcheck** config (`--config ../.golangci-fast.yml`) with no `continue-on-error`, i.e. it is blocking. This isn't a security gap — if anything agent's CI is stricter — but it means the two jobs aren't a literal mirror of each other in linter scope vs. enforcement mode. Not flagged in the plan's own gate-applicability table; worth a note for whoever reviews the workflow YAML (the plan itself calls this file out as needing human review since it can't be validated locally without `act`). -### Adversarial angle: header-spoofing to force a false `Secure` downgrade -**This is a real, previously-undocumented gap, though its practical severity is low.** +--- -`isLocalRequest(c)` and `requestScheme(c)` both trust client-supplied headers unconditionally: -- `requestScheme` reads `X-Forwarded-Proto` first, with no validation. -- `isLocalRequest` checks `c.Request.Host`, `c.Request.URL.Host`, `Origin`, `Referer`, and **`X-Forwarded-Host`** (attacker-settable) — none of these are filtered through Gin's trusted-proxy mechanism. `backend/internal/server/server.go:19`'s `router.SetTrustedProxies(nil)` only affects `Context.ClientIP()`, not these manual `c.GetHeader`/`c.Request.Host` reads. +## Security Review of the Fix (Task item 10) -**Exploitability assessment:** -- In production behind Caddy (the standard deployment), Caddy is the trust boundary and overwrites `X-Forwarded-Proto`/`X-Forwarded-Host` with the actual observed values before forwarding — so this vector is not reachable from the public internet in that topology. -- The fix's own target deployment mode (Tailscale, **no** TLS-terminating reverse proxy in front) is exactly the case where the Go backend is reached directly, with no trusted intermediary filtering these headers. -- However, forging these headers can only downgrade `Secure` on the **response to the attacker's own request** (their own login/refresh call) — it cannot be used to downgrade another (victim) user's cookie, because a victim's real browser sets `Host`/`Origin`/`Referer` truthfully for its own navigation, and no CORS middleware exists in this backend (confirmed via repo-wide grep — none found) to permit a cross-origin script to add a custom `X-Forwarded-Host` header to a credentialed request against this API. -- Net effect: an attacker with direct network access to the Go backend can trick the server into weakening **their own** session's cookie attributes. This does not compromise confidentiality/integrity for other users, and `HttpOnly`/`SameSite` are unaffected regardless. +**Question:** does the agent-side fix in `agent/muzzle/muzzle.go` close all 3 divergences from the plan's Section 2.3, not just the one named in the original GH issue? -**Recommendation (non-blocking):** worth a follow-up hardening item — scope `isLocalRequest`'s header trust to only apply when the request's actual remote-socket address (`c.Request.RemoteAddr`, not headers) is itself local/private, rather than trusting `X-Forwarded-Host`/`Origin`/`Referer` unconditionally. Not required to merge given the low practical impact above, and this is a distinct issue from the CGNAT-sharing risk already accepted in spec §9.1.5. +**Verified yes, all 3, independently:** -### CodeQL suppression scope -Confirmed narrowly scoped: `// codeql[go/cookie-secure-not-set]` appears exactly once repo-wide, directly above the single `c.SetCookie(...)` call in `setSecureCookie` — not file-wide or rule-wide. The rule's own default severity (`warning`) means it's non-blocking by CI policy independent of whether the suppression comment itself is recognized by a given CodeQL CLI version. +1. **Row 1 (traversal-disguised version prefix, GH #1160's own example)** — `GET /foo/../v1.44/images/x/json`. Confirmed via `TestFilter_SharedCorpus` / `TestMuzzle_SharedCorpus`: both now return `403`. Root cause fix: `normalizeDockerPath` in both files now strips the version-prefix regex against the *raw* path before `path.Clean` resolves `..` segments, so a version prefix only revealed by traversal resolution is never mistaken for a real one. +2. **Row 2 (non-numeric fake version prefix, read path and HEAD `/_ping` variant)** — `GET /vFOO/containers/json`, `HEAD /vBOGUS/_ping`. Confirmed via corpus. Root cause fix: agent's duplicated `/v*/...` loose-wildcard pattern entries were removed entirely; all matching now goes through the single numeric-anchored `versionPrefixRe` (`^/v\d+\.\d+`), identical source string in both files (verified byte-for-byte via the parity checker). +3. **Row 3 (fake version prefix reaching a write endpoint — the highest-severity row)** — `POST /vFOO/containers/abc/start` with write mode on. Confirmed via corpus. Root cause fix: `allowWrite`'s signature changed from re-deriving its own "unversioned" path (exact-path branch only) to accepting the caller's single pre-normalized path, so the pattern-matching branch (`allowedWritePatterns`) no longer matches against the raw un-stripped path. ---- +**Structural drift guard, independently exercised (not just trusted from the report):** I ran the parity checker against two deliberately-introduced mismatches and confirmed it fails loudly and correctly, then restored the file and re-confirmed a clean `git status`: +- Added an extra `allowedWritePatterns` entry to `agent/muzzle/muzzle.go` only → checker output: `allowedWritePatterns: present in agent, missing in backend: {POST /networks/*/connect}`, exit 1. +- Loosened agent's `versionPrefixRe` to `^/v\w+` → checker output: `versionPrefixRe: backend=^/v\d+\.\d+ agent=^/v\w+ (source strings differ)`, exit 1. This is the specific negative-path check for R9 (the Supervisor-requested regex-parity row), and it works. -## Tooling Notes (Environment Issues Found — Not Code Defects) +**Adversarial inputs beyond the committed corpus** (double-encoded traversal, mixed-case version segment, trailing slashes, double version-prefix nesting, case-sensitive `_PING`, encoded traversal segments) were run against both filters. One apparent divergence surfaced initially (`%2e%2e`-encoded traversal: agent said blocked, backend said allowed) — traced to a flaw in my own test harness, not the code: I had called `Filter.Allow` directly with a raw, still-percent-encoded string, bypassing the URL decoding every real request goes through. I verified with a raw HTTP request parsed via `http.ReadRequest` (the actual function `ServeProxy` uses) that `%2e%2e` decodes to `..` in `req.URL.Path` identically to how Gin's `net/url` parsing decodes it for backend — so in the real request-handling path, both filters see the same decoded string and agree (this input resolves to the already-corpus-tested "traversal that legitimately resolves to an allowed path" case). No new divergence found. All scratch test files were deleted after use; `git status` confirmed clean. -1. **CodeQL CLI version mismatch (found and fixed mid-session).** The default `codeql` on `PATH` (`/usr/local/bin/codeql-home`, CLI 2.16.0) is incompatible with this repo's pinned `codeql/go-queries@1.6.6`/JS query packs — `codeql database analyze` fatally errors on `resolve extensions-by-pack`, and the JS scan's `--build-mode=none` flag isn't recognized by that CLI version. **Fix used:** `gh codeql` extension's CLI (`/home/jeremy/.local/share/gh/extensions/gh-codeql/dist/release/v2.26.0`, put first on `PATH`) — this resolved cleanly and is documented as the working fix in a prior QA report on this same branch (now archived at `docs/reports/archive/qa_report_webdav-dropbox-gdrive-backuprestore_2026-07-13.md`, "Other Findings" #3). Future QA passes in this sandbox should default to the `gh-codeql` CLI to avoid re-diagnosing this. -2. **`lefthook run pre-commit`'s CodeQL step is actually a separate group (`lefthook run codeql`), not part of `pre-commit`.** CLAUDE.md's DoD text names `lefthook run pre-commit` for the CodeQL gate; the actual `lefthook.yml` config defines CodeQL as its own manual group (`codeql:`), invoked via `lefthook run codeql`. `pre-commit` itself only gates staged files (moot for already-committed work) and covers staticcheck/govet/semgrep/etc., not CodeQL. -3. **`trivy fs` scope mismatch** — see item 3/Trivy row in the checklist table above. -4. **Shared working tree / concurrent multi-agent writes.** This session's sandbox filesystem was being actively modified by other agents' commits throughout — 8 commits landed after this QA task began. This invalidated several early full-suite runs (results were silently built from a moving-target codebase) and required re-running against a verified-stable HEAD. Recommend the orchestration layer either serialize QA against a pinned commit/worktree, or have QA explicitly re-verify `HEAD` stability bracketing every long-running command (as done here from the point this was discovered onward). -5. **Resource contention causes reproducible, non-random false failures on this 4-core sandbox** when the full backend `-race` suite, full Playwright E2E suite, and full frontend coverage run concurrently: `internal/services` (backend) reliably times out past its 10-minute budget under contention despite passing in ~490s isolated; various E2E/vitest tests show as failed under contention and pass cleanly in isolation. All findings in this report were cross-checked in isolation before being classified as real vs. flake. +**Conclusion:** the fix is not narrowly tailored to only the committed test cases — it's a structural fix (single normalization function, single regex, unversioned-only allowlist data) that closes the entire bug class, not just the 3 named instances of it. --- -## Verdict +## Verification Method Notes + +- All findings above were independently reproduced, not taken on trust from commit messages or the plan document — corpus tests were re-run and their output inspected line-by-line, the parity checker was exercised both positively and negatively, and coverage/build/lint commands were run directly rather than assumed from the plan's own validation-gate descriptions. +- `codeql-results-go.sarif` / `codeql-results-js.sarif` (freshly generated 2026-07-20 17:31-17:33) both contain zero results at any severity (`jq '[.runs[].results[] | .level] | ...'` → `[]`), which is stronger than "zero Critical/High" — zero findings of any kind. +- `lefthook run pre-commit` (no modifier) skips everything on a clean tree (all hooks are staged-file-scoped and nothing is staged, since these commits are already committed) — this is expected lefthook behavior, not a gap. `lefthook run pre-commit --all-files` and `lefthook run codeql` were used instead to force full-repo evaluation: + - `codeql` (Go + JS + parity check): clean, as above. + - `--all-files` sweep results: `muzzle-allowlist-parity` passed; `golangci-lint-fast` (fast config, full repo) — `0 issues` for both `backend/` and `agent/`; `dockerfile-check` passed; `frontend-lint` — 0 errors (1174 pre-existing warnings, entirely outside this PR's diff — zero frontend files touched by the 7 commits); `shellcheck`, `check-yaml`, `go-vet`, `go-vet-agent`, `end-of-file-fixer`, `block-codeql-db`, `block-data-backups`, `check-lfs-large-files` — all clean/no findings. + - Two incidental items surfaced by the `--all-files` sweep, neither attributable to the 7 audited commits: (a) `check-version-match` failed because `.version` (`v0.27.0`) is behind the latest git tag (`v0.34.1`) — `.version` was last touched by an unrelated commit (`d231386d`, pre-dating this branch's divergence from `main`) and is not part of this PR's diff; pre-existing branch-hygiene drift, not a defect in this fix. (b) `trailing-whitespace` auto-fixed and re-staged one file — confirmed via `git status`/`git diff --stat` immediately after that it was exclusively this QA report's own not-yet-committed draft (`docs/reports/qa_report.md`), not any committed/audited source file; no audited file was modified. + - `semgrep`, scoped precisely to the 28 files the 7 audited commits actually touch (`bash scripts/pre-commit-hooks/semgrep-scan.sh $(git diff d2fa3154..HEAD --name-only)`, the same script and ruleset — `p/golang`, `p/javascript`, `p/typescript`, `p/react`, `p/secrets`, `p/dockerfile` — the lefthook hook itself invokes): **122 rules run, 28/28 targets scanned, ~100% parsed, 0 findings.** (The full unscoped `--all-files` semgrep sweep covers the entire repository including unrelated pre-existing code and was still in progress after several minutes when this scoped, directly-relevant run completed with a clean result; the scoped run is the one that answers "does this PR introduce a SAST finding," which it does not.) +- Trivy: no container/dependency scan was run to full completion (a `trivy fs` invocation returned 0 language files scanned, likely a directory-targeting issue, not re-investigated given `govulncheck` — the more precise Go-specific tool — was clean and this PR touches zero `go.mod`/`go.sum`/`Dockerfile` content). Recommend a full `make trivy`-equivalent container scan still be run as part of the normal PR CI pipeline once opened, per SECURITY.md's standard process; not expected to differ from the already-tracked, pre-existing findings in SECURITY.md's "Known Vulnerabilities" section since no new dependencies were introduced. -- **Primary scope (`6e4d2c0e`, `062855bd`, `9c16bda8` — the auth-cookie Secure-flag fix and its dedicated E2E test): READY TO MERGE.** All 10 DoD items pass for this scope specifically; the one adversarial security note above is a low-severity, non-blocking hardening recommendation, not a defect. -- **Extended scope (full `feature/backuprestore` branch through `00fa7eb2`): NOT READY.** 2 pre-existing Playwright specs (`backups-encryption.spec.ts`, `backups-remote-targets.spec.ts`) need locator/assertion updates to match intentional UI changes in `67ec1681` and `297872a6`. These are small, mechanical fixes (no application code changes needed) — recommend routing to `playwright-dev` or the owning frontend-dev session, then re-running the full E2E suite once to confirm. +## Recommendation -All other gates (coverage, CodeQL, GORM, lint, type-check, build, cleanup) pass cleanly for the full extended scope. +**Ready to hand back to the user for manual review/push.** No CRITICAL or HIGH findings. The two coverage-related findings (Finding 1, Finding 2) are process observations worth the user's attention before opening the PR against GitHub (where Codecov's own patch-coverage check may be stricter than this local advisory tool), but do not indicate a defect in the security fix itself — the muzzle normalization parity fix is verified correct, complete against all 3 documented divergences, and independently confirmed by both the shared corpus and out-of-corpus adversarial testing. All 12 commits on `feature/orthrus` remain unpushed (no upstream configured); the only uncommitted change in the working tree is this report itself. diff --git a/frontend/src/api/auditLogs.ts b/frontend/src/api/auditLogs.ts index 827718a60..b0c150b32 100644 --- a/frontend/src/api/auditLogs.ts +++ b/frontend/src/api/auditLogs.ts @@ -1,7 +1,13 @@ import client from './client' /** Audit log event category */ -export type EventCategory = 'dns_provider' | 'certificate' | 'proxy_host' | 'user' | 'system' +export type EventCategory = + | 'dns_provider' + | 'certificate' + | 'proxy_host' + | 'user' + | 'system' + | 'orthrus_write' /** Audit log action type */ export type AuditAction = @@ -18,6 +24,11 @@ export type AuditAction = | 'user_login' | 'user_logout' | 'settings_update' + | 'orthrus_write_allowed' + | 'orthrus_write_blocked' + | 'orthrus_write_rate_limited' + | 'orthrus_write_enabled' + | 'orthrus_write_disabled' /** Represents a single audit log entry */ export interface AuditLog { diff --git a/frontend/src/api/orthrus.ts b/frontend/src/api/orthrus.ts index 020f72bc9..3fd46c958 100644 --- a/frontend/src/api/orthrus.ts +++ b/frontend/src/api/orthrus.ts @@ -18,6 +18,9 @@ export interface OrthrusAgent { resolved_address?: string; // External Docker proxy port (0 = disabled, 1024–65535 = enabled) external_proxy_port: number; + // Opt-in write access to a fixed set of Docker write endpoints through + // this agent's External Docker Proxy. false = read-only (default). + write_enabled: boolean; } export interface PatchAgentRequest { @@ -26,6 +29,7 @@ export interface PatchAgentRequest { device_id?: string | null; resolved_address?: string | null; external_proxy_port?: number; + write_enabled?: boolean; } export interface ExternalProxyStatus { @@ -37,6 +41,12 @@ export interface ExternalProxyStatus { bind_address: string; connection_string: string; error: string; + // configured_write_enabled reflects the DB value; active_write_enabled + // reflects the value the currently-connected session actually negotiated + // at its last reconnect. The two can differ if an operator toggled + // write_enabled while the agent was already connected — see reconnectNotice. + configured_write_enabled: boolean; + active_write_enabled: boolean; } export interface ProvisionAgentRequest { diff --git a/frontend/src/components/hecate/AgentWriteModeDialog.tsx b/frontend/src/components/hecate/AgentWriteModeDialog.tsx new file mode 100644 index 000000000..8a731a599 --- /dev/null +++ b/frontend/src/components/hecate/AgentWriteModeDialog.tsx @@ -0,0 +1,198 @@ +import { AlertTriangle } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { type OrthrusAgent } from '../../api/orthrus'; +import { useAgentProxyStatus, usePatchAgent } from '../../hooks/useOrthrus'; +import { Button } from '../ui/Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/Dialog'; +import { Input } from '../ui/Input'; +import { Switch } from '../ui/Switch'; + +interface AgentWriteModeDialogProps { + agent: OrthrusAgent; + open: boolean; + onClose: () => void; +} + +const WRITE_MODE_OPERATION_KEYS = ['pull', 'start', 'stop', 'restart', 'remove', 'recreate'] as const; + +/** + * Write-mode dialog is intentionally separate from AgentExternalProxyDialog + * rather than a section bolted onto it — the port dialog governs a + * transport-layer setting (is there a TCP port bound at all), this dialog + * governs a permissions-escalation setting (what that tunnel is allowed to + * do). Keeping them as two components with minimal, independent local state + * is more testable than one component tracking two loosely-related + * "configured vs. active" pairs. See docs/plans/current_spec.md Section 3.5.1. + */ +export function AgentWriteModeDialog({ agent, open, onClose }: AgentWriteModeDialogProps) { + const { t } = useTranslation(); + const { mutate: patch, isPending } = usePatchAgent(); + + const isOnline = agent.status === 'online'; + + const { data: proxyStatus } = useAgentProxyStatus(agent.uuid, open && isOnline); + + const [desiredEnabled, setDesiredEnabled] = useState(agent.write_enabled); + const [confirmText, setConfirmText] = useState(''); + + useEffect(() => { + if (open) { + setDesiredEnabled(agent.write_enabled); + setConfirmText(''); + } + }, [open, agent.write_enabled]); + + // Only turning the toggle ON (from an off starting point) requires the + // typed-name confirmation gate — disabling is strictly safety-increasing + // and needs no extra friction. This is why the check compares against + // agent.write_enabled (the value the dialog opened with), not merely + // "is desiredEnabled true": flipping on then back off within the same + // dialog session must not require typing anything either. + const requiresConfirmation = desiredEnabled && !agent.write_enabled; + const confirmMatches = confirmText.trim() === agent.name; + const canSave = !requiresConfirmation || confirmMatches; + + const handleSave = () => { + if (!canSave) return; + const wasTurnedOn = requiresConfirmation; // off→on transition — same predicate + // already computed above to gate + // the typed-confirmation step; reused + // here rather than re-derived, since + // desiredEnabled/agent.write_enabled + // cannot change between this render + // and this synchronous save call. + patch( + { uuid: agent.uuid, req: { write_enabled: desiredEnabled } }, + { + onSuccess: (updatedAgent) => { + if (wasTurnedOn && updatedAgent.status === 'online') { + toast.success( + t('hecate.writeMode.restartRequiredToast', { name: agent.name }), + { id: `write-mode-restart-${agent.uuid}`, duration: 8000 }, + ); + } + onClose(); + }, + }, + ); + }; + + const configuredDiffersFromActive = + proxyStatus !== undefined && proxyStatus.active_write_enabled !== agent.write_enabled; + + const auditLogHref = `/audit-logs?resource_uuid=${encodeURIComponent(agent.uuid)}&event_category=orthrus_write`; + + return ( + !isOpen && onClose()}> + + + {t('hecate.writeMode.title', { name: agent.name })} + {t('hecate.writeMode.description')} + + +
+ {/* Toggle */} +
+ + {t('hecate.writeMode.toggleLabel')} + + { + setDesiredEnabled(checked); + if (!checked) setConfirmText(''); + }} + disabled={isPending} + /> +
+ + {/* Typed-name confirmation — only shown when enabling from off */} + {requiresConfirmation && ( +
+ + setConfirmText(e.target.value)} + placeholder={t('hecate.writeMode.confirmPlaceholder')} + disabled={isPending} + aria-describedby="write-mode-confirm-hint" + /> +

+ {t('hecate.writeMode.confirmHint')} +

+
+ )} + + {/* Security warning — distinct from AgentExternalProxyDialog's + network-exposure warning: different DOM node, different copy, + describes a permissions escalation rather than a network + reachability change. */} +
+
+ + {/* Fixed, non-editable list of permitted operations — shown only + while the toggle is (or is about to be) on. */} + {desiredEnabled && ( +
+

+ {t('hecate.writeMode.permittedOperationsHeading')} +

+
    + {WRITE_MODE_OPERATION_KEYS.map((key) => ( +
  • {t(`hecate.writeMode.operations.${key}`)}
  • + ))} +
+
+ )} + + {configuredDiffersFromActive && ( +

+ {t('hecate.writeMode.reconnectNotice')} +

+ )} + + + {t('hecate.writeMode.viewAuditLog')} + +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/hecate/OrthrusAgentManager.tsx b/frontend/src/components/hecate/OrthrusAgentManager.tsx index 95ba9e2a1..c6a0eedb4 100644 --- a/frontend/src/components/hecate/OrthrusAgentManager.tsx +++ b/frontend/src/components/hecate/OrthrusAgentManager.tsx @@ -1,9 +1,10 @@ -import { Check, Link2, Pencil, Settings, Trash2, X } from 'lucide-react'; +import { Check, Link2, Pencil, Settings, ShieldCheck, Trash2, X } from 'lucide-react'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AgentExternalProxyDialog } from './AgentExternalProxyDialog'; import { AgentProviderAssignDialog } from './AgentProviderAssignDialog'; +import { AgentWriteModeDialog } from './AgentWriteModeDialog'; import { type OrthrusAgent } from '../../api/orthrus'; import { useDeleteAgent, useRenameAgent } from '../../hooks/useOrthrus'; import { Badge } from '../ui/Badge'; @@ -37,9 +38,16 @@ interface AgentRowProps { onDelete: (uuid: string, name: string) => void; onAssignProvider: (agent: OrthrusAgent) => void; onConfigureProxy: (agent: OrthrusAgent) => void; + onConfigureWriteMode: (agent: OrthrusAgent) => void; } -const AgentRow = ({ agent, onDelete, onAssignProvider, onConfigureProxy }: AgentRowProps) => { +const AgentRow = ({ + agent, + onDelete, + onAssignProvider, + onConfigureProxy, + onConfigureWriteMode, +}: AgentRowProps) => { const { t } = useTranslation(); const { mutate: rename, isPending: isRenaming } = useRenameAgent(); const [editing, setEditing] = useState(false); @@ -143,6 +151,11 @@ const AgentRow = ({ agent, onDelete, onAssignProvider, onConfigureProxy }: Agent PROXY )} + {agent.write_enabled && ( + + WRITE + + )}
@@ -178,6 +191,15 @@ const AgentRow = ({ agent, onDelete, onAssignProvider, onConfigureProxy }: Agent >