Add container image, honest about attribution loss - #33
Merged
Conversation
The image runs the control plane, DLP, approvals, the dashboard, the runtime guards, and the audit chain exactly as a host install does. It does not attribute host egress to a process, because attribution reads /proc/net/tcp (per network namespace) and /proc/<pid>/fd (per PID namespace, gated by PTRACE_MODE_READ), and a container has its own of both and runs as uid 1000. That loss is measured rather than asserted. docs/install.md carries an eight-row matrix of flag combinations against a real HTTPS CONNECT through the container's forward proxy, including the finding that host attribution stays null even for a root container holding CAP_SYS_PTRACE, because Docker's default AppArmor profile permits readdir of /proc/<pid>/fd while denying readlink of its entries. Two combinations work and both cost most of the container's isolation. The sidecar case, sharing namespaces with one agent container instead of the host, attributes that agent fully and needs neither host namespace nor an AppArmor change. Build: two stages on node:22-slim pinned by digest, npm ci --ignore-scripts, npm prune --omit=dev, non-root, nothing chowned to the runtime user so the process cannot rewrite the dashboard JavaScript it serves, and a healthcheck that calls GET /health and checks the body rather than accepting any 200. COPY carries the build context's file modes, so a build on a umask 077 machine produced a 0600 tree that only uid 1000 could read, which broke the --user flag that attribution requires. Modes are normalized in the image so it does not depend on who built it. examples/container.config.yaml is monitor-first with two container changes: it binds 0.0.0.0, because a container's loopback is private and 127.0.0.1 is unreachable through -p, and it uses port 3000. Dependabot gains the docker ecosystem: a digest pin never expires on its own, so without it the image would keep shipping the base layer's unpatched CVEs silently.
- The README lists attribution as Linux-only; it does not describe the record shape a container produces. Say what the README actually says. - Replace a rhetorical count of lost isolation with the enumeration: network namespace, PID namespace, non-root user, AppArmor profile, plus an added capability to read any process's descriptors and memory. - The sidecar snippet set only https_proxy, but the sample record beneath it came from a plain-HTTP fetch through http_proxy. Set both, which is what an agent needs anyway.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A container image for Agentwall, plus the container section of
docs/install.md, plus thedocker ecosystem in Dependabot.
Files:
Dockerfile(new),.dockerignore(new),examples/container.config.yaml(new),docs/install.md,.github/dependabot.yml.release.ymlis untouched; the docker jobspec for 4.2a is at the bottom of this description.
The honest part
Process-level egress attribution reads
/proc/net/tcp(per network namespace) and then/proc/<pid>/fd(per PID namespace, and resolving those symlinks is gated byPTRACE_MODE_READ). A default container has its own of both namespaces and runs as uid1000, so the headline capability degrades to
pid null, comm unknown.Rather than assert that, I measured it. Eight flag combinations, one real HTTPS CONNECT
through the container's forward proxy each time, host client running as uid 1001 gid 1001,
Linux 6.8 / Docker 29.1.3 / AppArmor enabled:
pid null. Client socket is not in the container's netns at all.--network=hostpid null. Socket found; 1 pid visible, so no owner.--network=host --pid=hostpid null. 457 of 460/proc/<pid>/fdunreadable as uid 1000.--network=host --pid=host --user 1001pid null. Bare--user <uid>assigns gid 0, and the gid must match too.--network=host --pid=host --user 1001:1001pid null. AppArmordocker-defaultdenies the symlink read.--network=host --pid=host --user 1001:1001 --security-opt apparmor=unconfinedpid 1300177 comm curl. Same uid and gid only.--network=host --pid=host --user 0 --cap-add=SYS_PTRACE --security-opt apparmor=unconfinedpid 1300177 comm curl. Any process on the host.--pid=host --user 1001:1001 --security-opt apparmor=unconfinedpid null. No--network=host, so the socket is invisible and the PID namespace is moot.Two findings worth calling out, because I would have gotten both wrong by reasoning alone:
--user 0 --cap-add=SYS_PTRACE,readdirof every host/proc/<pid>/fdsucceeds(0 EACCES across 427 pids) and attribution still returns null, because
docker-defaultpermits
ptrace/readonly against peers in the same profile, soreadlinkof the fdentries returns EACCES. Only adding
--security-opt apparmor=unconfinedfixes it.ptrace_may_accesschecks gid as well as uid.--user 1001alone assigns gid 0 andfails;
--user 1001:1001is required.Both working combinations are expensive, and the doc says so plainly rather than listing
them as configuration. The root variant gives up four of the five isolation mechanisms a
container provides, and the doc states that it is not a smaller decision than installing on
the host.
The sidecar case is the one I would recommend, and it also is measured: sharing
namespaces with one agent container rather than the host attributes that agent fully with
no host namespace and no AppArmor change, because
docker-defaultallows the read betweentwo containers under the same profile.
{"host":"example.com","port":80,"scheme":"http","method":"GET", "client":{"pid":31,"comm":"wget"},"decision":"allow"}Two defects the build surfaced and this PR fixes
COPYcarries the build context's file modes. On this box (umask 077)public/,examples/, andpackage.jsonlanded in the image as 0600. The image then only workedfor uid 1000:
--user 1001died at startup onEACCESopening the policy file, which isexactly the flag host attribution requires. It also made the image bytes depend on who
ran
docker build, which a release artifact cannot do. Modes are normalized in the image.VOLUME ["/app/state"]is a trap here. It creates an anonymous volume perdocker run, initialized with uid 1000 ownership, unwritable by a run using--user 1001:1001. Removed; the doc says bind-mount instead, which is what an audit chainthat must outlive the container wants anyway.
Build properties
Two stages, both
node:22-slimpinned by digest(
sha256:f576cc608b02e6b04bb0700e13be83eb5ceb7bb24584c3181b0f4ecfa0cd0edf, resolved atexecution).
npm ci --ignore-scriptsso no dependency install script runs at image buildtime,
npm run build,npm prune --omit=dev. Runtime stage copies only the prunednode_modules,dist,public,examples,package.json. Nothing is chowned to theruntime user, so code execution inside Agentwall cannot rewrite the dashboard JavaScript it
serves or its own
dist/.USER node..dockerignoreis deny-by-default, because thisrepo's root holds gitignored operator files (
agentwall.config.yaml,.env,*.bak) thata denylist would eventually let into a layer.
HEALTHCHECKcalls the realGET /healthfromsrc/routes/health.tsusing node's globalfetch(no curl or wget in the image) and checksbody.status === "ok"rather thanaccepting any 200.
examples/container.config.yamlismonitor-first.config.yamlwith two changes: binds0.0.0.0, because a container's loopback is private and127.0.0.1is unreachable through-p, and port 3000. The file states the--network=hostconsequence. Override with-e AGENTWALL_CONFIG=...plus a read-only mount, which the measured runs above all did.Verification
(Published on 13000 rather than 3000 on the test box, where 3000 is occupied by an unrelated
service. Everything else is verbatim.)
Full suite:
npm run lintclean. No em dashes, en dashes, or emoji in any changed file (checkedbytewise with
LC_ALL=C grep).Not in this PR, deliberately
docs/install.mdcarries nocosign verifycommand. Nothing publishes an image today, so averify invocation would have a guessed registry path, a guessed tag, and a guessed
certificate identity, and would fail for every reader. That is the overclaim the attribution
section spends sixty lines refusing to make. The doc has one line that cannot rot
("Published images and their signature verification are documented alongside the release
workflow"); the real command lands with Step 5.2, after 4.3 has published something to run
it against.
Job spec for 4.2a (release.yml)
./Dockerfile, no build-args, no secrets.docker/login-actiontoghcr.iowithGITHUB_TOKEN.docker/metadata-actionforghcr.io/reesebuilt/agentwall, tags: the release tag pluslatest.docker/build-push-action, SHA-pinned,push: true,provenance: true.sigstore/cosign-installer, then keylesscosign sign --yesagainststeps.<build>.outputs.digest, not the tag.--certificate-identity https://github.com/reesebuilt/agentwall/.github/workflows/release.yml@refs/tags/<tag>--certificate-oidc-issuer https://token.actions.githubusercontent.com