Podman
Running sockguard in front of a Podman socket — the Docker-compat and native libpod API surfaces, which request_body.* keys govern which endpoint, the uninspected play/kube blind-write surface, ownership/visibility coverage, and current limitations.
Podman's podman system service exposes a single socket that speaks two
API families at once: a Docker-compatible surface at /vN.NN/... and its own
native surface at /libpod/.... Sockguard proxies both from one instance —
point upstream.socket at the Podman socket instead of Docker's, and every
existing rule, preset, and inspector that targets the Docker-compat paths
keeps working unchanged. The /libpod/... surface gets its own,
structurally separate set of inspectors described below, except native build,
which shares the existing request_body.build policy because both endpoints
carry the same build-context and query controls.
upstream:
socket: /run/podman/podman.sockTwo API Surfaces, One Proxy
| Surface | Path shape | Inspectors | Notes |
|---|---|---|---|
| Docker-compat | /vN.NN/containers/create, /vN.NN/images/json, ... | The existing Docker inspectors (request_body.container_create, request_body.exec, request_body.volume, etc.) apply unchanged | Podman implements enough of the Engine API that most Docker clients — Compose, Portainer, drydock, Portwing — work against it without modification |
| libpod-native | /vN.NN/libpod/containers/create, /vN.NN/libpod/pods/json, ... | A parallel set of libpod_*-prefixed inspectors, plus request_body.exec shared with the Docker-compat exec paths | Podman's own CLI (podman create, podman pod create, podman-py) and any client written against libpod's own OpenAPI spec use this surface |
Routing between the two is structural, not a body-shape guess: every
libpod matcher requires an exact /libpod/ path prefix, and Docker-compat
matchers are untouched by the libpod additions — zero diffs to the existing
Docker conformance tests was an explicit acceptance gate for this work. That
matters because the two surfaces use incompatible top-level body shapes
for the same conceptual operation. POST /containers/create's
HostConfig.Privileged/NetworkMode/PidMode nested object has no relation
to POST /libpod/containers/create's top-level privileged/netns/pidns
fields (Podman's SpecGenerator shape). A Docker-shaped decoder reading a
libpod body sees all-zero values everywhere and would read as "safe" —
exactly the fail-open failure mode sockguard avoids by keeping routing
path-exclusive and never falling back from one inspector to the other.
A client can use either surface interchangeably against the same Podman
daemon — for example, podman create over libpod and a Compose client over
Docker-compat, at the same time, through the same sockguard instance — and
each request is gated by the inspector for the surface it actually arrived
on.
Which request_body.* Key Governs Which Endpoint
| libpod endpoint | Config key | Notes |
|---|---|---|
POST /libpod/containers/create | request_body.libpod_container_create | Podman's native SpecGenerator create body. Field names mirror container_create where the semantics map across (privileged, host netns/pidns/ipcns/userns, bind-mount/device allowlists, capabilities, seccomp/AppArmor, image_trust), plus two libpod-only gates: allow_systemd_mode and allow_custom_id_mappings. |
POST /libpod/pods/create | request_body.libpod_pod_create | Pods have no Docker-compat equivalent. Covers pod-level host network (allow_host_network), shared PID namespace (allow_shared_pid_namespace), and the infra image's registry (allowed_infra_image_registries). |
POST /libpod/build | request_body.build, shared with Docker POST /build | Covers primary remote contexts, URL/image additionalbuildcontexts, networkmode=host, and Dockerfiles containing RUN, read from Containerfile and Dockerfile or from every file a JSON-array dockerfile names. Host/local/multipart and resource-usage host-file controls require the global blind-write acknowledgment. Direct and version-prefixed requests use the same tar/gzip bounds and body-sensitive startup validation as Docker's classic builder. |
POST /libpod/containers/{name}/exec, POST /libpod/exec/{id}/start | request_body.exec — shared with Docker-compat | libpod exec bodies use Docker's own field names on the wire, so there is deliberately no separate libpod_exec block. One allowed_commands/allow_privileged/allow_root_user/env-var policy governs exec on both surfaces, so nothing can be configured for one path family and forgotten for the other. |
PUT /libpod/containers/{name}/archive | request_body.container_archive — shared with Docker-compat | Podman routes this and Docker's PUT /containers/{name}/archive to a single compat.Archive handler, so one matcher covers both spellings. The inspector requires one nonempty, unambiguous path, matches Podman's case-insensitive field name, refuses relative targets when allowed_paths is configured because Podman resolves them against the container WorkingDir, and refuses a nonempty or ambiguous rename because Podman applies that transformation after reading the tar headers. allowed_paths, allow_setid, allow_device_nodes and allow_escaping_links otherwise apply identically on either path. |
POST /libpod/containers/{name}/update | request_body.container_update — shared with Docker-compat | The same allow_* gates as the Docker-compat update, read against libpod's own shape rather than Docker's. The body is UpdateEntities, whose embedded OCI specs.LinuxResources flattens to root-level memory/cpu/pids/blockIO/devices/unified instead of Docker's PascalCase, there is no HostConfig/Resources nesting, and the restart policy arrives in the restartPolicy/restartRetries query string rather than the body — so allow_restart_policy is enforced against the query here, with the key folded and every repeated value checked, because Podman decodes it with gorilla/schema. devices rides allow_all_devices; the OCI resource keys, the per-device IO throttles (DeviceReadBPs and friends) and r_limits ride allow_resource_updates; health_on_failure, no_healthcheck and health_startup_retries ride allow_restart_policy, since all three configure what the daemon does to the container on its own initiative. allow_privileged and allow_capabilities are inert on this path: UpdateEntities has no field for either, so a container cannot be made privileged through it. health_cmd and health_startup_cmd, Env/UnsetEnv, and health_log_destination are refused outright. Health timing and log-retention fields can create high-frequency checks or infinite daemon logs, so they require the global insecure_allow_body_blind_writes: true acknowledgment; the outright denials stay in force even with that acknowledgment. The opt-in require_memory_limit, require_cpu_limit, require_cpu_limit_hard, and require_pids_limit ratchets also cover this native path after ownership, applying Podman's partial lowercase resource overlay and explicit-zero clears to the inspected current state. An UpdateEntities root key sockguard does not recognize is allowed, not denied. 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. The trade is that somebody has to notice when the upstream body grows, so the field set the gates were reviewed against is pinned in app/testdata/podman-api/update-entities-root-fields.json: a test fails when that snapshot and the gate lists disagree in either direction, a monthly CI job re-derives the snapshot from Podman's own source and fails when upstream has grown a field, and at runtime an unrecognized root key is named in a debug-level log line. |
DELETE /libpod/containers/{name} | request_body.container_remove, shared with Docker-compat | Podman serves this and Docker's DELETE /containers/{name} from one compat.RemoveContainer handler, which reads a different set of flags on each. allow_force gates force on both. On this path the handler takes the volumes flag from volumes, not v, so allow_remove_volumes gates volumes, and also v, which the route's API docs list and Podman 5.8.6 ignores. depend is behind allow_remove_volumes too: it removes every container that depends on the one named, and when that one is a pod's infra container or a kube service container it removes the pod, whose containers' anonymous volumes Podman deletes whatever volumes says. depend never stops a running workload container, since the dependents are removed with the request's own force. A pod's infra container can be stopped, but only once every workload container in the pod is already stopped. With allow_remove_volumes off, that means podman --remote rm --all (which always sends depend) and the cleanup podman-remote sends after run --rm (volumes=true) are refused. For run --rm, Podman's own exit cleanup still removes the container and its volumes server-side, so the visible effect is an error line from the client. Turn on allow_remove_volumes for clients that use them. timeout and ignore aren't gated, and link isn't read here. Each flag is read once under its exact lowercase spelling, the same as on the compat path. |
DELETE /libpod/pods/{name} | request_body.container_remove | Removing a pod removes every container in it, and once they're gone Podman deletes the anonymous volumes of all of them. No parameter keeps the volumes, so every pod removal needs allow_remove_volumes, the same step depend needs on the container route. Without force, Podman refuses the removal if a workload container is running or paused, though the stopped ones are still removed. force stops the running ones, so it needs allow_force as well. With allow_remove_volumes off, that means podman --remote pod rm is refused. Turn on allow_remove_volumes for clients that remove pods, and allow_force for ones that send pod rm --force. timeout isn't gated. force is read once under its exact lowercase spelling, and podman-remote sends force=false on every removal. Owner isolation checks the pod the path names. |
DELETE /libpod/play/kube, DELETE /libpod/kube/play | request_body.container_remove | Kube down, which Podman serves from one handler under both spellings. It stops every pod the YAML in the request body names and force-removes them whatever the query says, so it stops their running containers and deletes their anonymous volumes. Every request needs both allow_force and allow_remove_volumes. Its force parameter also deletes the named volumes the YAML lists (PersistentVolumeClaims, and the volumes behind ConfigMap and Secret mounts), which is covered once allow_remove_volumes is open. It deletes the secrets the YAML names whatever force says, and no remove gate covers secrets. Sockguard doesn't parse the YAML, so opening both gates lets the client delete any pod or secret it can name, and with force any named volume that isn't in use. For the same reason owner isolation refuses kube down outright; see Ownership and Visibility Coverage below. |
POST /libpod/volumes/create | request_body.libpod_volume | Reuses the volume config shape, decoded against libpod's wire format (driver options live under Options, not DriverOpts). |
POST /libpod/networks/create | request_body.libpod_network | Reuses the network config shape, decoded against libpod's wire format (snake_case fields, no swarm/ingress/attachable/config-only concepts — libpod predates and is independent of Docker's swarm mode). |
POST /libpod/networks/{name}/connect | request_body.libpod_network — same allow_endpoint_config/endpoint_config gates as network | Podman's libpod.Connect handler decodes entities.NetworkConnectOptions, which is container plus an embedded, untagged PerNetworkOptions — so the endpoint fields are top-level and snake_case (static_ips, static_mac, aliases, options) and Docker's {"EndpointConfig":{...}} never appears. Sockguard reads that shape and runs it through the same gate as POST /networks/{id}/connect: static_ips is governed by allow_static_addressing, static_mac by allow_mac_pinning, aliases by allow_aliases, and options (the libpod analog of DriverOpts) is fail-closed under anything but allow_endpoint_config: true. static_mac is decoded exactly like Podman's HardwareAddr.UnmarshalJSON: a conventional MAC string, a base64 string, and a JSON byte array such as [82,84,0,28,46,70] all set the same policy field, while malformed or out-of-range forms fail closed. interface_name is not gated — it names an interface inside the container's own netns and has no Docker analog. allow_link_local_ips and allow_gw_priority have no libpod analog and never fire here. |
POST /libpod/networks/{name}/disconnect | request_body.libpod_network.allow_disconnect_force | The one libpod network route Podman registers straight onto the Docker-compat handler (compat.Disconnect), so the body really is Docker's {"Container","Force"} and the same gate applies unchanged. |
POST /libpod/networks/{name}/update | request_body.libpod_network.allow_dns_servers | Netavark-only, with no Docker analog at any Engine API version: it rewrites an existing network's DNS resolvers, so every container already attached starts resolving names through whatever the caller supplied. Default deny. Both directions are gated — adding a resolver redirects, removing one can redirect by omission — and a body that changes no resolver passes. The same knob gates network_dns_servers on POST /libpod/networks/create, so it governs the whole per-network resolver surface rather than half of it. Note the two spellings are different on the wire: update decodes entities.NetworkUpdateOptions (adddnsservers/removednsservers, run together), create decodes types.Network (network_dns_servers, snake_case). The snake_case add_dns_servers belongs to an internal type Podman never parses from a request. |
POST /libpod/images/pull | request_body.image_pull — shared with Docker-compat | Podman's native image pull, the counterpart of Docker's POST /images/create. One registry allowlist (allowed_registries/allow_official/allow_all_registries) governs both surfaces, so nothing can be configured for one API family and forgotten for the other. The reference arrives in reference (not Docker's fromImage/tag), Podman reads the key in any case and keeps the last value, so Sockguard refuses a request that repeats reference or spells it in another case. A docker:// transport prefix is accepted and stripped before matching the host. A pull carrying no usable reference is denied unless allow_all_registries: true. allow_imports does not apply here; libpod imports are a separate route (POST /libpod/images/import). |
POST /libpod/images/load | request_body.image_load — shared with Docker-compat | Podman's native image load. Verified against Podman v5.8.1: the route takes no query parameters and the whole request body is the archive. Docker manifest.json RepoTags and byte-exact OCI index.json effective names are checked against the same registry allowlist; io.containerd.image.name takes precedence over org.opencontainers.image.ref.name, matching Podman. Bare names are treated as localhost/..., and SHA-256, SHA-384, and SHA-512 OCI graphs are inspected. Sockguard mirrors Podman's permissive OCI metadata reader: advisory oci-layout, top-level schema-version, and descriptor media-type omissions do not disguise a loadable OCI image. If both OCI and Docker controls are present, both reference sets must be inspectable and pass policy because Podman can reject malformed config or layer content after metadata inspection and then fall back from OCI to Docker. An index.json that names no single image is the exception: Podman's oci-archive transport gives up before it reads any name and falls back to the Docker archive. A multi-manifest index is still checked for the names its annotations carry, because a containerd-store dockerd imports each descriptor and records them on the Docker-compat route; a missing or undecodable index carries none, so those loads are judged on manifest.json alone. Canonically duplicate controls, malformed mixed-format controls, and outer archive symlink or hardlink entries fail closed. allow_untagged applies only to genuinely untagged entries. Not to be confused with POST /libpod/local/images/load, below. |
POST /libpod/images/import | request_body.image_pull.allow_imports — shared with Docker-compat | The libpod counterpart of the import that rides on Docker's POST /images/create?fromSrc=, gated by the same allow_imports flag, which is false by default. Every request to the path is an import: with an effective URL query parameter the daemon fetches the tarball from an address the caller chose, and without one it reads the tarball from the request body. Body-form imports are spooled through a 512 MiB cap and, under enforce, return 413 Payload Too Large before Podman sees an oversized stream. Under warn or audit the oversized stream is logged and forwarded whole, so Podman sees it. URL imports retain the coarse flag and do not read or spool a request body. |
POST /libpod/secrets/create | request_body.libpod_secret | Reuses the secret config shape. libpod reads driver and driveropts from the query string, not a JSON body, so this inspector never reads r.Body — only the query-param path is exercised. driver=file, which podman-remote sends on every create because it's the containers.conf default, passes like an empty driver. Any driveropts needs allow_custom_drivers, because Podman hands them to the default driver too, and the file driver's path option is the directory on the daemon host the secret data is written to. |
See the Configuration Reference for the complete field-by-field mapping and environment-variable names for every key above.
The Uninspected Blind-Write Surface: play/kube and Manifests
POST /libpod/play/kube (and its identically-handled /libpod/kube/play
alias), POST /libpod/kube/apply, and POST/PUT /libpod/manifests/* have
no request-body inspector in this release — full Kubernetes-YAML/PodSpec
modeling is out of scope for this release. Admitting any of them requires the same
insecure_allow_body_blind_writes: true acknowledgment that covers
uninspected exec, exactly like every other body-bearing write sockguard
cannot yet constrain.
POST /libpod/build has partial, fail-closed coverage instead of being a wholly
blind endpoint. An allow rule is checked for primary remote contexts, every
current or legacy additionalbuildcontexts value, host networking, and
Dockerfile RUN instructions before Podman sees it. URL/image additional
contexts use request_body.build.allow_remote_context. Host volume controls
(volume, volumes, and transientRunMounts), localpath: additional
contexts, multipart local contexts, and rusage output to the daemon-host path
named by rusagelogfile require
insecure_allow_body_blind_writes: true because no narrower policy models
those host-facing inputs. Malformed, empty, unsupported, and duplicate
additional-context definitions stay denied even when all acknowledgments are
enabled.
The RUN check reads the files Podman builds, not only Dockerfile. With no
dockerfile parameter, Podman's libpod route builds Containerfile when the
context has one and Dockerfile otherwise, so sockguard inspects both and
refuses the build if either carries RUN. A context whose unused Dockerfile
has a RUN is therefore refused too. The compat POST /build route only ever
builds Dockerfile on Podman. podman-remote sends its -f files as a JSON
array in dockerfile, and every file in it is inspected. While RUN is
restricted, a dockerfile value Podman would read from outside the uploaded
context is refused: an http:// or https:// URL, an absolute path (Podman
reads it from the daemon host when the host has that file), a path that climbs
out with ../, and a name ending in .in, which Podman runs through the C
preprocessor before parsing it. podman-remote sends an absolute path in three
cases, and each is refused while RUN is restricted: a Containerfile outside
the build context, -f - (it writes stdin to a temporary file outside the
context), and a relative -f run from a directory reached through a symlink,
such as a project under /tmp or /var/folders on macOS. Keep the
Containerfile inside the context, write a stdin Containerfile into the context
first, and either drop -f or cd "$(pwd -P)" before building.
Podman serves the Docker-compatible POST /build and native POST /libpod/build
from one handler, so the compat path honors every one of those controls too.
All of them are therefore checked on both paths. Docker's own POST /build
reads none of these query parameters, so a Docker client is never affected;
the check only matters on a Podman upstream, where sending volume,
additionalbuildcontexts, or rusagelogfile to /build used to reach the
daemon unexamined.
Treat play/kube with more caution than a typical blind write. A single
request can carry a multi-container, multi-pod Kubernetes manifest —
one allowed call can provision an arbitrary number of privileged containers
from a document sockguard never parses. That is a materially larger blast
radius than the single-container blind writes the acknowledgment flag
otherwise covers, and no shipped preset (including podman-readonly.yaml
below) ever allows these paths.
The Daemon-Host Surface: the local API and image SCP
Three libpod routes have no Docker analog and, unlike every other write sockguard inspects, take their input from somewhere the request never travels. There is nothing on the wire to read, so each is refused rather than inspected.
POST /libpod/local/build and POST /libpod/local/images/load are Podman's
"local API": the build context (localcontextdir) and the image archive
(path) are absolute paths on the daemon host, both required, and
Podman v5.8.1 validates them only as absolute-and-exists — there is no
sandbox root. A client that can call either one reads the daemon's
filesystem through a directory it names itself. request_body.build's
RUN-instruction scan and request_body.image_load's RepoTags check both
work on the request body, so on these two routes they have nothing to read
and would pass a request through as though it had been inspected. Both are
therefore denied unless insecure_allow_body_blind_writes: true, at the
proxy and again at config-validation time, and allow_run_instructions does
not substitute for it. POST /libpod/build is unaffected: it still ships its
context as a tar and is still inspected.
POST /libpod/images/scp/{name} copies an image between hosts over SSH.
The source is the path segment, the destination is the destination query
parameter, and there is no request body at all. It runs in both directions
and each one crosses a boundary sockguard otherwise holds: outbound it moves
a local image to a host the caller names, which is an image push without a
registry to allowlist, and inbound it materializes a local image from a
remote one, which request_body.image_pull's registry allowlist never sees.
An unrecognized connection name is not refused — Podman turns it into a
literal ssh://<name> — so the destination really is arbitrary. It is
listed in both acknowledgment catalogs, so opening it needs
insecure_allow_body_blind_writes: true and
insecure_allow_read_exfiltration: true, one per direction. There is no
narrower policy for it; see Known Limitations.
POST /libpod/containers/{name}/restore belongs in the blind-write category
too. With ?import=1 Podman reads the whole request body as a CRIU checkpoint
archive and creates a container from it, so one allowed call provisions a
container whose spec — privileged, mounts, capabilities, devices — travels
inside a gzipped tar as spec.dump and never passes through containers/create
on either surface. The ?pod parameter also joins the restored container to a
pod. Nothing in Sockguard reads that archive, so an allow rule for the path
requires insecure_allow_body_blind_writes: true, exactly like play/kube.
Read-Exfiltration Additions
insecure_allow_read_exfiltration is extended with libpod's equivalent of
every Docker-compat exfiltration surface, plus the libpod-only entries below:
GET /libpod/containers/*/archive,GET /libpod/containers/*/export,GET /libpod/containers/*/logs,GET /libpod/containers/*/top,POST /libpod/containers/*/attachGET /libpod/pods/*/topGET /libpod/images/export,GET /libpod/images/*/get,POST /libpod/images/*/pushPOST /libpod/manifests/*/registry/*and the deprecated backward-compatPOST /libpod/manifests/*/push— writes at the API layer, but they push local manifest content to a caller-selected registry, the same exfiltration shape as an image pushPOST /libpod/images/scp/*— transfers a local image to another host over SSH, to a destination the caller picks. It is also in the blind-write catalog because the same call can pull an image in from a remote host; see the section aboveGET /libpod/generate/kube— despite the "generate" name, Podman registers this as aGETthat dumps an existing pod's or container's definition to YAML, which can include environment variables and other resource data. It is a read/export surface, not a write, so it lives in this catalog rather than the blind-write one above.POST /libpod/containers/*/checkpoint— aPOST, but with?export=1the response body is a tar.gz of the container's CRIU checkpoint: the process memory dump plus root-filesystem changes, so every secret the container held in memory. Its handler reads only the query string and never a request body, so there is nothing for a request-body inspector to check and it is gated here instead.POST /libpod/containers/*/mount— mounts the container's root filesystem on the daemon host and returns that path. It reads neither a query nor a body. The response does not carry file contents, so this is not an equivalent ofGET .../archive; it is gated on the disclosure of the storage driver's layout and the container-id-to-path mapping.GET /libpod/containers/showmounted— returns that mapping for every mounted container in one response, keyed by container ID. Two behaviours, and which one you get depends entirely on what else is configured. With owner isolation or a visibility policy active, the proxy answers403before Podman is queried, because the response carries neither labels nor names to scope safely. With neither policy active, an allow rule reaching this path forwards Podman's map unredacted: every mounted container's ID paired with its host mountpoint. The path is in the read-exfiltration catalog, so that rule fails startup validation unless you also setinsecure_allow_read_exfiltration: true; the acknowledgment is what makes the unredacted forward an explicit choice rather than an accident.
A broad GET /libpod/**-shaped allow rule catches every path above and
fails startup validation unless you explicitly opt in. Narrow, explicit
allowlists that omit every path above avoid needing the acknowledgment.
The podman-readonly.yaml preset below intentionally retains container top
for process monitoring, so it carries the acknowledgment while leaving native
pod top and every other exfiltration-gated route denied.
Ownership and Visibility Coverage
Owner-label isolation and read-side visibility cover the libpod surface the same way they cover Docker-compat:
POST /libpod/containers/create,POST /libpod/pods/create,POST /libpod/networks/create, andPOST /libpod/volumes/createget the configured owner label injected into their (lowercase-keyed)labelsfield.POST /libpod/volumes/createis the one exception — Podman'sVolumeCreateOptionscarries no JSON tag on its labels field, so it serializes as capitalizedLabelsand is stamped accordingly.- Podman reads query keys in any letter case and keeps the last value of a
repeated key, where dockerd reads the exact key and keeps the first. A
policy parameter sent twice or in another case (
?Driver=customon secret create,?Force=1or?force=0&force=1on container remove) would be checked as one value and executed as another, so Sockguard refuses it with a403. A parameter whose value is always validated (rusage,additionalbuildcontexts) is refused in another spelling whatever the gates are, and the others only while the check that reads them is enabled. This holds on every upstream flavor and covers the secretdriver, container removeforce,vandlink(force,volumes,vanddependon the libpod route), the build controls, image createfromImageandfromSrc, libpod pullreference, archivepathandrename, commitcontainer, and, under owner isolation, secret createreplace,ignoreandname. POST /libpod/secrets/createhas no JSON body at all (driver/labelsare query parameters), so it reuses the existing query-param stamping path. Under owner isolation, a create withreplaceorignoreset is also checked against the secret it names.replacedeletes an existing secret and stores the request's data under its name, andignorehands back the existing secret's ID, so the named secret has to be absent or the caller's own. Another owner's secret, or an unlabeled one, is a403. See Security for the details.- Pods have no Docker-compat equivalent, so they get a dedicated
KindLibpodPodresource kind with its own list/inspect owner-filtering and visibility coverage. - Every filter-capable libpod list and every path-targeted read for containers,
images, pods, networks, volumes, and secrets is owner-filtered and
visibility-filtered, so switching a client from Docker-compat to a native
spelling is not a way around the same scope. Path-targeted write actions are
owner-checked too, and so is the prune family:
POST /libpod/containers/prune,/images/prune,/networks/pruneand/volumes/pruneeach document afiltersparameter that takeslabeland read it throughutil.PrepareFilters, so the owner selector is injected exactly as it is on their Docker-compat twins and a prune removes only the caller's resources.POST /libpod/pods/pruneis the one prune route that accepts no filters at all, and it is refused instead (below). Native image lists and per-image reads apply the policy's name and image patterns as well as its label selectors. Query-targeted batch image operations are the exception recorded under Known Limitations. Read coverage follows the routes Podman actually serves rather than the/jsoninspect alone:/exists,/top,/healthcheck,/archive,/tree,/changes, volume/export, and the bareGET /libpod/networks/{id}spelling Podman registers on the sameInspectNetworkhandler as/libpod/networks/{id}/json. Native image SCP follows Podman's source grammar. A barePOST /libpod/images/scp/{image}source and the specialuser@localhost::{image}form name a local image, so owner isolation inspects the exact image portion before forwarding. An owned image passes; an unowned image followsallow_unowned_images; a foreign image is denied with a403; and a local inspect that returns not found is denied with a404and the reasonlibpod owner policy could not resolve image, without the request reaching Podman. That is the status a visibility policy already returns for a hidden image, so the two layers cannot be told apart by their answers. Aconnection::{image}source belongs to another daemon and cannot be classified by a local inspect, so enforce mode denies it withreason_code=owner_policy_denied_accessand reasonlibpod owner policy denied access to remote image source. Warn and audit modes forward it aswould_denywith the same code and reason. A malformed local-user source fails closed with that reason code and a403instead of issuing an empty inspect: the source could not be parsed, which is a different failure from a source that parsed and did not resolve. With no owner configured, all SCP forms pass through untouched. Route classification follows Podman's encoded-path router, so a percent-encoded slash remains inside the SCP source before the source is decoded once for inspection. Podman's earlier per-image POST routes remain distinct:/libpod/images/scp/push,/tag, and/untagcheck the image named exactlyscp, while a longer action path checks the image whose name begins withscp/; only an actual SCP route strips that segment. Because this is the routed path rather than the cleaned one, a trailing slash survives into rule matching and counts as an empty final segment. Podman routesPOST /libpod/images/scp/alpine/as an SCP of the imagealpine/, not ofalpine, so on the routed view a rule spelling one segment (/libpod/images/scp/*) does not cover it and a rule spelling two (/libpod/images/scp/*/*) does. The cleaned view (/libpod/images/scp/alpine) is evaluated first and has to allow on its own, so a two-segment rule by itself still denies the request. Write/libpod/images/scp/**to cover the whole route regardless of shape. - A native retag is checked on both of its images.
POST /libpod/images/{name}/tagruns on the same handler as the Docker-compatible route, readsrepoandtag, and moves the reference they name off whatever image holds it, so owner isolation inspects that reference after the source image. A reference nothing holds is allowed, the caller's own is allowed, another owner's is denied with a403, and an unlabeled one followsallow_unowned_images. Podman stores arepothat names no registry underlocalhost/on this route without looking anything up, sorepo=team/appis checked aslocalhost/team/app, the name the tag will actually land on. A registry-qualifiedrepois checked as written. On the Docker-compatible route of a Podman upstream the same shortrepois checked under both names, as written and underlocalhost/, because a daemon setting decides which one that route writes; see Known Limitations. The request shapes refused outright are the ones listed for the Docker-compatible route under Owner Label Isolation.POST /libpod/images/{name}/untagtakes the same parameters and is authorized on its source image alone: Podman removes a name only from the image the path resolves, sorepoandtagcannot reach another image. - The other native routes that name an image are checked on that name.
POST /libpod/commit(repoandtag),POST /libpod/build(everyt),POST /libpod/images/import(reference),POST /libpod/images/pull(reference) andPOST /libpod/images/load(the names in the archive) each write a reference Podman moves off whatever image held it, so owner isolation authorizes it like a retag target. Build, import and load tag without a lookup, so a name with no registry is checked underlocalhost/. A commit and a pull resolve the name against local images first, so they are checked as written and underlocalhost/. A build tag that starts with an image transport (containers-storage:app,oci-archive:out,dir:5000/out) is refused, because Podman's builder writes such an output to that transport instead of to the name it spells, and so is a build with amanifestparameter. A pull withallTags, or of a bare name with no tag, is refused. See Owner Label Isolation. - Query-selected native image batches are checked too. Sockguard decodes every
repeated
referencesvalue onGET /libpod/images/exportand every repeatedimagesvalue onDELETE /libpod/images/remove, then resolves all of them before forwarding the original request. Ownership requires every member to carry the caller's owner label even whenallow_unowned_imagespermits unlabeled images on ordinary per-image requests. A visibility-only policy applies its label and name/image-pattern axes to every native export member. Percent-encoded slashes, tags, and digests are decoded once. A comma is not a list separator in Podman's real handlers, so clients must repeat the query key for multiple images. Podman's case-insensitive decoder also acceptsReferences,Images, and Unicode-equivalent spellings; Sockguard combines every spelling in arrival order before checking, deduplicates exact decoded identifiers for lookup, and refuses more than 256 selected values before deduplication. Native batch removal also requiresnoprune=true;force=trueandlookupManifest=trueare refused because they can act on containers, parent images, or a manifest object outside the named-image preflight. Different case spellings and repetitions are evaluated conservatively using Podman's scalar decoder semantics. Inenforcemode a foreign, unlabeled, hidden, missing, or effect-expanding member prevents the daemon from seeing any part of the batch; each of those is a verdict, so awarnorauditprofile records the would-be denial and forwards the batch instead. A lookup failure is not a verdict and is refused in every mode, as is a malformed or oversized selector. Visibility-only export also treats a missing member as hidden instead of passing it through to Podman. - Docker-compatible image export is refused when ownership or visibility is
active and a name is supplied, under both
GET /images/get?names=…andGET /images/{name}/get. Moby registers both on the same handler, and its containerd image store can export a full multi-platform index for one tag, digest, or ID. A requested platform can also differ from the default platform returned by an ordinary image inspect. Sockguard cannot prove every exported platform's owner through that inspect shape, so it fails closed instead of treating the selector as one image. Podman's compatibility decoder also accepts case-folded spellings such asNames, so Sockguard folds that key; this is only more conservative on dockerd, which requires exact lowercasenames. An omittednameslist is still forwarded, and the two daemons disagree about what it means: Podman answers400 no images to download, while Moby'sgetImagesGethas no empty-list check at all and answers200with an emptyapplication/x-tararchive. Sockguard does not substitute an error for either. The refusal itself follows the profile's rollout mode on both layers:403underenforce, awould_denyrecord and a forwarded request underwarnandaudit. - Per-image deletion is refused under ownership on both API families.
Docker-compatible
DELETE /images/{name}can prune uninspected parents and, on Moby's containerd store, remove an entire index or caller-selected platforms. NativeDELETE /libpod/images/{name}has nonoprunecontrol, always permits recursive dangling-parent cleanup, and can useforceorlookupManifestto expand or retarget the action. Native batch removal with explicit safe controls is the supported owner-scoped form, and it is a Podman answer only: dockerd serves no/libpod/images/remove, so on a Docker upstream owner isolation leaves owner-filteredPOST /images/pruneas the only image removal it admits and no way to delete a named image. POST /libpod/commitis owner-checked through its query, not its path. Podman registers the compatPOST /commitand the nativePOST /libpod/commitoncompat.CommitContainerandlibpod.CommitContainerrespectively, and neither names a container in the path: both read thecontainerquery parameter. Owner isolation reads the same parameter, authorizes that container with the usual verdicts, and stamps the owner label into the commit body's config so the new image is owned rather than landing unlabeled. A request with nocontainerparameter, with the parameter repeated or spelled in two cases, or carrying achangesvalue with aLABELinstruction is denied outright: the first names nothing to check, the second would be checked against one container and executed against another because Moby reads the first value while Podman reads the last, and the third would overwrite the stamp, since both engines apply change instructions on top of the body config. Image labels set in the body config are untouched.- Response redaction covers every libpod read whose Docker-compat counterpart
is redacted, so the same switch is not a way around redaction either. That
is a separate layer from the two above, and it needed its own libpod field
names rather than the compat ones:
redact_container_env,redact_mount_pathsandredact_network_topologyapply toGET /libpod/containers/{id}/json,redact_container_envandredact_mount_pathstoGET /libpod/images/{name}/json,redact_mount_pathstoGET /libpod/volumes/jsonandGET /libpod/volumes/{name}/json,redact_network_topologytoGET /libpod/networks/json,GET /libpod/networks/{id}/jsonand its bareGET /libpod/networks/{id}alias, andredact_sensitive_datatoGET /libpod/secrets/jsonandGET /libpod/secrets/{name}/json. See Response Redaction on the libpod Surface for which field names differ and what is deliberately left alone. - Native
POST /libpod/networks/{name}/connectanddisconnectauthorize both the network named by the URL and the bodyContainerreference. A foreign network or container is denied, and an unresolved body container fails closed before Podman receives the request. Docker-compatible network membership routes use the same two-resource ownership check. GET /libpod/eventsis owner-filtered but is only partly expressible as a visibility policy, and that asymmetry is deliberate. Podman evaluates several values under one event filter key disjunctively, so alabel=value injected beside a client-supplied one ORs with it instead of narrowing. Owner isolation by itself replaces thelabelkey outright and so leaves exactly one value, for which disjunctive and conjunctive evaluation are the same thing. A visibility policy's selectors are ANDed by definition and the endpoint has no second filter key to hold the extra one, so sockguard injects a single selector where the policy has one and refuses the endpoint with a403andvisibility_libpod_events_unscopeablewhere it has more. Owner isolation plus even one visibility selector is also an inexpressible conjunction, so that combination is refused withowner_visibility_podman_events_unscopeablerather than dropping either constraint. Reporting a subset was rejected for the same reason an emptied/libpod/system/dfwas: a client watching an event stream cannot tell a quiet host from a filter that stopped applying. The refusals are independent of rollout mode, and a deployment with no visibility policy is unaffected.GET /libpod/system/dfis one fixed-path read that cannot be filtered, and it is refused with a403instead whenever owner isolation or a visibility policy is active. Podman's native disk-usage report is not the Docker-compat body under another path: its image, container and volume entries carry no labels at all, so no owner label and no visibility selector has a field to match. Refusing keeps it from being a way around isolation the way filtering does for scopeable libpod reads; see Security for the full field list and why an emptied report was the wrong answer. The Docker-compatGET /system/dfthat Podman also serves is filtered normally.GET /libpod/containers/showmountedis refused the same way and for the same reason. Podman answers it with a bare map of container ID to the daemon host's mount path for that container, built from every container on the host, so one body is both a host-filesystem disclosure and a cross-owner enumeration. It carries no labels, has no Docker-compat counterpart, and accepts no query parameters, so redacting the paths would still hand over the enumeration and there is no field left to decide an entry by. It is not inpodman-readonly.yaml, and it is in the read-exfiltration catalog, so a rule reaching it, including a broad one such asGET /libpod/containers/*, fails startup validation unlessinsecure_allow_read_exfiltration: trueis set. With that acknowledgment in place and neither an owner nor a visibility policy configured, the proxy forwards Podman's unredacted container-ID-to-host-mountpoint map. The acknowledgment gates the rule; the isolation layers are what turn the read into a403.GET /libpod/containers/statsandGET /libpod/pods/statsare refused the same way, new in v2.1. Both are collection endpoints rather than per-resource ones: a request that names nothing reports every running container or pod on the host, by design rather than by omission — Podman's ownValidatePodStatsOptionssetsallwhen nothing is given, "if nothing's specified get all running pods". Neither accepts afiltersparameter, so a label filter has nothing to attach to, and their records carry container and pod IDs and names but no labels, so neither an owner label nor a visibility selector has a field to match. Both layers answer403before the upstream is contacted, audited asowner_libpod_container_stats_unscopeableandowner_libpod_pod_stats_unscopeable(and theirvisibility_counterparts), regardless of rollout mode.podman-readonly.yamlallowedGET /libpod/pods/statsbefore v2.1 and no longer does; see Known Limitations below for what that costs a deployment that runs neither isolation layer.GET /libpod/secrets/jsonis refused under owner isolation or a visibility policy, new in v2.1, and it is the one refusal that is not about a missing field. Both layers used to inject alabelfilter into it the way they do for every other libpod list. Podman evaluates this endpoint's filters withutils.IfPassesSecretsFilter, whose switch acceptsnameandidand returnsinvalid filterfor anything else, andcompat.ListSecretsturns that into a500, so the injected label did not narrow the list, it broke every request. Dropping the injection alone would have forwarded the whole host's secret inventory instead, so the path is refused, audited asowner_libpod_secret_list_unscopeableandvisibility_libpod_secret_list_unscopeable. The items do carrySpec.Labels, so unlike the stats reads a response-side filter could scope this one; none exists today, which is what would move it back off this list.GET /libpod/secrets/{name}/jsonnames one secret and is unaffected. The Docker-compatGET /secretsis refused the same way whenupstream.flavorresolves to Podman, because Podman serves it from that samecompat.ListSecretshandler and it hit the identical500; it is audited asowner_podman_secret_list_unscopeableandvisibility_podman_secret_list_unscopeable, and on a Docker upstream it is untouched.GET /libpod/manifests/{name}/existsandGET /libpod/manifests/{name}/jsonare refused whenever owner isolation or a visibility policy is active. Manifest lists carry no owner or policy labels and neither endpoint accepts a filter. The/jsonroute also falls back to fetching the caller-named reference from a registry when no local list exists, so treating local lookup failure as safe would expose remote content. Owner refusals useowner_libpod_manifest_exists_unscopeableandowner_libpod_manifest_json_unscopeable; visibility refusals usevisibility_libpod_manifest_exists_unscopeableandvisibility_libpod_manifest_json_unscopeable. These are hard403refusals in enforce, warn, and audit modes because forwarding cannot produce a scoped response. With neither isolation policy configured, the reads pass through unchanged.POST /libpod/pods/pruneis refused under owner isolation, one of two writes in that position (kube down, below, is the other).PodPruneHelperat Podman v5.8.1 callsruntime.PrunePods(r.Context())with no options and turns the result into a report, so the endpoint documents no parameters, names no resource, and reports what it removed only after removing it. Forwarding it under owner isolation would delete every prunable pod on the host regardless of owner, so the refusal is a403audited asowner_libpod_pod_prune_unscopeable, independent of rollout mode: warn mode buys a measurement of what enforcement would cost, and there is no measurement to take once another owner's pods are gone. Remove pods one at a time throughDELETE /libpod/pods/{name}, which names a pod the ownership layer checks, or run host-wide pod pruning without owner isolation. A visibility policy does not refuse it, because neither of its axes decides a write.- Kube down,
DELETE /libpod/play/kubeand itsDELETE /libpod/kube/playalias, is refused under owner isolation too. Podman 5.8.6 serves both from one handler whose only parameter isforce, and what it removes is named in the request body: the pods, secrets and volumes of a Kubernetes YAML that sockguard doesn't parse. A client could name another owner's pod, secret or volume there and have it removed, so the refusal is a403audited asowner_libpod_kube_down_unscopeable, independent of rollout mode like the pod prune one. Remove pods one at a time throughDELETE /libpod/pods/{name}instead. Without owner isolation, kube down is gated byrequest_body.container_remove; see the table above.POSTon the same paths is kube play, which this doesn't refuse. - Version normalization follows Podman's
VersionedPathroute grammar, not only release-shapedvN.N.Nstrings. Prerelease and four-component strings that Podman accepts are normalized before rules, ownership, and visibility evaluate the path, so a broad catch-all rule does not bypass the isolation checks by changing only the version segment. - Resource names that happen to equal an API action word remain protected.
Owner matching reserves keywords only for the exact method and collection
path, while
GETandHEADvisibility treats write-only keywords as normal resource identifiers. - libpod network inspect responses are unwrapped from Podman's occasional
single-element-array envelope (
GET /libpod/networks/{id}/jsonsometimes returns[{...}]instead of a bare object) before label extraction. - Cross-owner pod-membership checks apply on both sides of the
relationship:
POST /libpod/pods/createrequests that join another owner's namespaces are denied, andPOST /libpod/containers/createrequests that target another owner's pod (SpecGenerator.pod) are denied — the samecontainer:<ref>namespace-sharing checks Docker-compat create already applies, extended to libpod's uniform{"nsmode":"container","value":"<ref>"}namespace object andpod/infra_imagefields.
Set upstream.flavor to the engine that is actually behind the socket, or leave
it on auto. Owner-isolation checks branch on it and a wrong value does not
fail startup: on a Podman upstream configured as docker, an image retag gets
the single-name target check instead of the two-name one that also covers
localhost/{repo}:{tag}.
Two endpoints are the exception, and they need upstream.flavor: podman set
(or left on auto, which probes for it) to be handled correctly. Podman
registers GET /events and GET /libpod/events on a single handler, so the
Docker-compat spelling carries Podman's filter semantics, and Podman evaluates
several values under one filter key disjunctively where dockerd ANDs them.
Sockguard therefore writes a visibility policy's single
visible_resource_labels selector as the sole label filter value, replacing
whatever the client sent, and refuses the request with a 403 and reason code
visibility_podman_events_unscopeable when the policy carries two or more
selectors, because two injected values would stream every event matching
either one. Owner isolation plus even one visibility selector has the same
problem: the owner label and visibility label are two constraints under that
disjunctive key, so Sockguard refuses the request with 403 and reason code
owner_visibility_podman_events_unscopeable rather than forwarding an
owner-only or OR-widened stream. A patterns-only policy is forwarded untouched,
as it is on Docker. On a Docker upstream nothing about /events changes. See
Security for the operator-facing version of this.
The Docker-compat GET /secrets is the other one. Podman serves it from
compat.ListSecrets, the same handler behind GET /libpod/secrets/json, whose
filter grammar accepts only name and id and answers 500 for any other
key, so the label filter owner isolation and a visibility policy inject into
every other list broke the endpoint outright rather than narrowing it. On a
Podman upstream a configured owner or a visibility policy carrying selectors
now gets 403 and owner_podman_secret_list_unscopeable or
visibility_podman_secret_list_unscopeable before the daemon is contacted; a
patterns-only visibility policy injects nothing there and is forwarded
untouched, and a Docker upstream keeps the conjunctive injection unchanged.
Denial reasons from the libpod-only inspectors carry a libpod prefix:
libpod container create denied: …, libpod network connect denied: …,
libpod image pull denied: …. The inspectors shared with the Docker-compat
surface do not, because one policy and one code path serves both spellings.
POST /libpod/build reports build denied: …, libpod exec create and start
report exec denied: …, and PUT /libpod/containers/*/archive reports
container archive denied: …. So a reason grep is not a reliable way to
separate libpod-originated denials; split them by normalized_path instead.
Response Redaction on the libpod Surface
The response.redact_* options are a different layer from ownership and
visibility: they rewrite fields inside a body the caller is already entitled
to see. Four of them default to true, so this applies to a deployment that
configured nothing.
Reusing a Docker-compat redactor on a libpod path is a decision made per endpoint, never the default, because Podman's bodies agree with Docker's far less often than the route names suggest. Where the shapes match, the same handler runs. Where they don't, the libpod field names are pinned separately:
| libpod read | Option | What is redacted |
|---|---|---|
GET /libpod/containers/{id}/json | redact_container_env, redact_mount_paths, redact_network_topology | Config.Env; Mounts[].Source, HostConfig.Binds, the daemon storage paths LogPath and every value under GraphDriver.Data, and the native host paths Rootfs, ResolvConfPath, HostnamePath, HostsPath, StaticDir, OCIConfigPath, ConmonPidFile, and PidFile; HostConfig.NetworkMode, the NetworkSettings address block and each Networks entry — the remaining fields use Docker's json tags, plus libpod's own AdditionalMACAddresses list, which has no compat counterpart |
GET /libpod/images/{name}/json | redact_container_env, redact_mount_paths | Config.Env and every value under GraphDriver.Data. *libimage.ImageData's Config field is *ociv1.ImageConfig, whose Env carries the identical json tag Docker's compat handler uses, and its GraphDriver field is the same {Name, Data} shape container inspect redacts, so the same handler runs on both routes. There is no Mounts, HostConfig, or NetworkSettings on this shape, so redact_network_topology has nothing to do here. The list route GET /libpod/images/json is unaffected — ImageSummary has neither field |
GET /libpod/volumes/json, GET /libpod/volumes/{name}/json | redact_mount_paths | Mountpoint. The list body is a bare array, not Docker's {"Volumes": [...]} envelope |
GET /libpod/networks/json, GET /libpod/networks/{id}/json, GET /libpod/networks/{id} | redact_network_topology | Podman v4 and later: subnets, routes, network_dns_servers, network_interface, and inspect's containers map. Podman v2.2.1-v3.4.4: the raw CNI plugin's host bridge or macvlan master; plugin-level and IPAM-nested dns.nameservers, dns.domain, and dns.search; plus IPAM routes, ranges, static addresses, and backward-compatible flat subnet, gateway, rangeStart, and rangeEnd. List's top-level and per-plugin Bytes fields are removed because they are base64 copies of the complete raw conflist/plugin and would retain the same topology |
GET /libpod/secrets/json, GET /libpod/secrets/{name}/json | redact_sensitive_data | SecretData, the plaintext Podman returns for ?showsecret=true. It is a top-level field, not the Spec.Data Docker uses — Podman's Spec has no Data at all. The redactor still covers the list route, but owner isolation and visibility refuse that route before a response exists to rewrite; the redaction is what a deployment running neither layer gets |
Three things are deliberately left alone. Modern ipam_options and legacy
plugins[].ipam.type name the allocator, not an address; the former maps onto
Docker's IPAM.Driver and IPAM.Options, which redact_network_topology
also keeps. Legacy plugins[].dns.options and
plugins[].ipam.dns.options control resolver behavior rather than naming
internal resolvers or domains, so they are the only DNS subfields retained;
nameservers and search are emptied, domain is redacted, and unknown DNS
keys are removed at either nesting level. This matches the modern contract's
selective treatment of network_dns_servers. And on container inspect,
HostConfig.Mounts, MaskedPaths and ReadonlyPaths simply do not exist on
the libpod shape — Podman folds every mount into Binds — so those rewrites
have nothing to act on rather than being skipped.
Network inspect is reached on two paths and returns two envelopes, and both
halves of that are easy to miss. Podman registers GET /libpod/networks/{id}
against the same handler as GET /libpod/networks/{id}/json but documents
only the suffixed one, so redaction covers both spellings. And through v3.0.0
the handler wrote its whole report slice, making the response [{...}]; from
v3.1.0 onward it writes a bare object. Sockguard redacts either and re-emits
the shape it was given, so the client's decoder sees what its own daemon
sends.
The object inside those envelopes also changed. Podman v2.2.1 through v3.4.4
returns its raw lowercase CNI conflist on inspect. Its list report embeds
libcni.NetworkConfigList, so that route instead exposes capitalized
Plugins entries and base64 Bytes copies at both the list and plugin level.
Podman v4.0 switches both routes to the modern network type. Sockguard detects
the fields on the wire rather than trusting the requested version prefix, so
an intermediary or compatibility server cannot make the wrong schema bypass
redaction. A malformed plugin, interface name, IPAM topology field, range, or
Bytes field fails closed.
Version-Prefix Handling
Sockguard strips the Docker API version prefix (/v1.45/) before matching
rules, same as it always has. Podman's own clients send their full daemon
semver as that prefix — three parts (/v5.0.0/, /v4.9.3/), not Docker's
one-or-two-part form (/v1.45/), so the path normalizer
doesn't count dot-separated groups at all. It takes /v, one digit, then any
run of alphanumerics, . and - up to the next /, which is Podman's own
VersionedPath class and covers prerelease and dev builds
(/v5.8.1-dev/, /v5.8.1-rc1/) alongside Docker's /v1.45/.
/v5.0.0/libpod/containers/json, /v5.8.1-dev/libpod/containers/json,
and /libpod/containers/json normalize to the identical rule-matching path.
This is transparent — you never configure or reference the version prefix
yourself — but it is worth knowing if you are reading raw access logs: the
path field carries the client's original version-prefixed request, while
normalized_path carries the post-strip form rules actually matched
against.
Rootful vs. Rootless
Podman runs two distinct deployment shapes, and sockguard works the same
way against either — only upstream.socket changes:
- Rootful —
sudo podman system serviceon a well-known root-owned socket, conventionally/run/podman/podman.sock. This is the shape most sockguard deployments run behind: a root-owned socket bind-mounted into sockguard's container, mirroring how the Docker suite points at/var/run/docker.sock. - Rootless — a per-user
podman system serviceon the invoking user's$XDG_RUNTIME_DIR, conventionally$XDG_RUNTIME_DIR/podman/podman.sock(commonly/run/user/<uid>/podman/podman.sock). Requires a usablenewuidmap/newgidmapand/etc/subuid//etc/subgidrange for the invoking user.
Sockguard does not auto-detect which mode a given socket is running in,
and does not need to — the wire protocol is identical either way. One
caveat worth documenting rather than silently living with: the practical
blast radius of a gate like allow_privileged or allow_host_userns
differs between the two modes even though sockguard enforces the identical
default (deny) against both. A "privileged" rootless container is still
confined by the outer rootless user namespace; the same flag on a rootful
daemon grants genuine host-level privilege. Sockguard's policy surface
treats both requests identically by design — it has no way to know which
mode the socket it's proxying is running in, and the safer assumption is
the rootful one — so don't read an allow_privileged: true rootless
deployment as equivalently risky to the same setting against a rootful one,
or vice versa; the setting name is the same, the ceiling it grants is not.
Starting Point: podman-readonly.yaml
The podman-readonly.yaml
preset ships a read-only monitoring posture covering both surfaces in one
file — list/inspect/stats/top/changes for containers, list/inspect for pods
(libpod only), images, networks and volumes, per-secret inspect, plus
health/version/info/events. It retains top for process monitoring, so both the
Docker-compatible and native libpod routes require
insecure_allow_read_exfiltration: true: Docker-compatible caller-selected
ps_args control the daemon host's ps output, and both routes return process
command lines that bypass response redaction. Native
GET /libpod/pods/{id}/top carries the same global guard but stays denied by
this preset's narrow pod rules. That acknowledgment is global rather than
per-rule, so anything you add to this preset later that the exfiltration
catalog covers, container logs being the obvious one, is admitted without a
fresh startup refusal to warn you. Every other exfiltration-gated endpoint is
excluded: archive, export, logs, attach, image get, and image push on both
surfaces, plus the libpod-only exfiltration surfaces alongside them —
mounted-container inventory (showmounted), generate/kube, and manifest
pushes, none of which have a Docker-compat counterpart. The preset allows no
writes at all, so it never needs insecure_allow_body_blind_writes. See
Presets for how to mount a preset as your config file.
Two rules left the preset in v2.1: GET /libpod/pods/stats and
GET /libpod/secrets/json, both of which the isolation layers now refuse
(above). A preset that advertises owner isolation cannot also serve a read
that has no owner-scoped form, and either rule could only ever have ended in a
403 for anyone running a layer. The secrets one was worse than inert before
that: the label filter both layers injected made Podman answer 500, so the
preset's own secret list did not work under isolation at all. Add either rule
back to your own config if you run single-tenant with neither isolation layer
on.
Known Limitations
Deferred past this release (see the roadmap for where these land):
-
Full body modeling for
play/kubeandkube/apply— these stay behind the blind-write acknowledgment described above rather than getting a real inspector. (generate/kubeis not part of this list: it is aGETread-exfiltration endpoint and remains gated byinsecure_allow_read_exfiltration, as described earlier.) -
Narrower policy for
POST /libpod/local/build,POST /libpod/local/images/load, andPOST /libpod/images/scp/*— all three name their input outside the request (a daemon-host path, or an SSH destination), so there is no body for an inspector to model and they stay behind the acknowledgments described above. Constraining them would mean policy over host paths and SSH destinations, which is a different kind of rule than anythingrequest_body.*expresses today; no shipped preset allows any of them. -
Body modeling for
POST /libpod/containers/*/restore— the CRIU checkpoint archive it accepts under?import=1is a gzipped tar carrying the container's OCI spec, and inspecting it means unpacking that archive and runningspec.dumpthrough the create gates. It stays behind the blind-write acknowledgment instead. -
Query-parameter policy for
POST /libpod/containers/*/checkpointandPOST /libpod/containers/*/mount— both are gated wholesale byinsecure_allow_read_exfiltrationrather than by a narrower control, such as admitting a checkpoint only whenexportis unset. Neither reads a request body, so a request-body inspector is not the shape of that fix. -
Healthcheck command updates on
POST /libpod/containers/*/updateare refused outright rather than checked againstrequest_body.exec.allowed_commands. Routing them through the exec allowlist is the better long-run answer; refusing is the fail-closed placeholder until it exists. -
Manifest-list write inspection — expected to land alongside future image-trust work, since manifest lists are a registry-content concern.
-
Full read-side coverage of the broader
/libpod/generate/*family beyond thegenerate/kubeexfiltration-gate entry above (e.g.generate/systemd). -
Rootful/rootless auto-detection — documented above as a semantic caveat instead of an enforced distinction.
-
Range-overlap analysis for
idmappings—allow_custom_id_mappingsis a blunt allow/deny gate for this release, not a range-aware check. -
Response redaction for
GET /libpod/pods/{id}/json. Pods have no Docker-compat equivalent, so there is no existing handler to route the path to, andInspectPodDatais not a renamed container body: it spells its bind-mount array as lowercasemountsand carries the infra container's network configuration underInfraConfig. Underredact_mount_pathsorredact_network_topology, a pod inspect still returns host bind-mount sources and the infra container's addresses. Owner isolation and visibility policy do cover the path, so a caller only sees its own pods; what is missing is the field-level rewrite inside a pod it is entitled to inspect. -
Response redaction for
GET /libpod/info. Every field the/inforedactors rewrite is a Docker Engine field Podman does not have: there is noSwarmobject (Podman has no swarm) and none of theContainerd/FirewallBackend/DiscoveredDevices/NRIkeysredact_host_topologycovers.libpod/define.Infois a different document with lowercase keys, so the body is passed through rather than run through a rewrite that would match nothing. -
Response redaction for
GET /libpod/containers/json. There is nothing in the body to redact:ListContainer.Mountsis a list of container-side destination paths, not host sources, and the shape has noNetworkSettingsand noHostConfigat all. It is listed here because the Docker-compatGET /containers/jsonis redacted, and the asymmetry is a property of the two bodies rather than a gap. -
Response redaction for
GET /libpod/system/df. Podman's native disk-usage report has no field for any redactor to act on and no label for any owner or visibility selector to match, so it is refused with a403under isolation rather than filtered — see the bullet in Ownership and Visibility Coverage above. It is named here so the response-redaction picture is complete: this is the one libpod read where the answer is a refusal instead of a rewrite, and a deployment with neither owner isolation nor a visibility policy still gets the unredacted report. -
Response redaction for
GET /libpod/containers/showmounted. The handler walksruntime.GetAllContainers()and answers with a bare{"<container id>": "<host mountpoint>"}map, so it discloses host storage paths and enumerates every container on the host in one body. Redacting the values alone would leave the enumeration, which makes it the same problemGET /libpod/system/dfhas and takes the same answer: a refusal decided by the ownership and visibility layers rather than a rewrite in the response filter. The refusal is wired as of v2.1; the response-side rewrite is not, and is not planned, because a redacted map is still an enumeration. Both layers refuse it, so what is listed here is the residual case, a deployment running neither:403before Podman is queried with either isolation layer active, and Podman's unredacted map forwarded with neither. The endpoint is not inpodman-readonly.yamland no default rule admits it. A rule that does (GET /libpod/**reaches it) needsinsecure_allow_read_exfiltration: trueto pass startup validation, because the path is in the read-exfiltration catalog. -
Neither spelling of the secret list has a scoped form under either isolation layer.
GET /libpod/secrets/jsonand, on a Podman upstream, the Docker-compatGET /secretsare one handler. Podman's secret filter grammar accepts onlynameandidand answers500for any other key, so the label filter that scopes every other list cannot be pushed upstream, and both layers refuse the path. The gap is narrower than the stats one: the report items carrySpec.Labels, so a response-side filter over that field could scope this list the wayGET /system/dfis scoped, and building it is what would lift the refusal. Building it for one spelling alone is what is ruled out, not the filter itself: it would leave one handler answering403on its native path and a filtered list on its compat path off the same policy. Until then,GET /libpod/secrets/{name}/jsonis the scoped read, since it names one secret and both layers resolve it normally, and a deployment that wants the whole list has to run without owner isolation and without a visibility policy. The rule leftpodman-readonly.yamlin v2.1 for that reason. -
Tecnativa-style env-var compatibility for the libpod surface — the existing
CONTAINERS=1/POST=0-style compat env vars only ever generate rules against literal Docker-compat paths (/containers/**, and similar), never/libpod/..., so a Tecnativa-compatible env-var configuration gives you no libpod coverage at all today. A libpod-specific compat layer is tracked as its own follow-up rather than folded in here. -
Podman remote/SSH transport (
podman --remoteover SSH rather than a local/proxied socket) is out of scope. -
AllowedRuntimeshas no libpod equivalent —request_body.container_create.allowed_runtimesgates the Docker-compatHostConfig.Runtimefield, butlibpod_container_createhas no matching hook today. -
There is no owner-scoped form of the collection stats reads to offer you.
GET /libpod/containers/statsandGET /libpod/pods/statsare refused outright under owner isolation or a visibility policy, so a single-tenant deployment that legitimately wants host-wide stats has to run without either layer, or allow the paths and accept that they answer403while a layer is on. Filtering them was not an option: neither takes afiltersparameter and neither report carries labels, so there is nothing to attach a filter to and nothing to decide an entry by. Correlating them against the label-filtered list endpoints would work, but that is the same multi-request mechanism refused for/libpod/system/df.Per-container stats survive:
GET /libpod/containers/{name}/statsis a different route,{name}is an identifier both layers already check, and it stays allowed. Podman's own API docs mark it "DEPRECATED. This endpoint will be removed with the next major release. Please use/libpod/containers/statsinstead", so the only scopeable spelling is the one upstream intends to delete — worth knowing before you build a dashboard on it. Pods have no equivalent at all: Podman registers no per-pod stats route, so pod stats is all-or-nothing.The refusal is a plain
403rather than a truncated stream, which matters because the two endpoints have different default shapes.GET /libpod/containers/statsstreams unless the caller sendsstream=false(one NDJSON record per five-second tick);GET /libpod/pods/statsreturns a single JSON body unless the caller asks for a stream. Sockguard answers before the upstream is contacted either way, so a client written for the streaming shape gets a status line and a normal error body rather than a half-read record. -
Batch ownership enforcement cannot turn Podman's collection-wide removal into an owner-filtered operation.
DELETE /libpod/images/removewith no non-emptyimagesvalues makes Podman's image engine enumerate and remove a collection, including withall=false;all=trueis collection-wide when images are omitted for the same reason. Owner isolation refuses the empty shape. With at least one image, Sockguard still requiresnoprune=trueand refusesforce=trueorlookupManifest=true; those controls can remove unnamed parents, containers, or a manifest object that the named-image preflight did not authorize. Podman's native per-image and batch exports resolve their named references individually and remain supported after ownership or visibility checks. Docker-compatible export cannot get the same treatment. Both export routes reach the same Moby handler, and one tag, digest, or ID may be a multi-platform index in its containerd image store, so every named compatibility export is refused while either isolation layer is active. Per-image deletion is also unavailable under ownership because neither API family exposes a form whose recursive and platform/manifest effects can all be authorized. Use native batch removal with the safe controls above. These checks do not make the daemon action transactional. A tag can be rebound after its preflight inspect, and Podman can still partially apply an authorized multi-image delete if a later daemon-side operation fails. -
The Docker-compatible retag route has one reading of its target on dockerd and two on Podman. On dockerd a name means one reference, and Sockguard checks it as the client spelled it. On Podman that is exact only for a registry-qualified
repo. Where arepothat names no registry lands depends oncompat_api_enforce_docker_hubin the daemon'scontainers.conf, which Sockguard cannot see. With the defaulttrue, Podman looks the name up locally, aregistries.confalias first andlocalhost/next, and tags whichever name that lookup found, or the Docker Hub name when nothing holds it. The inspect of the short name goes through the same lookup, so it answers with the image holding the name the tag moves. With the option off, Podman looks nothing up and stores the name underlocalhost/, the way the native route does, while the inspect of the short name still resolves the alias first.So when
upstream.flavorresolves to Podman, set or detected, Sockguard inspects a shortrepounder both names,{repo}:{tag}andlocalhost/{repo}:{tag}, and forwards the retag only when both pass: each is held by nothing, by the caller's own image, or by an unlabeled one withallow_unowned_imageson. That holds whichever way the option is set. It costs one more inspect on such a retag, and one refusal the default setting would not need on its own: a caller whose short name resolves through an alias to its own image is denied while another owner holdslocalhost/{repo}:{tag}, although Podman would have moved the aliased name. Spell the registry inrepo(docker.io/library/nginx,localhost/nginx) to name one reference and get one check. A shortrepoPodman cannot store underlocalhost/is refused with a403before anything is inspected: one whose first component has an upper-case letter, and one over 245 characters. A registry-qualifiedrepo, and every retag on a dockerd upstream, is inspected once.Podman also decides whether a request is a compat one from the URL instead of the route: the third
/-separated piece islibpodon the native API, and on an unversioned/images/{name}/tagit is the image name. An unversioned retag of an image namedlibpod, or of one whose name starts withlibpod/, is therefore a native request to Podman. It skips the lookup and stores a shortrepounderlocalhost/whatever the option says. Sockguard refuses that path with a403on every upstream, whateverupstream.flavorresolved to. The versioned path (/v1.41/images/libpod/tag), which the Docker CLI and SDKs always send, is a compat request to Podman too and is unaffected.A reference held only by a manifest list with no local instance for the host platform inspects as not found, so it is treated as a reference nothing holds.
-
A pull of a name with no registry is exact on Podman only without a platform. Podman pulls a short name under the name a local image already holds, alias first, and the inspect of the name as written resolves the same way, so it answers for the image the pull replaces. Naming a platform (
platformon the compat route,Arch,OSorVarianton the native one) makes Podman skip that lookup and resolve the name throughregistries.conf, which Sockguard cannot read. So whenupstream.flavorresolves to Podman, or to nothing Sockguard recognizes, such a pull is refused with a403. Spell the registry in the name to pull for a platform. A dockerd upstream has one reading of a name and is not narrowed. -
Not every field of a libpod network body has a gate.
POST /libpod/networks/createpassesdns_enabled,internal,ipv6_enabledandroutesthrough uninspected, andPOST /libpod/networks/{name}/connectpassesinterface_name. These are modeled as deliberate omissions rather than oversights — each either has no Docker analog for the shared gate to reuse or carries no host-side privilege — but "the libpod network write surface is inspected" means every endpoint is routed at an inspector, not that every field in it is gated.
Presets
Ready-made sockguard configs for drydock, drydock with self-update, drydock with compose, drydock with build, drydock with mediated build, Portwing, Portwing with exec, Portwing with compose, Portwing with build, Portwing with mediated build, Traefik, Portainer, Watchtower, Homepage, Homarr, Diun, Autoheal, GitHub Actions and GitLab runners, the CIS Docker Benchmark, read-only dashboards, and Podman read-only monitoring.
CIS Docker Benchmark
How sockguard's container_create body inspection maps to the inspectable subset of the CIS Docker Benchmark v1.6.0 — and the cis-docker-benchmark.yaml preset that turns it on.