v2.2.0
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 rootedr.URL.Path. A non-OPTIONSasterisk-form line (GET * HTTP/1.1) arrives with the path*, because net/http intercepts onlyOPTIONS *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.NormalizePathpreserves both shapes verbatim, so they reachedEvaluateunrooted, and there the match-all matcher/**compiles to answered an unconditionaltrue— 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.RequestURIsubstitutes/for an empty path, so the daemon sawGET /where policy saw the empty string. Sockguard now answers400with reason coderequest_target_not_rootedfor 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 empty200before 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-inlocalvolume driver forwards itstype/o/deviceoptions tomount(2), so{"type":"none","o":"bind","device":"/"}makes a volume that is a bind mount of an arbitrary host path. Nothing readMount.VolumeOptions.DriverConfigonPOST /containers/create, and the bind-mount loop only ever looked atHostConfig.Bindsand mounts ofType: "bind", so aType: "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 throughPOST /volumes/createwithDriver: localand bindDriverOpts, then mounted by name later, which the allowlist never sees at all — the volume inspector'sallow_driver_optsgate 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 — anocarrying abindorrbindtoken, or atypeofnoneorbind, together with adevice— and runs that device through the samenormalizeBindMountandbindPathAllowedhelpers aBindsentry goes through, so/srv/../etcis compared as/etc, the denial readsbind mount source %q is not allowlistedexactly 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 relativeBindssource, where the relative form means a named volume and not a host path.POST /volumes/createandPOST /libpod/volumes/createare checked the same way, againstcontainer_create.allowed_bind_mountsandlibpod_container_create.allowed_bind_mountsrespectively — neither volume group gets anallowed_bind_mountskey of its own, for the reasoncontainer_createhas noallow_endpoint_configkey of its own: one list an operator widens, not two that can drift. Matching is on wholeotokens rather than a substring, so an NFSaddr=bind.example.comis 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, asizequota) and any third-party driver's options are untouched, as is everyHostConfig.BindsandType: "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 fromcompileRuntimePolicy'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 noinsecure_allow_body_blind_writesacknowledgment and no warning. Its body isvolume.UpdateOptions, a singleClusterVolumeSpec, and the field that matters isSpec.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 siblingPOST /volumes/createhad been inspected by default since v1.5.Spec.Availabilitywas the same shape of hole one step down:drainorpauseforces a volume off every node publishing it. The route is inspected now, through the samerequest_body.volumeblock 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. EveryClusterVolumeSpecfield is denied by default and each needs an explicit opt-in:allow_cluster_volume_secretsforSpec.Secretson its own, andallow_cluster_volume_updatesforAvailability,Group,AccessMode,CapacityRangeandAccessibilityRequirements. 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 existingallow_driver_optsfor 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 arePUT /volumes/createandPUT /volumes/prune, which have noPUTroute of their own and resolve to the update handler with the namecreateorprune. Today moby'sCluster.UpdateVolumeapplies onlyAvailabilityand 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 —volumeIdentifierexcludes only thePOSTcreate and prune spellings, socheckOwnedResourcewas 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 underapp/configs/were replayed through the production evaluator:portainer.yaml(and its copy atexamples/compose/portainer/sockguard.yaml) is the only one that reaches the route, through itsmethod: "*", 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.
serviceMountdecoded noVolumeOptionsat all and the service bind loop only looked atType: "bind", so aTaskTemplate.ContainerSpecmount ofType: "volume"carrying local-driver bind options walked pastrequest_body.service.allowed_bind_mountson bothPOST /services/createandPOST /services/{id}/update. The service inspector now runsVolumeOptions.DriverConfigthrough the samedenyLocalVolumeBindDeviceReasoncheck the entry above added forPOST /containers/create, against the service allowlist and with the samebind mount source %q is not allowlisteddenial, 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/dffilter and the response filter all forwarded a304 Not Modifieduntouched, so a client that fetched a list or an inspect before a policy tightened — or before any policy existed — could sendIf-None-MatchorIf-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. A304that arrives anyway is refused with a502instead of relayed, with its own reason code at each layer (visibility_not_modified_unfilterable,owner_not_modified_unfilterable, andupstream_response_rejected_by_policyfor the response filter) so it is not read as a policy lookup that failed. A204still passes through: it is not a revalidation and has no stale representation behind it. This was pre-existing and theoretical — neither dockerd nor Podman emitsETagorLast-Modifiedon 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 toGETandHEAD, where a304can only mean cache revalidation:POST /containers/{id}/startandPOST /containers/{id}/stop(compat and/libpod) legitimately answer304as an idempotent no-op when the container is already in the requested state, and refusing those turned a correct no-op into a502for orchestrators and retry loops. A304on any other method now passes through unchanged. - A
HEADon a response-filtered read no longer returns the daemon's length and validator for the unfiltered body.HEAD /containers/jsonunderresponse.name_patternsorresponse.image_patternspassed the upstreamContent-LengthandETagstraight through, and so did the/images/jsonand/libpodspellings of the same routes, plusHEAD /system/dfunder either visibility policy or owner isolation. AHEADhas 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 theETagis a validator computed over them. Sockguard now forwards the request and clearsContent-Length,ETag,Last-Modifiedand the rest of the representation headers, using the same list the buffered rewrites and the fail-closed502paths already use, so the response goes out with no length at all rather than a fabricated one.Content-Typeis kept. The route is not refused, which is the difference fromGET /libpod/system/dfand the other unscopeable libpod reads: those carry no labels and no names, so no policy axis applies to any method andGETis refused too, whereas hereGETis fully scoped and only theHEAD's metadata is not. Routes whose selectors are injected into the upstream request instead —GET /networks,GET /volumes,GET /servicesand 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 toHEADa 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/jsonor/images/jsonbody element by element and stopped whenencoding/json'sDecoder.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-formed200the client read as the complete list. Sockguard now requires the closing delimiter and refuses any non-whitespace trailing bytes with the same502the 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.FuzzVisibilityFilternow asserts the refusal directly, usingencoding/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 nativeGET /libpod/images/{name}/json) had no entry in the response filter's dispatch table, so with everyresponse.redact_*option enabled the body still came back byte-identical, includingConfig.Env(the image's baked-in build-time environment — a common secret carrier from DockerfileENVor--build-arg) andGraphDriver.Data(the storage driver's host filesystem paths for the image's layers). Image inspect now reuses container inspect's existing helpers:Config.Envis emptied underredact_container_envandGraphDriver.Datais masked underredact_mount_paths, gated exactly as they are on container inspect. The libpod route shares the same handler;*libimage.ImageData'sConfig(*ociv1.ImageConfig) andGraphDriver(*DriverData{Name, Data}) fields carry the identical json tags Docker's compat handler uses, verified against Podman v5.8.1's pinnedcontainers/commonrelease. This gap is pre-existing in v2.0.0, not a v2.1.0 regression. golang.org/x/cryptomoves 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 ingolang.org/x/crypto/sshwhere a deadlocked channel stalls the connection. Sockguard never imports thesshpackage; the module is an indirect requirement reached throughsigstore-goandcertificate-transparency-goforcryptobyteon the opt-inimage_trustpath, never the core proxy path, andgovulncheckreports 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 failedCI: 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.ToRegexStringdecodes a pattern to runes before it quotes them, and Go'sregexpdecodes 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 coversGET /containers/sec%FFret/json, the%FEspelling, 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*, sostrings.HasPrefixdemanded 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, sodeny /containers/sec<U+FFFD>ret/*aboveallow /containers/**admitted the request the deny describes.literalPrefixForPatternnow stops at the first runeregexpreads as U+FFFD whichever way it is spelled, whichutf8.ValidStringalone 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 verifyis the runtime counterpart tosockguard validate. Validate is an offline check on a config file; verify loads the config the wayservedoes (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 withok,fail, orskip.configloads and validates the effective config structurally.upstreambuilds the endpoints (loading their TLS material), runs the same reachability probeserveruns at startup, asks the Docker API through the same readiness probe/healthissues, and resolves the engine flavor through the sameGET /versionprobeserveresolves policy semantics from.listenerissuesGET <health.path>against each effective listener.tlsopens the cert, key, and client CA each mutual-TLS listener names — the filesystem half of validation the admin API'sPOST /admin/validatedeliberately skips, which is a probing oracle over the network and is just the local filesystem here.image-trustreports 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. Askipis 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; anyfailexits non-zero, so it also works as a container healthcheck or a deploy gate.--jsonemits the same five checks in the same order as a machine-readable report, and--listen-socket/--upstream-socketoverride the config exactly as they do onserve. No new probe was written for any of this: each check calls the code the running proxy already calls, andinternal/health's single-shotMonitor.Probeis a thin export of the check/healthalready 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 afterSIGTERM/SIGINTbefore force-closing them. Defaults to"30s", unchanged from the previous hardcoded value; unlikeupstream.hijack_inactivity_timeout,0is 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 seamshutdownServersconsults, 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'sAutomaticEnvonly consults a variable for a key it already knows about, soSOCKGUARD_LISTEN_SOCKT=/run/sockguard.sockwas 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 serveandsockguard validatenow 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: theregisterDefaultswalk overConfig'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 (therulesblock, pointer blocks likeclients.global_concurrency, pointer ints likelisten.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 noSOCKGUARD_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, andRELEASING.mdstep 6 now namesrenovate.jsonas release metadata, becauserelease-cut.ymlrefuses 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/ownershipstamps 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 — ranfilter.RejectDuplicateCaseVariantJSONKeysover the raw body and then decoded the same bytes again for the mutation itself, building two identicalmap[string]anytrees per request and throwing one of them away. The duplicate-case-variant guard now runs against the tree the mutation already decoded, through the newfilter.RejectDuplicateCaseVariantJSONValue— the same walk over the same value, reaching the same verdict. Measured over a realistic 3.9 KBPOST /containers/createbody: 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/apipathas well:NormalizePath,CanonicalizePath,StripVersionPrefix,HasVersionPrefix,NormalizePodmanRoutePath, and the hijack-candidate setIsHijackCandidatePath,IsContainerAttachPath,IsExecStartPath,IsLibpodContainerAttachPathandIsLibpodExecStartPath(G39).internal/filtermixed policy evaluation with pure request classification, andinternal/ownership,internal/visibility,internal/responsefilter,internal/ratelimit,internal/proxy,internal/configandinternal/cmdall 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/filterkeepsNormalizePath,HasVersionPrefix,NormalizePodmanRoutePathandIsHijackCandidatePathas exported one-line wrappers, andisExecStartPathandisLibpodExecStartPathas unexported ones, so no call site inside or outside the package changed.internal/apipathstays a leaf:go list -deps ./internal/apipathnames no module-internal package but itself, which is what letsinternal/filterand the three packages that importinternal/filterall read one definition.internal/ratelimit, which importedinternal/filterforNormalizePathand nothing else, imports the leaf instead and drops that dependency entirely. Every moved helper's unit tests, mutation-kill tests andNormalizePathbenchmarks moved with it; the fuzz targets stay ininternal/filter, where their persisted corpora and their name-plus-package registration inci-verify.yml,quality-fuzz-monthly.yml,lefthook.ymlandscripts/local-fuzz.shlive, and reach the same code through the wrappers.internal/filter'smatchesBuildkitTunnelInspectionand its narrower siblingIsBuildkitTunnelPathstay put as a pair, since the wider half has a single consumer. No behaviour change. isNodeUpdatePathandisLibpodPath, duplicated verbatim betweeninternal/filter,internal/ownership, andinternal/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*Identifierextractors (containerIdentifier,execIdentifier,networkIdentifier,volumeIdentifier,serviceIdentifier,taskIdentifier,secretIdentifier,configIdentifier,nodeIdentifier) also collapse onto one parameterizedresourceIdentifierhelper, again kept as one-line wrappers with the same exported behavior. No behaviour change.POST /containers/createdecodes into a recycled target instead of a fresh one (PERF-2). The inspector allocated a fresh ~600-bytecontainerCreateRequestfor every request, plus a backing array for each list field the body carried and buckets forLabels/Sysctls, and dropped all of it again before it returned. It now takes the target from async.Pooland puts it back, which leaves the decode itself alone: still the samejson.Unmarshalagainst the same type, so every field the policy reads and every type error a malformed body produces are exactly what they were.BenchmarkInspectContainerCreateon go1.26.6 (darwin/arm64, M4 Pro), 20 interleaved runs a side through benchstat:strict_full_walkgoes from 45 to 36 allocs/op, 7.218 to 6.178 KiB/op and 5016 to 4701 ns/op,permissive_early_exitfrom 30 to 27 allocs/op, 6.897 to 6.014 KiB/op and 2930 to 2534 ns/op. That benchmark builds a freshhttp.Requestevery 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 failsTestContainerCreateRequestResetForReuseClearsEveryFieldinstead 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/differentialtoapp/internal/differential(CQ-24). It waspackage differentialoutsideinternal/, used only by tests, so it was semver-stable public API by accident; nothing outside this module ever imported it.git mvcarried its history; the real-dockerd importer inapp/integration/, the fuzz-targetpkgpaths inci-verify.yml/quality-fuzz-nightly.yml/quality-fuzz-monthly.yml, the coverage-exclusion pattern inscripts/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.Bufferinstead of allocating a fresh one per request.io.ReadAllstarted every inspect,/info,/system/dfand 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.withResponseBodyborrows the buffer from async.Poolalongside the onestreamArrayResponsealready 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 unsolicitedContent-Encodingis decompressed through the same compressed-stream bound, and theLimitedReaderis sized toMaxResponseBodyBytes+1so 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, becauseencoding/jsoncopies every key, string andjson.Numberout of the input andwriteResponseBodymarshals into a new slice.BenchmarkModifyResponseInspecton 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.
streamArrayResponsedecoded every array element whole into amap[string]anyand re-marshalled it, so aGET /containers/jsonentry'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 ajson.RawMessageper 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\uescape) instead of being canonicalized, and the top-level key order, escaping and rewritten values are whatencoding/jsonproduced 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, soTestListPartialDecodeMatchesFullDecoderuns every list route through both paths and fails on any difference; it was verified against three deliberately shortened sets.BenchmarkModifyResponseContainerListover a 500-entry list: 92,325 allocs/op and 3.90 MB/op before on every profile; after, 64,485 and 3.24 MB withredact_mount_pathsandredact_network_topologyboth 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.
firstAllowedCatalogPathwalks 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:reachabilityAdvancelooped overprogram.Instand testedreachabilityHasper instruction,reachabilityCandidateRunesdid the same per state, andreachabilityAcceptsscanned 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 withbits.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 (reachabilityAdvancemaps 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:BenchmarkFirstAllowedCatalogPathLongPatternat a 1 KB identifier goes 30.4ms to 1.68ms with allocations 81,577 to 2,270, andBenchmarkValidateAndCompileRulesLongPatternat 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 inapp/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 returnscatalogReachabilityIndeterminate, the same conservative verdict the program-size, state and transition caps already return:allowedCatalogPathsreports 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 underapp/configs/are two orders of magnitude inside the budget. Matching semantics are untouched: the catalog and rule languages compile exactly as they did, andFuzzCatalogReachability's differential against the independentcatalogFuzzTemplateMatchesoracle 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 bycompare-matrix.tsx), anddocs/content/docs/migration.mdx's five per-competitor sections each carry a "versions checked 2026-09-05" line naming Tecnativadocker-socket-proxyv0.5.0, LinuxServerdocker-socket-proxy3.4.4-r0-ls96, wollomaticsocket-proxy1.13.1, 11notesdocker-socket-proxyv2.1.8, and hectormcetusguardv1.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: auditjobs inrelease-cut.ymlandrelease-from-tag.ymlnow runblockwith an explicitallowed-endpointslist. #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 isproduction.cloudfront.docker.com, notproduction.cloudflare.docker.com; the Chainguardstaticbase image's blobs redirect to an opaque per-account*.r2.cloudflarestorage.combucket, so that one has to be a wildcard; andpackages.wolfi.devis never contacted at all.*.blob.core.windows.netis listed explicitly on the Docker publish job because buildkit'scache-to: type=ghaspreads across ten or moreproductionresultssaNhosts in a single run, more than the one cache host harden-runner resolves for itself.disable-sudois not added alongsideblockthe 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 toegress-policy: auditfor 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 workingPartialcontrol for granular container write ops. Tecnativa's own shippedhaproxy.cfgdenies every non-GET request before theALLOW_*rules ever run, soALLOW_RESTARTS=1/ALLOW_START=1/etc. are documented but dead in the config Tecnativa ships. The cell now readsDocumented only (POST gate blocks them)inREADME.md's feature-comparison table and inwebsite/src/app/data/comparison-rows.ts's "Granular POST ops" row. LinuxServer's cell is untouched: its own README states those sameALLOW_*vars "work even whenPOST=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 fifteenLIBPOD_*Podman compat vars, andTZ.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}/updatedecodeshandlers.UpdateEntities, whose embeddedspecs.LinuxResources,define.UpdateHealthCheckConfiganddefine.UpdateContainerDevicesLimitsall 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 inapp/testdata/podman-api/update-entities-root-fields.jsonat Podmanv5.8.1, the release the inspector is verified against, re-derived identical atv6.1.1and atmain, and recorded alongside the exact fetch commands and theopencontainers/runtime-specversion Podman pins for the OCI half.libpodContainerUpdateKnownFieldsis assembled from the same five gate listsinspectLibpodenforces 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 withgo/ast, resolving embedded structs and json tags the wayencoding/jsonresolves them rather than grepping for them, and skips itself unlessSOCKGUARD_TEST_PODMAN_UPSTREAM_REFnames a ref so the suite still runs offline; the monthlyquality-api-version-watch.ymlworkflow sets it tomain, 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.mdxrecords 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 newChanged Pathsgate job diffs the PR against its base and setsdocs_only;Docker Buildand the fuzz matrix are gated on it. Measured on #438, aCHANGELOG.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 anon.pull_request.pathsfilter 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 reportsskippedand satisfies it.Docker Buildstays 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 insecurity-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.tomlwas already committed, but nothing ran it:ci-verify.ymlnever passedrun-qltyto the reusablego-ci.yml, andlefthook.ymlhad no Qlty command, so the config was inert on both sides. The go-ci caller now setsrun-qlty(skipped on the weekly schedule, alongside GoReleaser) withqlty-egress-policy: blockand portwing's proven endpoint allowlist copied verbatim rather than re-derived, which publishes theGo CI / Qlty Checkcontext. Two scripts mirror portwing's:scripts/qlty-check-gate.shholds the gate itself, andscripts/ci/go-qlty.shis the fixed adapter the reusable workflow invokes, assertingMODULE_DIRECTORY=appbecause sockguard's Go module lives inapp/while the Qlty check is whole-repository. The pre-push hook runs the same gate script betweengo-lintandgo-test, the position portwing uses, so the local and hosted checks cannot drift; a missingqltyCLI 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'sqlty check/qlty coveragestatuses, 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.tomlstill enables no[[plugin]]blocks, soqlty check --allreports 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 andscripts/verify-tag-release-metadata.mjs's stable-tag gate now coverSECURITY.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-parsesRawQueryand allocates a freshurl.Valueson every call, and several surfaces read it more than once for a single request:POST /swarm/updatereads three separaterotate*flags, the #152 resource-limit guard reads?version=and?rollback=off onePOST /services/{id}/update, and the proxy's request-deadline classifier probes?stream=first for presence and then for value onGET /containers/{id}/stats. The parse is memoized on thelogging.RequestMetathe chain already threads through the wrappedResponseWriterand 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 /buildstays 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/buildon 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 rewritingr.URL.RawQuerydownstream of the filter (ownership and visibility both do) and aRequestMetarecycled through itssync.Pool. Both force a reparse instead of answering from a stale entry. The sharedurl.Valuesis 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 explicitrules:block always wins over compat, even when byte-identical to the built-in defaults. That contradictsrulesMatchDefaultsinapp/internal/config/compat.go, which activates compat whenever the effective ruleset still matches the defaults regardless of where it came from, and it already contradicteddocs/content/docs/migration.mdx. Both now say what the code does: a byte-identicalrules:block still activates compat, and only arules:block that differs from the default wins outright. - The Watchtower preset's header comment and
docs/content/docs/presets.mdxnow record whyapp/configs/watchtower.yamlgrantsPOST /containers/{id}/updateand why the grant coverscontainrrr/watchtowertoo: 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 addedSetNoRestartPolicy, which is the only caller of that route and only ever submits the restart policy, neverResources— matching the preset's existing restart-only grant withallow_resource_updatesleft denied. - Removed the dead
js-yamlentry fromwebsite/package.jsonanddocs/package.json'soverridesblocks. It pinned a floor for a transitive dependency during the v1.4.4 security refresh;npm ls js-yaml --allnow 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.
RouteCategoryruns on every request throughrouteLabel, 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
/healthrequests 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<<23against uninstrumented wall time for one exhausted search, butvalidateAndCompileRulesspends a fresh budget on every catalog row and CI runs the suite undergo 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 now1<<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
filtersparameter of its own now has the encoded selectors appended to the raw query instead of being decoded into aurl.Valuesand 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 carriesfilters(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: gzipbody failed on the gzip magic bytes and the read came back asupstream Docker response rejected by sockguard policy. dockerd and Podman don't compress the JSON API, but a client that sendsAccept-Encoding: gzipthrough to a remote daemon behind a TLS-terminating proxy can get one back. Two halves. The proxy now pinsAccept-Encoding: identityon 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 aSetand not aDelbecause net/http's Transport re-addsAccept-Encoding: gzipto any request that carries none. And if an upstream compresses anyway,readResponseBodyandstreamArrayResponsedecode gzip before parsing, and the rewritten body goes back to the client uncompressed withContent-Encodingdropped andContent-Lengthcorrected, which is whatClearUpstreamRepresentationHeadersalready 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-wrappedgzip, 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 theModifyResponseswitch handed those two routes to their handlers on it. Every other handler in the package already re-checked its own options and returned early, soredact_host_topologyon its own — an option that only ever rewritesGET /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 itsETagand 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 noMounts,HostConfigorNetworkSettingsblock to redact. The Podman-native/libpod/containers/{id}/jsonand/libpod/images/{name}/jsonspellings 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.matchGlobSegmentsis 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 whilesplitGlobSegmentsstripped one from the pattern. The two strips cancelled for a rooted pattern and erased the distinction for a rootless one, socontainers/*compiled to exactly the segments/containers/*does and matched/containers/json,*/jsonmatched/containers/json, and a bare*matched/containersand/_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, whichNormalizePathandNormalizePodmanRoutePathnever produce from an HTTP request-target. This is a behavior change: amatch.paththat does not start with/is now a config validation error naming the rule and the rooted spelling to write instead, in rootrulesand inclients.profiles[*].rulesalike. Correcting the walker on its own would have been worse than the bug for one case, a rootlessdenythat was firing by accident and would have gone silently inert, so the shape fails closed at startup the same way amatch.pathcarrying 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 underapp/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 reachesfilter.CompileRulewithout passing config validation:internal/clientaclreads a comma-separated glob allowlist off the calling container's labels. Correcting the walker was not enough there. A rootless single-star label such ascontainers/*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,**/jsonmatches/containers/json, and*/**compiles to^[^/]*(/(?s:.*))?$and also spans the leading slash. A label readingcom.sockguard.allow.get=**therefore handed that client everyGETthe 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.compileContainerLabelRulesnow 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 answer502until 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.FuzzPathMatchnow 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.ymlchanges. Itsglobmatched.github/workflows/*.ymlonly, so an edit to the zizmor config itself — which changes what the scan reports — never re-triggered the gate locally;globis now a list covering both.github/workflows/*.ymland.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.
matchGlobSegmentsis 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'spath.Cleanstrips a trailing slash, so neither half is reachable on an ordinary Docker route;NormalizePodmanRoutePathdeliberately keeps the slash gorilla/mux routes on, so both were reachable on the libpod image-SCP route view thatPOST /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, soallow POST /libpod/images/scp/*admitted/libpod/images/scp/alpine/, which Podman routes as an SCP of the imagealpine/rather than ofalpine. Refusing to spend a segment let an ordered deny be dodged, sodeny POST /libpod/images/scp/*/*sitting aboveallow 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 thepush,tag, anduntagroutes 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.../pushallow and then be routed as an SCP. No shipped preset changes behavior: all 24 deny everyPOST /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 ofNormalizePathalone and asserts the trailing-slash half is present,FuzzPathMatchcarries the walker-versus-regex invariant on both views, and both fuzz corpora are seeded with trailing-slash inputs. GRPC=1/SESSION=1no longer refuse startup for an operator who has migrated torequest_body.buildkit. Tecnativa compatibility mode auto-sets the deprecatedinsecure_accept_opaque_buildkit_tunnelsacknowledgement for those two vars, and it runs before validation, so a config carrying arequest_body.buildkitmediation policy plus a leftoverGRPC=1from 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 wheneverrequest_body.buildkitis 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/grpcand/sessionrules 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 theGRPC/SESSIONenv 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 configuredinsecure_accept_opaque_buildkit_tunnelsalongsiderequest_body.buildkitis 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}/pushand.../push/was accepted with noinsecure_allow_body_blind_writesorinsecure_allow_read_exfiltrationacknowledgment, while at runtime both of the viewsfilter.evaluateRequestPolicychecks 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 languagefirstAllowedCatalogPathsearches: the decoded probe.../pushis 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 whatNormalizePodmanRoutePathpreserves: 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.../pushstill belongs to the image-push route registered ahead of the catch-all while.../push/falls into it, which is howfilter.isLibpodImageScpRoutePathdecides 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, soPOST /libpod/images/scp/is an SCP call with no source, verified by replaying Podman v5.8.1's registration order frompkg/api/server/register_images.gothrough gorilla/mux v1.8.1, where it dispatches toImageScpwith an empty name whilePOST /libpod/images/scpwithout the trailing slash matches no route at all.isLibpodImageScpRoutePathtreated the bare route as a non-route, so sockguard decided it on the decoded path/libpod/images/scpalone 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:libpodImageScpSourceanswered "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 namedscp/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, sincepath.Cleanleaves the bare route spelled/libpod/images/scp. Podman refuses an empty source deeper in, inExecuteTransfer'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 inapp/configs/, or the client profiles inside them, allows anyPOST /libpod/images/scpspelling. 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 theinsecure_allow_read_exfiltrationwarning now says which of the two anexposed_endpointsentry 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 aboveallow /containers/**admittedGET /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:literalPrefixForPatternkept the trailing slash whenever the text after the first/**started with/, so the gate demanded/containers/secret/and turned/containers/secretaway 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/**/jsondoes 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.lookupunlocked the mutex and then read the LRU node'sresultfield, whilestoreoverwrites 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 -racereports 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.debounceorreload.poll_intervalis now named in the log instead of silently falling back. Both were parsed withtime.ParseDurationand the error was dropped, sodebounce: 250(no unit) ran on the 250ms default andpoll_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_tcpplusinsecure_allow_unauthenticated_clientslet 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.1unchanged; the GA tree matches2.1.0-rc.2, the candidate actually promoted after rc.1's GitHub publish failed on the release-body byte limit (#429). /healthre-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 andhealthCacheTTLwas 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/healthis 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, becausecheck()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 thesockguard_upstream_socket_upgauge stay in agreement with what/healthreports.- The Docker-compat
GET /secretsanswered500on a Podman upstream whenever owner isolation or a visibility policy was configured. Podman registers/secrets,/vX.Y.Z/secretsand/libpod/secrets/jsonall ontocompat.ListSecrets(pkg/api/server/register_secrets.goat v5.8.1), and that handler runs every secret throughutils.IfPassesSecretsFilter, whose switch acceptsnameandidand returnsinvalid filter %qfor anything else;utils.InternalServerErrorturns that into a500. Both isolation layers inject alabelfilter into/secrets—needsOwnerFilterandneedsVisibilityLabelFiltereach list it — so on Podman the injection did not narrow the list, it broke every request, and the operator saw an upstream500with nothing in the access log saying sockguard caused it. v2.1.0 fixed the native/libpod/secrets/jsonspelling by refusing it (owner_libpod_secret_list_unscopeable,visibility_libpod_secret_list_unscopeable); the compat spelling had no flavor gate at all, since/eventswas the only path that did. It now gets the same refusal, gated on the resolvedupstream.flavor:403withowner_podman_secret_list_unscopeableorvisibility_podman_secret_list_unscopeable, decided before the daemon is contacted so the host's secret inventory is never read, and independent ofwarn/auditrollout mode because the request the operator would be measuring against is the one Podman answers with a500. 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 carriesSpec.Labelsexactly 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 answering403through 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/eventsgate 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 conjunctivelabelinjection unchanged, since dockerd's swarm secret list honors it.
Tests
TestRunServeErrorPaths/validate_before_opening_log_outputclearsSOCKGUARD_*from its environment first, the way the validate-command tests already do, so the unknown-variable warning that the podman integration job'sSOCKGUARD_TEST_PODMAN_SOCKETcorrectly 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.1is green again without weakening it anywhere else. TestListLabelFilterComposesOwnerAndVisibilitySelectorsnow covers all five/libpod/*/jsonlist routes instead of three. The table had rows for/libpod/containers/json,/libpod/pods/jsonand/libpod/volumes/json;/libpod/images/jsonand/libpod/networks/jsoncompose the owner and visibilitylabelselectors the same way and had no regression pin of their own.TestWithRequestTimeout_DoesNotSeverLiveStream's exempt-path table now holdsGET /eventsandGET /containers/{id}/logs?follow=1open, not just six other long-lived routes. Both are classified long-lived byisLongLivedUpstreamRequestalready; they had no behavioral proof the per-request deadline actually leaves them alone.TestNewHTTPServerDoesNotSeverLiveEventsStreamadds a second angle on the same gap, driving a real*http.Serverbuilt bynewHTTPServer(not a stub handler underhttptest'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.Decoderof their own, and they had already drifted once:responsefilter'sstreamArrayResponserequired the closing]and refused trailing content, whilevisibility'sflushFiltereddid neither until the 2.1.x fix above. The cases now live intesthelp.JSONArrayTerminationCasesand both packages assert against them, so a case added for either is a case the other has to answer.responsefilterneeded no production change:streamArrayResponse(the compat and/libpodlist routes) anddecodeJSONObjectArray(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.FuzzFilterModifyResponsenow carries the whole-bodyjson.ValidoracleFuzzVisibilityFiltergot, so a rewritten body that was not one complete JSON document fails the target instead of reaching a client as a well-formed200; ran clean over 11.9 million executions. FuzzCatalogReachabilitygives 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 requestfilter.Evaluateallows can never draw acatalogUnreachableverdict (whichallowedCatalogPathsreads 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 drivingcompileCatalogMachineand 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/pusheras/{id}/pushwould have been suppressed on both sides. The walker models all three identifier shapes,catalogIdentifierRoutePathincluded, so the generator reaches the libpod image-SCP dual view on both of its shapes: the trailing empty segment that carriesPOST /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 barePOST /libpod/images/scp/into it. Each dual-view request is paired with its decoded view and the pair is checked against the two normalizationsfilter.evaluateRequestPolicyactually applies, not against its own raw text. The one witness the evaluator is allowed to deny is a witness on that dual view, whichfirstAllowedCatalogPathdocuments itself as not modeling; there the target assertsallowedCatalogPathsfalls 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 from5.10/5.15to5.15, the CapAdd case moves from5.16/5.17to5.3, and the read-only-rootfs case's ID moves from5.30(host userns) to5.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.
acquireStreamArrayBufferinresponsefiltertakes the pool'sGetas a parameter, so the fallback for a pool that returns something other than a*bytes.Bufferis reachable without drainingsync.Pool, whosePutdiscards entries at random under-race. The stale-socket probe's 200ms dial timeout and its dialer are package vars ininternal/cmd, so a test can read the bound the probe handsnet.DialTimeoutinstead of trying to time a connect that never blocks.internal/ratelimitgainscasFailHook, 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.openReadOnlyis now a package var over the build-tagged open, so a descriptor whoseStatandCloseboth fail provesReadFilereports the failure that stopped the read rather than the close that followed it. Andinternal/filterreachesimagetrust.LoadLiveTrustedRootthrough 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.
maxImageLoadDecompressedBytesis now a package-level var instead of a const, so the test overrides it down to 4 KiB for its own run (restored viat.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.mdwrite-up (dated 2026-07-20) is no longer tracked in the repo; it's archived locally in the gitignored.planning/. Public security material stays inSECURITY.md,SECURITY-ASSURANCE.md, and the docs site.