Skip to content

feat(visibility): scope /events to the policy on a Podman upstream - #380

Draft
scttbnsn wants to merge 7 commits into
dev/v2.0from
feat/upstream-flavor-detection
Draft

feat(visibility): scope /events to the policy on a Podman upstream#380
scttbnsn wants to merge 7 commits into
dev/v2.0from
feat/upstream-flavor-detection

Conversation

@scttbnsn

Copy link
Copy Markdown
Contributor

Scopes GET /events to the visibility policy when the upstream is Podman, and adds the upstream-flavor detection needed to know when that applies.

Why this needs flavor detection at all

Podman serves /events and /libpod/events from the same handler (pkg/api/server/register_events.go lines 39, 41, 73 in v5.8.1), so the Docker-compat spelling carries Podman's semantics. And Podman's event filters are disjunctive within a key: libpod/events/filters.go:112 says "Filters under the same key are disjunctive while each key must match". Docker's are not.

So appending a second label selector, which is what the visibility layer does everywhere else, widens the stream on Podman instead of narrowing it. There's no way to tell from the request which daemon is on the other end, hence upstream.flavor.

Behavior

auto probes GET /version once at startup and reads the engine name (Podman Engine vs Engine). An ambiguous result fails startup rather than guessing:

  • defaulting to docker silently restores the exact hole on the exact deployment the field exists for
  • defaulting to podman breaks working Docker installs on a transient blip

Failing is the only outcome that's wrong in a direction the operator can see. The probe runs after the reachability check, so an unreachable daemon reports as unreachable rather than ambiguous. upstream.flavor is reload-immutable alongside upstream.socket and upstream.endpoints, so nothing can repoint at a different daemon mid-process.

On the /events path itself: zero selectors forwards untouched, one selector is written as the sole label value (replacing what the client sent, since a surviving client value would sit beside it under the same disjunctive key), and two or more returns 403 visibility_podman_events_unscopeable without contacting the upstream.

Refusing beats streaming a superset here. /events is long-lived NDJSON and is already exempt from the upstream deadline, and this package filters responses by buffering the whole body, which would hang the stream forever. A client watching a silently widened stream can't tell it apart from a quiet host. This deliberately ignores rollout warn mode, which trades one response for one log line: that trade doesn't hold for a connection-lifetime stream.

Docker is provably untouched. TestDockerCompatEventsIsUnchangedByFlavorDetection drives both explicit docker and the unset zero value through the real middleware against a conjunctive fake daemon, and the dispatch is a single gated condition.

Verification

17/17 mutations killed. Two initially died on a compile error rather than a test failure, so I re-ran them as behavior-only mutations to get real --- FAIL lines. Explicit-wins uses a counting round-tripper that fails if it's ever called, so "no HTTP request at all" is checked rather than assumed.

go build, go test, golangci-lint (0 issues), gofmt, -race on the five changed packages. FuzzClassify 4.2M execs, FuzzPodmanEventLabelFilter 1.9M execs, no crashers. The three new fuzz targets are scheduled in the nightly and monthly tiers, not branch-CI Tier 1, whose inventory is pinned exactly by reusable-ci-config.test.mjs.

Merge notes

Two things need cleanup when this and #379's sibling branch both land, tracked as P23:

  • docs/content/docs/podman.mdx will carry both the Known Limitation describing this hole and the paragraph describing the fix. One has to go.
  • setLibpodEventsLabelFilter and setPodmanEventLabelFilter will coexist. They're named apart on purpose so they don't collide at compile time, but they should collapse into one.

Found along the way, not fixed here

Ownership's addOwnerLabelFilter replaces the label key while visibility appends to it, and ownership runs second. So on any list endpoint with both configured, ownership silently discards every visibility selector. I confirmed it with a throwaway test: the forwarded query came back as filters={"label":["com.sockguard.owner=o1"]} with both visibility selectors gone.

That's a live bug on Docker, independent of this PR, and it's being fixed separately. It cuts in this PR's favor: ownership always writes last and leaves exactly one value, so the replace semantics here introduce no ownership bypass.

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.
@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.

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sockguard-website Ready Ready Preview Aug 30, 2026 12:30am

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