This directory contains a Quint formal specification of the docker-socket-policy proxy. It models the proxy as a state machine and verifies security invariants.
| File | Purpose |
|---|---|
docker_socket_policy.qnt |
Request-handling spec: policy types, state machine, endpoint routing table, 9 invariants (6 P0 / 3 P1), 6 attack scenario simulations |
listener.qnt |
Listening-socket startup: flag/group selection, existing-path checks, single-instance lock, 6 invariants, one run test per design-table row. Instances listener_locked (Go, Rust) and listener_unlocked (TypeScript) |
listener-design.md |
Design of the listening socket (dockerd parity) that listener.qnt models |
# Install Quint (requires Node.js)
# See: https://quint-lang.org/docs/install
# Type-check the spec (proves type safety)
quint typecheck spec/docker_socket_policy.qnt
# Random-simulation verification (fast, no Java required)
quint run --max-steps=50 --invariants allInvariants --backend rust \
spec/docker_socket_policy.qnt
# Random-simulation with TypeScript backend (slower, no binary download)
quint run --max-steps=50 --invariants allInvariants --backend typescript \
spec/docker_socket_policy.qnt
# Listener model: typecheck, simulate the locked model, run the table tests
quint typecheck spec/listener.qnt
quint run spec/listener.qnt --main=listener_locked --max-steps=30 --invariant allListenerInvariants
quint test spec/listener.qnt --main=listener_locked
quint test spec/listener.qnt --main=listener_unlocked
# Formal model-checking via Apalache (exhaustive, requires Java)
quint verify --max-steps=10 --invariants allInvariants spec/docker_socket_policy.qnt| Invariant | What It Checks | Guard / Mutator |
|---|---|---|
noPrivilegedAccess |
No created container has privileged=true |
ContainerConfigMutator sets privileged=false |
alwaysHostNetwork |
Every container's network mode matches its policy's configured value (caller cannot override) | ContainerConfigMutator enforces networkMode from policy |
imagesAlwaysAllowed |
All images match an allowed_image_prefix |
RegistryGate via nondet policy match |
validImagesOnly |
No container has invalid image ref (InvalidTag, InvalidDigest) |
createContainer guard rejects invalid variants |
envOnlyFromFile |
No inline env vars when policy sets env_file |
EnvFileGate → envAllowed() |
proxyLives |
Proxy process stays running | Error-handling recovery |
| Invariant | What It Checks | Guard |
|---|---|---|
volumesInWhitelist |
All volume mounts are in the policy whitelist | MountSourceGate → volumeAllowed() |
flagsInAllowlist |
All CLI flags pass allowlist + denylist | CmdGate → flagAllowed() |
routingTableComplete |
Every endpoint in the routing table has an explicit action | Explicit endpointsTable.contains() check |
| Invariant | What It Checks | Protection in the model |
|---|---|---|
neverListensOnTcp |
No instance serves unless --listen-socket was a Unix path |
start rejects fd://3, tcp://…, http://… (exit 2) |
groupBeforeMode |
The socket never has group bits while its group differs from the selected group | bind at 0600, then chown, then chmod 0660 |
neverWorldWritable |
The socket mode never has o+w |
chmod only ever sets 0660 |
neverUnlinksNonSocket |
A regular file at the path is never removed | probe refuses a non-socket (exit 1) |
noLiveTakeover |
A serving instance's socket is still the one at the path | lock (flock on <path>.lock) plus the connect(2) probe |
groupSelectionMatchesTable |
The selected group and warning match the design's group-selection table | start resolves the group moby-style; the table is written out as data |
allListenerInvariants is their conjunction. noLiveTakeover is shown to fail on listener_unlocked:
quint run spec/listener.qnt --main=listener_unlocked --max-steps=30 --invariant noLiveTakeover # violation expectedTwo invariants are structurally tautological within the Quint model — they can't be falsified by any action sequence the simulator generates, so they don't get real coverage from quint run/quint verify:
-
proxyLives—proxyRunningis set once ininitand every action preserves it (proxyRunning' = proxyRunning); nothing in the model ever sets itfalse. The real guarantee ("a panic/error on one request doesn't crash the whole proxy") is enforced by language-specific mechanisms outside the model: Go's stdlibnet/http.Serverrecovers per-request panics, Rust'stokio::spawnisolates panics per connection task, and TypeScript's request handler wrapshandle()in a.catch(). These are exercised by each implementation's own test suite, not by the Quint simulation. -
routingTableComplete— checks thatendpointsTable(a fixed constant) contains a fixed list of literals declared in the same file. It documents the intended routing table but doesn't cross-check it against any of the three Router implementations; that comparison has to be done manually (or viaquint-analyzer) againstgo/internal/proxy/router.go,rs/src/proxy.rs, andts/src/proxy.ts. -
listener.qntchecks the design, not the code. Nothing in the model is derived from the Go, Rust or TypeScript sources. Conformance rests on each implementation's unit and integration tests, which carry the same names as the Quintruns (groupDefaultPresent,pathStaleReplaced, …) so every design-table row can be traced across all four.raceWithoutLockTest(inlistener_unlocked) is the formal record of the TypeScript gap: Node has noflock, so two TypeScript instances starting together can orphan one another's socket (#46). -
Listener fault bias.
stepcrashes an instance on 1 in 10 draws instead of half of all steps, so random runs actually interleave live instances. Every crash stays reachable from every phase, so the reachable state space is unchanged.
| Scenario | Attacker Action | Prevented By |
|---|---|---|
| Privileged Escalation | Request createContainer(privileged=true) |
noPrivileged — guard rejects privileged containers |
| Exec Escape | Request execContainer("beacon") |
execContainer unconditionally returns false |
| Unlisted Image | Request createContainer("attacker/malware:latest") |
imagesAlwaysAllowed — no matching policy |
| Invalid Image Ref | Request createContainer(InvalidTag/InvalidDigest) |
validImagesOnly — createContainer guard rejects non-valid |
| Inline Env | Request createContainer with VALIDATOR_KEY=secret |
envFromFileOnly — envFile=true policy rejects inline env |
| Docker Socket Mount | Request createContainer with /var/run/docker.sock |
volumesWhitelisted — /var/run/docker.sock not in policy |
| Privileged Flag | Request createContainer with --privileged flag |
flagsAllowlisted — --privileged is in deniedFlags |
┌─────────┐
│ init │
└────┬────┘
│
readOnlyRequest(_)
│
▼
┌──────────────────────────────────────────────────────┐
│ │
│ createContainer(name, imageRef, ...) │
│ │ guards: nondet, imageNameAllowed, flagAllowed, │
│ │ volumeAllowed, envAllowed, not(priv) │
│ │ ValidImage only (invalid rejected) │
│ │ mutators: enforcedPrivileged, networkMode, │
│ │ effectiveFlags, effectiveVolumes, │
│ │ effectiveEnvVars │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐
│ │ start(name) / unpause(name) │
│ │ ┌─ guard: containerExists │
│ │ │ (paused → Running for unpause) │
│ │ └─ effect: state = Running │
│ │ │
│ │ stop(name) / kill(name) │
│ │ ┌─ guard: containerExists │
│ │ └─ effect: state = Exited │
│ │ │
│ │ pause(name) │
│ │ ┌─ guard: containerExists │
│ │ └─ effect: state = Paused │
│ │ │
│ │ restart(name) │
│ │ ┌─ guard: containerExists │
│ │ └─ effect: state = Running │
│ │ │
│ │ wait(name) │
│ │ ┌─ guard: containerExists │
│ │ └─ effect: no state change (read-only) │
│ │ │
│ │ removeContainer(name) │
│ │ └─ guard: containerExists │
│ │ └─ effect: remove from set │
│ └──────────────────────────────────────────────────┘ │
│ │
│ pullImage(image) ──► nondet policy match │
│ │
└──────────────────────────────────────────────────────┘
Update the Quint spec whenever:
- A new gate is added to the middleware chain — add a guard in
createContainerand a new invariant - A new endpoint is added to the router — add an entry to
endpointsTableand a check inallEndpointsMatched - A policy field is added — extend the
Policytype and add a corresponding invariant - A security invariant is identified — add it to the invariants module
Quint formal verification runs in CI via .github/workflows/ci.yml (quint job), which type-checks the spec and runs random simulation with all invariants:
- run: quint typecheck spec/docker_socket_policy.qnt
- run: quint run --max-steps=100 --invariants allInvariants --backend typescript spec/docker_socket_policy.qntIn practice the job calls make typecheck, make test-spec and make verify BACKEND=typescript, which also cover listener.qnt.
Releases are handled by .github/workflows/release.yml, which auto-bumps the patch version on push to main, creates a draft release, builds Docker images, generates SPDX + CycloneDX SBOMs with syft, and signs them with Cosign.