Skip to content

v2.2.0

Choose a tag to compare

@github-actions github-actions released this 06 Sep 14:49
· 70 commits to main since this release
177c490

v2.2.0

[2.2.0] - 2026-09-06

v2.2.0 closes the volume-mount and read-side gaps that survived v2.1.0 and
adds the operator-facing checks that were missing around them. The local
volume driver's type/o/device options can no longer bind-mount a host
path past allowed_bind_mounts through either container create or a Swarm
service mount. PUT /volumes/{name} is inspected instead of being an unread
write, and a request target that is not a rooted path no longer reaches rule
evaluation. On the read side the response filter now handles gzip bodies,
HEAD, 304 revalidation and image inspect instead of forwarding them
unredacted, and it only parses a body a route can actually redact. New in
this line: sockguard verify checks a loaded config against the daemon it
will actually talk to, server.shutdown_grace replaces the hardcoded 30s
drain, and a SOCKGUARD_* variable that matches no configuration key warns
at startup instead of being silently ignored. The performance work is
allocation-level: route metric labels, the /health cache, visibility label
injection, owner-label mutation, POST /containers/create decoding and
query-string parsing all stop repeating work per request.

One change is breaking. A match.path without a leading / now fails
config validation. Before this release such a pattern loaded and matched
every rooted request path, so path: "containers/**" behaved like a
catch-all rather than the container-scoped rule it reads as. A config that
used that spelling is now rejected at startup and has to be rewritten with
the leading slash. No shipped preset, Tecnativa-compatibility rule, or
documented pattern uses it, so a deployment on stock configuration is
unaffected.

The Helm image digest is temporarily clear for the cut, so the chart falls
back to appVersion: "2.2.0" until the stable multi-arch manifest exists and
can be re-pinned on the active development line.

Security

  • A request target that is not a rooted path no longer reaches rule evaluation, and a /** catch-all no longer allows one. HTTP/1.1 has four request-target forms and Go's server parses all of them, but only origin-form and an absolute-form URI carrying a path produce a rooted r.URL.Path. A non-OPTIONS asterisk-form line (GET * HTTP/1.1) arrives with the path *, because net/http intercepts only OPTIONS * and hands everything else straight to the handler; an absolute-form line with no path (GET http://host), an opaque target (GET foo:bar), and a CONNECT authority-form line (CONNECT host:2375) all arrive with an empty path. NormalizePath preserves both shapes verbatim, so they reached Evaluate unrooted, and there the match-all matcher /** compiles to answered an unconditional true — the one matcher kind wider than the pattern standing behind it, since ^(/(?s:.*))?$ matches neither * nor any other rootless string, and every other kind already refused. What got forwarded would not even have been what was evaluated: url.URL.RequestURI substitutes / for an empty path, so the daemon saw GET / where policy saw the empty string. Sockguard now answers 400 with reason code request_target_not_rooted for any unrooted target, from a layer that sits inside access/audit logging, request-ID and trace correlation and the metrics recorder, and outside the client ACLs, so a container-label grant of /** cannot match one either. The match-all fast path is separately constrained to the empty-or-rooted set its own regex accepts, so the two agree even on inputs nothing can now deliver. No Docker or Podman endpoint is addressed by * or by an empty path, so this was theoretical rather than a live bypass; it is the same class as the rootless-pattern fix under Fixed above, and closing it is what makes "the fast path is only an optimization" true for every matcher kind. OPTIONS * is unchanged: net/http answers it itself with an empty 200 before the middleware chain runs, so it reaches neither policy evaluation nor the daemon.
  • A volume-type mount could bind-mount any host path, straight past allowed_bind_mounts. Docker's and Podman's built-in local volume driver forwards its type/o/device options to mount(2), so {"type":"none","o":"bind","device":"/"} makes a volume that is a bind mount of an arbitrary host path. Nothing read Mount.VolumeOptions.DriverConfig on POST /containers/create, and the bind-mount loop only ever looked at HostConfig.Binds and mounts of Type: "bind", so a Type: "volume" entry carrying those driver options mounted the host root into the container while the allowlist saw a named volume and passed it through. The same volume could also be made ahead of time through POST /volumes/create with Driver: local and bind DriverOpts, then mounted by name later, which the allowlist never sees at all — the volume inspector's allow_driver_opts gate denied the whole options map when it was off, but turning it on for a legitimate tmpfs or NFS volume opened the bind case with it. Sockguard now recognizes a local-driver options map that asks for a bind — an o carrying a bind or rbind token, or a type of none or bind, together with a device — and runs that device through the same normalizeBindMount and bindPathAllowed helpers a Binds entry goes through, so /srv/../etc is compared as /etc, the denial reads bind mount source %q is not allowlisted exactly as it does for a bind, and there is one allowlist rather than two. A device that is not an absolute path is denied rather than skipped: an options map that has already asked for a bind is naming a device the daemon resolves against its own working directory, which is not the same case as a relative Binds source, where the relative form means a named volume and not a host path. POST /volumes/create and POST /libpod/volumes/create are checked the same way, against container_create.allowed_bind_mounts and libpod_container_create.allowed_bind_mounts respectively — neither volume group gets an allowed_bind_mounts key of its own, for the reason container_create has no allow_endpoint_config key of its own: one list an operator widens, not two that can drift. Matching is on whole o tokens rather than a substring, so an NFS addr=bind.example.com is not read as a bind request, and option keys are matched case-insensitively because the two daemons do not agree on whether they lowercase them first. Non-bind local options (tmpfs, nfs, cifs, a size quota) and any third-party driver's options are untouched, as is every HostConfig.Binds and Type: "bind" path.
  • PUT /volumes/{name} had no inspector, so an allow rule on it was an unread write. The Swarm cluster-volume (CSI) update was absent from compileRuntimePolicy's inspection table and from the body-sensitive write catalog alike, which is the worst of both: no gate read the body, and startup validation did not treat the endpoint as a blind write either, so allowing it drew no insecure_allow_body_blind_writes acknowledgment and no warning. Its body is volume.UpdateOptions, a single ClusterVolumeSpec, and the field that matters is Spec.Secrets — each entry names a Swarm secret whose value the daemon hands to the CSI plugin, so a client that could reach the route could point the plugin at a secret it was never granted, while the sibling POST /volumes/create had been inspected by default since v1.5. Spec.Availability was the same shape of hole one step down: drain or pause forces a volume off every node publishing it. The route is inspected now, through the same request_body.volume block that governs create, with the same 1 MiB bound and fail-closed decode posture as its siblings — a malformed body, a field-level type mismatch, or an oversized one is denied rather than forwarded. Every ClusterVolumeSpec field is denied by default and each needs an explicit opt-in: allow_cluster_volume_secrets for Spec.Secrets on its own, and allow_cluster_volume_updates for Availability, Group, AccessMode, CapacityRange and AccessibilityRequirements. The split is deliberate, and the broad flag does not admit Secrets: an operator who needs a CSI resize or drain should not thereby open secret rewriting. Secrets was given its own flag rather than folded onto the existing allow_driver_opts for the same reason — driver options are a routine setting on ordinary local volumes, so reusing that flag would have opened the escalation field for everyone who already sets it. Matching follows moby's own route, PUT /volumes/{name:.*}, so a slash-bearing name is covered, and so are PUT /volumes/create and PUT /volumes/prune, which have no PUT route of their own and resolve to the update handler with the name create or prune. Today moby's Cluster.UpdateVolume applies only Availability and ignores the rest of the spec, but that is a daemon-version fact rather than a guarantee, so every field is gated. Owner isolation already covered the request — volumeIdentifier excludes only the POST create and prune spellings, so checkOwnedResource was authorizing the target volume all along — and there is now a regression test pinning that, since the body gates would be worth much less without it. The endpoint joins the body-sensitive write catalog scored as inspected, spelled with the slash-bearing identifier shape so a rule constrained below one literal segment is still recognized as reaching it. This is a behavior change for one shipped preset. The 25 configs under app/configs/ were replayed through the production evaluator: portainer.yaml (and its copy at examples/compose/portainer/sockguard.yaml) is the only one that reaches the route, through its method: "*", path: "/volumes/**" allow, so a Portainer deployment that issues a cluster-volume update now needs one of the two flags. Every other preset denies the route already and is unaffected. The preset is left as it is rather than opened by default, because the reason to inspect the endpoint is exactly that a wildcard-method volume rule was admitting an unread write.
  • The same volume-type bind reached the host through the Swarm service API. serviceMount decoded no VolumeOptions at all and the service bind loop only looked at Type: "bind", so a TaskTemplate.ContainerSpec mount of Type: "volume" carrying local-driver bind options walked past request_body.service.allowed_bind_mounts on both POST /services/create and POST /services/{id}/update. The service inspector now runs VolumeOptions.DriverConfig through the same denyLocalVolumeBindDeviceReason check the entry above added for POST /containers/create, against the service allowlist and with the same bind mount source %q is not allowlisted denial, so the two surfaces cannot disagree about what a bind is.
  • A cached read can no longer be revalidated past the read-side filters. The visibility filter, owner isolation's GET /system/df filter and the response filter all forwarded a 304 Not Modified untouched, so a client that fetched a list or an inspect before a policy tightened — or before any policy existed — could send If-None-Match or If-Modified-Since, have the daemon confirm its copy, and go on using a body no filter ever saw. The validator behind that copy is the daemon's, computed over the unfiltered body, and every axis that would have narrowed it (visibility selectors and name/image patterns, the owner label, the redaction options) is reloadable, so the cached copy and the current policy can disagree by an arbitrary amount. Sockguard now strips the whole RFC 9110 precondition set — If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since — from every request on its way to the daemon, at the one point every proxied request passes through, so a revalidation arrives as the full fetch the filters can inspect. The client's request is left intact in the access and audit records. A 304 that arrives anyway is refused with a 502 instead of relayed, with its own reason code at each layer (visibility_not_modified_unfilterable, owner_not_modified_unfilterable, and upstream_response_rejected_by_policy for the response filter) so it is not read as a policy lookup that failed. A 204 still passes through: it is not a revalidation and has no stale representation behind it. This was pre-existing and theoretical — neither dockerd nor Podman emits ETag or Last-Modified on these routes, so the strip changes nothing observable against either — but the documented fail-closed guarantee should not depend on an upstream detail Sockguard does not control. The response filter's refusal applies only to GET and HEAD, where a 304 can only mean cache revalidation: POST /containers/{id}/start and POST /containers/{id}/stop (compat and /libpod) legitimately answer 304 as an idempotent no-op when the container is already in the requested state, and refusing those turned a correct no-op into a 502 for orchestrators and retry loops. A 304 on any other method now passes through unchanged.
  • A HEAD on a response-filtered read no longer returns the daemon's length and validator for the unfiltered body. HEAD /containers/json under response.name_patterns or response.image_patterns passed the upstream Content-Length and ETag straight through, and so did the /images/json and /libpod spellings of the same routes, plus HEAD /system/df under either visibility policy or owner isolation. A HEAD has no body for the pattern or owner filter to walk, but the daemon still sizes and validates one, and on those routes it is the body the policy exists to narrow: the length is a count of the containers, images and volumes the caller may not see, and the ETag is a validator computed over them. Sockguard now forwards the request and clears Content-Length, ETag, Last-Modified and the rest of the representation headers, using the same list the buffered rewrites and the fail-closed 502 paths already use, so the response goes out with no length at all rather than a fabricated one. Content-Type is kept. The route is not refused, which is the difference from GET /libpod/system/df and the other unscopeable libpod reads: those carry no labels and no names, so no policy axis applies to any method and GET is refused too, whereas here GET is fully scoped and only the HEAD's metadata is not. Routes whose selectors are injected into the upstream request instead — GET /networks, GET /volumes, GET /services and the rest — are untouched, because the daemon already computes their length over the scoped list. Pre-existing and theoretical in the sense that it takes a caller who is already allowed to HEAD a list route, but the leak is a live fingerprint of hidden resources rather than a fail-closed gap.
  • The visibility list parser no longer completes a truncated array or drops trailing bytes. The pattern response filter walked the buffered /containers/json or /images/json body element by element and stopped when encoding/json's Decoder.More() went false, which happens both when the array closes and when the input simply runs out. It never read the closing ] and never checked what followed it, so a body ending mid-array, or a valid array with a second value or garbage after it, was rewritten into a well-formed 200 the client read as the complete list. Sockguard now requires the closing delimiter and refuses any non-whitespace trailing bytes with the same 502 the non-array case already used; trailing whitespace is still valid. Elements were always filtered individually, so this was never a confidentiality bypass — the leak would have been a client trusting a list Sockguard could not vouch for in full. FuzzVisibilityFilter now asserts the refusal directly, using encoding/json's whole-body parse as an independent oracle against the streaming decoder under test, instead of only bounding the output length; the old bound could only catch a body that grew, and both of these gaps were bodies that were silently completed. Pre-existing and theoretical: it takes an upstream that is not the daemon this build expects.
  • Image inspect responses were not redacted at all. GET /images/{name}/json (and native GET /libpod/images/{name}/json) had no entry in the response filter's dispatch table, so with every response.redact_* option enabled the body still came back byte-identical, including Config.Env (the image's baked-in build-time environment — a common secret carrier from Dockerfile ENV or --build-arg) and GraphDriver.Data (the storage driver's host filesystem paths for the image's layers). Image inspect now reuses container inspect's existing helpers: Config.Env is emptied under redact_container_env and GraphDriver.Data is masked under redact_mount_paths, gated exactly as they are on container inspect. The libpod route shares the same handler; *libimage.ImageData's Config (*ociv1.ImageConfig) and GraphDriver (*DriverData{Name, Data}) fields carry the identical json tags Docker's compat handler uses, verified against Podman v5.8.1's pinned containers/common release. This gap is pre-existing in v2.0.0, not a v2.1.0 regression.
  • golang.org/x/crypto moves from v0.55.0 to v0.56.0, clearing GO-2026-6354 (CVE-2026-78662) and GO-2026-6355 (CVE-2026-56855), two denial-of-service bugs in golang.org/x/crypto/ssh where a deadlocked channel stalls the connection. Sockguard never imports the ssh package; the module is an indirect requirement reached through sigstore-go and certificate-transparency-go for cryptobyte on the opt-in image_trust path, never the core proxy path, and govulncheck reports zero reachable vulnerabilities either side of the bump. Grype matches on module version rather than reachability, so once the 2026-09-04 vulnerability DB shipped the finding failed CI: Verify's Docker Build on every branch, and it flags the published v2.1.0 image the same way.
  • A rule pattern carrying malformed UTF-8, or a literal U+FFFD, matched less than the regex it compiles to. glob.ToRegexString decodes a pattern to runes before it quotes them, and Go's regexp decodes the request path the same way: every byte that is not part of a well-formed UTF-8 sequence steps as U+FFFD with width one. So a deny spelling /containers/sec<U+FFFD>ret/* compiles to ^/containers/sec\x{FFFD}ret/[^/]*$, which covers GET /containers/sec%FFret/json, the %FE spelling, and every other lone malformed byte in that position. Nothing in front of that regex agreed with it. The literal-prefix fast reject was the pattern's own bytes up to the first *, so strings.HasPrefix demanded the three-byte U+FFFD encoding and turned those requests away before the regex was ever consulted, and the segment walker, the trailing-/** prefix test and the plain literal comparison all compare bytes too, so each of them answered no a second time. A gate that turns a request away does not deny it, it hands it to whatever allow sits below, so deny /containers/sec<U+FFFD>ret/* above allow /containers/** admitted the request the deny describes. literalPrefixForPattern now stops at the first rune regexp reads as U+FFFD whichever way it is spelled, which utf8.ValidString alone would miss because a real U+FFFD is valid UTF-8, and a pattern carrying such a rune now compiles to the anchored regex rather than to a byte-comparing fast path. The regex is the dialect's definition and the walkers are only the optimization, so a pattern that can tell the two apart forfeits the optimization. Patterns whose text survives UTF-8 decoding unchanged keep every fast path they had, ordinary multi-byte ones like /日本/* included. This takes a mangled byte in an operator's own pattern to reach, so it is theoretical rather than a live bypass, but the narrowed side was the deny, which is the dangerous direction. It is the same class as the stacked-/** prefix fix under Fixed, the gate and the regex disagreeing about a pattern they are both derived from, reached through the decoder rather than through an optional group.

Added

  • sockguard verify is the runtime counterpart to sockguard validate. Validate is an offline check on a config file; verify loads the config the way serve does (flags, SOCKGUARD_* environment, file) and then checks that what it names is reachable right now, so an operator can tell a broken deployment from a broken policy without reading a log. It runs five checks and prints one line each with ok, fail, or skip. config loads and validates the effective config structurally. upstream builds the endpoints (loading their TLS material), runs the same reachability probe serve runs at startup, asks the Docker API through the same readiness probe /health issues, and resolves the engine flavor through the same GET /version probe serve resolves policy semantics from. listener issues GET <health.path> against each effective listener. tls opens the cert, key, and client CA each mutual-TLS listener names — the filesystem half of validation the admin API's POST /admin/validate deliberately skips, which is a probing oracle over the network and is just the local filesystem here. image-trust reports whether image trust is configured and, when it uses keyless identities, whether the Sigstore trust root loads; a keyed-only policy is reported as passing without touching the network, because it never needs one. A skip is a check that does not apply to this deployment — an opt-in feature that is off, a listener that is not up — and never changes the exit code, so verify is usable before the proxy has started as well as against a running one; any fail exits non-zero, so it also works as a container healthcheck or a deploy gate. --json emits the same five checks in the same order as a machine-readable report, and --listen-socket / --upstream-socket override the config exactly as they do on serve. No new probe was written for any of this: each check calls the code the running proxy already calls, and internal/health's single-shot Monitor.Probe is a thin export of the check /health already runs so the two cannot drift apart.
  • The shutdown grace period is now configurable via server.shutdown_grace, instead of a hardcoded 30s. It bounds how long sockguard waits for in-flight requests to finish on both the main and admin listeners after SIGTERM/SIGINT before force-closing them. Defaults to "30s", unchanged from the previous hardcoded value; unlike upstream.hijack_inactivity_timeout, 0 is a valid value meaning "close immediately," so it's validated as a non-negative Go duration rather than a strictly positive one. The field is immutable across hot reload — it's read once at startup into the seam shutdownServers consults, so a reload has no way to make a changed value take effect before the next shutdown.
  • A SOCKGUARD_* environment variable that matches no configuration key now warns at startup instead of being silently ignored. Viper's AutomaticEnv only consults a variable for a key it already knows about, so SOCKGUARD_LISTEN_SOCKT=/run/sockguard.sock was read by nothing: the listener stayed on its config-file or default value and no output said so. That silence is the dangerous direction for a default-deny proxy, where the operator believes they tightened something. sockguard serve and sockguard validate now both check the environment at startup and log one line per unrecognized variable, naming the variable and, when the spelling is within two edits of a real one, the variable it was probably meant to be (did_you_mean=SOCKGUARD_LISTEN_SOCKET). Only the name is logged, never the value, since a typo lands on a variable holding a registry credential or a TLS key path as easily as on one holding a socket path. What counts as recognized is reconstructed from the loader's own Viper state rather than a hand-kept list: the registerDefaults walk over Config's mapstructure tags supplies the schema, and the config file supplies the rest, so it cannot drift from what Viper binds. Both halves matter, because a key that exists only because the YAML declares it is still a key the environment overrides. That is how the four YAML-only cases the configuration reference lists behave (the rules block, pointer blocks like clients.global_concurrency, pointer ints like listen.socket_uid, and anything inside a list entry): reported when nothing would have read the variable, silent once the file declares the block that makes it bindable. Tecnativa compatibility variables (CONTAINERS, POST, ALLOW_START, SOCKET_PATH, LOG_LEVEL) carry no SOCKGUARD_ prefix and are never flagged. The warning is advisory and never fails startup: a variable nothing reads cannot make a config invalid.

Changed

  • Renovate targets dev/v2.2, the development branch of the 2.2 line, and RELEASING.md step 6 now names renovate.json as release metadata, because release-cut.yml refuses a tag whose line the bot does not target.
  • The owner-label mutation pass decodes each create body once instead of twice (PERF-20). Every request internal/ownership stamps an owner label into — container, service, network, volume, secret and config create, node and swarm update, commit, and the libpod container/pod/network/volume create endpoints — ran filter.RejectDuplicateCaseVariantJSONKeys over the raw body and then decoded the same bytes again for the mutation itself, building two identical map[string]any trees per request and throwing one of them away. The duplicate-case-variant guard now runs against the tree the mutation already decoded, through the new filter.RejectDuplicateCaseVariantJSONValue — the same walk over the same value, reaching the same verdict. Measured over a realistic 3.9 KB POST /containers/create body: 101.45µs → 64.26µs per request (-37%), 79.1 KiB → 48.4 KiB allocated (-39%), 1415 → 808 allocations (-43%). Nothing changes about what gets injected, when, or what bytes go upstream; the one visible difference is that a body whose JSON does not parse is now reported as a decode failure instead of the ambiguity check's wrapping of the same parse error.
  • The request-classification helpers that carry no policy dependency now live in internal/apipath as well: NormalizePath, CanonicalizePath, StripVersionPrefix, HasVersionPrefix, NormalizePodmanRoutePath, and the hijack-candidate set IsHijackCandidatePath, IsContainerAttachPath, IsExecStartPath, IsLibpodContainerAttachPath and IsLibpodExecStartPath (G39). internal/filter mixed policy evaluation with pure request classification, and internal/ownership, internal/visibility, internal/responsefilter, internal/ratelimit, internal/proxy, internal/config and internal/cmd all imported it partly for the classification half. Everything moved takes a method or a path string and returns a bool or a string, reads no config and no policy state, and moved verbatim. internal/filter keeps NormalizePath, HasVersionPrefix, NormalizePodmanRoutePath and IsHijackCandidatePath as exported one-line wrappers, and isExecStartPath and isLibpodExecStartPath as unexported ones, so no call site inside or outside the package changed. internal/apipath stays a leaf: go list -deps ./internal/apipath names no module-internal package but itself, which is what lets internal/filter and the three packages that import internal/filter all read one definition. internal/ratelimit, which imported internal/filter for NormalizePath and nothing else, imports the leaf instead and drops that dependency entirely. Every moved helper's unit tests, mutation-kill tests and NormalizePath benchmarks moved with it; the fuzz targets stay in internal/filter, where their persisted corpora and their name-plus-package registration in ci-verify.yml, quality-fuzz-monthly.yml, lefthook.yml and scripts/local-fuzz.sh live, and reach the same code through the wrappers. internal/filter's matchesBuildkitTunnelInspection and its narrower sibling IsBuildkitTunnelPath stay put as a pair, since the wider half has a single consumer. No behaviour change.
  • isNodeUpdatePath and isLibpodPath, duplicated verbatim between internal/filter, internal/ownership, and internal/responsefilter, now live in a new leaf package, internal/apipath, that all three import; the old package-local names stay as one-line wrappers so no call site changed. internal/ownership/paths.go's nine near-identical *Identifier extractors (containerIdentifier, execIdentifier, networkIdentifier, volumeIdentifier, serviceIdentifier, taskIdentifier, secretIdentifier, configIdentifier, nodeIdentifier) also collapse onto one parameterized resourceIdentifier helper, again kept as one-line wrappers with the same exported behavior. No behaviour change.
  • POST /containers/create decodes into a recycled target instead of a fresh one (PERF-2). The inspector allocated a fresh ~600-byte containerCreateRequest for every request, plus a backing array for each list field the body carried and buckets for Labels/Sysctls, and dropped all of it again before it returned. It now takes the target from a sync.Pool and puts it back, which leaves the decode itself alone: still the same json.Unmarshal against the same type, so every field the policy reads and every type error a malformed body produces are exactly what they were. BenchmarkInspectContainerCreate on go1.26.6 (darwin/arm64, M4 Pro), 20 interleaved runs a side through benchstat: strict_full_walk goes from 45 to 36 allocs/op, 7.218 to 6.178 KiB/op and 5016 to 4701 ns/op, permissive_early_exit from 30 to 27 allocs/op, 6.897 to 6.014 KiB/op and 2930 to 2534 ns/op. That benchmark builds a fresh http.Request every iteration, so inspect's own share of the saving is bigger than the totals suggest: the decode by itself drops from 30 allocs and 1568 B to 21 and 496. Recycling is only safe if the reset is exhaustive, so the reset is checked by a reflection walk over the struct rather than by review: a schema field added without a matching reset line fails TestContainerCreateRequestResetForReuseClearsEveryField instead of reading back the previous request's value. The three fields the decode carries but no rule reads (HostConfig.MemoryReservation, DeviceRequests[].DeviceIDs, DeviceRequests[].Options) stay in the struct on purpose: they allocate nothing when absent, and dropping them would turn today's malformed-body denial into a pass-through for a body that sets them to the wrong JSON type.
  • The proxy-vs-daemon differential test harness moved from app/differential to app/internal/differential (CQ-24). It was package differential outside internal/, used only by tests, so it was semver-stable public API by accident; nothing outside this module ever imported it. git mv carried its history; the real-dockerd importer in app/integration/, the fuzz-target pkg paths in ci-verify.yml/quality-fuzz-nightly.yml/quality-fuzz-monthly.yml, the coverage-exclusion pattern in scripts/ci/go-test.sh, and .coderabbit.yaml's path instructions all now point at the new location.
  • The response filter's whole-body read now fills a pooled bytes.Buffer instead of allocating a fresh one per request. io.ReadAll started every inspect, /info, /system/df and volume-list read at 512 bytes and grew by copying, so a body of any size paid several throwaway allocations before a single field was redacted. withResponseBody borrows the buffer from a sync.Pool alongside the one streamArrayResponse already uses for its output, hands the bytes to a callback, and returns the buffer on every exit including the read-error, over-cap and decode-error paths the caller turns into a 502. The bounds are unchanged and are still checked in the same order: the upstream body is closed, an unsolicited Content-Encoding is decompressed through the same compressed-stream bound, and the LimitedReader is sized to MaxResponseBodyBytes+1 so a body of exactly 8 MiB is read whole and one byte more is refused rather than truncated. The callback form is what makes it safe — the pooled bytes are gone by the time the caller sees a payload, because encoding/json copies every key, string and json.Number out of the input and writeResponseBody marshals into a new slice. BenchmarkModifyResponseInspect on a container inspect body: 61,152 B/op and 685 allocs/op before, 50,060 B/op and 675 allocs/op after (PERF-18).
  • A list response's fields now stay as the bytes the daemon sent unless the active option set actually redacts them. streamArrayResponse decoded every array element whole into a map[string]any and re-marshalled it, so a GET /containers/json entry's Names, Image, Command, Ports, Labels, State and Status cost an allocation per string, per nested map and per interface box on their way to being re-encoded byte-identically. Each list route now declares the top-level keys its mutator reads, rewrites or deletes; the element is decoded one level deep into a json.RawMessage per key, only the declared keys are decoded the rest of the way, and everything else is re-emitted from the original bytes with insignificant whitespace removed. Output is unchanged apart from untouched values keeping their own formatting (a nested object's key order, a \u escape) instead of being canonicalized, and the top-level key order, escaping and rewritten values are what encoding/json produced before. The declared set has to be a superset of what the mutator reaches for, since a key left out is a redaction that silently does not happen, so TestListPartialDecodeMatchesFullDecode runs every list route through both paths and fails on any difference; it was verified against three deliberately shortened sets. BenchmarkModifyResponseContainerList over a 500-entry list: 92,325 allocs/op and 3.90 MB/op before on every profile; after, 64,485 and 3.24 MB with redact_mount_paths and redact_network_topology both on (6.8 ms to 6.6 ms), 47,731 and 2.80 MB with topology alone (6.8 ms to 5.6 ms), and 31,704 and 1.91 MB with mount paths alone (6.6 ms to 3.8 ms) (PERF-23).
  • Config validation's route-reachability search no longer costs time that grows with the length of a rule pattern, and it now has a budget that bounds the work outright. firstAllowedCatalogPath walks a product NFA over the catalog language, its exclusions and every rule up to the target allow, and every step of that walk scanned the full instruction list of every machine: reachabilityAdvance looped over program.Inst and tested reachabilityHas per instruction, reachabilityCandidateRunes did the same per state, and reachabilityAccepts scanned for a match instruction the same way. A rule pattern compiles to roughly one instruction per byte, so every transition was proportional to the pattern rather than to the handful of NFA positions actually live. That is what put the 7.5s single call the fuzz target's own comment records into reach, and what made the fuzzer cap its generated identifiers and patterns to keep throughput. All three now iterate the live set by word with bits.TrailingZeros64. Three other costs went with them: the product state is one flat word slice instead of one allocation per machine, so its map key is a single copy into a reused buffer and only a state that survives the seen check is turned into a string; the witness is a parent link plus the rune that got there, reconstructed on success, instead of a string concatenation per queued state that re-copied the whole prefix; and the candidate-rune working set is reused across states rather than allocated per state. The search also drops a state whose target machine has gone empty, which is exactly as sound as the catalog-empty drop already there (reachabilityAdvance maps an empty state set to itself, and acceptance requires both machines to accept), and it is what stops a long literal pattern from walking the rest of the catalog language after the pattern itself was ruled out one character in. Measured on the benchmarks added with this change: BenchmarkFirstAllowedCatalogPathLongPattern at a 1 KB identifier goes 30.4ms to 1.68ms with allocations 81,577 to 2,270, and BenchmarkValidateAndCompileRulesLongPattern at the same size goes 44.9ms to 3.67ms with allocations 124,483 to 4,620. What remains is inherent: a pattern spelled with hundreds of * segments keeps hundreds of positions live at once, so each transition legitimately costs as much as the whole program, and the existing state and transition caps do not bound that. A fourth cap, maxCatalogReachabilitySteps, now bounds the NFA work one call may spend at 1<<23, counting both the live instructions it visits and the words of a state set it scans to find them, so a policy of many short rules is priced too. That is 148x the 56,537 steps the heaviest policy in app/configs/ spends on its most expensive catalog row, which is also the ceiling across the whole test suite, so only a pattern built to be expensive reaches it. Exhausting it returns catalogReachabilityIndeterminate, the same conservative verdict the program-size, state and transition caps already return: allowedCatalogPaths reports the endpoint under its stable catalog spelling and the acknowledgment is still demanded, so giving up can only over-report, never hide an allow rule. This is a behavior change for one shape: a config whose pattern packs a few hundred * segments into a single path segment used to be searched to a verdict and can now come back indeterminate, so the sensitive endpoints it touches get named conservatively instead of being cleared. A walk of both sensitive-endpoint catalogs over such a config is bounded at under a second where it measured 14s before. No shipped config is affected; all 25 under app/configs/ are two orders of magnitude inside the budget. Matching semantics are untouched: the catalog and rule languages compile exactly as they did, and FuzzCatalogReachability's differential against the independent catalogFuzzTemplateMatches oracle is unchanged and green.
  • Every competitor comparison claim now names the upstream version it was checked against. README.md's feature-comparison table, website/src/app/data/comparison-rows.ts (rendered by compare-matrix.tsx), and docs/content/docs/migration.mdx's five per-competitor sections each carry a "versions checked 2026-09-05" line naming Tecnativa docker-socket-proxy v0.5.0, LinuxServer docker-socket-proxy 3.4.4-r0-ls96, wollomatic socket-proxy 1.13.1, 11notes docker-socket-proxy v2.1.8, and hectorm cetusguard v1.1.4, re-checked at every release cut, so a "cannot do X" claim can't go stale silently (DOC-10).
  • The release workflows finish the harden-runner egress rollout (#282): all seven remaining egress-policy: audit jobs in release-cut.yml and release-from-tag.yml now run block with an explicit allowed-endpoints list. #282 deliberately held the release jobs back until two audited release-candidate cuts had run clean; 2.1.0-rc.1, 2.1.0-rc.2 and the 2.1.0 GA have since run, and every host in every list was read out of those runs' harden-runner audit logs rather than guessed, with an in-file comment naming the step that needs it. Three of them differ from what a paper exercise produces: Docker Hub's blob CDN is production.cloudfront.docker.com, not production.cloudflare.docker.com; the Chainguard static base image's blobs redirect to an opaque per-account *.r2.cloudflarestorage.com bucket, so that one has to be a wildcard; and packages.wolfi.dev is never contacted at all. *.blob.core.windows.net is listed explicitly on the Docker publish job because buildkit's cache-to: type=gha spreads across ten or more productionresultssaN hosts in a single run, more than the one cache host harden-runner resolves for itself. disable-sudo is not added alongside block the way stage 1 paired them: that's a separate control with its own failure mode, and this change stays scoped to egress. None of this can be proven by CI, only by a real tag cut, so the next one is the proof: if v2.1.1 blocks a host these lists missed, the fix is flipping that one workflow back to egress-policy: audit for the cut and adding the host afterwards, never bypassing the gate.
  • The README and website comparison tables no longer credit Tecnativa's ALLOW_* vars as a working Partial control for granular container write ops. Tecnativa's own shipped haproxy.cfg denies every non-GET request before the ALLOW_* rules ever run, so ALLOW_RESTARTS=1/ALLOW_START=1/etc. are documented but dead in the config Tecnativa ships. The cell now reads Documented only (POST gate blocks them) in README.md's feature-comparison table and in website/src/app/data/comparison-rows.ts's "Granular POST ops" row. LinuxServer's cell is untouched: its own README states those same ALLOW_* vars "work even when POST=0", the opposite of Tecnativa's behavior.
  • The LinuxServer migration section in the docs now lists the LinuxServer env vars sockguard has no equivalent for, instead of documenting only the ten write-side ALLOW_* vars it supports: DISABLE_IPV6, the five GET-only sub-resource gates (ALLOW_ARCHIVE, ALLOW_CHANGES, ALLOW_EXPORT, ALLOW_LOGS, ALLOW_TOP), the fifteen LIBPOD_* Podman compat vars, and TZ. DISABLE_IPV6's "ignored" note moves out of the Tecnativa section, since it's LinuxServer's own variable, not Tecnativa's.
  • The libpod container-update inspector's allow-unknown posture is now a recorded decision with a drift check behind it, instead of an unstated fall-through. POST /libpod/containers/{name}/update decodes handlers.UpdateEntities, whose embedded specs.LinuxResources, define.UpdateHealthCheckConfig and define.UpdateContainerDevicesLimits all flatten to the request root, and a root key none of the gate lists names has always fallen through to allow. That stays: Podman discards a body field its own build does not define, so deny-on-unknown would refuse working requests on the first Podman minor that adds a field — including fields Podman itself ignores — while buying nothing, since a key this build cannot name is a key it cannot gate either. What was missing is the other half of that trade, which is noticing when the upstream body grows. The 32 root keys the gates were reviewed against are pinned in app/testdata/podman-api/update-entities-root-fields.json at Podman v5.8.1, the release the inspector is verified against, re-derived identical at v6.1.1 and at main, and recorded alongside the exact fetch commands and the opencontainers/runtime-spec version Podman pins for the OCI half. libpodContainerUpdateKnownFields is assembled from the same five gate lists inspectLibpod enforces rather than written out a second time, so the test cannot disagree with the code: a gate list edited without the snapshot being refreshed, or a snapshot refreshed without a gate being extended, fails in either direction and names every field that moved. A second test re-derives the set from Podman's own source with go/ast, resolving embedded structs and json tags the way encoding/json resolves them rather than grepping for them, and skips itself unless SOCKGUARD_TEST_PODMAN_UPSTREAM_REF names a ref so the suite still runs offline; the monthly quality-api-version-watch.yml workflow sets it to main, and that workflow is renamed "Quality: Upstream API Watch" now that it watches both daemons' request shapes rather than Docker's Engine API version alone. At runtime an unrecognized root key is named in a debug-level log line, bounded to eight names of 64 bytes each because the 1 MiB body cap would otherwise let an allowed caller drive log volume. No allow or deny outcome changes on any request. docs/content/docs/podman.mdx records the decision next to the endpoint it governs.
  • A documentation-only PR no longer runs the Docker image build or the 29-job Go fuzz matrix in ci-verify.yml. A new Changed Paths gate job diffs the PR against its base and sets docs_only; Docker Build and the fuzz matrix are gated on it. Measured on #438, a CHANGELOG.md-only PR, that workflow ran 43 jobs and 90 runner-minutes, 29 jobs and 67 of those minutes in the fuzz matrix alone — enough to queue every other PR behind it at the org's 20-concurrent-job ceiling. The scoping is a job rather than an on.pull_request.paths filter because a required status check inside a workflow that never triggers produces no check run at all and deadlocks branch protection, while a skipped job inside a workflow that did trigger reports skipped and satisfies it. Docker Build stays a required context and now reports skipped-green on those PRs. Everything else — Go lint and unit tests, the Node workspaces, CodeQL, Gitleaks, Dependency Review, Shellcheck, Actionlint, and the Grype/Gosec/Govulncheck gates in security-grype.yml — is unchanged. The gate fails open: any error inside it, or a failure of the job itself, runs the full matrix.
  • CI and the pre-push hook now run the organization's repository-run Qlty gate (#267). .qlty/qlty.toml was already committed, but nothing ran it: ci-verify.yml never passed run-qlty to the reusable go-ci.yml, and lefthook.yml had no Qlty command, so the config was inert on both sides. The go-ci caller now sets run-qlty (skipped on the weekly schedule, alongside GoReleaser) with qlty-egress-policy: block and portwing's proven endpoint allowlist copied verbatim rather than re-derived, which publishes the Go CI / Qlty Check context. Two scripts mirror portwing's: scripts/qlty-check-gate.sh holds the gate itself, and scripts/ci/go-qlty.sh is the fixed adapter the reusable workflow invokes, asserting MODULE_DIRECTORY=app because sockguard's Go module lives in app/ while the Qlty check is whole-repository. The pre-push hook runs the same gate script between go-lint and go-test, the position portwing uses, so the local and hosted checks cannot drift; a missing qlty CLI hard-fails with an install hint instead of skipping, matching this repo's shellcheck and zizmor gates rather than its skip-when-absent goreleaser and govulncheck gates. This is the CLI gate that runs in Actions, not the Qlty Cloud app's qlty check/qlty coverage statuses, which fail org-wide on billing minutes and stay non-required. The gate is only as strong as the config it runs, and .qlty/qlty.toml still enables no [[plugin]] blocks, so qlty check --all reports no issues on the tree today; adopting the plugin set portwing and drydock carry is a separate change.
  • RELEASING.md's release-facing files list and scripts/verify-tag-release-metadata.mjs's stable-tag gate now cover SECURITY.md's supported-versions table, so a stale table (#421, #431) fails the release cut instead of shipping quietly.
  • A request's query string is now parsed once and shared by every inspector and middleware that reads it, instead of once per read. url.URL.Query() re-parses RawQuery and allocates a fresh url.Values on every call, and several surfaces read it more than once for a single request: POST /swarm/update reads three separate rotate* flags, the #152 resource-limit guard reads ?version= and ?rollback= off one POST /services/{id}/update, and the proxy's request-deadline classifier probes ?stream= first for presence and then for value on GET /containers/{id}/stats. The parse is memoized on the logging.RequestMeta the chain already threads through the wrapped ResponseWriter and the request context — the same per-request state that already carries the normalized path — so it costs no extra allocation on a request that reads the query once or not at all. Measured on an M4 Pro with -benchmem: swarm update 33 to 19 allocs/op and 7431 to 6469 B/op, the service-update guard 22 to 17 allocs/op and 6805 to 6357 B/op, the stats classification 20 to 16 allocs/op and 6710 to 6277 B/op. POST /build stays level at 23 allocs/op, because its inspector is the only thing that reads a build request's query anywhere in the chain (the deadline classifier exempts /build on the path alone, before it would look at one), so there is no repeat there to collapse. The memo is keyed on the raw query string rather than a bare already-parsed flag, which is what makes it safe against the two ways that key goes stale here: a middleware rewriting r.URL.RawQuery downstream of the filter (ownership and visibility both do) and a RequestMeta recycled through its sync.Pool. Both force a reparse instead of answering from a stale entry. The shared url.Values is read-only by contract, with callers that fold or rewrite keys building their own map from it. No allow or deny decision changes on any request.
  • README.md's and docs/content/docs/configuration.mdx's Tecnativa compat-vs-rules: sentence said an explicit rules: block always wins over compat, even when byte-identical to the built-in defaults. That contradicts rulesMatchDefaults in app/internal/config/compat.go, which activates compat whenever the effective ruleset still matches the defaults regardless of where it came from, and it already contradicted docs/content/docs/migration.mdx. Both now say what the code does: a byte-identical rules: block still activates compat, and only a rules: block that differs from the default wins outright.
  • The Watchtower preset's header comment and docs/content/docs/presets.mdx now record why app/configs/watchtower.yaml grants POST /containers/{id}/update and why the grant covers containrrr/watchtower too: verified against both upstreams' pkg/container/client.go, the core recreate flow (list/inspect, stop, force-remove, create, start, rename, exec hooks, image pull/remove, network connect) is identical, but nicholas-fedor/watchtower added SetNoRestartPolicy, which is the only caller of that route and only ever submits the restart policy, never Resources — matching the preset's existing restart-only grant with allow_resource_updates left denied.
  • Removed the dead js-yaml entry from website/package.json and docs/package.json's overrides blocks. It pinned a floor for a transitive dependency during the v1.4.4 security refresh; npm ls js-yaml --all now shows nothing in the tree depends on it, so the override was only feeding the Dependency Dashboard a v5 update offer for a package nothing uses.

Performance

  • Route metric labels no longer allocate. RouteCategory runs on every request through routeLabel, and it split the path twice (once to test for an API version prefix, once to route it) and concatenated the label it returned. It now walks the path once with an index cursor into a stack-allocated segment array, folding the version-prefix check into the same pass, and looks the label up in a per-family table interned at init. A versioned container list costs 0 allocations instead of 5 (128 B) and 19 ns/op instead of 98; no route label changes.
  • Concurrent /health requests no longer serialize on the health cache's mutex. The cached upstream verdict is published as a single atomic pointer, so a request that hits the cache reads it with no lock; probes still serialize under the same mutex and the same single-flight, so two concurrent misses still produce one dial. Under contention a cache hit costs 24 ns/op instead of 155. TTL, failure-TTL and eviction semantics are unchanged.
  • A glob-dense rule pattern no longer costs a minute of startup validation under instrumentation. PERF-24's catalog-reachability step budget was sized at 1<<23 against uninstrumented wall time for one exhausted search, but validateAndCompileRules spends a fresh budget on every catalog row and CI runs the suite under go test -race -covermode=atomic, where the coverage counters cost more on these NFA loops than the race detector does. A 1204-byte pattern of a few hundred * segments took 63-92s there against a 60s bound. The budget is now 1<<20, which is still 18x the 56,537 steps the heaviest shipped preset spends on its most expensive catalog row and 2.4x what a 1KB literal pattern needs to be proved exactly, and takes the same pattern to 0.04s uninstrumented and 5.2s under CI's flags. No config's verdict changes and the search still fails closed on exhaustion.
  • Injecting visibility label filters no longer parses and rebuilds the whole query string. A list request whose query carries no filters parameter of its own now has the encoded selectors appended to the raw query instead of being decoded into a url.Values and re-encoded parameter by parameter, which costs 19 allocations instead of 28 and 853 ns/op instead of 1125 on a request with two other parameters. A query that already carries filters (or a percent escape, or a semicolon separator) still takes the decode-and-merge path unchanged, and the forwarded query means the same thing either way.

Fixed

  • A gzip-encoded upstream response no longer turns every filtered read into a 502. The response filter handed the daemon's bytes straight to a JSON decoder, so a Content-Encoding: gzip body failed on the gzip magic bytes and the read came back as upstream Docker response rejected by sockguard policy. dockerd and Podman don't compress the JSON API, but a client that sends Accept-Encoding: gzip through to a remote daemon behind a TLS-terminating proxy can get one back. Two halves. The proxy now pins Accept-Encoding: identity on the upstream-bound request, next to the conditional-header strip and for the same reason, so the far side doesn't compress in the first place; it's a Set and not a Del because net/http's Transport re-adds Accept-Encoding: gzip to any request that carries none. And if an upstream compresses anyway, readResponseBody and streamArrayResponse decode gzip before parsing, and the rewritten body goes back to the client uncompressed with Content-Encoding dropped and Content-Length corrected, which is what ClearUpstreamRepresentationHeaders already did for every other substituted body. The 8 MiB response cap now counts decoded bytes on that path, so it doubles as the gzip-bomb guard, and the compressed stream is bounded too. A coding this package can't decode (br, zstd, a doubly-wrapped gzip, gzip) is refused with an error that names it rather than guessed at. Routes the filter doesn't rewrite are untouched, encoding and all.
  • Container inspect and image inspect no longer parse a response body for a redaction option that cannot rewrite one field of it. Filter.Enabled() is the OR of all five response options, and the ModifyResponse switch handed those two routes to their handlers on it. Every other handler in the package already re-checked its own options and returned early, so redact_host_topology on its own — an option that only ever rewrites GET /info — still sent a container or image inspect body through the read, decode, redact-nothing, re-encode round trip. That flattened the daemon's bytes, dropped its ETag and the rest of the representation headers, and turned a body this package refuses to parse (over the 8 MiB cap, malformed, or carrying a second JSON document behind the first) into a 502 on a read no enabled option applied to. Both handlers now gate on the options they actually read: env, mount paths and network topology for container inspect, env and mount paths for image inspect, which has no Mounts, HostConfig or NetworkSettings block to redact. The Podman-native /libpod/containers/{id}/json and /libpod/images/{name}/json spellings share those handlers and are fixed with them.
  • A rule pattern without a leading / no longer matches a rooted request path, and no longer loads at all. matchGlobSegments is the allocation-free stand-in for the anchored regex a single-star pattern compiles to, and it stripped one leading slash from the request path while splitGlobSegments stripped one from the pattern. The two strips cancelled for a rooted pattern and erased the distinction for a rootless one, so containers/* compiled to exactly the segments /containers/* does and matched /containers/json, */json matched /containers/json, and a bare * matched /containers and /_ping. The regex each of those patterns compiles to accepts none of them (^containers/[^/]*$, ^[^/]*/json$, ^[^/]*$), because the [^/]* a * becomes cannot cross a separator, and the regex is the dialect's definition while the walker is only an optimization. On an allow rule the gap was a widening: a pattern the operator spelled as one relative segment quietly covered a rooted request, which is every request there is. The segment-glob literal-prefix gate hid it as well, trimming a leading slash off both the path and the prefix where the regex arm compares the two as they stand. Neither trim happens now. A rooted path's leading empty segment has to be spent by a leading empty pattern segment, so a rootless pattern matches only a rootless path, which NormalizePath and NormalizePodmanRoutePath never produce from an HTTP request-target. This is a behavior change: a match.path that does not start with / is now a config validation error naming the rule and the rooted spelling to write instead, in root rules and in clients.profiles[*].rules alike. Correcting the walker on its own would have been worse than the bug for one case, a rootless deny that was firing by accident and would have gone silently inert, so the shape fails closed at startup the same way a match.path carrying an API version prefix or a literal % already does, both of which are rejected for the same reason: the pattern can only ever be dead. Nothing shipped is affected. All 25 configs under app/configs/, every rule the Tecnativa compatibility layer generates, and every pattern in the docs are already rooted. Container-label ACLs get their own reject, because they are the one path that reaches filter.CompileRule without passing config validation: internal/clientacl reads a comma-separated glob allowlist off the calling container's labels. Correcting the walker was not enough there. A rootless single-star label such as containers/* does now grant nothing, but a rootless label carrying ** grants far too much and always did, because ** is defined as any sequence of characters including /: ** compiles to ^(?s:.*)$ and matches every rooted path, **/json matches /containers/json, and */** compiles to ^[^/]*(/(?s:.*))?$ and also spans the leading slash. A label reading com.sockguard.allow.get=** therefore handed that client every GET the global policy allows rather than the narrow relative grant it looks like, which is a hole straight through the per-client boundary the label exists to draw. compileContainerLabelRules now refuses any label path without a leading / before compiling it, through the fail-closed route a label pattern that will not compile already takes: the offending label and path are logged at error level and the client's requests answer 502 until it is fixed. Nothing about the matcher changes for **; it agrees with its own regex and a bare ** really is a catch-all, just an unrooted spelling of one. The enforcement is the refusal at both places a pattern is accepted from an operator. internal/cmd's catalog-reachability model carried an arm that re-rooted a segment glob to mirror the walker's old trim, a no-op for a rooted pattern and a model of the widening for a rootless one; it is gone, so the automaton and the matcher agree again. FuzzPathMatch now carries three rootless patterns alongside its rooted ones instead of a comment excusing the divergence, and a new differential runs a generated corpus of 326 patterns, half of them rootless, across all five matcher kinds against every path view, asserting that the matcher, the literal-prefix gate and the compiled regex give the same answer on each. A companion test states the deep-wildcard truth outright, so nothing downstream gets written against a belief that rootless means dead, and the container-label reject is covered end to end through the middleware for **, **/json, */** and the single-star forms, each paired with its rooted spelling.
  • The zizmor pre-push gate now runs when only .github/zizmor.yml changes. Its glob matched .github/workflows/*.yml only, so an edit to the zizmor config itself — which changes what the scan reports — never re-triggered the gate locally; glob is now a list covering both .github/workflows/*.yml and .github/zizmor.yml.
  • The segment-glob fast path and the regex its patterns compile to now agree on a trailing slash, which counts as a real, empty final segment. matchGlobSegments is the allocation-free stand-in for the anchored regex a single-star pattern compiles to, and the two disagreed in both directions on a path ending in /. The walker absorbed a trailing empty segment after its last pattern segment, so /containers/* matched /containers/abc/ where ^/containers/[^/]*$ does not, and it refused to spend a pattern segment on that empty segment, so /*/*/* did not match /a/b/ where the regex does. NormalizePath's path.Clean strips a trailing slash, so neither half is reachable on an ordinary Docker route; NormalizePodmanRoutePath deliberately keeps the slash gorilla/mux routes on, so both were reachable on the libpod image-SCP route view that POST /libpod/images/scp/{name} is evaluated against alongside its decoded path. The regex wins, because it is the dialect's definition and the walker is only an optimization: a pattern matches a path only when the path carries exactly as many /-separated segments as the pattern spends. Both halves were policy bugs on that route. Absorbing the slash let a rule spelling one segment cover two, so allow POST /libpod/images/scp/* admitted /libpod/images/scp/alpine/, which Podman routes as an SCP of the image alpine/ rather than of alpine. Refusing to spend a segment let an ordered deny be dodged, so deny POST /libpod/images/scp/*/* sitting above allow POST /libpod/images/scp/** missed /libpod/images/scp/tenant/ and the allow below it fired. The lenient reading was rejected because the trailing slash is exactly what separates the SCP route from the push, tag, and untag routes Podman registers earlier, and so was normalizing the slash away for matching while preserving it upstream, which would let /libpod/images/scp/victim/push/ borrow a .../push allow and then be routed as an SCP. No shipped preset changes behavior: all 24 deny every POST /libpod/images/scp/... shape at the decoded view, which is evaluated first and is untouched by this fix, so the route view is never reached. No legitimate request is narrowed either, because an image reference cannot end in / and the route view is only computed when the escaped path differs from the cleaned one. The matcher differential test now builds its corpus from both path views instead of NormalizePath alone and asserts the trailing-slash half is present, FuzzPathMatch carries the walker-versus-regex invariant on both views, and both fuzz corpora are seeded with trailing-slash inputs.
  • GRPC=1/SESSION=1 no longer refuse startup for an operator who has migrated to request_body.buildkit. Tecnativa compatibility mode auto-sets the deprecated insecure_accept_opaque_buildkit_tunnels acknowledgement for those two vars, and it runs before validation, so a config carrying a request_body.buildkit mediation policy plus a leftover GRPC=1 from the old environment failed the mutual-exclusion check with a message naming a key the operator never wrote. Compatibility mode now leaves the acknowledgement alone whenever request_body.buildkit is configured at the top level or on any client profile. The posture tightens rather than relaxes: the acknowledgement only ever gated startup admission, so with a top-level policy the generated /grpc and /session rules are admitted by mediation instead, and every gRPC message on those tunnels is checked against the configured policy rather than passing uninspected. A policy on a client profile alone still refuses to start, because those generated rules are top-level and apply to every client the profile does not match, but the refusal now names the GRPC/SESSION env vars the rules came from and the two cures that work, rather than pointing at a config file the rules are not in. An explicitly configured insecure_accept_opaque_buildkit_tunnels alongside request_body.buildkit is still refused.
  • The libpod image-SCP route view is now part of both what the validator searches and what the runtime evaluates. A policy allowing both POST /libpod/images/scp/{name}/push and .../push/ was accepted with no insecure_allow_body_blind_writes or insecure_allow_read_exfiltration acknowledgment, while at runtime both of the views filter.evaluateRequestPolicy checks allow the trailing-slash spelling and Podman routes it to the image-SCP handler, an uninspectable image ingest that is also an SSH egress. The gap was in the catalog language firstAllowedCatalogPath searches: the decoded probe .../push is removed from the SCP catalog by that entry's own push/tag/untag exclusions, and .../push/ was not in the language at all, because the identifier placeholder stood for a run of non-empty segments and nothing else. Both SCP catalog entries now spell their identifier as a route path, which is what gorilla/mux resolves {name:.*} to and what NormalizePodmanRoutePath preserves: a run of clean segments that may be absent, or may end on the empty segment a trailing slash leaves. The exclusions keep their decoded spelling on purpose, so a bare .../push still belongs to the image-push route registered ahead of the catch-all while .../push/ falls into it, which is how filter.isLibpodImageScpRoutePath decides the same request at runtime. The absent name is the same bug one route over, and it needed a runtime fix as well: {name:.*} matches the empty string, so POST /libpod/images/scp/ is an SCP call with no source, verified by replaying Podman v5.8.1's registration order from pkg/api/server/register_images.go through gorilla/mux v1.8.1, where it dispatches to ImageScp with an empty name while POST /libpod/images/scp without the trailing slash matches no route at all. isLibpodImageScpRoutePath treated the bare route as a non-route, so sockguard decided it on the decoded path /libpod/images/scp alone and a rule written for its siblings, allow POST /libpod/images/* covering pull, load and import, admitted it. It is recognized now, so the request has to pass the route view too, which that rule does not match; the change can only turn an allow into a deny. Owner isolation had the same blind spot one layer down: libpodImageScpSource answered "not an SCP route" for the empty source, so the request fell through to the generic image classifier and spent an upstream inspect on an image named scp/ before failing closed. The empty source is an SCP source with no image to look up now, and the malformed-source denial already sitting there answers it with no inspect at all. The route candidate the ownership middleware derives from the escaped path is gated one character earlier for the same reason, since path.Clean leaves the bare route spelled /libpod/images/scp. Podman refuses an empty source deeper in, in ExecuteTransfer's "no source image specified", so the practical exposure was a 500 and a daemon-side temp file rather than a transfer, but the routing disagreement is the bug and it should not rest on the handler's argument validation. No shipped preset changes behavior: none of the 25 configs in app/configs/, or the client profiles inside them, allows any POST /libpod/images/scp spelling. A witness that is the bare route, or that ends on the empty segment, is a request spelling whose decoded view has to allow as well, which the automata do not model, so the verdict over-reports rather than under-reports on those shapes. Every concrete path the audit names is confirmed through the production evaluator first and an endpoint whose concrete path cannot be confirmed is named by its catalog spelling instead, so the insecure_allow_read_exfiltration warning now says which of the two an exposed_endpoints entry is: that warning is the one caller that reports exposure instead of refusing it, where an over-report is telemetry rather than a fail-closed refusal. The configuration reference now says the same thing.
  • A deny rule whose path pattern stacks a second /**, a bare star, or a literal straight onto a /** no longer misses the bare route it names. deny /containers/secret/**/** sitting above allow /containers/** admitted GET /containers/secret. Every /** compiles to an optional group, so the anchored regex the pattern compiles to matches its own literal head with all of those groups collapsed, and that is what the rule means. The allocation-free literal-prefix gate that runs in front of the regex to skip work disagreed: literalPrefixForPattern kept the trailing slash whenever the text after the first /** started with /, so the gate demanded /containers/secret/ and turned /containers/secret away before the regex was consulted. A fast path that changes the verdict is the bug rather than the optimization, and this one failed in the open direction, because a deny the gate rejects is not a quieter deny, it is a fall-through to whatever allow sits below. The prefix now keeps that slash only when the text after the group still guarantees one, which /containers/**/json does and /containers/**/** does not. The same derivation covers three shapes with the same cause: a longer stack (/containers/**/**/**), a stack running into a bare star (/containers/**/***), and a stack welded straight onto a literal (/containers/**/**json, whose regex matches /containersjson). Patterns that still guarantee the slash are unchanged, so /containers/**/json, /containers/**/* and /containers/*/**/** keep the prefix they had, and no shipped preset spells a stacked-/** pattern. The change can only widen a gate that was never allowed to reject, so it adds no match the regex does not already make. A generated differential now crosses every literal head against every wildcard tail and asserts, over both path views a compiled rule is handed, that the prefix never rejects a path the pattern's own regex accepts, and the matcher's verdict is checked against that regex on the way past.
  • A profile-index cache hit no longer reads the cached value outside the lock. profileLRU.lookup unlocked the mutex and then read the LRU node's result field, while store overwrites that same field in place under the lock. Two requests arriving from one source IP (or carrying one client certificate) therefore raced on the memoized profile name: go test -race reports it, and a torn read of the string header can hand the middleware a profile nobody stored. The value is now copied while the lock is still held.
  • A malformed reload.debounce or reload.poll_interval is now named in the log instead of silently falling back. Both were parsed with time.ParseDuration and the error was dropped, so debounce: 250 (no unit) ran on the 250ms default and poll_interval: "10 seconds" left polling disabled, with nothing tying the running behavior back to the config file. Validation still rejects those values before startup; when the fallback is reached anyway it now logs one warning naming the key, the value that failed to parse, and the default it fell back to. The fallback itself is unchanged.
  • A non-loopback plaintext TCP main listener now warns at startup, the way the admin listener already did. insecure_allow_plain_tcp plus insecure_allow_unauthenticated_clients let the Docker API listener run unencrypted with every routable host admitted as a client, and that acknowledgment was made once in a config file and never mentioned again — while the far smaller admin surface has warned about the same shape since #21. Startup now emits one warning per effective listener in that shape, naming the listener and its address. Loopback addresses, unix sockets, and listeners with complete mutual TLS stay silent.

Documentation

  • README.md and the docs roadmap said v2.1.0 promotes 2.1.0-rc.1 unchanged; the GA tree matches 2.1.0-rc.2, the candidate actually promoted after rc.1's GitHub publish failed on the release-body byte limit (#429).
  • /health re-probes the upstream once its 2-second cache lapses instead of freezing the first verdict for the life of the process. With the watchdog off, the handler published its own probe as watchdog state, which pinned the has-state flag true with no reset, so every later request short-circuited on the stored answer and healthCacheTTL was unreachable from the request path. A liveness probe therefore kept getting the boot-time verdict: healthy long after the daemon went away, and — the worse direction — 503 forever when the daemon was simply slower to come up than the proxy, since nothing ever re-checked. The request that finds the verdict stale now runs the replacement probe itself and answers with its result, so the TTL is a real bound on how old the answer can be; every other request arriving while that probe runs is served the verdict on hand instead of queueing behind it. That second half matters because /health is unauthenticated and sits ahead of the rate limiter: queueing callers would mean one blocked goroutine per caller for the whole dial timeout against a blackholed daemon, repeating every failure-TTL window, each one logging its own warning. At most one goroutine is ever waiting on the upstream, and the unreachable warning is logged once per probe rather than once per request served, so log volume during an outage is bounded by the failure TTL instead of by request rate. The probe runs on a context of its own, because check() deliberately discards caller-canceled verdicts instead of caching them and evicts the entry it held, so a client that hangs up mid-check would otherwise drive one upstream dial per request. With the watchdog enabled nothing changes: it still owns the snapshot at its configured interval, so its once-per-edge transition logging and the sockguard_upstream_socket_up gauge stay in agreement with what /health reports.
  • The Docker-compat GET /secrets answered 500 on a Podman upstream whenever owner isolation or a visibility policy was configured. Podman registers /secrets, /vX.Y.Z/secrets and /libpod/secrets/json all onto compat.ListSecrets (pkg/api/server/register_secrets.go at v5.8.1), and that handler runs every secret through utils.IfPassesSecretsFilter, whose switch accepts name and id and returns invalid filter %q for anything else; utils.InternalServerError turns that into a 500. Both isolation layers inject a label filter into /secretsneedsOwnerFilter and needsVisibilityLabelFilter each list it — so on Podman the injection did not narrow the list, it broke every request, and the operator saw an upstream 500 with nothing in the access log saying sockguard caused it. v2.1.0 fixed the native /libpod/secrets/json spelling by refusing it (owner_libpod_secret_list_unscopeable, visibility_libpod_secret_list_unscopeable); the compat spelling had no flavor gate at all, since /events was the only path that did. It now gets the same refusal, gated on the resolved upstream.flavor: 403 with owner_podman_secret_list_unscopeable or visibility_podman_secret_list_unscopeable, decided before the daemon is contacted so the host's secret inventory is never read, and independent of warn/audit rollout mode because the request the operator would be measuring against is the one Podman answers with a 500. Refusing rather than fetching unfiltered and filtering in-proxy is the same call the libpod spelling already made, and it is the same handler: the compat body carries Spec.Labels exactly as the libpod body does, so a response-side filter could scope either, and building one for the compat path alone would leave one Podman handler answering 403 through its native path and a filtered list through its compat path off one policy. A patterns-only visibility policy injects nothing into /secrets, so it is forwarded untouched, matching the no-selector branch the Podman /events gate already takes; a deployment with no owner and no visibility policy is unaffected; GET /secrets/{id} names one secret and both layers still resolve it normally; and a Docker upstream keeps the conjunctive label injection unchanged, since dockerd's swarm secret list honors it.

Tests

  • TestRunServeErrorPaths/validate_before_opening_log_output clears SOCKGUARD_* from its environment first, the way the validate-command tests already do, so the unknown-variable warning that the podman integration job's SOCKGUARD_TEST_PODMAN_SOCKET correctly triggers no longer fails its clean-stderr assertion.
  • The gitleaks allowlist names the two synthetic secret values the response-filter partial-decode and benchmark fixtures carry, scoped to those two files, so the committed-secrets scan on dev/v2.1 is green again without weakening it anywhere else.
  • TestListLabelFilterComposesOwnerAndVisibilitySelectors now covers all five /libpod/*/json list routes instead of three. The table had rows for /libpod/containers/json, /libpod/pods/json and /libpod/volumes/json; /libpod/images/json and /libpod/networks/json compose the owner and visibility label selectors the same way and had no regression pin of their own.
  • TestWithRequestTimeout_DoesNotSeverLiveStream's exempt-path table now holds GET /events and GET /containers/{id}/logs?follow=1 open, not just six other long-lived routes. Both are classified long-lived by isLongLivedUpstreamRequest already; they had no behavioral proof the per-request deadline actually leaves them alone. TestNewHTTPServerDoesNotSeverLiveEventsStream adds a second angle on the same gap, driving a real *http.Server built by newHTTPServer (not a stub handler under httptest's default config) through an actual accept loop and asserting a /events-shaped stream stays open for a full second under the server's own timeouts.
  • The response filter and the visibility filter are now held to one shared array-termination table. Both packages stream the same Docker list bodies through a json.Decoder of their own, and they had already drifted once: responsefilter's streamArrayResponse required the closing ] and refused trailing content, while visibility's flushFiltered did neither until the 2.1.x fix above. The cases now live in testhelp.JSONArrayTerminationCases and both packages assert against them, so a case added for either is a case the other has to answer. responsefilter needed no production change: streamArrayResponse (the compat and /libpod list routes) and decodeJSONObjectArray (the /libpod/networks/{id} single-element array envelope Podman sent through v3.0.0) already refused an unterminated array, trailing non-whitespace, and a second JSON document after the array, and already accepted trailing whitespace. FuzzFilterModifyResponse now carries the whole-body json.Valid oracle FuzzVisibilityFilter got, so a rewritten body that was not one complete JSON document fails the target instead of reaching a client as a well-formed 200; ran clean over 11.9 million executions.
  • FuzzCatalogReachability gives the sensitive-endpoint validator's bounded route search (firstAllowedCatalogPath) a differential fuzz target against the production evaluator, closing a gap CodeRabbit raised on #383. It builds each probe route by substituting a valid identifier into a catalog template, so membership in the catalog's route language is established by construction rather than by a suffix check, then asserts that a request filter.Evaluate allows can never draw a catalogUnreachable verdict (which allowedCatalogPaths reads as "nothing exposed") and that a reachable verdict names a witness inside the catalog language, outside every exclusion, stable under the route view's normalization, and allowed by the evaluator. Every catalog and exclusion membership question is answered twice, once by the production catalog compiler and once by an independent segment walker written from scratch, and the two have to agree: an oracle driving compileCatalogMachine and the reachability state helpers would have shared an overbroad catalog language with the code under test and hidden its own counterexamples, so an exclusion loose enough to read /team/pusher as /{id}/push would have been suppressed on both sides. The walker models all three identifier shapes, catalogIdentifierRoutePath included, so the generator reaches the libpod image-SCP dual view on both of its shapes: the trailing empty segment that carries POST /libpod/images/scp/{name}/push/ into the SCP catch-all past the push route registered ahead of it, and the absent name that carries the bare POST /libpod/images/scp/ into it. Each dual-view request is paired with its decoded view and the pair is checked against the two normalizations filter.evaluateRequestPolicy actually applies, not against its own raw text. The one witness the evaluator is allowed to deny is a witness on that dual view, which firstAllowedCatalogPath documents itself as not modeling; there the target asserts allowedCatalogPaths falls back to the catalog spelling, so the over-report can never turn into an under-report. It is registered in the nightly and monthly deep-fuzz tiers, not the Tier 1 smoke matrix.
  • The CIS Docker Benchmark conformance test's control-number comments and test-case IDs are realigned with docs/content/docs/cis-docker-benchmark.mdx: the host-PID case moves from 5.10/5.15 to 5.15, the CapAdd case moves from 5.16/5.17 to 5.3, and the read-only-rootfs case's ID moves from 5.30 (host userns) to 5.12, matching the CIS control its own comment already named. examples/compose/cis-docker-benchmark/README.md's security-tradeoffs table had the same drift and is corrected the same way; app/configs/cis-docker-benchmark.yaml's comments already matched the docs.
  • Five mutants the monthly run kept reporting as lived now have a seam that makes them killable. Each one guards a branch production only takes under a fault a unit test cannot arrange, so the code gained a small hook, a nil-checked function var or a var'd constant, and a test that fails when the mutation is applied by hand. acquireStreamArrayBuffer in responsefilter takes the pool's Get as a parameter, so the fallback for a pool that returns something other than a *bytes.Buffer is reachable without draining sync.Pool, whose Put discards entries at random under -race. The stale-socket probe's 200ms dial timeout and its dialer are package vars in internal/cmd, so a test can read the bound the probe hands net.DialTimeout instead of trying to time a connect that never blocks. internal/ratelimit gains casFailHook, nil in production and one nil check per CAS attempt on the hot path, so a test can lose a compare-and-swap deliberately and prove the loop retries instead of falling through to the deny valve. boundedio.openReadOnly is now a package var over the build-tagged open, so a descriptor whose Stat and Close both fail proves ReadFile reports the failure that stopped the read rather than the close that followed it. And internal/filter reaches imagetrust.LoadLiveTrustedRoot through a package var, so the keyless trust-root download can fail with no network and the fail-closed error still names the download. Behavior is unchanged in all five: every seam is nil or bound to the same function production already called.
  • The image-load gzip-bomb test no longer builds a payload past the real 2 GiB decompressed limit to trip the guard. maxImageLoadDecompressedBytes is now a package-level var instead of a const, so the test overrides it down to 4 KiB for its own run (restored via t.Cleanup) and shrinks the payload to match; the guard's behavior and error path are unchanged. The test dropped from 3.87s to under a second, which matters because the filter package's mutation-testing leg pays this test's cost on every mutant.

Removed

  • The internal security_best_practices_report.md write-up (dated 2026-07-20) is no longer tracked in the repo; it's archived locally in the gitignored .planning/. Public security material stays in SECURITY.md, SECURITY-ASSURANCE.md, and the docs site.