🔧 chore(release): sync main from dev/v1.6 for v1.6.0-rc.1 - #200
Conversation
Branch discipline routes every change through the active dev branch (currently dev/v1.6); CodeRabbit's default only reviews PRs into main, so the whole release train was skipped with 'reviews are disabled for this base branch'.
* ✨ feat(release): distribute stable releases via Homebrew tap - ✨ feat(release): publish sockguard cask to CodesWhat/homebrew-tap via GoReleaser on stable tags - ✨ feat(ci): add verify-homebrew macOS smoke job (Gatekeeper path, version match, quarantine check) - 🔧 config(ci): render GoReleaser snapshot in branch CI and assert generated cask contents - 🧪 test(release): add scripts/homebrew-release.test.mjs covering config, workflows, and docs - 📝 docs: document Homebrew install path and trust boundary in README, getting-started, RELEASING * 🐛 fix(release): gate Homebrew tap token requirement to stable tags - Prerelease tags (containing "-") skip the cask upload via skip_upload: auto, so the "Require Homebrew tap token" step must not hard-fail on them; add if: !contains(github.ref_name, '-') - Assert the stable-only condition in homebrew-release.test.mjs - Clarify README.md and getting-started.mdx docs: Homebrew's SHA-256 checksum verification proves archive integrity, not publisher identity, and is not a substitute for Apple notarization; point policy-bound users at the container image or cosign-verified release binary instead
…165) * 🐛 fix(ci): pre-pull digest-pinned busybox ref in nightly integration Docker Hub re-pushed the busybox:1.37 tag, so the digest pinned in helpers_test.go's busyboxPinnedRef no longer matched what a bare `docker pull busybox:1.37` materialized in the runner's local image store. dockerd won't pull-on-create for a digest ref that isn't already local, so all four tests that pull by digest failed with "No such image". The pre-pull step now greps the pinned ref straight out of helpers_test.go and pulls that, so it can never drift from the constant again. Bumped busyboxPinnedRef's digest to the current busybox:1.37 index digest. * 🐛 fix(ci): bind sentinel pre-pull to busyboxPinnedRef constant Extract the named constant instead of the first busybox substring, and fail before docker pull if the value is missing or not digest-pinned.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
… 399c8cb (#162) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* chore(deps): update non-major (github-actions) * 🔧 chore(deps): regenerate npm lockfile to match package.json ranges dev/v1.6 HEAD carried a package-lock.json out of sync with package.json (knip, turbo, oxc-parser, and others) from a sibling npm dependency PR that merged despite failing CI (Biome Lint / TS Test / Build Workspaces all failed on `npm ci`: lock file did not satisfy package.json ranges). Regenerate via `npm install --package-lock-only` so this branch's CI can install cleanly; no package.json ranges changed. * 🔧 chore(deps): tidy go.mod/go.sum dev/v1.6 HEAD carried a go.mod/go.sum out of tidy state (unused go.opentelemetry.io/otel/sdk/metric indirect require, and several indirect deps resolved to older patch versions than go.mod's graph now allows). This is the pre-push goreleaser-snapshot hook's `go mod tidy` step surfacing pre-existing drift, not a change caused by this PR's own diff. Committing the tidy output so the local clean-tree gate (and every subsequent Renovate PR rebased on top of dev/v1.6) doesn't trip over it. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
* chore(deps): pin dependencies * 🔧 chore(deps): regenerate lockfile for pinned dependency versions npm install --package-lock-only to sync package-lock.json with the pinned turbo/knip/biome/lefthook/postcss/typescript versions re-applied on top of current dev/v1.6 (turbo and knip had moved to newer versions via sibling merges; pinned those instead of downgrading). * 🔧 chore(deps): dedupe sharp after lockfile regen npm install --package-lock-only left sharp nested under next/node_modules at the pre-override 0.34.5 instead of hoisted to the root override's ^0.35.0 range. npm dedupe fixes it (matches what the lockfile-dedupe pre-push guard expects). * 🔒 fix(deps): pin postcss override exactly to close a real CVE regression Pinning docs/package.json's postcss to an exact "8.5.25" (this PR's own pin-dependencies intent) left the root override's "^8.5.24" range inconsistent with it, and npm's incremental lockfile resolution stopped applying the override to next's own vendored postcss — reintroducing next/node_modules/postcss@8.4.31 (GHSA-6g55-p6wh-862q and friends, HIGH). Pinning the override to the same exact "8.5.25" and forcing a clean re-resolution of the postcss subtree restores the single hoisted, patched postcss and fixes the Grype dependency-scan failure this PR's push triggered. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
* chore(deps): pin dependency @types/react to 19.2.18 * 🔧 chore(deps): regenerate lockfile for @types/react pin Regen against dev/v1.6's current pinned postcss/tailwindcss versions after rebase; confirms the #160 override-pin fix resolves postcss cleanly everywhere (no @8.4.31 regression). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* 🔧 chore(deps): lock file maintenance Full fresh regen (rm package-lock.json && npm install) against everything currently merged into dev/v1.6. Required --legacy-peer-deps to complete resolution due to a pre-existing upstream mismatch (fumadocs-ui@16.10.5 pins an exact peer fumadocs-core@16.10.5, but ^16.10.5 now resolves to 16.14.0) — confirmed this blocks a from-scratch install on dev/v1.6 as-is too, unrelated to this change. npm ci and npm run build both verified clean against the resulting lockfile. * 🔧 chore(deps): pin fumadocs-core/fumadocs-ui to matching exact versions The previous lockfile regen used --legacy-peer-deps to work around an ERESOLVE against fumadocs-ui's exact peer dep on fumadocs-core, but that silently dropped fumadocs-ui's own transitive deps (@radix-ui/react-tabs, rehype-raw, etc.) from the lockfile entirely — passed locally only because Turbopack was serving a stale cached build, but broke CI's `npm ci` for real (module-not-found on both packages). Root cause: fumadocs-ui@16.14.0 (latest matching the old ^16.10.5 range) requires peer fumadocs-core@16.14.0 exactly. Pinning both packages to the same exact 16.14.0 lets a plain `npm install`/`npm ci` resolve cleanly with no flags, and correctly pulls in fumadocs-ui's full dependency tree. Verified with a fully clean node_modules (including workspace-nested docs/node_modules and website/node_modules) via npm install, npm ci, and an uncached `turbo build --force` — all pass. --------- Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
- Add v1.5.2 (in flight) roadmap entry: nightly digest-drift fix, Homebrew tap, dependency refresh including majors - Restructure v1.6.0 into delivery waves with linked issue numbers: Wave 1 (#149, #151, #152) parallel, Wave 2 (#153 then #148) sequential on the route classifier, Wave 3 (#150) gates GA - Note the roadmap update in CHANGELOG.md Unreleased/Changed
#176) Next 16.2.12 refuses to build under TypeScript 7 with "TypeScript 7.0.2 does not provide the compiler API required by Next.js" unless experimental.useTypeScriptCli is set, since Next's default type-checking path depends on TS6's compiler API and TS7 dropped it in favor of a CLI interface. Add the flag to both website/next.config.ts and docs/next.config.ts so Renovate's pending TypeScript 7 bump (#172) can land without a build break. Verified locally against the current TS 6.0.3 toolchain: root `npm run build` (both workspaces), `npm test` (92/92 passing), and `npx biome check .` all pass clean, with only pre-existing warnings unrelated to this change.
… redaction gap (#178) - Deny POST /containers/create and POST /services/create|update requests carrying a Mounts entry of Type "image" when image_trust is in enforce mode. Docker API 1.48+'s image-type mount source is an unverified image reference mounted into the container's filesystem, invisible to the existing bind-mount checks (Type == "bind" only), and previously bypassed cosign verification entirely even when the top-level Image field passed. - Strip the Engine API 1.53 network-inspect Status field (per-subnet IPAM allocation stats) under redact_network_topology, alongside the existing IPAM.Config/Containers/Peers redaction.
…anch reviews (#177) * 🔧 chore(deps): drop obsolete js-yaml override fumadocs-core 16.14.0 / fumadocs-mdx 15.2.2 no longer resolve js-yaml anywhere in the tree, so the ^4.3.0 override matches nothing. Removing it instead of bumping it to v5 — supersedes the original intent of this PR. Lockfile delta is only the embedded overrides mirror. * 🔧 chore(coderabbit): nest base_branches under auto_review base_branches at the reviews: level is silently ignored — every dev/v1.6 PR got 'Review skipped: auto reviews are disabled on base/target branches other than the default branch' despite the config. The key only takes effect nested under reviews.auto_review.
Pinned to 7.0.2 in website/ and docs/. Builds go through the TypeScript CLI via experimental.useTypeScriptCli (landed in #176) since TS7 no longer exposes the compiler API Next.js 16 uses. Supersedes the Renovate branch which predated the exact-pin convention and the dev/v1.6 lockfile regeneration. Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
… services (#152) (#182) * 🔧 config(request-body): add resource-limit require_* flags for container update and service (#152) - container_update gains require_memory_limit/require_cpu_limit/require_cpu_limit_hard/require_pids_limit - service gains require_cpu_limit/require_cpu_limit_hard - all default false, reload-mutable, mirrored on client profiles - filter.ContainerUpdateOptions/ServiceOptions carry the new fields for config plumbing; enforcement lands in the new resource-limit guard, not in the existing pre-ownership inspectors * ✨ feat(filter): add post-ownership resource-limit guard (#152) New internal/filter/resource_limit_guard.go enforces the require_* resource-limit flags added for container_update and service. It runs as its own middleware layer AFTER ownership (see the serve.go wiring commit) rather than inside the existing pre-ownership request-body inspectors: - container update: root-only decode of the update patch (nested HostConfig/Resources are decoys and never read), a moby-faithful overlay of patch onto a bounded GET of the container's current HostConfig, then the shared create-time predicate against the effective state. Omission cannot grandfather a pre-existing weak container (the documented "ratchet" edge). - service create/ordinary update: request TaskTemplate.Resources.Limits.NanoCPUs must be positive when either CPU flag is set. - service rollback (manual ?rollback=previous, or an automatic UpdateConfig.FailureAction: rollback) validates the daemon-owned PreviousSpec/current Spec that would actually become active, with a version/CAS check (409 on mismatch) — the request body proves nothing once the daemon is about to apply a stored spec instead. container_create.go's denyResourceLimitReason is extracted to a free function (resourceLimitDenyReason) shared by container-create and this guard's container-update path, so the two can never drift; the create-time method becomes a one-line wrapper. Four new reason codes (resource_limit_request_invalid 400, resource_limit_policy_denied 403, resource_limit_policy_lookup_failed 502, resource_limit_policy_state_changed 409). Only the policy_denied path honors the resolved profile's warn/audit rollout posture; a corrupt request, a failed daemon lookup, or a stale version are hard errors in every mode. No body rewriting anywhere — the guard only decides allow/deny and always forwards the original bytes. * 🔧 chore(serve): wire the resource-limit guard into the handler chain (#152) Insert withResourceLimitGuard between withHijack and withOwnership in buildServeHandlerLayersWithRuntime's append order. Later appends wrap (execute before) earlier ones, so runtime order becomes ...filter -> visibility -> ownership -> resource-limit guard -> hijack -> proxy: ownership still decides before any resource-state daemon lookup, and the guard still sits ahead of hijack/proxy for everything ownership allows. withResourceLimitGuard reuses servePolicyConfig/clientProfiles/ clientacl.RequestProfile — the same PolicyConfig, profile map, and resolver wiring withFilter already uses — plus the two new runtime inspectors (container/service state GETs) through the shared upstream resolver. Also adds a startup/reload warning (mirroring warnIfLabelACLEnabled's once-per-process pattern) when a require_* container_update flag is set while allow_resource_updates is false: structurally valid but very likely operator confusion, since the flag is a no-op until the gate is open. Updates TestBuildServeHandlerLayers for the new layer. * ✨ feat(logging): add resource-limit guard audit/access-log fields (#152) Adds a pooled logging.ResourcePolicyMeta on RequestMeta, populated only by filter.ResourceLimitGuard when it actually evaluated policy for a request (the common "no applicable require_* flag" case leaves it nil — zero allocation, zero log fields). Fields are classification-only: kind/operation/state-source/requirements/result/violation-class/ state-lookup — never raw current/effective resource values, inspect JSON, PreviousSpec content, labels, or identifiers. - access log: resource_policy_* attrs appended alongside the existing correlation attrs, only when evaluated. - audit log: an optional "resource_policy" object. The audit event takes its own value copy (auditResourcePolicyContextFrom) rather than the pooled pointer — RequestMeta's pool return (and zeroing) happens before the audit event reaches the async writer goroutine, so holding the pooled pointer itself would race; the copy is taken synchronously in AuditLogMiddleware before the event is queued, matching how every other auditEvent field is already a plain value snapshotted at that point. - putRequestMeta returns meta.ResourcePolicy to its own pool before zeroing RequestMeta, so the guard's per-request allocation is reused. * 📝 docs(resource-limit): document the container-update & service resource-limit guard (#152) - CHANGELOG: [Unreleased] entry covering the 6 new require_* flags, the effective-state merge semantics, the two rollback-path closures, the 4 new reason codes, and the ratchet migration edge. - README roadmap: sharpen the "Resource parity" v1.6.0 row to name the actual delivered flags and rollback-path coverage. - docs/content/docs/configuration.mdx: new bullets under Request Body Inspection explaining the guard's merge semantics, fail-closed reason codes, rollout-mode interaction, and the ratchet migration note; extends the container_update and service rows in the Request Body Policy Reference table; adds the 6 new SOCKGUARD_* env var rows. - app/configs/sockguard.yaml: commented example block for request_body.container_update.* and request_body.service.* resource flags, since the shipped default config stays deny-all with no request_body section active. * 🐛 fix(filter): reject ambiguous rollback query values in the resource-limit guard ?rollback=previous&rollback=none (or any repeated rollback param) let the guard silently pick one of Docker's parsed values while validating a different rollback intent than the daemon might act on. serviceManualRollbackQuery now requires exactly one value, denying with resource_limit_request_invalid otherwise, and guardServiceManualRollback's helper return is checked so a duplicate-value 400 can no longer fall through to respondAllow. Refs #152 * 🧪 test(filter): add resource-limit guard unit test matrix Table-driven coverage for the #152 post-ownership guard: container-update omitted/zero/null/negative/weaker/stronger/mixed scalar semantics against moby's Memory:0-is-unchanged merge, PidsLimit's pointer-clear semantics (0/-1/null), CpuQuota-alone-satisfies-hard vs CpuShares-alone-fails-hard, the ratchet (weak current + omission denies, one compliant patch remediates), nested-decoy/case-variant/duplicate-key rejection, inspect 404/500/timeout/oversize/malformed handling with call-counting stubs, a nil inspector failing closed, the allow_resource_updates gate keeping the inspector unreachable (panicking stub), and a zero-requirements pure pass-through regression. Service coverage: create/update Limits matrix, manual rollback (safe-body/weak-PreviousSpec denies, weak-body/safe- PreviousSpec allows, fake PreviousSpec/RollbackConfig ignored, version mismatch 409), automatic rollback via UpdateConfig.FailureAction, reason code/status/static-message assertions, and rollout-mode softening that only ever applies to resource_limit_policy_denied. Adds a decode fuzz target over the container-update body. Refs #152 * 🧪 test(config): cover the resource-limit require_* config surface Verifies all 6 require_* flags (4 container_update + 2 service) default disabled, load from SOCKGUARD_REQUEST_BODY_* env overrides, load from both the global request_body block and a client profile's request_body block, map completely onto filter.Options, and stay present via mapstructure tag reflection so a future field rename can't silently drop one from the schema. Refs #152 * 🧪 test(cmd): cover resource-limit guard wiring and the require_* warning Asserts the guard's layer index sits after withOwnership in buildServeHandlerLayers's append order and, wired end-to-end through a real ownership middleware, that a foreign container is denied by ownership before the guard's daemon GET ever runs (only ownership's own lookup fires). Covers warnResourceLimitRequireOnce: fires at most once per process via sync.Once, checks both the default policy and every client profile for a require_* flag enabled while allow_resource_updates is false, and stays silent when the gate is open or no require_* flag is set. Also checks compileClientProfiles carries all 6 resource-limit flags into a named profile's compiled policy. Refs #152 * 🧪 test(logging): cover resource-policy audit and access-log fields Confirms every ResourcePolicyMeta.Result class (allow/deny/would_deny/ invalid/lookup_failed/state_changed) surfaces its resource_policy_* access log fields and resource_policy audit context, that both are entirely absent from requests the guard never evaluated, that the sync.Pool zeroes metadata before reuse, that the audit deep copy survives concurrent pool reuse under -race, and that the audit context type is reflectively limited to the bounded classification fields (kind/operation/source/ requirements/result/violation/lookup) with no raw values, IDs, or labels. Refs #152 * 🧪 test(reload): assert the 6 resource-limit require_* flags stay reload-mutable Locks in that ImmutableDiff treats all 4 container_update require_* flags plus allow_resource_updates and the 2 service require_* flags as hot-reloadable config, matching the design's reload-mutable contract for #152. Refs #152 * 🧪 test(integration): validate the resource-limit guard against a real daemon Wires filter.ResourceLimitGuardWithOptions into the integration handler chain (post-hijack/pre-ownership in append order, matching production) with real Docker-socket-backed container and service inspectors. The container-update tier creates a legacy unlimited container and confirms an omitted-field update is denied and leaves HostConfig.Memory at 0 (the Memory:0-is-unchanged encoding is only checkable against a real daemon, not a mock), then confirms a weaker-but-positive update is forwarded verbatim and a subsequent denied update leaves daemon state unchanged. The Swarm service tier stays behind SOCKGUARD_TEST_ENABLE_SWARM=1, refuses to run against a docker daemon already in a swarm, and always leaves via force in t.Cleanup; it covers create/full-replacement-update denial, manual rollback to a weak PreviousSpec vs. a safe one, and automatic rollback (UpdateConfig.FailureAction=rollback) validating the current Spec. helpers_test.go gains a shared dockerSocketRoundTripper and HostConfig.Memory on the container-create request type so the new suite and the existing direct-socket helpers use one transport constructor. Refs #152 * 📝 docs(changelog): keep the #152 entry under Unreleased after the v1.5.2 rebase
#183) * ✨ feat(config): add multi-listener schema and validation (#149) Config.Listeners []ListenerConfig is additive: legacy listen: stays untouched forever. EffectiveListeners() is the single read path every downstream consumer must use — it synthesizes a single "default" entry with allowed_profiles ["*"] when Listeners is empty, so back-compat is byte-for-byte for every config that only sets listen:. - allowed_profiles: required non-empty on explicit entries, "*" wildcard preserves legacy global behavior, reserved as a profile name, cannot mix with concrete names. - listen/listeners mutual exclusivity, provenance-tracked (YAML key, SOCKGUARD_LISTEN_* env var, or --listen-socket flag all count as "explicit") via a defaults-free probe Viper pass in load.go. - Validation rewrite: listener name regex/uniqueness/cap 32/reserved "admin", exactly-one-of-socket-or-address per explicit entry, all-pairs bind-target uniqueness across listeners[*] and admin.listen. - ListenConfig gains optional socket_uid/socket_gid; socket_mode is 0600 (default) or 0660 (requires explicit socket_gid) everywhere a ListenConfig appears (legacy listen, listeners[*], admin.listen). - Admin.MountOn: required when admin rides a main listener and there are 2+ effective listeners, so admin traffic doesn't silently mount on every listener by default. * 🔧 chore(config): use tagged switch for profile-name validation Style/lint fixup on the #149 wildcard-profile-name reservation check — staticcheck's QF1002 flagged the plain switch over name == X comparisons as better expressed as a tagged switch on name. * ✨ feat(reload): per-listener immutable diff projection (#149) ImmutableDiff now branches on explicit listeners: usage. Legacy configs (both old/new have an empty Listeners list) keep reporting a single "listen" key exactly as before. Once either side uses the explicit listeners: list, diffListeners takes over: the listener set is immutable by name (add/remove/rename/rebind all reject, reorder is a no-op), and every per-listener field is immutable except allowed_profiles — the sole reload-mutable field, consistent with clients.profiles already being reload-mutable. Switching between legacy and explicit list mode is immutable even when structurally a no-op, since the two modes bind through different code paths. Deviation: diffListenerFields compares TLS as a single whole (reflect.DeepEqual) rather than per-subfield (cert_file/key_file/etc individually) — coarser than the source design sketch, but every entry still names exactly which listener and that "tls" changed. * ✨ feat(banner): render one entry per bound listener (#149) Info.Listen (single string) becomes Info.Listeners ([]string) — one "name unix:<path>" / "name tcp://<addr>" entry per effective listener, in bind order, followed by the dedicated admin listener's entry when configured. Render must only ever be called after every listener in the list has bound and passed the publish barrier, so the banner is a confirmation rather than a promise. * ✨ feat(inbound): stamp non-spoofable listener identity + admission gate (#149) New internal/inbound package stamps every accepted connection with an Identity{Name, Role, Network} via http.Server.ConnContext, composed ahead of clientacl's own ConnContext so identity is derived solely from which net.Listener accepted the connection — never from request content. withListenerAdmission enforces each listener's allowed_profiles scope using that identity plus the profile clientacl already resolved. A listener with the wildcard AllowedProfiles admits everything, matching pre-#149 behavior byte-for-byte. The gate is a complete no-op when cfg.Listeners is empty (legacy singular listen: config, which always synthesizes one wildcard listener) — the fail-closed missing-identity check only activates once an operator opts into explicit listeners:. * ✨ feat(logging): tag access + audit records with listener name (#149) Both RequestMeta.ListenerName (access log) and auditEvent.ListenerName (audit log, JSON "listener_name") are populated from the connection's inbound.Identity — never request-controllable. Additive: audit's existing TransportListener field keeps its pre-existing "unix"/"tcp" transport-kind meaning unchanged. * ✨ feat(cmd): bind + serve multiple independently scoped main listeners (#149) Adds the serverGroup runtime for Config.EffectiveListeners(): - serve_listeners.go: two-phase bind barrier (bindMainListeners binds every main listener before any of them serves; a failure partway through closes everything already bound, in reverse order, with no window where a strict subset is live), publishMainListeners fans every member's terminal Serve() error into one buffered channel (listenerResult), and concurrent shutdownMainListeners tears every member down in parallel within the shutdown grace period. Unix socket members capture a (dev, ino) identity right after bind so shutdown only unlinks a socket path that still resolves to the exact inode this process created (removeSocketIfOwned) — legacy listen.socket/admin.listen.socket keep the pre-#149 unconditional removal, unchanged. - serve_deps.go: createSocketListener is generalized to take an explicit file mode + optional uid/gid (socketListenFileMode mirrors config's own validateSocketOwnership rules), and a new createNamedListener binds one listeners[*] entry (unix or TCP). listenUnixSocketWithMode adds a stale-socket guard: any live dial on the target path refuses to bind (steals nothing); a failed dial (refused, ENOENT, timeout) proceeds with the pre-existing remove-then-listen sequence, since only the connect-succeeded case risks stealing a listener that's actually serving traffic. - serve.go: wires withListenerAdmission into the shared handler chain ahead of withClientACL's own position, and replaces the single bind/serve/shutdown block with the serverGroup — one reload.SwappableHandler shared by every main listener, the admin server bound+served sandwiched between the main bind and main serve phases, and concurrent shutdown of every main member alongside the admin server. Existing tests updated for the new shapes: TestBuildServeHandlerLayers gets the new "withListenerAdmission" layer in its pinned lists: and TestRunServe_AdminShutdownErrorLogs is rewritten to not assume admin-then-main shutdown ordering (now concurrent) — the old version's shared shutdownCalls counter read/written from two goroutines was itself a data race under -race. * ✨ feat(observability): per-listener metrics + close admin mount_on gap (#149) 🐛 fix(cmd): every main listener shares one handler chain (reload.SwappableHandler), so an in-band admin endpoint (admin.enabled, no dedicated admin.listen) was reachable on every listener regardless of admin.mount_on — validation already required mount_on once there are 2+ effective listeners, but nothing enforced it at request time. New mountOnGate wraps withAdminEndpoint/withPolicyVersionEndpoint and only forwards to them when the connection's inbound identity matches Admin.MountOn; with <=1 effective listener it's a pass-through, preserving today's zero-config "admin rides the sole main listener" behavior byte-for-byte. ✨ feat(metrics): add a listener label to requestLabels/denyLabels/ durationLabels (sourced from inbound.Identity, falling back to "default" when absent — matching the legacy synthesized listener name), and a new sockguard_listener_up{listener,role,network} gauge. SetListenerUp is called with up=true right after publish for every main listener and the admin listener, and up=false at the start of drain in shutdownServers — the series is created once and never removed, so a stopped listener reads 0 rather than disappearing. * ✨ feat(health): surface per-listener state in /health (#149) health.HealthResponse gains a Listeners []ListenerStatus field (name/role/network/state; states bound -> serving -> draining -> stopped on a clean run, or failed if Serve() returns unexpectedly before an intentional drain). Monitor.ListenersFunc, when set, both populates that field and folds listener state into the 503 decision: any listener not "serving" or "draining" makes /health unhealthy alongside (or independently of) an upstream failure. internal/cmd wires this with a new listenerStatusBoard — a small mutex-guarded map keyed by listener name, updated at every lifecycle transition (bind, publish, fan-in failure, shutdown start, shutdown complete) for both main listeners and the dedicated admin listener. Both the liveness and readiness monitors share the same board, so either endpoint reports the same listener state. Threaded as an explicit board parameter through bindMainListeners, publishMainListeners, shutdownMainListeners, and shutdownServers. * 🐛 fix(config): transport-capability checks honor listeners: (#149) validateClientsListenerExclusions and validateClientsCertificateProfiles read cfg.Listen directly, so clients.unix_peer_profiles, clients.client_certificate_profiles, clients.allowed_cidrs, clients.container_labels, and clients.source_ip_profiles all rejected valid configs that used the new listeners: list instead of the legacy listen: block — every listeners[*] entry was invisible to these checks regardless of its actual transport. New hasEffectiveListener walks cfg.EffectiveListeners() so "at least one compatible listener" holds for both legacy and explicit multi-listener configs, matching the final design's item 12. Found by writing configs/multi-listener.yaml against the real validator. * 📝 docs(listeners): document multi-listener config and ship example preset - CHANGELOG entry for the `listeners:` feature (#149): allowed_profiles semantics, mutual exclusivity with legacy `listen:`, admin.mount_on requirement, and observability additions - README: listeners: config snippet and a link to the new preset under the bundled-presets list - app/configs/multi-listener.yaml: working two-unix-socket preset demonstrating scoped ci/ops listeners with disjoint allowed_profiles * 🐛 fix(cmd): fail startup on ambiguous stale-socket probe results (#149) Only a proven ECONNREFUSED probe result, plus a matching Lstat inode/device identity check taken before and after the probe, may remove an existing unix socket file as stale. Every other probe outcome (a successful dial, a timeout, a permission error, or the path changing identity mid-probe) now fails startup instead of unlinking a socket sockguard can't prove is dead — closing the gap where a dial timeout was previously treated as "not live" and removed. * 🐛 fix(cmd): bind admin listener inside the two-phase bind barrier (#149) bindAdminServer now binds the dedicated admin listener as the final member of the same all-or-none bind transaction as the main listeners: mains bind, then admin binds, and only after every bind in the group has succeeded does anything start Serve. An admin bind failure rolls back every already-bound main listener (reverse order); a main listener bind failure never reaches the admin bind at all. This replaces the old startAdminServer path, which bound and started serving the admin listener strictly after the main-listener bind barrier had already published — a window where main listeners could be live while the admin bind was still pending, or vice versa on failure. Folded in as part of the same rewrite (interdependent within runServeWithDeps): any main or admin Serve() return before an intentional group drain is now fatal (including nil and http.ErrServerClosed, neither of which can legitimately occur while the group is healthy), draining the whole group before returning a process error; listener-group shutdown now runs under a fresh 30s deadline instead of the already-cancelled command context, force-closes listeners/servers at the deadline, and explicitly tears down hijacked connections (Docker attach/exec streaming) that http.Server.Shutdown does not close on its own. Removes the now-dead startAdminServer function and its direct-unit tests, replacing them with equivalent bindAdminServer coverage. * 🐛 fix(listeners): close remaining #149 design-conformance gaps - config: bind-target uniqueness across listeners/admin now compares a normalized TCP endpoint (case-insensitive host, canonical IP spelling, numeric port without leading zeroes) instead of a raw string, so differently-spelled duplicates of the same literal endpoint are still caught. Explicit TLS on a unix listener and socket_uid/socket_gid on a TCP listener are now rejected instead of silently ignored. - health: the /health response always encodes "listeners" as an array (empty, not omitted) so clients get one stable response schema whether or not a ListenersFunc is wired up. - reload: ImmutableDiff reports individual TLS subfields (listeners.<name>.tls.cert_file, .key_file, ...) instead of collapsing any TLS change into a single opaque "tls" entry, so a failed reload's diagnostic names the exact field an operator needs to restart for. * 🧪 test(config): cover multi-listener schema, validation, and exclusions (#149) Table-driven and fuzz coverage for the listeners: schema: name/uniqueness/ reserved-name/cap rules, exactly-one-of-socket-or-address, TLS/ownership field misuse per transport, allowed_profiles wildcard-vs-concrete semantics, all-pairs bind-target uniqueness (including the normalized-TCP-endpoint cases), and validateClientsListenerExclusions' unix-only/TCP-only client constraint checks. FuzzEffectiveListenerValidation exercises EffectiveListeners() plus validation against arbitrary listener sets to guard the never-panics / never-silently-accepts-conflicting-config invariant. * 🧪 test(inbound): pin non-spoofable listener identity propagation (#149) Covers ConnContext composition (identity stamped before the existing clientacl.ConnContext runs, so both are present together), context round-tripping via inbound.FromContext, and that identity is derived only from the listener the connection actually arrived on — never from anything attacker-controlled in the request itself. * 🧪 test(observability): cover per-listener health, metrics, and log fields (#149) health: /health returns 503 whenever any required listener isn't serving outside an intentional drain, and always encodes "listeners" as an array. metrics: sockguard_listener_up{listener,role,network} and the listener label on the request/duration/deny/throttle families. logging: access and audit records carry listener/listener_name without disturbing the existing audit transport field. * 🧪 test(reload): cover per-listener immutable diff and reload rejection (#149) listeners_diff_test.go/listeners_diff_fuzz_test.go pin ImmutableDiff's per-name per-field projection: allowed_profiles is the only field a swap may change, every other field (including individual TLS subfields) is immutable and reported as listeners.<name>.<field>, and add/remove/rename are reported distinctly from a same-name field change. TestReloadCoordinatorFailedListenerReloadLeavesOldGenerationServing (new, in serve_reload_test.go) proves a failed reload — whether from an invalid allowed_profiles value or an attempted immutable-field change — keeps the old handler generation live and serving rather than falling through to a deny-all handler. * 🧪 test(cmd): cover bind barrier, listener admission gate, and default-profile warning (#149) serve_listeners_feature_test.go: the stale-socket probe safety matrix (dead refused socket replaced; live socket, ambiguous timeout, non-socket file, and inode-changed-mid-probe all preserved and fail startup); the bind barrier rolling back main listeners in reverse on an admin bind failure and never reaching/serving admin on a main bind failure; any premature Serve return draining and failing the whole group; the listener-status board state machine and concurrent access; pre-publish gauge registration; and shutdown using a fresh deadline, force-closing at it, and tearing down hijacked connections within it. serve_listener_admission_test.go: withListenerAdmission on the single shared handler chain denies 403 listener_profile_not_allowed for a resolved profile outside a listener's allowed_profiles, admits wildcard/matching listeners, and fails closed (500) on missing/unknown inbound identity. serve_listener_warning_test.go: the startup warning when clients.default_profile isn't included in a listener's allowed_profiles, silent for wildcard or explicitly-included cases. * 🧪 test(integration): exercise unix multi-listener profile isolation end-to-end (#149) Builds the real binary and drives it against two unix-socket listeners (ci/ops) with disjoint allowed_profiles over a mocked upstream: the same unix-peer-uid-derived ci profile is served on its own listener and denied 403 listener_profile_not_allowed on the other, proving profile-bleed across listeners is impossible end-to-end, not just at the unit level. * 📝 docs(changelog): keep the #149 entry under Unreleased after the v1.5.2 rebase * 🐛 fix(logging): dedupe audit event fields after the #152 logging rebase * 🧪 test(cmd): cover #149 listener-bind and socket-identity branches under the coverage gate PR #183 landed at 95.8% production coverage against the 96% CI floor (run 31004873914). The #149 multi-listener surface left several branches entirely unexercised because higher-level bind-barrier tests only ever drive them through stubbed deps fields: - createNamedListenerImpl, chownSocket, defaultProbeUnixSocket, and createSocketListener's chown-success/chown-failure paths in serve_deps.go were all 0% — every existing test stubs deps.createNamedListener/deps.probeUnixSocket directly instead of calling through to the real implementation. - statSocketIdentity and socketIdentityFromFileInfo's guard branches (empty path, nil lstat func, nil FileInfo, non-*syscall.Stat_t Sys()) and removeSocketIfOwned's inode-match/mismatch happy path were uncovered because the one integration test that reaches them never hits a real matching inode. - hijackedConnTracker.transition's nil-receiver guard, its already-closed-tracker branch, and the plain StateClosed case, plus listenerStatusBoard's nil-receiver guards and setState-on-unknown-name no-op, were never called outside their always-non-nil call sites. Added direct table-driven and real-socket unit tests for each, matching the existing shortSocketPath/newServeTestDeps/socketFileInfo test helpers. Local production coverage: 96.3% (mirrors ci-verify.yml's coverage.prod.txt gate).
) * ✨ feat(policy): add shared JSON codec for admission-mutation writes Introduces internal/filter/json_mutate.go, a fold-aware JSON document codec for code that writes into (not just reads) a Docker API request body: exact-duplicate-key rejection via a token-scan pass (map-decode silently collapses exact duplicates before any value-tree check could see them), depth/node-count/EOF/root bounds, and a canonicalizing parse usable both before and after a mutation is applied. internal/ownership/middleware.go's nestedObject/nestedObjectPath/ foldedObjects/foldedStrings/foldedArrays/foldedStringEquals were independently-maintained copies of the same fold/merge logic; they now call the exported filter.NestedObject/NestedObjectPath/FoldedObjects/ FoldedStrings/FoldedArrays/FoldedStringEquals instead, so there is one reviewed implementation shared by owner-label stamping and the new admission-mutation engine (a following commit). * ✨ feat(policy): add per-request admission-mutation logging record Adds logging.MutationRuleOutcome/MutationRecord (a pooled per-request trace of which admission-mutation rules matched and what they did) and wires it into the existing access/audit pipeline: - RequestMeta gains a Mutation field; putRequestMeta returns it to a dedicated sync.Pool before zeroing, matching the existing pooling pattern for RequestMeta itself. - AccessLogMiddleware appends mutation_rule_ids/mutation_changed to the structured log line and elevates an otherwise-allowed request to WARN when a warn-mode mutation rule was evaluated, mirroring how a warn-rollout policy deny is already elevated. - AuditLogMiddleware deep-copies the pooled MutationRecord into a fresh auditMutationRecord before the event is handed to the async audit channel — required because the pooled record can be recycled for an unrelated request before the channel consumer encodes it. No production code path populates these yet; the admission-mutation engine that does lands in a following commit. * ✨ feat(policy): add fail-closed declarative admission-mutation engine Adds internal/filter/mutation.go: a bounded, config-driven admission mutation engine (#151) supporting exactly two rule operations (inject_labels label-map merge, remap_image single string-field replace) with per-rule enforce/warn/audit rollout modes. Core invariants: - enforce-mode rules are applied to the actual document that will be forwarded; warn/audit-mode rules are evaluated only against an independent deep-cloned shadow document and never influence what is committed. - The request body is only read/rewritten when at least one rule is configured for the request's surface, and only actually rewritten (replaceRequestBody) when an enforce rule changed something — otherwise the original bytes readBoundedBody restored stay in place byte-for-byte. - Every mutation result is re-parsed through the same strict scanner used on the client's input (parseMutationDocument) before it can be forwarded — canonicalize-before-and-after, not just before. - remap_image validates its computed result with go-containerregistry's weak-reference grammar (the same grammar imagefetch.PinnedReference already applies), invoked only when a remap_image rule is configured AND matched — mirroring how the existing (also opt-in) image-trust verifier only ever touches go-containerregistry when image_trust is configured. Two supporting pieces in filter's existing infrastructure: - body_read.go: replaceRequestBody, a transport-safe body-rewrite helper (Content-Length/Transfer-Encoding/GetBody kept in lockstep; the proxy layer only ever reads req.ContentLength, never the literal header). - request_rejection.go: requestRejectionError gains an optional reasonCode override so the engine's four new reason codes (mutation_request_invalid, mutation_request_too_large, mutation_apply_failed, mutation_postcondition_failed) survive to the denial response instead of collapsing to the generic status-derived code. Nothing in filter's dispatch tables calls this engine yet; that wiring lands in the next commit so this one stays independently buildable and reviewable. * ✨ feat(policy): wire admission mutations into the filter dispatch chain Wires the mutation engine from the previous two commits into compileRuntimePolicy's dispatch table (#151): - filter.Options gains a Mutation field; MiddlewareWithOptions compiles one mutationEngine per MiddlewareWithOptions call (reload rebuilds it along with everything else) and shares it identically across the default policy and every client profile — mutation config is global, not per-profile (see mutation.go's MutationOptions doc comment). - compileRuntimePolicy registers the two mutation entries (newContainerCreateMutationPolicy / newServiceMutationPolicy) immediately BEFORE the existing container_create/service entries, at the same (method, matches, severity) tuple. inspectAllowedRequest buckets matches by severity and runs every policy in the single matched bucket in slice order — verified by re-reading middleware.go's own inspectAllowedRequest, not assumed — so this placement guarantees mutation always applies/canonicalizes before container_create/service's own body inspection runs against whatever bytes are in r.Body afterward. - MiddlewareWithOptions stashes the resolved logging.RequestMeta onto the request's context (logging.WithMeta) so mutation.go's recordMutationOutcome — an inspectorFunc, which only ever receives *http.Request — can reach it without widening that signature for every other existing inspector. - inspectAllowedRequest's rejection-handling branch now prefers requestRejectionError.reasonCode when the inspector set one explicitly, falling back to the existing status-derived code otherwise — needed for the mutation engine's four reason codes to actually reach the denial response. Existing compileRuntimePolicy(nil, PolicyConfig{}) call sites in middleware_test.go and middleware_method_dispatch_test.go are updated for the new third parameter (nil mutation engine — rulesFor(nil) is a documented no-op). * ✨ feat(policy): add mutations.rules[] config schema and validation Adds the operator-facing config surface for #151: - config.go: MutationsConfig / MutationRuleConfig / InjectLabelsMutationConfig / ImageRemapMutationConfig, plus Config.Mutations. Deliberately global (not part of clients.profiles[]) — v1 has one mutation authority and no global/profile merge rules. - load.go: decodeMutationsStrict re-decodes the mutations subtree with ErrorUnused=true, WeaklyTypedInput=false, and no decode hook, on top of (and overwriting) the lenient Config-wide decode every other block still gets. Unlike the rest of this legacy schema, a typo'd key or a coerced "id: 0" is a load-time error here, not a silently accepted no-op — called from both Load and LoadBytes so the admin /admin/validate and signed-policy-bundle paths get the same strictness as a file-based startup load. - validate.go: validateMutationsConfig enforces rule/label-count bounds, id format + uniqueness, mode/surface enums, exactly-one-of inject_labels/remap_image, label-surface restriction (inject_labels invalid on service_update), key/value size and control-character bounds, exact-match image literals parsing via go-containerregistry's weak-reference grammar, cross-rule overlap rejection (same label key or overlapping image `from` pattern on a shared surface — which rule would win is otherwise order-dependent), and rejects injecting the reserved ownership.label_key when ownership.owner is configured. - filter_options.go: MutationsConfig.ToFilterOptions converts to filter.MutationOptions. - cmd/serve.go: serveFilterOptions attaches cfg.Mutations.ToFilterOptions() to filter.Options.Mutation. internal/config now imports go-containerregistry for exact-match image literal validation at config-load time — outside the request hot path, and only reached when an exact-match remap_image rule is actually configured, mirroring how image_trust's use of the same dependency is conditioned on that feature being configured. * 📝 docs(policy): document fail-closed admission mutations - CHANGELOG.md: ### Added entry for mutations.rules[] (#151). - README.md: policy-surfaces summary line now mentions declarative admission mutation. - docs/content/docs/configuration.mdx: new "Admission Mutations" section — schema table, example, the global-not-per-profile scope decision, and the strict-decode behavior. - app/configs/cis-docker-benchmark.yaml: commented example rule tagging admission-approved containers with a compliance label, off by default. * 🐛 fix(policy): harden admission-mutation body handling and validation Fixes surfaced while writing the mutation test suite: - config/validate.go: reject empty inject_labels values (spec requires non-empty label values; previously only whitespace/control chars and size were checked). - filter/body_read.go: replaceRequestBody now clones the final byte slice before installing it as the request body/GetBody closure, so a caller mutating its buffer after commit can never retroactively alter bytes already forwarded or replayed. - filter/mutation.go: stop passing decode/apply errors to logRequestError — those errors can carry body-derived text (e.g. offending key/value fragments), which the design's logging invariant explicitly forbids; only the generic denial message and reason code are now logged. * 🧪 test(filter): cover admission-mutation engine and JSON codec - json_mutate_test.go: ambiguity corpus (exact/folded duplicates at root/nested/in-array, case-sensitive data-map exemptions, trailing data, null/array/scalar roots, depth/node caps), numeric-lexeme and JSON-looking-string preservation across marshal/reparse, and replaceRequestBody transport-state reset including anti-aliasing of the caller's buffer. - mutation_test.go: full-chain integration per surface (container create, service create, service update) proving injected/remapped bytes reach the required-label check, registry policy, and image-trust verifier on the post-mutation reference; warn/audit byte-for-byte transport non-interference; audit-record outcome vocabulary with no sensitive value leakage; fail-closed behavior on malformed/duplicate-key/oversized/unparseable-remap bodies and body read errors, with zero upstream calls. - FuzzMutationRoundTrip seed corpus and fuzz test: parse/mutate/ marshal/reparse invariants (forwardable output reparses clean with no residual ambiguity). * 🧪 test(config): cover mutations.rules[] schema and validation Strict-decode rejection of unknown keys and privileged-injection vectors (set_json/patch/path/exec/webhook) at every level of the mutations subtree, both via Load and LoadBytes; YAML typing-attack corpus (wrong scalar/list/object shapes); rule-shape validation (id pattern/length, mode enum, surfaces enum/dedup, exactly-one-of inject_labels/remap_image, service_update label rejection); bounds (64 rules, 32 labels/rule, 256 labels total, key/value/image byte caps); label key/value character rules; owner-label-key reservation when ownership is enabled; overlap rejection (same label key on a shared surface, exact-in-prefix and prefix-in-prefix image matches) alongside acceptance of genuinely non-overlapping rules; and ToFilterOptions type-preserving translation to filter.MutationOptions. * 🧪 test(cmd): cover admission mutations through the built serve chain Full buildServeHandlerLayers integration per surface (container create, service create, service update) via a real upstream test server: label injection satisfying a downstream required-label check with a single canonical Labels field forwarded, service create mutating both root and TaskTemplate.ContainerSpec label maps plus the task image, and service update remapping the image while preserving the ?version= query string and a >53-bit Version.Index integer untouched. * 🧪 test(logging): cover mutation record pooling and audit deep-copy MutationRecord pool round-trip zeroes Rules/ActualChanged/ HasWarnEvaluation on reuse; newAuditMutationRecord deep-copies rule outcomes so mutating or reusing the pooled source after enqueue can't retroactively change an already-emitted audit event; nil/empty records are omitted from the audit event rather than emitted empty. * 🧪 test(reload): assert mutations config stays hot-reload mutable mutations and every mutations.* subtree must be absent from ImmutableFields — admission-mutation rules are meant to be editable via config hot-reload, not restart-only. * 🔧 chore(deps): promote mapstructure to a direct dependency (#151) decodeMutationsStrict imports go-viper/mapstructure/v2 directly for the strict mutations-subtree decode; go mod tidy moved it out of indirect. * 📝 docs(changelog): keep the #151 entry under Unreleased after the v1.5.2 rebase * 🐛 fix(config): normalize remap_image.match in ToFilterOptions (#151) Validation accepts remap_image.match case-insensitively but never wrote the canonical form back onto the config value, so ToFilterOptions was handing a raw "Exact"/"Prefix" straight to the filter engine and relying on filter.newMutationEngine's own normalization to save it. Normalize in ToFilterOptions too, so this package's output is canonical independent of that internal detail. Addresses CodeRabbit comment on PR #181 (validate.go:742). * 📝 docs(mutations): clarify audit-mode behavior and prefix-match scope (#151) - cis-docker-benchmark.yaml: the compliance-label example comment claimed sockguard marks approved containers, but the pasted rule is mode: audit, which only records what would happen and never writes the label. Restate that plainly instead of leaving it to the trailing note. - configuration.mdx: remap_image.match is a literal-string match with no docker.io/library alias expansion, so the pin-internal-registry docker.io/ example never touches an unqualified alpine:3.21 or nginx:1.27 reference. Add a second exact-match rule plus a paragraph spelling that out, and note in the field table that inject_labels keys and values must both be non-empty (whitespace-only included). Addresses three CodeRabbit comments on PR #181 (cis-docker-benchmark.yaml:136, configuration.mdx:669, configuration.mdx:676). * 🧪 test(filter): cover admission-mutation branches under the coverage floor (#151) mutation.go and json_mutate.go landed under the 96% production coverage gate (95.8% on PR #181, CI run 31003217007). Add table-driven and direct unit tests for the branches that were missing real coverage: default rule mode, malformed-rule skip, nil-engine/nil-doc/unknown-kind guards, empty body and non-POST/nil-body/nil-request inspect() short-circuits, remap no-op paths (unsupported surface, absent target field, from mismatch, result equal to current), empty-result and non-object-target failure denials, mutationRemapMatch's full match-kind table, the JSON strict scanner's depth/node/EOF/truncation error paths for both object and array nesting, deepCloneJSONValue's array branch, the Folded* helper family, soleFoldedObject/navigateFoldedObjectPath's not-found paths, and NestedObject's nil-variant skip. internal/filter production coverage: 96.1% -> 98.2%. * 🧪 test(logging): cover mutation record pool fallback and access log fields (#151) Go coverage is per test-binary: internal/filter's mutation tests already drive AccessLogMiddleware end to end with mutation records attached, but that doesn't count toward internal/logging's own coverage since it's a different package's test binary. Add direct logging-package tests for joinMutationRuleIDs, the WARN-elevation branch for an allowed request with a warn-mode mutation evaluation, the omitted-fields case when no rule matched, and GetMutationRecord's defensive nil-fallback when the pool returns a wrong-typed value (mirroring the existing requestMetaPool fallback test's New-override + drain pattern, since sync.Pool's internal LIFO/victim-cache ordering means Put alone isn't reliable here). internal/logging production coverage: 97.0% -> 98.5%.
…unnel (#153) (#184) * ✨ feat(filter): validate Engine API 1.55 and gate BuildKit's opaque tunnel Closes the scoped gap list from #153: - New insecure_accept_opaque_buildkit_tunnels ack gates any rule that would admit POST /session, POST /grpc, or a moby.buildkit.v1.Control method path (mirrors validateBodyBlindWriteRules / validateReadExfiltrationRules). Tecnativa's GRPC=1/SESSION=1 compat env vars auto-set the new ack with a deprecation warning so existing configs keep working. - New drydock-with-build.yaml / portwing-with-build.yaml presets ship classic-builder-only POST /build support (DOCKER_BUILDKIT=0) on top of the respective -with-compose.yaml baseline, without opening the BuildKit session/gRPC tunnel; the -with-compose.yaml header comments now point to them instead of describing the gap as unresolved. - POST /containers/create denies unknown HostConfig.Mounts types fail-closed, validates VolumeOptions/ImageOptions.Subpath against path-traversal escapes, and gates privilege-escalating TmpfsOptions.Options (exec/dev/suid) behind the new allow_tmpfs_privileged_options. - POST /networks/create denies an explicit EnableIPv4: false unless allow_disable_ipv4 is set; endpoint GwPriority now falls under the existing allow_endpoint_config gate on both /networks/*/connect and containers/create's NetworkingConfig.EndpointsConfig. - GET /images/{name}/attestations?statement=true is denied by default (new response.allow_attestation_statements); new response.redact_host_topology redacts GET /info host-fingerprinting fields independent of Swarm mode. - X-Registry-Auth / X-Registry-Config headers are bounded-decoded (8 KiB cap, standard/URL-safe/unpadded base64) on image pull, image push, and build before use; X-Registry-Auth serveraddress is checked against the configured registry allowlist when one is set. BuildKit gRPC mediation (parsing/enforcing policy inside the tunnel) is deferred to a v1.7 epic, not implemented here. Refs: #153 * 🧪 test: cover Engine API 1.55 validation and BuildKit tunnel gating - internal/cmd: startup-validator coverage for validateBuildkitTunnelRules(ForPolicy) (global + per-profile), plus TestPresetConfigsDenyAttestationStatementsByDefault, which walks every app/configs/*.yaml preset (not a fixed list) and asserts each denies attestation statements by default. - internal/config: compat_test.go covers GRPC/SESSION auto-acking insecure_accept_opaque_buildkit_tunnels with a deprecation warning, and that it does not override an explicit ack or fire without those env vars. - internal/filter: mount type/subpath/tmpfs coverage (container_create_test.go), network EnableIPv4/GwPriority coverage (network_test.go), registry-header decode coverage including base64-variant, duplicate-key, and credential-non-leak cases (registry_auth_test.go, build_test.go, image_pull_test.go), TestContainerUpdateResourceControlFieldsCompleteness pinning the full guarded field set including all five blkio arrays, and new fuzz-seed corpus entries (/v1.55/, /session, /grpc) across FuzzPathMatch/FuzzGlobToRegex/FuzzNormalizePath/FuzzCompileRule. - internal/responsefilter: host-topology redaction and attestation-statement gating coverage. - New TestMaxSupportedEngineAPIVersionPin backed by app/testdata/docker-api/max-supported-version.txt. - New TestServeHandlerRejectsH2CClientPreface integration test proving a raw HTTP/2 client preface is parsed as an ordinary, policy-denied HTTP/1.1 request rather than tunneled or upgraded. Refs: #153 * 📝 docs: document Engine API 1.55 and BuildKit transport changes - security.mdx: new "Compose / BuildKit Transport" section under Layer 5 with a supported-transport matrix (classic builder vs. BuildKit session/gRPC vs. native gRPC-over-h2c) and the insecure_accept_opaque_buildkit_tunnels acknowledgment story. - presets.mdx: document drydock-with-build.yaml and portwing-with-build.yaml; the -with-compose.yaml entries now point to them instead of describing the build gap as unresolved. - configuration.mdx: document all five new fields (insecure_accept_opaque_buildkit_tunnels, response.redact_host_topology, response.allow_attestation_statements, request_body.network.allow_disable_ipv4, request_body.container_create.allow_tmpfs_privileged_options) in the YAML sample, prose, reference table, and environment-variable table; also documents the new bounded X-Registry-Auth/X-Registry-Config header decoding and the mount type/subpath/GwPriority validation. - CHANGELOG.md: Unreleased entry covering all of the above. Refs: #153 * 🔧 chore(ci): add monthly Engine API version watch New quality-api-version-watch.yml fetches Docker's public Engine API version-history page monthly, extracts the highest documented v1.NN, and fails the job — a red, human-visible CI check rather than a quietly-filed bot issue — when it exceeds the pin in app/testdata/docker-api/max-supported-version.txt (currently 1.55). A maintainer then reviews the new API's changelog entry, updates the pin, and files follow-up filter work for any new inspectable fields. Refs: #153 * 📝 docs(response): correct redact_host_topology fields and classic-build body wording (#153) - configuration.mdx said redact_host_topology strips kernel/OS/arch/ name/labels; the implementation clears Containerd, FirewallBackend, DiscoveredDevices, and NRI. All four mentions now list the real fields. - security.mdx called the classic POST /build body JSON-decodable; it is a bounded, inspectable tar build-context stream.
…predicates, hijack parity (#148) (#189) * 🐛 fix(filter): strip Podman's three-part semver version prefix (#148) stripVersionPrefix consumed at most one optional ".N" group, so a versioned Podman libpod client (which sends its full daemon semver, e.g. /v5.0.0/) fell through with the version prefix still attached and could never match a /libpod/**-shaped rule. Add a second optional .N component; Docker's own vN/vN.N prefixes are unaffected. - Add consumeOptionalDotDigits helper and apply it twice in stripVersionPrefix. - Extend TestStripVersionPrefix, TestStripVersionPrefixMatchesLegacyRegex (regex oracle widened to {0,2} dot-groups), and TestNormalizePath with three-part/four-part/adversarial-digit-run cases; add a dedicated three-way equivalence test for /libpod/, /v1.45/libpod/, and /v5.0.0/libpod/ all normalizing identically. - Add a redactDeniedPath regression for a three-part-prefixed denied path. - Add a NormalizePath bench case and FuzzNormalizePath seeds for the three-part form. - Pin internal/metrics's independent stripVersionPrefix already handles three-part versions correctly (no fix needed there). - Update the differential package's oracle comments and flip the three-part-version evasion test case now that it's a valid prefix. * ✨ feat(filter): add libpod path predicates (#148) New app/internal/filter/libpod_normalize.go: isLibpodPath plus per-resource matchers (isLibpodContainerCreatePath, isLibpodPodCreatePath, isLibpodExecCreatePath, isLibpodExecStartPath, isLibpodContainerAttachPath, isLibpodPlayKubePath) the later PR2+ inspectors will need. Every matcher is exact-prefix-guarded on "/libpod/" so it can never fire on a Docker-compat path, and vice versa — zero-alloc string ops matching the style of the existing exec.go/network.go matchers. Also adds isContainerAttachPath, the Docker counterpart isLibpodContainerAttachPath needed for the mutual-exclusivity tests (container-attach previously had no filter-side predicate at all; the hijack layer matched it inline). Table tests cover every matcher's positive path plus cross-resource and Docker-shaped negatives, and a path-adversarial suite from the #148 design doc: traversal out of /libpod/ collapsing to the Docker path, doubled/trailing slashes, case variance, and versioned prefixes (including the three-part Podman form) resolving into the namespace identically. * ✨ feat(proxy): recognize libpod hijack endpoints (#148) Extend isHijackEndpointNormalized to peel a leading "libpod/" segment before the existing resource/action check, so POST /libpod/containers/{id}/attach and POST /libpod/exec/{id}/start upgrade to a raw TCP stream exactly like their Docker-compat counterparts. Transport plumbing (buffers, half-close, inactivity timeout) is untouched. Add filter.IsHijackCandidatePath, exported specifically so a cross-package test can exercise the real production matchers on both sides of the proxy/filter split without duplicating either one's logic (unexported identifiers aren't visible across packages, and internal/proxy already imports internal/filter for NormalizePath). TestHijackFilterParity asserts every path the hijack layer accepts — Docker exec/attach and the new libpod predicates — is covered by a filter-side matcher and vice versa; a mismatch is a two-parser-drift smuggling bug per the #148 design doc's "Agreed core" item 3. * 🧪 test(filter): pin default-deny for all libpod paths (#148) TestDefaultConfigDeniesAllLibpodPaths asserts config.Defaults()'s shipped rules — no allow rule for any /libpod/ path, PR1 adds only routing/normalization plumbing — deny every representative libpod request: unversioned, Docker-style two-part versioned, and Podman's three-part semver versioned, covering container/pod create, exec create/start, attach, play/kube, and the read surface.
…xtures (#148) (#191) * 🧪 test(filter): capture libpod container-create golden fixtures (#148) Real POST /libpod/containers/create request bodies captured from a live podman (client 6.0.2 darwin/arm64) talking to a podman machine VM (Podman Engine 5.8.1 linux/arm64), covering basic create, privileged, host netns/pidns/ipcns/userns, container:<ref> namespace sharing, bind/tmpfs mounts, named volumes, devices, cap_add/cap_drop, seccomp/apparmor/selinux opts, resource limits (including cpu.shares-only), systemd mode, custom idmappings, labels, sysctls, read-only rootfs, and non-root user. Resolves the field-name ambiguity the design doc left open (ipcns, not ipc) and surfaces several real-world quirks no draft anticipated: the mounts/volumes field-casing split, singular "sysctl" naming, unsplit device path strings, and systemd defaulting to "true" even when --systemd is never passed on the CLI. README.md documents exact client/server versions and per-fixture capture provenance. * ✨ feat(filter): add libpod container-create body inspector (#148) libpod_container_create_types.go / libpod_container_create.go implement POST /libpod/containers/create body inspection, structured like container_create_types.go/container_create.go: subset decode of the SpecGenerator body, a policy struct, and an inspect() method gated by isLibpodContainerCreatePath (path-exclusive — never reads a Docker-shaped body, and the Docker inspector never reads a libpod-shaped one). Gates mirror ContainerCreateOptions where the semantics map: privileged, host netns/pidns/ipcns/userns, namespace-sharing restriction reading {nsmode,value}, allowed_bind_mounts via the shared normalizeBindMount, devices, allowed_capabilities, seccomp/apparmor/selinux allowlists (including selinux disable), require_non_root_user, memory/cpu/cpu-hard/ pids limits from resource_limits, sysctls, read_only_filesystem, and image trust reusing the existing ImageTrust wiring on the image field. Two libpod-only gates have no Docker analog: allow_systemd_mode (denies non-"false" systemd by default, since SpecGenerator's own default is "true") and allow_custom_id_mappings. Field names and gate semantics are pinned from the golden fixtures captured in the prior commit, not from either draft of the design doc. * ✨ feat(config): wire libpod_container_create request-body config (#148) Adds LibpodContainerCreateRequestBodyConfig under request_body under a new libpod_container_create mapstructure key, following ContainerCreateRequestBodyConfig's field/tag pattern. ToFilterOptions plumbs it into filter.LibpodContainerCreateOptions, and validateLibpodContainerCreateConfig mirrors validateContainerCreateConfig (image_trust validation, allowlist sanity checks). All defaults fail closed; ImageTrust.RequireRekorInclusion defaults to true, matching container_create's default. * ✨ feat(filter): wire libpod container-create inspector (#148) middleware.go registers the libpod inspector in compileRuntimePolicy's policy list, keyed by isLibpodContainerCreatePath (path-exclusive, right after the Docker container-create entry). cmd/rules.go adds POST /libpod/containers/create to bodySensitiveWriteEndpoints and a bodyInspectionConfiguredForEndpoint arm, so the startup blind-write validator recognizes the new inspector the same way it does every other body-sensitive endpoint. Kept minimal and additive — a sibling PR is touching the same three files (pod-create/exec/volume inspectors) and will need a rebase against this. * 🧪 test(filter): add libpod container-create test suite (#148) Per-gate table tests driven off the golden fixtures for every one of the ~20 gates in libpod_container_create.go, plus: - TestInspectorRoutingIsPathExclusive: the design doc's central test — a single body carrying both a Docker-shaped dangerous value (HostConfig.Privileged) and a libpod-shaped safe value (top-level "privileged") with opposing truth values (and the mirror body), proving neither inspector ever reads the other's shape, and that each is a structural no-op on the other family's path. - Fail-closed decode tests: malformed JSON, oversized body (1 MiB cap via requestRejectionError), empty body allowed, and nil-request/wrong-method/ wrong-path/nil-body no-ops. - Image-trust tests reusing the existing mockImageVerifier/ mockSignatureFetcher fixtures from container_create_test.go, including the verify→pin digest rewrite and duplicate-case-variant-key rejection. - FuzzLibpodContainerCreate, seeded from all 20 fixtures plus adversarial inputs; 20s local smoke run (~1.3M execs) found no crashes. Every captured fixture defaults "systemd":"true" (SpecGenerator's own default even when --systemd is never passed), which the new allow_systemd_mode gate denies by default — so every "expect allow" assertion in this file explicitly opts a policy into AllowSystemdMode where the fixture would otherwise trip that gate first and mask the gate actually under test. Assertions expecting deny from a gate that runs before the systemd check in inspect()'s order don't need it. * 📝 docs(configuration): document libpod_container_create (#148) Adds request_body.libpod_container_create to the YAML sample, a prose paragraph describing the gates (mirroring the container_create section's structure), a Request Body Policy Reference table row, and a dedicated environment-variable table following the container_create precedent. CHANGELOG gains an Unreleased ### Added entry summarizing the new inspector, its gate set, and the fixture-derived field-name resolution.
…spection (#148) (#190) * ✨ feat(filter): add libpod pod-create, volume, network, secret inspectors Adds request-body inspection for Podman's libpod-native write endpoints that had no coverage: POST /libpod/pods/create, POST /libpod/volumes/create, POST /libpod/networks/create, and POST /libpod/secrets/create. - libpod_pod_create.go: new inspector gated by allow_host_network (pod-level netns {nsmode:"host"}), allow_shared_pid_namespace (shared_namespaces containing "pid"), and allowed_infra_image_registries (reuses the image_pull registry-allowlist shape). Cross-owner pod-membership checks are explicitly deferred to a later PR (see the TODO in libpod_pod_create.go). - libpod_volume.go / libpod_network.go / libpod_secret.go: libpod-shaped decode structs that reuse the EXISTING volume/network/secret config types rather than introducing parallel config — libpod's wire shapes differ from Docker-compat (volume options live under "Options" not "DriverOpts", network options are snake_case with no swarm-mode fields, secret driver is a query param not a body field) but the policy question is identical. - config.go/filter_options.go/validate.go: new request_body.libpod_pod_create config surface plus request_body.libpod_volume/libpod_network/libpod_secret keys mapped onto the pre-existing config types. - middleware.go: wires the four new inspectors into compileRuntimePolicy, and extends matchesExecInspection so the existing shared execPolicy also matches the libpod exec create/start paths (no separate libpod_exec config — see the follow-up exec.go commit). All wiring is additive to shared files (config.go, middleware.go, etc.) to minimize rebase conflict surface with the concurrent libpod container-create inspector PR. Refs #148 * ✨ feat(filter): extend exec policy to cover libpod exec create/start POST /libpod/containers/{name}/exec and POST /libpod/exec/{id}/start route to the identical Go handlers the Docker-compat exec paths do (confirmed against Podman v5.8.1's own route table), so per the #148 design doc (decision C3) both families now share the SAME execPolicy/ExecOptions and request_body.exec config — there is no separate libpod_exec block. - exec.go: execStartIdentifier now also reports which path family (Docker vs libpod) matched a given exec-start path; inspectExisting dispatches to the new InspectStartLibpod field for libpod paths instead of InspectStart, since the two point at different upstream endpoints (GET /exec/{id}/json vs GET /libpod/exec/{id}/json) and must stay independently configured so setting one never silently satisfies the other's fail-closed "no exec inspection configured" guard. - NewLibpodExecInspector / NewLibpodExecInspectorWithRoundTripper add the libpod-path variant of the exec-inspect upstream constructor; both it and the existing Docker constructor now delegate to a shared newHTTPExecInspector helper. - serve.go: attachRuntimeInspectors wires policy.Exec.InspectStartLibpod alongside the existing InspectStart. Refs #148 * ✨ feat(cmd): wire libpod endpoints into startup gate tables Extends the three startup validators driven by bodySensitiveWriteEndpoints / sensitiveExfilEndpoints / bodyInspectionConfiguredForEndpoint to cover the new libpod surface: - bodySensitiveWriteEndpoints gains libpod pod-create, exec create/start, and volume/network/secret create — all of which have real inspectors — plus (design doc decision C2) play/kube, its kube/play alias, kube/apply, and manifest writes, none of which get any request-body inspector in this release (full Kubernetes-YAML/PodSpec modeling is out of scope), so admitting them requires insecure_allow_body_blind_writes exactly like any other uninspected body-sensitive write. - sensitiveExfilEndpoints gains libpod's container archive/export/logs/ attach and image export/get/push read surface, confirmed against Podman v5.8.1's route table rather than assumed from naming symmetry with the Docker-compat entries (libpod has no /attach/ws variant and no plugin API at all). GET /libpod/generate/kube is included here rather than in bodySensitiveWriteEndpoints, deliberately deviating from the design doc's literal placement: Podman registers it as a GET (libpod.GenerateKube) that dumps existing pod/container definitions to YAML, a read/export surface that can leak env vars, not a write. - bodyInspectionConfiguredForEndpoint arms unconditionally true for the four libpod endpoints with real fail-closed inspectors, and shares the existing exec case (keyed on request_body.exec.allowed_commands) across both the Docker-compat and libpod exec paths. Refs #148 * 🧪 test(filter): libpod inspector coverage and exec parity tests - libpod_pod_create_test.go / libpod_volume_test.go / libpod_network_test.go / libpod_secret_test.go: gate tables for each new inspector (default-deny behavior, explicit allow, malformed/oversized body handling, path/method non-match), plus negative cross-shape tests proving POST /containers/create and POST /libpod/pods/create bodies never leak into each other's decode — e.g. libpod's netns.nsmode:"host" has zero effect on the Docker-compat container-create decision, and Docker's HostConfig.NetworkMode:"host" has zero effect on the libpod pod-create decision. - exec_test.go: exec-parity tests proving identical request bodies produce identical allow/deny verdicts on the Docker-compat and libpod exec paths through the single shared execPolicy, dispatch tests proving inspectExisting calls the InspectStart/InspectStartLibpod function matching the request's own path family (and never the other), and constructor/round-tripper tests for NewLibpodExecInspector[WithRoundTripper] including a mock upstream that only accepts GET /libpod/exec/{id}/json to prove the libpod path is actually used. Refs #148 * 🧪 test(cmd): libpod gate-table validator tests Covers the additions to internal/cmd/rules.go from #148: - Allow-with-config success cases for libpod pod-create, exec (shared request_body.exec allowlist), and volume/network/secret create. - Blind-write validator rejection for /libpod/containers/*/exec without a configured allowlist, and for /libpod/play/kube (+ its kube/play alias), /libpod/kube/apply, and /libpod/manifests/* without insecure_allow_body_blind_writes — plus the mirrored allow-with-explicit- opt-in case for play/kube. - Read-exfiltration validator rejection (and explicit-opt-in allow) for the new libpod container/image read surface and GET /libpod/generate/kube. - Direct bodyInspectionConfiguredForEndpoint coverage for every new libpod case, including the shared exec config across both libpod exec create and exec start. Refs #148 * 📝 docs(configuration): document libpod pod-create/exec/volume/network/secret inspection - configuration.mdx: new prose bullets and Request Body Policy Reference / environment-variable rows for request_body.libpod_pod_create, request_body.libpod_volume, request_body.libpod_network, and request_body.libpod_secret; the exec section and reference table now state explicitly that request_body.exec governs both the Docker-compat and libpod exec paths (no separate libpod_exec config); the insecure_allow_body_blind_writes bullet documents the uninspected play/kube/kube-apply/manifest surface and its N-privileged-containers blast radius; insecure_allow_read_exfiltration documents the new libpod read/export surface including GET /libpod/generate/kube. - CHANGELOG.md: Unreleased Added/Docs/Tests entries for #148 PR3+4. Refs #148 * 🔒 security(cmd): close libpod manifest registry-push exfiltration gap POST /libpod/manifests/*/registry/* and the backward-compat POST /libpod/manifests/*/push read local manifest content and hand it to a caller-chosen registry — an exfiltration surface like the existing image/plugin push entries, but they weren't in sensitiveExfilEndpoints, so rules admitting them bypassed insecure_allow_read_exfiltration. Add both sentinels and narrow-rule rejection cases. #148 * 🧪 test(cmd): consolidate libpod gate tests into one table Fold the eight standalone allow/reject/opt-in validator tests for libpod pod-create/exec/volume/network/secret gates into a single table-driven TestValidateAndCompileRulesLibpodGates, keeping each case's endpoint assertions and #148 design-decision comments. #148 * 📝 docs(configuration): document every enforced libpod_volume/network/secret field The env-var table listed one variable per libpod_volume/libpod_network/ libpod_secret group, and two of those three (allow_endpoint_config, allow_template_drivers) aren't actually consulted by the libpod inspectors. Replace with a row per field each inspector really enforces (allow_custom_drivers/allow_driver_opts for volume; allow_custom_drivers/allow_driver_options/allow_custom_ipam_config/ allow_ipam_options for network; allow_custom_drivers for secret) and call out the inherited fields that have no libpod analog instead of implying they're enforced. #148 * 🐛 fix(filter): reuse libpodNamespace decl shared with container-create inspector after rebase
…d surface (#148) (#192) * 🧪 test(integration): add podmanintegration-tagged libpod suite (#148) PR6 of the #148 Podman/libpod train: real-daemon coverage for the libpod inspectors built in PR2-PR5, mirroring the existing integration-tagged Docker suite's structure and skip-cleanly convention. - app/integration/podman_libpod_integration_test.go and podman_helpers_test.go (new, //go:build podmanintegration): plain libpod container-create passes; privileged container-create and host-network / disallowed-infra-image-registry pod-create are denied with "libpod "-prefixed reasons; exec create/start honor the shared request_body.exec config via a real create+start+exec round trip; libpod_volume/network/secret driver gates fire. - app/internal/cmd/rules_podman_integration_test.go (new, same tag): proves the POST /libpod/play/kube blind-write acknowledgment gate against a live daemon, not just rules_test.go's synthetic configs. - app/integration/helpers_test.go: broaden the build tag to `integration || podmanintegration` so the podman suite reuses the daemon-agnostic handler/rule-compilation helpers instead of duplicating them. All tests t.Skip cleanly when SOCKGUARD_TEST_PODMAN_SOCKET is unset, so `go test ./...` and the existing dockerd suite are unaffected; the 96% coverage gate excludes nothing new (verified locally at 96.2% after filtering differential/testcert/testhelp). * 🔧 chore(ci): run podmanintegration suite against rootful+rootless podman (#148) New workflow .github/workflows/quality-integration-podman.yml runs the podmanintegration-tagged tests added in the prior commit on ubuntu-latest, mirroring quality-integration.yml's real-dockerd structure (harden-runner, pinned action SHAs, pre-pulled pinned sentinel image). Two jobs against the distro-packaged podman: - rootful: required leg, `sudo podman system service` on the well-known root socket, chmod'd so the unprivileged test process can dial it directly. - rootless: continue-on-error. ubuntu-latest has usable newuidmap/newgidmap and /etc/subuid /etc/subgid ranges for the runner user (verified with an explicit prerequisite-check step), but the runner has no logind session, so XDG_RUNTIME_DIR has to be created and chowned by hand rather than relying on pam_systemd. That's more failure-prone than the rootful path most sockguard deployments actually run behind, so a transient rootless-only failure never blocks a PR. * 🐛 fix(ci): fix rootful socket perms and rootless subuid check (#148) Both legs of the new podman integration workflow failed on the real runner (run 31021161589): - Rootful (92357909184): every test hit "stat /run/podman/podman.sock: permission denied". The socket chmod alone wasn't enough — /run/podman itself is root-owned 0700, so the unprivileged test process can't even traverse into the directory to reach the (now world-writable) socket file. chmod the directory too, and add a curl --unix-socket _ping check as the runner user right after starting the service, so a future regression here fails loudly at setup instead of inside the suite. - Rootless (92357909108): "Verify rootless prerequisites" died on `getent subuid runner` with "Unknown database: subuid" — glibc on ubuntu-latest has no subuid/subgid NSS database registered at all, so getent can't answer the question regardless of what /etc/subuid contains. Check the files directly with grep instead. * 🐛 fix(ci): version-prefix real podman requests, fix exec/cleanup bugs (#148) Run 31021969781's rootful leg fixed the earlier permission-denied issue but surfaced new failures once tests actually hit real Podman 4.9.3: - Bare (unversioned) libpod routes other than GET /libpod/_ping 404 on real Podman, unlike dockerd. Container-create and exec-create now fetch the daemon's Libpod-Api-Version via _ping and prefix every request that needs to reach Podman with /v<version>/libpod/...; TestProxyReachesRealPodmanVersion (which hit bare /libpod/version) is removed as no longer viable. - Deny-reason assertions used raw strings.Contains against JSON bodies whose %q-quoted values are escaped (\") — switched to decoding filter.DenialResponse and comparing .Reason. - The exec test's denied/allowed Cmd payloads had no explicit User, so isRootUser("") tripped the root-exec-user gate before the command-allowlist gate ever ran; added "User":"nobody" to isolate the allowlist check. - removeLibpodContainer force-removes a RUNNING container, which makes Podman stop it first (SIGTERM + grace period) — that alone exceeds the shared 5s HTTP client timeout. Gave it its own 30s client instead of widening the shared one. - POST /libpod/exec/{id}/start is hijack-capable, and internal/proxy/hijack.go always forwards the version-stripped path upstream by design (pinned by existing hijack tests) — fine for dockerd, currently incompatible with real Podman's non-_ping versioning requirement. Dropped the real exec-start round trip with a comment explaining why; exec's shared-config enforcement is already fully proven by the exec-create deny/allow pair. All changes verified locally against a real `podman machine` socket (macOS, Podman Engine 5.8.1), not just compiled. * 🐛 fix(ci): create podman runtime subdir and export XDG_RUNTIME_DIR in-step for rootless leg
* ✨ feat(dockerresource): add KindLibpodPod/KindLibpodNetwork for #148 ownership+visibility wiring Pods have no Docker-compat equivalent, and libpod network inspect uses lowercase "labels" (plus an occasional single-element array wrap) unlike every other Docker-compat-shaped inspect body — both need their own Kind so ownership/visibility dispatch can route on Kind alone without threading libpod-ness through every call site. - LibpodInspectPath: dedicated /libpod/{kind}/{id}/json builder for the two new kinds, wired into InspectPath's existing switch - DecodeLibpodLabels/decodeLibpodNetworkLabels: lowercase-keyed pod labels, plus array-unwrap-then-decode for network inspect * ✨ feat(ownership): stamp owner labels and check pod cross-owner refs on libpod endpoints Extends mutateOwnershipRequest with libpod cases so POST /libpod/containers/create, /pods/create, /volumes/create, /networks/create, and /secrets/create get the owner label injected when ownership.owner is configured — matching each endpoint's actual wire shape rather than assuming a single convention: - container/pod/network create: lowercase top-level "labels" - volume create: capitalized "Labels" (VolumeCreateOptions has no json tag on that field, so it falls back to the Go field name) - secret create: no JSON body at all — driver/labels are query params, reuses the existing addOwnerLabelToBuildQuery helper Read-side owner filtering (list injection + per-resource inspect/ action dispatch) is extended to every /libpod/ list and inspect path for containers, pods, networks, volumes, and secrets — not just pods — so switching a client from Docker-compat to libpod-native paths can't bypass ownership isolation for reads on existing resources. Closes the cross-owner pod-membership deferral called out in #190: POST /libpod/pods/create requests joining another owner's namespaces are denied, and POST /libpod/containers/create requests targeting another owner's pod (SpecGenerator "pod" field) are denied, reusing the existing container:<ref> namespace-sharing and embedded-resource reference checks against libpod's uniform {"nsmode":"container","value":"<ref>"} namespace shape. Deny reasons for libpod-family requests get the "libpod " prefix, matching the convention libpod's body inspectors already use. * 🧪 test(ownership): cover libpod owner stamping, pod cross-owner denial, and read dispatch Table-driven tests mirroring the existing ownership test patterns: label stamping on every libpod create endpoint (including the capitalized-Labels volume-create deviation and the secret-create query-param path), cross-owner pod-membership denial in both directions (pod-create namespace joins, container-create pod targeting) plus the same-owner allow counterparts, libpod read-path owner-filter dispatch across container/pod/network/volume/secret, exec-identifier resolution, and a negative test proving libpod predicates never fire on Docker-compat paths. * ✨ feat(visibility): filter libpod list/inspect paths, including pods and network array-unwrap Wires every /libpod/ list endpoint into the existing visibility label-filter injection (needsVisibilityLabelFilter), and every inspect/action path (containers, pods, networks, volumes, secrets) into resourceVisible dispatch — pods route through the new KindLibpodPod since they have no Docker-compat equivalent, the rest reuse the existing Docker-compat Kind constants since ownership labels live in the same shape once decoded. Every libpod inspect endpoint is uniformly suffixed "/json" (unlike Docker-compat's mixed bare-path/json-suffixed convention), so the new identifier helpers use suffixedIdentifier rather than singleSegmentIdentifier. Network inspect responses are unwrapped from Podman's occasional single-element-array envelope before label extraction (upstream Inspector round-trip, dockerresource.DecodeLibpodLabels). Hidden-resource deny reasons get the "libpod " prefix for requests under /libpod/, matching the body-inspector convention. * 🧪 test(visibility): cover libpod list/inspect filtering, pod visibility, and network array-unwrap Table-driven tests: visibility label-filter injection on every libpod list endpoint, KindLibpodPod list/inspect visibility (hidden-pod 404, visible-pod forward, rollout-mode would-deny), the "libpod " deny-reason prefix across libpod pod/container and Docker-compat paths, an end-to-end upstreamInspector round-trip for both bare-object and array-wrapped network inspect responses, and a negative test proving libpod predicates never fire on Docker-compat paths. * 📝 docs(148): document libpod owner-label isolation and cross-owner pod checks CHANGELOG: new Unreleased/Added entry after the existing #148 entries, and updates the pod-create entry's "deferred to a later PR" note now that cross-owner pod-membership checks have landed. configuration.mdx: new paragraph under Owner Label Isolation covering the libpod-specific label casing/query-param deviations, KindLibpodPod, and the two-directional cross-owner pod check. * 🧪 test(visibility): fuzz libpod path identifiers, table-drive pod visibility tests - Add FuzzLibpodPathIdentifiers covering libpod_paths.go's identifier helpers: no match outside /libpod/, collection routes never classify as a single-resource identifier, matched identifiers are non-empty and (bar the readSubresourceIdentifier-based container helper) never contain '/'. Wired into the CI fuzz matrix. - Convert TestMiddlewareLibpodPodListAndInspectVisibility and TestMiddlewareLibpodDoesNotAffectDockerCompatVisibilityPaths to table-driven form per repo convention; assertions unchanged. Addresses CodeRabbit comments 3722497023 and 3722497031 on PR #193. * 📝 docs(configuration): close the libpod_pod_create cross-owner deferral note The libpod_pod_create prose bullet and reference table row still said cross-owner pod-membership checks were deferred to a later release. This PR implements them (see the Owner Label Isolation section) — update both to describe the now-enforced behavior instead. Addresses CodeRabbit comment 3722497036 on PR #193.
#148) (#195) * 📝 docs(podman): add Podman guide covering both API surfaces (#148) New docs/content/docs/podman.mdx explains sockguard as a Podman socket proxy: the Docker-compat vs. native /libpod surfaces and their separate inspectors, which request_body.* key governs which libpod endpoint, the uninspected play/kube blind-write surface and its blast-radius warning, libpod read-exfiltration additions, ownership/visibility coverage, three-part semver version-prefix handling, rootful vs. rootless socket paths, and current limitations (#194's hijacked libpod exec-start gap plus the design doc's deferred-past-v1.6 list). Registered in the docs nav via meta.json — the issue's acceptance criterion for #148. * ✨ feat(presets): add podman-readonly read-only preset (#148) New app/configs/podman-readonly.yaml covers a read-only monitoring posture on both of Podman's API surfaces in one file: Docker-compat list/inspect/stats/top/changes for containers plus list/inspect for images/networks/volumes, and their libpod-native equivalents including pod list/inspect (no Docker-compat analog). Every exfiltration-gated endpoint on both surfaces (archive/export/logs/attach/get/push, generate/kube) is deliberately excluded, so unlike readonly.yaml this preset validates clean without insecure_allow_read_exfiltration; it allows no writes at all, so insecure_allow_body_blind_writes is never needed either. Verified via `sockguard validate --config configs/podman-readonly.yaml`. Documented in presets.mdx. * 🧪 test(cmd): add podman-readonly conformance test (#148) New app/internal/filter/podman_readonly_preset_conformance_test.go fires representative requests at podman-readonly.yaml's real filter chain (reusing the existing presetCase/buildDrydockPresetHandler/ fireDrydockCase helpers), pinning three things: both API surfaces admit the same shape of narrow reads, every path in sensitiveExfilEndpoints (both surfaces) stays denied, and no write reaches upstream on either surface — including libpod-only writes with no Docker-compat analog like pod create and play/kube. The existing preset-walking tests (TestPresetConfigsValidate, TestPresetConfigsPassBuildChain, TestPresetConfigsDenyAttestation StatementsByDefault) already auto-discover the new preset file. * 🔧 chore(ci): retry libpod connectivity checks in the podman workflow Both legs of quality-integration-podman.yml checked for the socket FILE appearing, then immediately ran a single connectivity check (curl .../libpod/_ping on the rootful leg, podman ... version on the rootless leg). The socket file existing doesn't mean the listener is accepting connections yet — that check has hit connection-refused once in the narrow window between the two. Wrap both checks in a 10-attempt, 1s-apart retry loop that dumps the service log and exits non-zero only if every attempt fails. * 🐛 fix(ci): re-chmod podman socket each probe attempt and bound probes with timeouts - podman system service replaces the socket file after initial bind on some startups, silently undoing the one-shot chmod; re-apply it every retry and dump socket state on failure - 🔧 add per-command timeouts to all readiness probes so a blocked curl or podman version can't eat the job timeout * 🧪 test(filter): cover version-prefixed paths in podman-readonly conformance and fuzz seeds * 📝 docs(podman): fix generate/kube gate classification and preset read-scope wording
* 🧪 test(conformance): tri-tool conformance matrix workflow + harness Publishes #150's Sockguard + Portwing + drydock conformance matrix. New weekly/workflow_dispatch quality-tri-tool-conformance.yml boots the audited examples/compose/tri-tool bundle from published images only (never source-built) across three rows -- current-standard, current-edge, and legacy-floor (the audited 0.8.1/1.5.2 pins from PR #155) -- and runs scripts/tri-tool-conformance/run-matrix.sh's ten ordered assertions per row: pristine fresh-volume boot, auth handshake plus one negative probe per mode, inventory/inspect, events, logs, lifecycle, configured exec (Edge only), the remote-update trigger, expected denials, and a route-drift tripwire that diffs sockguard's own access log against a checked-in known-routes.json manifest. - 🧪 scripts/tri-tool-conformance/{run-matrix.sh,lib.sh}: the driver + shared helpers; --self-test exercises the route normalizer and tripwire diff logic against a fixture with no Docker required - 🧪 scripts/tri-tool-conformance/normalize-routes.jq + known-routes.json: route-shape normalizer and the manifest seeded from app/configs/portwing.yaml / portwing-with-exec.yaml's allow rules - 🧪 scripts/tri-tool-conformance-run-matrix.test.mjs: wires --self-test into `npm test` - 🔧 examples/compose/tri-tool/docker-compose.conformance-overlay.yml: test-only probe container overlay; the audited bundle itself is untouched - 🔧 examples/compose/tri-tool/{docker-compose.yml,docker-compose.edge-exec.yml,README.md}: rename SOCKGUARD_VERSION (tag-only) to SOCKGUARD_IMAGE (full ref) so the harness and the new pre-GA release gate can target a release-candidate image on any of sockguard's three registries - 📝 CHANGELOG.md: Tests + Docs entries under [Unreleased] * 📝 docs(releasing): pre-GA tri-tool conformance gate Adds RELEASING.md step 4: before promoting a minor/major release candidate to GA, run the new quality-tri-tool-conformance.yml workflow against the candidate image (sockguard_image dispatch input) and require all three matrix rows green. * 🔒 security(ci): fix tri-tool conformance gate image default + summary short-circuit - sockguard_image dispatch input now defaults to empty (falls through to the compose bundle's own pinned default, same as a scheduled run) instead of ghcr.io/codeswhat/sockguard:latest, which pointed at a different registry/tag than the compose default a scheduled run uses. Input description and RELEASING.md's pre-GA gate step now say to pass the candidate ref explicitly. - summary job now asserts artifact count == matrix row count BEFORE aggregating, not just checking for total emptiness after -- a row that dies before write_artifact runs no longer passes the gate silently. * 🔒 security(ci): pin probe container UID to match sockguard's socket owner curlimages/curl's own image default UID (100) can't reach a socket owned 65532:65532 mode 0600, so the probe's curl calls were failing on their own filesystem permission mismatch instead of exercising the socket mode the assertion actually cares about. * 🔒 security(script): allowlist sockguard_image before it's ever exported --sockguard-image gets interpolated into a compose file and pulled/run. The workflow that drives this script is workflow_dispatch/schedule only (no pull_request trigger, so the input is maintainer-controlled, not attacker-controlled via a PR), but validating it against the allowlisted sockguard registries/repos is defense in depth worth having regardless. * 🐛 fix(conformance): tolerate malformed access-log lines, fail closed on zero routes - wait_for_access_log_route's jq filter (lib.sh) now skips non-object JSON and missing/null normalized_path instead of erroring on them. Under `set -o pipefail`, a jq error on any line -- even one arriving after a real match was already printed -- flipped the whole pipeline's exit status non-zero and reported the wait as failed regardless of what grep actually found, poisoning assertions 3 (inventory-inspect) and 4 (events). - normalize-routes.jq gets the same select(type=="object") guard: a bare-string JSON line would otherwise abort the whole `[inputs | ...]` collection rather than just being skipped, poisoning assertion 10. - assert_route_drift now fails closed (route_drift_status, moved to lib.sh so --self-test can exercise it directly) when zero access-log records were captured, instead of recording a vacuous empty-diff PASS -- that almost always means log capture or the normalizer broke, not that nothing happened. - Extended testdata/access-log-fixture.jsonl with a missing-normalized_path line and a bare-string JSON line, and added self-test coverage for both the jq tolerance and the fail-closed behavior. - Fixed lib.sh's stale compose_cmd doc comment (the function is `compose` and runs directly, it doesn't echo an invocation for callers to expand). * 🐛 fix(conformance): pre-pull busybox pins, checked chown, exact digests, aligned skip names - Pre-pull OLD_BUSYBOX_REF and NEW_BUSYBOX_REF explicitly before any assertion needs them, checked, instead of letting the first containers/create implicitly trigger the pull -- a slow/flaky pull on a shared runner otherwise reads as an unrelated timeout in whichever assertion needed the image first. Corrected the header comment: only NEW_BUSYBOX_REF matches app/integration/helpers_test.go's busyboxPinnedRef, and that repo's own pre-pull is for its own CI job, not this one. - portwing_token.txt (standard mode) is now chown'd to 65532:65532 before the chmod 0400 -- it stayed owned by the script runner while portwing reads it as UID 65532. Every chown/chmod in the secrets-setup block (standard and edge) is now checked explicitly instead of `&&`-chained silently, failing the row with a clear setup-error message instead of surfacing later as a confusing auth-handshake failure. - Image digests are now captured via `docker image inspect` right after `compose up` in assert_pristine_boot, from the images the row actually ran. resolve_metadata no longer does a `docker compose pull` at the end of the row, which could catch an upstream tag having moved mid-run and record a digest that never ran any of this row's assertions. - assert_logs no longer pre-emptively records a "lifecycle" FAIL when the primary sentinel create fails -- assert_lifecycle already records its own FAIL for that case, so the artifact was getting a duplicate entry. - The abort-path skip loop (pristine-boot/auth-handshake failure) now emits exactly the same assertion names the success path does (exec-policy-allowed/-denied instead of exec-policy on exec rows, expected-denials-build/-exec/-export instead of expected-denials), and adds a skipped "auth-handshake" entry for the case where pristine boot itself is what failed and auth-handshake was never even attempted.
* 🔧 chore(release): prepare v1.6.0-rc.1 changelog entry - 📝 docs(changelog): promote [Unreleased] to [1.6.0-rc.1] - 2026-08-05 with a release-candidate intro covering the full v1.6 roadmap (#149 #151 #152 #153 #148 #150) - 🎨 style(changelog): fix missing blank line before ### Tests and a doubled blank line before [1.5.2] introduced by merge resolutions * 🧪 test(logging): de-flake request-ID entropy batching test The constructor queues an async refill signal, so the background goroutine's refillSync could race the test's manual refillSync — both passing the low-watermark guard before either enqueues, recording 2 entropy fills where the test expects 1 (seen on PR #199 CI). Stop the goroutine before filling so exactly one fill stocks the pool in every interleaving. Verified with -race -count=200.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedToo many files! This PR contains 186 files, which is 86 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (186)
You can disable this status message by setting the 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. Comment |
biggest-littlest
left a comment
There was a problem hiding this comment.
Sync of reviewed dev/v1.6 content for the v1.6.0-rc.1 cut.
ALARGECOMPANY
left a comment
There was a problem hiding this comment.
LGTM, matches dev/v1.6 tip.
…sync squash (a3a9cc4) is a prior snapshot of dev/v1.6, fully superseded by this tree
The merge-base changed after approval.
biggest-littlest
left a comment
There was a problem hiding this comment.
Re-approving after the ours-merge of main; tree still equals the dev/v1.6 tip.
Syncs
mainto thedev/v1.6tip (33b9405) ahead of cuttingv1.6.0-rc.1, per the release branch discipline:mainonly advances by merging from the active dev branch, immediately before each cut.Everything here already landed on
dev/v1.6through reviewed, CI-green PRs: #189 #190 #191 #192 #193 #195 (Podman/#148), #197 (tri-tool conformance/#150), #199 (rc.1 changelog prep), plus the earlier #149/#151/#152/#153 trains.After merge, the tree-equality check (
git diff --quiet origin/main origin/dev/v1.6) must pass before the tag is cut via the release-cut workflow withrelease_tag: v1.6.0-rc.1.