Skip to content

fix(ownership): stop discarding visibility selectors on shared list endpoints - #381

Draft
scttbnsn wants to merge 10 commits into
dev/v2.0from
fix/ownership-visibility-filter-compose
Draft

fix(ownership): stop discarding visibility selectors on shared list endpoints#381
scttbnsn wants to merge 10 commits into
dev/v2.0from
fix/ownership-visibility-filter-compose

Conversation

@scttbnsn

Copy link
Copy Markdown
Contributor

Ownership was silently discarding every visibility selector on shared list endpoints.

The bug

Runtime layer order is visibility, then ownership. Visibility appends its selectors to the label filter key; addOwnerLabelFilter replaces that key. Ownership runs second, so on any endpoint where both are configured the forwarded query carried the owner selector and nothing else. The caller got owner scoping and no visibility scoping, with no log line saying so.

It reproduced on every shared path: docker_containers, docker_images, docker_volumes, docker_nodes, libpod_containers, libpod_volumes.

forwarded "label" filter = [com.sockguard.owner=team-a],
                     want [com.sockguard.owner=team-a tier=prod zone=eu]

Nothing caught it because each layer's tests configure only that layer, and the failure is silently wider results rather than an error.

Appending is correct here, and the reason is not what you'd guess

label values are ANDed, not ORed, so composing narrows. Verified in source rather than assumed:

  • Docker api/types/filters/parse.go:132 MatchKVList returns false unless every value matches (docker/docker@v28.5.0). Call sites: daemon/list.go:463, both image backends' setupLabelFilter, daemon/network/filter.go:36, volume/service/by.go:79.
  • Podman pkg/filters/filters.go:105 MatchLabelFilters returns false on the first value with no matching label.

The "one filter key is a disjunction" rule is real but applies to Args.Match fields like name and status, and to Podman's event filters. Not to label on list endpoints. Those are different code paths and both rules are true.

The hazard the old code was actually defending against

Docker's Swarm control-plane lists (/services, /tasks, /nodes, /secrets, /configs) fold label through convertKVStringsToMap into a map[string]string. Args.Get iterates a map, so duplicate label keys collapse to a nondeterministic winner. Distinct keys still AND.

So client-supplied label values do still have to be dropped, but the reason is key collapse, not disjunction. Ownership keeps dropping them.

What changed

app/internal/dockerfilters/injected.go records, per request, which filter values sockguard itself injected. Visibility records its selectors unconditionally, including ones the client already sent (no query rewrite needed, but they're still policy-enforced). Ownership preserves those and still drops everything client-supplied. Order-independent, so it doesn't just move the clobber somewhere else.

One breaking change: response.visible_resource_labels may no longer select on the ownership label key while ownership.owner is set. That's now a startup error. Without it the config resolves nondeterministically on Swarm lists, so it silently loses the visibility scope today. Blast radius is narrow, since it only fires when both are set and name the same key.

Verification

11/11 mutations killed. Two survived the first pass and both were real test defects, not spurious:

  • dropping slices.Clip is only observable with spare capacity, so the fixture now derives a size that has it
  • an assertion aliased, because the per-profile error message contains the root message verbatim and a Contains check passed off the wrong error. Counted now, not matched.

go build, go vet, gofmt, golangci-lint (0 issues), go test ./... green across 3 consecutive full runs. Pre-push hooks ran in full.

Also fixes a pre-existing flake found on the way: startUnixHTTPServer named its socket from UnixNano(), which two parallel subtests read identically, and it collided once with bind: address already in use.

Left for separate work

  • /libpod/secrets/json is broken on Podman and was before this change. IfPassesSecretsFilter accepts only name and id and errors on label, which both layers already injected. Fail-closed, not a leak, but that endpoint errors rather than filters. There are libpod lanes in flight, so it shouldn't land here.
  • Visibility-only plus Swarm plus a colliding client key can still resolve nondeterministically, since visibility deliberately preserves client label values (pinned by an existing test). Same defect family, but changing it breaks documented behavior and would widen this diff.

Draft until the review queue has room.

Sockguard's visibility middleware narrows list and event responses by
injecting a label filter into the `filters` query parameter, and that
injection is only sound if the daemon ANDs the values it finds under one
key. dockerd does. Podman does not, on its event stream. Telling the two
apart is therefore a prerequisite for filtering correctly, and nothing in
the proxy could do it.

Detect probes GET /version through the caller's RoundTripper and reads
the Components array. Podman's compat version handler reports a
component named "Podman Engine"; moby's SystemVersion reports "Engine".
The classifier scans every component before deciding rather than
trusting Components[0], and Podman wins over Docker when both names are
present, because a daemon that answers to both is the one whose event
filter is disjunctive. Matching is case-insensitive and substring-based
for Podman, exact for Docker.

The failure contract is deliberately narrow: the flavor and the error
are never both meaningful, so a caller can branch on the error alone, and
a successful probe returns only docker or podman. "auto" is a config
spelling and never comes back from a probe, so it cannot reach a
consumer as a third state to handle. A non-200, an unparseable body, an
unrecognized engine and a body over 1 MiB are all errors rather than a
guess. FuzzClassify pins those invariants against arbitrary upstream
responses; 4.2M execs, no crashers.

Stdlib only, and no caller yet.
Podman registers GET /events, GET /vN/events and GET /libpod/events on
one handler (pkg/api/server/register_events.go at v5.8.1 points all three
at compat.GetEvents), so the Docker-compat spelling carries Podman's
filter semantics on a Podman upstream. libpod/events/filters.go's
applyFilters is disjunctive within a key and conjunctive across keys, and
util.FiltersFromRequest flattens every value of `label` into that single
key. Visibility injects its selectors by appending, and visibility
selectors are ANDed by definition, so a policy with two or more
visible_resource_labels selectors became an OR on /events: a client saw
every event matching either selector, which is strictly more than the
policy allows. Owner isolation was never exposed, because
addOwnerLabelFilter replaces the value instead of appending and leaves
exactly one, for which the two evaluations coincide.

The fix needs to know which engine is behind the socket, so upstream adds
a `flavor` field. An explicit docker or podman is taken as written and
issues no request at all; auto probes GET /version once at startup,
after the reachability check, bounded by a 5s timeout. Resolution is
process-scoped because upstream.socket, upstream.endpoints and now
upstream.flavor are all reload-immutable, so no reload can point
sockguard at a different daemon.

An ambiguous probe fails startup rather than picking a side. Defaulting
to docker silently restores the hole on the exact deployment the field
exists for, and defaulting to podman breaks working Docker installs on a
transient failure; only refusing to start is wrong in a direction the
operator can see, and the error names the two values that resolve it.
Narrowing the failure to "only when the config has selectors" was
rejected: that predicate would have to restate visibility's own notion
of having selectors, and drift between the two fails open.

On Podman, /events now takes a single selector as the sole `label` value,
replacing whatever the client sent, because a surviving client value
would sit beside it under the same disjunctive key. Two or more selectors
are refused with a 403 and reason visibility_podman_events_unscopeable.
Refusal rather than a superset: /events is a long-lived stream that this
package cannot filter on the response side without buffering it forever,
and a client watching a silently widened stream cannot tell it from a
quiet host. A patterns-only policy is forwarded untouched, as before. The
refusal ignores rollout warn mode on purpose; warn trades one response
for one log line, which does not hold for a stream that runs as long as
the client keeps the connection.

Docker upstreams are byte-for-byte unchanged: the dispatch is gated on
the resolved flavor, and TestDockerCompatEventsIsUnchangedByFlavorDetection
drives both an explicit docker and an unset field through the real
middleware against a conjunctive fake daemon.
…hly tiers

FuzzClassify and FuzzConfigured (upstreamflavor) and FuzzPodmanEventLabelFilter
(visibility) shipped unscheduled, which scripts/workflow-fuzz-coverage.test.mjs
rejects: every in-tree target must run in at least one tier.

Nightly and monthly only, not the branch-CI Tier 1 matrix. Tier 1 runs on every
PR and its inventory is pinned exactly by reusable-ci-config.test.mjs, so adding
targets there costs PR wall-clock for all three and edits a frozen contract. The
coverage gate is satisfied either way.
The runtime chain is visibility then ownership, and both write the same
`label` filter key on the way upstream. Visibility appends its
configured selectors; addOwnerLabelFilter then replaced the key
outright. Ownership runs second, so on every list endpoint both cover
(/containers/json, /images/json, /networks, /volumes, /services,
/tasks, /secrets, /configs, /nodes, /events and the /libpod/
counterparts) the daemon received {"label":["com.sockguard.owner=..."]}
and nothing else. The caller got owner scoping and no visibility
scoping, results were wider than configured, and nothing logged it.

Reordering the two would only move the clobber, so the layers now
compose explicitly. dockerfilters records, per request, which filter
values sockguard itself injected; ownership keeps those and still drops
every client-supplied value under the key. Both scopes reach the daemon
together.

That is safe because `label` is the exception to the usual reading that
one filter key is an OR. Docker matches it with
api/types/filters.Args.MatchKVList, which returns false unless every
value matches, on containers (daemon/list.go), images
(daemon/images/image_list.go and the containerd snapshotter's
setupLabelFilter), networks (daemon/network/filter.go), volumes
(volume/service/by.go) and events (daemon/events/filter.go). Podman
matches it with containers/common's filters.MatchLabelFilters, which
returns false on the first value with no matching label. Args.Match,
the disjunctive matcher, is what `name` and `status` use, not `label`.
So the two selector sets narrow rather than widen.

Client-supplied values stay dropped for a reason the old comment got
wrong. It is not OR semantics: Docker's Swarm control-plane lists fold
`label` through convertKVStringsToMap over a randomly-ordered
Args.Get (daemon/cluster/filters.go), so a client value repeating the
owner key can displace the enforced one nondeterministically. The same
collapse is why response.visible_resource_labels may no longer select
on the ownership label key while ownership.owner is set, per profile or
globally; that combination is now a startup error rather than a filter
the daemon resolves by coin flip.
startUnixHTTPServer named its socket from time.Now().UnixNano(), which two
parallel subtests can read identically. It collided during a full-suite run on
2026-08-29 and failed with "bind: address already in use".

t.TempDir() is not the fix here: it derives the path from the test name and
nests, and a unix socket path is capped at 104 bytes on macOS, which is why the
helper avoided it in the first place. os.MkdirTemp with a short prefix is
kernel-unique and keeps the path well inside the limit.
@vercel

vercel Bot commented Aug 29, 2026

Copy link
Copy Markdown

Deployment failed for project sockguard-website with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@biggest-littlest biggest-littlest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on behalf of the review rotation.

@ALARGECOMPANY ALARGECOMPANY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on behalf of the review rotation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants