Skip to content

fix(filter): guard container process-list reads - #395

Merged
scttbnsn merged 46 commits into
dev/v2.0from
scttbnsn/p8-container-top-guard
Sep 3, 2026
Merged

fix(filter): guard container process-list reads#395
scttbnsn merged 46 commits into
dev/v2.0from
scttbnsn/p8-container-top-guard

Conversation

@scttbnsn

@scttbnsn scttbnsn commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Container and pod process-list reads now sit behind insecure_allow_read_exfiltration. Three routes join the exfiltration catalog: Docker-compatible GET /containers/{id}/top, native GET /libpod/containers/{id}/top, and native GET /libpod/pods/{id}/top. The compat route runs the daemon host's ps with caller-selected ps_args and returns process command lines with no response redaction; both libpod routes expose the same class and support a streaming mode. Version-prefixed requests normalize to the same three routes.

What refuses what

Startup validation does the heavy lifting. Its audit searches the catalog's whole route language against the authored rule literals, so a wildcard rule, a broad /containers/**, an exact container name, and an ordered deny that shadows the catalog's representative path are all refused at load time without the acknowledgment. The compat catalog entry uses the slash-bearing identifier shape, because Docker registers the route as /containers/{name:.*}/top and a rule like GET /containers/*/*/top is genuinely reachable. The two libpod entries stay single-segment, since Podman's {name} is one segment.

The request-time gate is narrower than the earlier draft of this branch claimed. It matches all three routes by their /top suffix and it does two things validation can't: it stops a warn or audit profile from passing a process-list denial through as would_deny, and it holds the line as defense in depth if a rule shape ever escapes the load-time audit. sockguard match calls the same gate, so an offline check reports the effective decision rather than the underlying allow rule.

Presets

The drydock presets and example, the CIS preset and example, the multi-listener preset, and the multi-host example drop their GET /containers/*/top rules. None of those configs carries the acknowledgment, so keeping the rule would refuse startup. That's the reason, not that nothing calls it: drydock's integration conformance test pinned top as allowed.

Portwing's five presets and its three synchronized examples keep theirs. They already set insecure_allow_read_exfiltration: true for the logs rule, so the top rule validates unchanged and Portwing keeps its process view.

podman-readonly.yaml keeps container top on both API surfaces and now carries the acknowledgment with the tradeoff written down. Native pod top stays denied by its narrow pod rules. Worth knowing before you extend that preset: the flag is global rather than per-rule, so anything you add later that the exfiltration catalog covers, container logs being the obvious one, is admitted with no fresh startup refusal to warn you. The Podman and presets docs both say so now.

Breaking

  • A config that allows container or pod top without insecure_allow_read_exfiltration: true exits 1 at startup instead of serving it.
  • The drydock, CIS, multi-listener and multi-host presets no longer serve GET /containers/{id}/top.
  • A warn or audit profile no longer passes a process-list denial through; it refuses the request.

Verification

  • filter middleware tests across every rule shape: exact name, multi-segment name, ordered deny over a broad allow, both libpod routes, and every rollout mode
  • validation tests for wildcard, broad, and slash-bearing top rules on the default policy and on a named client profile
  • preset and compose-example conformance plus the build-chain validation over every shipped config
  • go test -count=1 ./internal/filter ./internal/cmd ./internal/config, go test -count=1 -tags integration ./integration/, go vet ./..., golangci-lint run
  • node --test scripts/*.test.mjs, which pins the corrected docs prose in both the configuration reference and the changelog

Use one non-HTML-escaping encoder configuration for rewritten object and array responses so identical values have identical raw-byte spellings while keeping compact bodies and existing response metadata handling.
…/p8-container-top-guard

# Conflicts:
#	CHANGELOG.md
#	README.md
#	app/internal/proxy/timeout.go
#	docs/content/docs/configuration.mdx
#	docs/content/docs/migration.mdx
…/p8-container-top-guard

# Conflicts:
#	CHANGELOG.md
…/p8-container-top-guard

# Conflicts:
#	CHANGELOG.md
#	app/internal/config/config.go
#	app/internal/config/validate.go
#	docs/content/docs/configuration.mdx
@vercel

vercel Bot commented Aug 30, 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 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: b32b1969-addd-4796-822e-6002725c9cb3

📥 Commits

Reviewing files that changed from the base of the PR and between 39cf4e3 and 6c25d29.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (62)
  • README.md
  • app/configs/cis-docker-benchmark.yaml
  • app/configs/drydock-with-build.yaml
  • app/configs/drydock-with-compose.yaml
  • app/configs/drydock-with-mediated-build.yaml
  • app/configs/drydock-with-selfupdate.yaml
  • app/configs/drydock.yaml
  • app/configs/multi-listener.yaml
  • app/configs/podman-readonly.yaml
  • app/integration/cis_docker_benchmark_conformance_test.go
  • app/integration/drydock_preset_conformance_test.go
  • app/integration/github_actions_runner_conformance_test.go
  • app/integration/gitlab_runner_conformance_test.go
  • app/integration/portwing_preset_conformance_test.go
  • app/internal/cmd/match.go
  • app/internal/cmd/match_test.go
  • app/internal/cmd/rules.go
  • app/internal/cmd/rules_test.go
  • app/internal/cmd/serve.go
  • app/internal/cmd/serve_test.go
  • app/internal/cmd/serve_upstream_timeout_test.go
  • app/internal/cmd/serve_warn_test.go
  • app/internal/config/compose_example_sync_test.go
  • app/internal/filter/bench_test.go
  • app/internal/filter/container_archive.go
  • app/internal/filter/drydock_preset_conformance_test.go
  • app/internal/filter/libpod_unscopeable_writes.go
  • app/internal/filter/libpod_unscopeable_writes_test.go
  • app/internal/filter/middleware.go
  • app/internal/filter/middleware_test.go
  • app/internal/filter/norace_test.go
  • app/internal/filter/podman_readonly_preset_conformance_test.go
  • app/internal/filter/portwing_preset_conformance_test.go
  • app/internal/filter/race_test.go
  • app/internal/ownership/commit.go
  • app/internal/ownership/commit_test.go
  • app/internal/ownership/identifier_collection_test.go
  • app/internal/ownership/libpod_coverage_test.go
  • app/internal/ownership/libpod_paths.go
  • app/internal/ownership/middleware.go
  • app/internal/ownership/middleware_test.go
  • app/internal/ownership/paths.go
  • app/internal/ownership/system_data_usage.go
  • app/internal/proxy/proxy_test.go
  • app/internal/proxy/timeout.go
  • app/internal/proxy/timeout_test.go
  • app/internal/responsefilter/filter.go
  • app/internal/responsefilter/filter_test.go
  • app/internal/responsefilter/system_data_usage_filter.go
  • app/internal/visibility/libpod_events.go
  • app/internal/visibility/podman_events.go
  • app/internal/visibility/podman_events_test.go
  • docs/content/docs/configuration.mdx
  • docs/content/docs/index.mdx
  • docs/content/docs/migration.mdx
  • docs/content/docs/podman.mdx
  • docs/content/docs/presets.mdx
  • docs/content/docs/security.mdx
  • examples/compose/cis-docker-benchmark/sockguard.yaml
  • examples/compose/drydock/sockguard.yaml
  • examples/compose/multi-host/sockguard.yaml
  • scripts/release-metadata.test.mjs

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.

@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 11:46am

@scttbnsn

Copy link
Copy Markdown
Contributor Author

@greptileai Review exact head 730d17f920e8124348c7f62450547eb7471697cf. Review the exact-head process-list isolation change: startup versus request-time rule enforcement, first-match and exact-name coverage, Docker versus Podman top stream classification, and timeout exemption behavior.

…-fail-closed

# Conflicts:
#	CHANGELOG.md
#	app/internal/ownership/libpod_coverage_test.go
#	app/internal/ownership/libpod_paths.go
#	app/internal/visibility/libpod_events.go
#	docs/content/docs/podman.mdx
…json-encoding

# Conflicts:
#	app/internal/responsefilter/filter_test.go
…-container-top-guard

# Conflicts:
#	CHANGELOG.md
#	README.md
#	app/internal/cmd/rules.go
#	app/internal/cmd/serve.go
#	app/internal/cmd/serve_warn_test.go
#	app/internal/config/compose_example_sync_test.go
#	app/internal/filter/drydock_preset_conformance_test.go
#	app/internal/filter/podman_readonly_preset_conformance_test.go
#	docs/content/docs/configuration.mdx
#	docs/content/docs/podman.mdx
#	docs/content/docs/security.mdx
#	examples/compose/traefik/docker-compose.yml
#	scripts/release-metadata.test.mjs
Startup validation now reads the authored rule literals, not only a
representative probe path, so the exact-name and shadowed-allow cases are
refused before the policy compiles and never reach the request-time gate.
The request-time gate split the path after the first identifier segment, so
GET /containers/a/b/top missed it. Docker registers the compat route as
/containers/{name:.*}/top, so a rule like GET /containers/*/*/top admits a
slash-bearing name the gate walked straight past. Match everything before a
trailing /top with a non-empty name instead.

Startup validation has to speak the same language, so the compat top catalog
entry now carries catalogIdentifierPath. Podman's libpod routes use {name},
which is one segment, so both libpod entries keep the default shape.
The test passed config.Defaults() as the config and a disjoint compiled rule
set, so exposed_endpoints=[] proved nothing: the audit walks cfg.Rules, and
cfg.Rules was empty. Feed the same exact-name rule to both sides and assert
the warning names GET /containers/payments/top, which is what the literal
audit actually finds.
All five Portwing presets, the Portwing compose example, and both tri-tool
examples already set insecure_allow_read_exfiltration: true for the logs rule,
so their GET /containers/*/top rules validate unchanged. Removing them cost
Portwing its process view and bought nothing. The drydock, CIS, multi-listener
and multi-host removals stand: those configs carry no acknowledgment, so the
rule would now refuse startup.

The conformance tests that pinned Portwing top as denied move back to allowed,
and the shipped-config table is renamed for what it actually pins: which
configs admit top and which carry the acknowledgment they need to.
The docs credited the request-time gate with refusing exact-name and ordered
process-list allows. Startup validation does that: its audit searches the
catalog's whole route language against the authored rule literals, so both
shapes are refused at load time and the warning names the concrete reachable
path once the acknowledgment is set. The claim that an empty endpoint list
could still hide such a rule was false for the same reason.

The gate's real job is narrower, and now says so: it stops a warn or audit
profile from passing a process-list denial through, and holds as defense in
depth. README's rollout-modes row carries that carve-out.

The changelog bullet gets the BREAKING marker it was missing, naming all
three consequences, and drops the "unused container-top rules" reason: the
drydock conformance test pinned top, so those presets lost it because they
carry no acknowledgment, not because nothing called it.

podman.mdx and presets.mdx now say that podman-readonly.yaml's
acknowledgment is global, so a later addition to that preset gets no fresh
startup refusal.
Owner isolation refused a target the daemon could not resolve with the same
403 it uses for a resource that resolves to another owner, so a client
deleting something already gone read as a permissions failure and the layer
disagreed with visibility, which answers 404 for a resource its policy hides.

Split the denial verdict in two. verdictDenyMissing carries the unresolved
case and answers 404; verdictDeny keeps 403 for a resolved resource whose
labels fail the policy, for a remote image SCP source, and for a malformed
local one. Both are equally fail-closed: nothing reaches the requested
upstream path in enforce mode, warn and audit still log the would-be denial
and forward, and an inspect error is still a 502. Denial checks now ask
verdict.denied() rather than comparing against one constant, so a missing
target cannot leak through a `!= verdictDeny` test.

The rule covers every unresolved reference, not just the one the URL names:
exec sessions, container:<ref> namespace-sharing targets, and the images,
volumes, networks, secrets and configs embedded in create and update bodies.
The reason vocabulary now tracks the status, "could not resolve X" for a
lookup that came back empty and "denied access to X" for one that came back
with the wrong owner, so the SCP malformed-source reason moved to the second
wording since nothing was ever looked up for it.
The four Docker-compat prune routes have carried an injected owner label
filter since owner isolation shipped. Their libpod spellings carried nothing:
libpodNeedsOwnerFilter listed only GET reads and both call sites gated it on
GET/HEAD, so POST /libpod/{containers,images,networks,volumes}/prune went
upstream verbatim and deleted every prunable resource on the host whoever
owned it.

All four document a JSON-encoded `filters` parameter taking `label` and read
it through util.PrepareFilters at Podman v5.8.1, and containers/prune is
routed to the same compat.PruneContainers handler as the compat path, so they
take the same injection the compat routes take. libpodNeedsOwnerFilter now
takes the method, like its Docker-compat counterpart, instead of leaving the
method test to each caller.

POST /libpod/pods/prune has no filters to inject: its swagger block documents
no parameters and PodPruneHelper calls runtime.PrunePods with no options at
all. It is refused instead, through a write-side twin of the unscopeable-read
catalog, and the refusal is unconditional — warn mode buys an operator a
measurement of what enforcement would cost, and there is none to take once
another tenant's pods are gone. DELETE /libpod/pods/{name} still names a pod
the ownership layer checks.
POST /commit was the one write in the API that ownership never classified.
Every other check starts from a path identifier, and commit names its
container in the `container` query parameter instead, so
allowPathOwnershipRequest walked straight past it. A client holding the
`POST /commit` rule that configs/portainer.yaml ships (and that COMMIT=1
generates through the Tecnativa compat layer) could commit another owner's
container, and the image it produced carried no owner label at all: invisible
to the owner-filtered image list and outside every later ownership check.

Commit is now classified off the query on all three registered spellings
(moby's POST /commit, Podman's compat POST /commit, and its native POST
/libpod/commit). The named container is authorized as an embedded reference,
so it gets the same verdicts as the rest of the branch: 403 for a container
another owner holds, 404 for one that does not resolve, 502 for a lookup
failure. The owner label is stamped into the commit body's config, and a body
is synthesized with a JSON content type when the client sends none, because
both engines accept an empty commit body.

Three request shapes are refused outright rather than authorized:

- No `container` parameter. There is nothing to check, and owner isolation
  does not forward a write it could not classify.
- A repeated or two-case-variant `container`. Moby reads the first value and
  Podman reads the last, so the container checked would not be the container
  committed. filter.FoldedScalarQueryValue, exported here for the purpose,
  is the same helper the container-archive policy uses for that disagreement.
- A `changes` value carrying a LABEL instruction. Both engines apply changes
  on top of the body config (moby through dockerfile.BuildFromConfig, Podman
  through buildah's parseOverrideChanges), so a LABEL there overwrites the
  stamp. The refusal is on the instruction rather than the key it sets:
  deciding which key a LABEL line touches means parsing Dockerfile quoting,
  multi-pair lines and continuations, and getting that wrong is a silent
  bypass instead of a broken request. Image labels can still be set in the
  body config.
setPodmanEventLabelFilter serves both GET /events and GET /libpod/events, and
derived its filter key from compatEventsPath, so half its callers got a key
named after a path they never used. The result was right by coincidence: the
only path visibilityLabelFilterKey answers differently for is the Swarm node
list, which no event stream reaches.

The key is not path-derived at all. It is the name of the filter Podman's
event handler accepts, so it is now a named constant with the reasoning on it,
and the test asserts the two things the coincidence was standing in for: the
injected and sole-value-marked key is that one on both spellings, and it still
matches what owner isolation derives for those paths. If those diverge the
ownership layer stops seeing the marker and appends an owner value beside the
visibility selector, which Podman ORs, widening the stream this path narrows.

No behavior change, so no test fails before it.
…odes

podman.mdx still said a local SCP source that inspects as not found "passes
through so Podman can return its real error". It has not passed through since
the fail-closed change, and it now answers a synthetic 404 without contacting
Podman. The same page's Known Limitations entry for showmounted still said
"Neither is wired today" about a refusal both isolation layers have carried
since the mounted-inventory fix, so it is rewritten as the residual case it
actually is: a deployment running neither layer.

security.mdx's Layer 6 gets the status contract in one place. A target the
daemon could not resolve is a 404, a target that resolved under another
owner's label is a 403, a lookup that errored is a 502, and none of the three
reaches the upstream path. The 404 half is the load-bearing part: it matches
what a visibility policy already answers for a hidden resource, so the pair of
layers cannot be told apart by status code, and it is what an idempotent
teardown expects when it deletes something already gone.

Both pages and the changelog also pick up the two surfaces this branch newly
classifies: the libpod prune family, owner-filtered like its Docker-compat
twin with pods/prune refused because it takes no filters at all, and container
commit, owner-checked off its query parameter with the owner label stamped
into the image it produces. The changelog marks both breaking, since a
host-wide libpod prune and a commit carrying a LABEL change instruction both
worked before and now do not under owner isolation.

Two smaller corrections ride along, both about claims the commit work
falsified. The Known Limitations entry for the batch image endpoints said
owner isolation cannot check them because they name their images in the query;
the query is not the obstacle, the set is. And commit.go's own comment said
Tecnativa's COMMIT=1 generates the rule that opens the endpoint, when COMMIT=1
alone generates GET,HEAD and it takes POST=1 to widen the section.

The "exhaustive" claim about the collection-route helper tests is now
"modeled", which is what those tests actually pin.
…-fail-closed

# Conflicts:
#	CHANGELOG.md
#	app/internal/ownership/libpod_coverage_test.go
#	app/internal/ownership/libpod_paths.go
#	docs/content/docs/podman.mdx
…iner-top-guard

# Conflicts:
#	CHANGELOG.md
#	README.md
#	docs/content/docs/configuration.mdx
#	docs/content/docs/podman.mdx
#	docs/content/docs/security.mdx
@scttbnsn
scttbnsn marked this pull request as ready for review September 3, 2026 21:08

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

Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.

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

Rebuilt on dev/v2.0 as a stacked chain, CI green, adversarial review findings folded in on the branch.

@scttbnsn
scttbnsn merged commit 7fecea9 into dev/v2.0 Sep 3, 2026
62 of 64 checks passed
@scttbnsn
scttbnsn deleted the scttbnsn/p8-container-top-guard branch September 3, 2026 23:02
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