From 87a1c8c7d2be435c1365bf5ccddbae4601413a3f Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 08:59:14 -0400 Subject: [PATCH 1/5] spec: model the HostConfig default-deny on container create (#88) Add module create_model to spec/docker_socket_policy.qnt: the mutator, MountSourceGate and HostConfigGate on a create request's HostConfig. LEGACY_CREATE models all three before #88 (only Binds host paths were checked, and not at all without volumes; Privileged was not forced without a container_config). - Rule property createChecksEveryField; security properties daemonAttachesOnlyForcedNetwork (NetworkingConfig read under any spelling of EndpointsConfig attaches only the forced network), daemonSeesOnlyAllowlistedHostConfig (what the daemon reads, under canonical field names, is allowlisted and keeps every forced value) and daemonMountsOnlyWhitelisted (every host mount is a policy volume, read-only when the volume is, never propagating). - Instance create runs the table rows and createSoundTest; create_pre88 runs pre88UnsoundTest, which witnesses the #88 reproductions. - The state machine uses the create rules: createContainer takes the HostConfig, and the P0 invariants hostConfigInAllowlist and hostMountsInWhitelist check what the daemon read. Policy gains readOnlyVolumes; the TOOLS policy has no volumes and no container_config. - make test-spec runs both instances. Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 + spec/docker_socket_policy.qnt | 524 ++++++++++++++++++++++++++++++++-- 2 files changed, 500 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 6c28ed6..f6e18d1 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,8 @@ test-spec: $(QUINT) test $(ROUTER_SPEC) --main=pull_pre83 $(QUINT) test $(ROUTER_SPEC) --main=pull_body_ignored $(QUINT) test $(ROUTER_SPEC) --main=pull_pre84 + $(QUINT) test $(SPEC) --main=create + $(QUINT) test $(SPEC) --main=create_pre88 verify-ts: $(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript diff --git a/spec/docker_socket_policy.qnt b/spec/docker_socket_policy.qnt index cec3426..9bfa69a 100644 --- a/spec/docker_socket_policy.qnt +++ b/spec/docker_socket_policy.qnt @@ -17,6 +17,8 @@ module policy { imageDigestAllowed: bool, containerConfig: ContainerConfig, volumes: Set[str], + // The volumes with read_write: false (#88). + readOnlyVolumes: Set[str], envFile: str, privileged: bool, allowedCliFlags: Set[str], @@ -181,10 +183,427 @@ module network_endpoints_pre87 { }) } +// ─── Module: create_model ─────────────────────────────────────────────── +// +// The HostConfig check on POST /containers/create (#88): what of a create +// request's HostConfig reaches the daemon. Mirrors ContainerConfigMutator, +// MountSourceGate and HostConfigGate in go/internal/middleware, +// rs/src/middleware.rs and ts/src/middleware.ts. +// +// A HostConfig field is a key, spelled as the caller spelled it, and an atom +// standing for its value. "null" is an absent or null field, "zero" any other +// zero value (false, 0, "", {}), and "[]" an empty list; every other atom is +// a non-zero value. A list field holds one entry, the atom. The model's field +// names stand for the classes in the README table: CapAdd, Devices and +// GroupAdd for the fields that must be zero, FutureField for a field the +// proxy does not know, CapDrop and MemorySwappiness for the fields that may +// hold any value, PidMode and IpcMode for the namespace modes. +// +// The daemon matches field names without regard to case, and the last match +// in the body wins. CASE_FOLD lists the variants in the model; the daemon +// reads a variant under its canonical name, and when a name appears twice, +// either value may be the one it keeps. The same holds inside +// NetworkingConfig, whose only field is EndpointsConfig: a request's +// `networking` entries are its NetworkingConfig keys, each with one network +// name as value, and NetworkEndpointsGate (#87) reads the exact key only. +// +// LEGACY_CREATE = true models all three before #88: the only check was that +// each Binds host path is a policy volume, skipped when the policy has no +// volumes; Mounts, bind options and every other field went through; and the +// mutator did not force Privileged when the policy has no container_config. +// NetworkEndpointsGate (#87) is in both instances, as it is on main before +// #88. false models all three after the fix. + +module create_model { + import policy.* + + const LEGACY_CREATE: bool + + type Field = { key: str, value: str } + // A HostConfig.Binds entry: host path and bind options. + type Bind = { source: str, opts: Set[str] } + // A HostConfig.Mounts entry. kind is its Type; propagation is + // BindOptions.Propagation. + type MountEntry = { kind: str, source: str, readOnly: bool, propagation: str } + type CreateReq = { entries: Set[Field], binds: Set[Bind], mounts: Set[MountEntry], networking: Set[Field] } + // A host path the daemon mounts into the container. + type HostMount = { source: str, writable: bool, privatePropagation: bool } + + pure val ZERO_VALUES = Set("null", "zero", "[]") + pure def isZero(v: str): bool = ZERO_VALUES.contains(v) + + pure val CASE_FOLD: str -> str = Map( + "privileged" -> "Privileged", + "securityopt" -> "SecurityOpt", + "networkmode" -> "NetworkMode", + "endpointsconfig" -> "EndpointsConfig", + ) + pure def canon(k: str): str = if (CASE_FOLD.keys().contains(k)) CASE_FOLD.get(k) else k + pure def isVariant(k: str): bool = canon(k) != k + + pure val ANY_VALUE = Set("NetworkMode", "CapDrop", "MemorySwappiness") + pure val MODES: str -> Set[str] = Map( + "PidMode" -> Set(), + "IpcMode" -> Set("private", "shareable", "none"), + ) + pure val NULL_ONLY = Set("MaskedPaths") + pure val NO_NEW_PRIVILEGES = Set("no-new-privileges", "no-new-privileges:true") + // The value the mutator writes for SecurityOpt: the policy's list. + pure val POLICY_SECURITY_OPT = "policySecurityOptions" + + pure val BIND_OPTS = Set("ro", "rw", "Z", "rprivate") + pure val PRIVATE_PROPAGATION = Set("", "private", "rprivate") + pure val SHARED_PROPAGATION = Set("shared", "rshared", "slave", "rslave") + + // The value a field may hold when it reaches the daemon, under its + // canonical name. + pure def valueAllowed(p: Policy, k: str, v: str): bool = + if (NULL_ONLY.contains(k)) v == "null" + else if (isZero(v)) true + else if (ANY_VALUE.contains(k)) true + else if (MODES.keys().contains(k)) MODES.get(k).contains(v) + else if (k == "OomScoreAdj") v != "-1000" + else if (k == "SecurityOpt") + NO_NEW_PRIVILEGES.contains(v) or p.containerConfig.securityOptions.contains(v) or v == POLICY_SECURITY_OPT + else false + + pure def hasContainerConfig(p: Policy): bool = or { + p.containerConfig.networkMode != "", + p.containerConfig.restartPolicy != "", + p.containerConfig.securityOptions.size() > 0, + p.containerConfig.user != "", + } + + pure def forcesPrivileged(p: Policy): bool = not(LEGACY_CREATE) or hasContainerConfig(p) + + // ContainerConfigMutator. It writes canonical names only. + pure def mutate(p: Policy, fs: Set[Field]): Set[Field] = { + val nm = p.containerConfig.networkMode + val forcesSecOpt = p.containerConfig.securityOptions.size() > 0 + val kept = fs.filter(f => not(or { + f.key == "Privileged" and forcesPrivileged(p), + f.key == "NetworkMode" and nm != "", + f.key == "SecurityOpt" and forcesSecOpt, + })) + kept + .union(if (forcesPrivileged(p)) Set({ key: "Privileged", value: "zero" }) else Set()) + .union(if (nm != "") Set({ key: "NetworkMode", value: nm }) else Set()) + .union(if (forcesSecOpt) Set({ key: "SecurityOpt", value: POLICY_SECURITY_OPT }) else Set()) + } + + // HostConfigGate, on the mutated fields. + pure def fieldOk(p: Policy, f: Field): bool = + not(isVariant(f.key)) and valueAllowed(p, f.key, f.value) + + // MountSourceGate on a Binds entry. + pure def bindOk(p: Policy, b: Bind): bool = and { + p.volumes.contains(b.source), + b.opts.subseteq(BIND_OPTS), + p.readOnlyVolumes.contains(b.source) implies (b.opts.contains("ro") and not(b.opts.contains("rw"))), + } + + // MountSourceGate on a Mounts entry. + pure def mountOk(p: Policy, m: MountEntry): bool = or { + m.kind == "tmpfs" and m.source == "", + and { + m.kind == "bind", + p.volumes.contains(m.source), + p.readOnlyVolumes.contains(m.source) implies m.readOnly, + PRIVATE_PROPAGATION.contains(m.propagation), + }, + } + + // NetworkEndpointsGate (#87): when the policy forces network_mode, every + // network under the exact key EndpointsConfig is "default" or the mode. + pure def endpointsOk(p: Policy, r: CreateReq): bool = { + val nm = p.containerConfig.networkMode + nm == "" or r.networking.forall(f => f.key == "EndpointsConfig" implies Set("default", nm).contains(f.value)) + } + + pure def createAllowed(p: Policy, r: CreateReq): bool = and { + endpointsOk(p, r), + if (LEGACY_CREATE) + p.volumes.size() == 0 or r.binds.forall(b => p.volumes.contains(b.source)) + else and { + mutate(p, r.entries).forall(f => fieldOk(p, f)), + r.networking.forall(f => not(isVariant(f.key))), + r.binds.forall(b => bindOk(p, b)), + r.mounts.forall(m => mountOk(p, m)), + }, + } + + // ─── What the daemon reads ────────────────────────────────────────── + + pure def daemonFields(p: Policy, r: CreateReq): Set[Field] = + mutate(p, r.entries).map(f => { key: canon(f.key), value: f.value }) + + // The networks the daemon attaches: every EndpointsConfig entry, under any + // spelling of the key. + pure def daemonNetworks(r: CreateReq): Set[str] = + r.networking.filter(f => canon(f.key) == "EndpointsConfig").map(f => f.value) + + pure def daemonPrivileged(p: Policy, r: CreateReq): bool = + daemonFields(p, r).exists(f => f.key == "Privileged" and not(isZero(f.value))) + + // Every host path the daemon mounts. A volume mount stands for host + // storage named by its source; tmpfs mounts nothing from the host. + pure def daemonMounts(r: CreateReq): Set[HostMount] = + r.binds.map(b => { + source: b.source, + writable: not(b.opts.contains("ro")), + privatePropagation: b.opts.intersect(SHARED_PROPAGATION).size() == 0, + }).union(r.mounts.filter(m => m.kind != "tmpfs").map(m => { + source: m.source, + writable: not(m.readOnly), + privatePropagation: PRIVATE_PROPAGATION.contains(m.propagation), + })) + + // ─── Universe ─────────────────────────────────────────────────────── + + pure val BEACON: Policy = { + serviceName: "beacon", + allowedImagePrefixes: Set("chainsafe/lodestar"), + imageTagPattern: "", + imageDigestAllowed: false, + containerConfig: { + networkMode: "host", + restartPolicy: "unless-stopped", + securityOptions: Set("no-new-privileges:true"), + user: "2001:2001", + }, + volumes: Set("/home/beacon", "/etc/jwt_dir"), + readOnlyVolumes: Set("/etc/jwt_dir"), + envFile: "", + privileged: false, + allowedCliFlags: Set(), + flagRules: List(), + deniedFlags: Set(), + } + + // No volumes and no container_config. + pure val TOOLS: Policy = { + serviceName: "tools", + allowedImagePrefixes: Set("example/tools"), + imageTagPattern: "", + imageDigestAllowed: false, + containerConfig: { networkMode: "", restartPolicy: "", securityOptions: Set(), user: "" }, + volumes: Set(), + readOnlyVolumes: Set(), + envFile: "", + privileged: false, + allowedCliFlags: Set(), + flagRules: List(), + deniedFlags: Set(), + } + + pure val MODEL_POLICIES = Set(BEACON, TOOLS) + + pure val KEYS = Set( + "Privileged", "privileged", "SecurityOpt", "securityopt", "NetworkMode", "networkmode", + "CapAdd", "Devices", "GroupAdd", "FutureField", "MaskedPaths", "CapDrop", + "MemorySwappiness", "PidMode", "IpcMode", "OomScoreAdj", + ) + pure val VALUES = Set( + "null", "zero", "[]", "true", "host", "private", "container:x", "SYS_ADMIN", + "-1000", "500", "no-new-privileges", "seccomp=unconfined", "bridge", + ) + pure val SOURCES = Set("/home/beacon", "/etc/jwt_dir", "/") + + pure def fieldsReq(fs: Set[Field]): CreateReq = { entries: fs, binds: Set(), mounts: Set(), networking: Set() } + pure def bindReq(source: str, opts: Set[str]): CreateReq = + { entries: Set(), binds: Set({ source: source, opts: opts }), mounts: Set(), networking: Set() } + pure def mountReq(kind: str, source: str, readOnly: bool, propagation: str): CreateReq = + { entries: Set(), binds: Set(), mounts: Set({ kind: kind, source: source, readOnly: readOnly, propagation: propagation }), networking: Set() } + pure def netReq(fs: Set[Field]): CreateReq = { entries: Set(), binds: Set(), mounts: Set(), networking: fs } + + // Every single-field request, every single-bind request, every + // single-mount request, and every NetworkingConfig with the CLI's + // {"default"} plus one more entry, over the model's atoms. + pure val REQS: Set[CreateReq] = + tuples(KEYS, VALUES).map(((k, v)) => fieldsReq(Set({ key: k, value: v }))) + .union(tuples(SOURCES, Set("ro", "rw", "rshared", "Z").powerset()).map(((s, o)) => bindReq(s, o))) + .union(tuples(Set("bind", "tmpfs", "volume"), SOURCES.union(Set("")), Set(true, false), Set("", "rprivate", "rshared")) + .map(((k, s, ro, prop)) => mountReq(k, s, ro, prop))) + .union(tuples(Set("EndpointsConfig", "endpointsconfig"), Set("default", "host", "evil-net")) + .map(((k, n)) => netReq(Set({ key: "EndpointsConfig", value: "default" }, { key: k, value: n }))) + ) + + // ─── Properties ───────────────────────────────────────────────────── + + // The rule: an allowed create has only fields spelled as the daemon names + // them, each with a value its rule allows, and only binds and mounts the + // policy allows. Close to a tautology on `create`, since createAllowed + // tests exactly this. + pure val createChecksEveryField: bool = + MODEL_POLICIES.forall(p => REQS.forall(r => + createAllowed(p, r) implies and { + r.entries.forall(f => not(isVariant(f.key))), + r.networking.forall(f => not(isVariant(f.key))), + mutate(p, r.entries).forall(f => valueAllowed(p, f.key, f.value)), + r.binds.forall(b => bindOk(p, b)), + r.mounts.forall(m => mountOk(p, m)), + } + )) + + // The security claim, on what the daemon reads: only allowlisted fields + // with allowed values, Privileged false, and every value the policy forces + // kept. It does not mention createAllowed's rules or LEGACY_CREATE, so + // that create_pre88 fails it. + pure val daemonSeesOnlyAllowlistedHostConfig: bool = + MODEL_POLICIES.forall(p => REQS.forall(r => + createAllowed(p, r) implies daemonFields(p, r).forall(f => and { + valueAllowed(p, f.key, f.value), + f.key == "Privileged" implies isZero(f.value), + (f.key == "NetworkMode" and p.containerConfig.networkMode != "") + implies f.value == p.containerConfig.networkMode, + (f.key == "SecurityOpt" and p.containerConfig.securityOptions.size() > 0) + implies f.value == POLICY_SECURITY_OPT, + }) + )) + + // The security claim on mounts: every host path the daemon mounts is a + // policy volume, a read-only volume is never writable, and no mount + // propagates. With no volumes, nothing is mounted. + pure val daemonMountsOnlyWhitelisted: bool = + MODEL_POLICIES.forall(p => REQS.forall(r => + createAllowed(p, r) implies daemonMounts(r).forall(m => and { + p.volumes.contains(m.source), + m.writable implies not(p.readOnlyVolumes.contains(m.source)), + m.privatePropagation, + }) + )) + + // The security claim on networks: when the policy forces network_mode, + // the daemon attaches no network but the mode (or the CLI's "default", + // which the mutator rewrites). #87 checked the exact key only. + pure val daemonAttachesOnlyForcedNetwork: bool = + MODEL_POLICIES.forall(p => REQS.forall(r => + (createAllowed(p, r) and p.containerConfig.networkMode != "") + implies daemonNetworks(r).subseteq(Set("default", p.containerConfig.networkMode)) + )) + + pure def one(k: str, v: str): CreateReq = fieldsReq(Set({ key: k, value: v })) + pure def withVariantEndpoints(n: str): CreateReq = + netReq(Set({ key: "EndpointsConfig", value: "default" }, { key: "endpointsconfig", value: n })) + pure val NO_HOST_CONFIG: CreateReq = fieldsReq(Set()) +} + +// After #88, all three. +module create { + import create_model(LEGACY_CREATE = false).* + + // ─── Tests: one run per table row ─────────────────────────────────── + // + // Rows shared with the language tables (same names), on `create` only: + // most deny rows are allowed on create_pre88. + + run capAddDeniedTest = assert(not(createAllowed(BEACON, one("CapAdd", "SYS_ADMIN")))) + run devicesDeniedTest = assert(not(createAllowed(BEACON, one("Devices", "/dev/sda")))) + run groupAddDeniedTest = assert(not(createAllowed(BEACON, one("GroupAdd", "true")))) + run unknownFieldDeniedTest = assert(not(createAllowed(BEACON, one("FutureField", "true")))) + run maskedPathsEmptyDeniedTest = assert(not(createAllowed(BEACON, one("MaskedPaths", "[]")))) + run pidModeHostDeniedTest = assert(not(createAllowed(BEACON, one("PidMode", "host")))) + run pidModeContainerDeniedTest = assert(not(createAllowed(BEACON, one("PidMode", "container:x")))) + run ipcModeHostDeniedTest = assert(not(createAllowed(BEACON, one("IpcMode", "host")))) + run ipcModePrivateAllowedTest = assert(createAllowed(BEACON, one("IpcMode", "private"))) + run oomScoreAdjNegativeDeniedTest = assert(not(createAllowed(BEACON, one("OomScoreAdj", "-1000")))) + run oomScoreAdjPositiveAllowedTest = assert(createAllowed(BEACON, one("OomScoreAdj", "500"))) + run capDropAllowedTest = assert(createAllowed(BEACON, one("CapDrop", "SYS_ADMIN"))) + run memorySwappinessAllowedTest = assert(createAllowed(BEACON, one("MemorySwappiness", "-1000"))) + run networkModeAllowedTest = assert(createAllowed(TOOLS, one("NetworkMode", "bridge"))) + run zeroValuesAllowedTest = + assert(createAllowed(BEACON, fieldsReq(Set( + { key: "CapAdd", value: "null" }, { key: "Devices", value: "[]" }, + { key: "FutureField", value: "zero" }, { key: "MaskedPaths", value: "null" }, + { key: "PidMode", value: "zero" }, { key: "Privileged", value: "zero" }, + )))) + run securityOptSeccompUnconfinedDeniedTest = + assert(not(createAllowed(TOOLS, one("SecurityOpt", "seccomp=unconfined")))) + run securityOptNoNewPrivilegesAllowedTest = + assert(createAllowed(TOOLS, one("SecurityOpt", "no-new-privileges"))) + run securityOptOverwrittenByPolicyTest = + assert(and { + createAllowed(BEACON, one("SecurityOpt", "seccomp=unconfined")), + daemonFields(BEACON, one("SecurityOpt", "seccomp=unconfined")) + == Set({ key: "SecurityOpt", value: POLICY_SECURITY_OPT }, { key: "Privileged", value: "zero" }, + { key: "NetworkMode", value: "host" }), + }) + run caseVariantPrivilegedDeniedTest = assert(not(createAllowed(BEACON, one("privileged", "true")))) + run caseVariantZeroSecurityOptDeniedTest = assert(not(createAllowed(BEACON, one("securityopt", "null")))) + run networkingConfigVariantDeniedTest = assert(not(createAllowed(BEACON, withVariantEndpoints("evil-net")))) + run networkingConfigVariantBareDeniedTest = assert(not(createAllowed(TOOLS, withVariantEndpoints("evil-net")))) + run privilegedNeutralisedTest = + assert(createAllowed(BEACON, one("Privileged", "true")) and not(daemonPrivileged(BEACON, one("Privileged", "true")))) + run privilegedNeutralisedEmptyContainerConfigTest = + assert(createAllowed(TOOLS, one("Privileged", "true")) and not(daemonPrivileged(TOOLS, one("Privileged", "true")))) + run mountBindRootDeniedTest = assert(not(createAllowed(BEACON, mountReq("bind", "/", false, "")))) + run mountBindAllowedTest = assert(createAllowed(BEACON, mountReq("bind", "/home/beacon", false, ""))) + run mountBindReadOnlyVolumeWritableDeniedTest = + assert(not(createAllowed(BEACON, mountReq("bind", "/etc/jwt_dir", false, "")))) + run mountBindReadOnlyVolumeReadOnlyAllowedTest = + assert(createAllowed(BEACON, mountReq("bind", "/etc/jwt_dir", true, ""))) + run mountVolumeTypeDeniedTest = assert(not(createAllowed(BEACON, mountReq("volume", "/home/beacon", false, "")))) + run mountTmpfsAllowedTest = assert(createAllowed(BEACON, mountReq("tmpfs", "", false, ""))) + run mountTmpfsWithSourceDeniedTest = assert(not(createAllowed(BEACON, mountReq("tmpfs", "/", false, "")))) + run mountPropagationRsharedDeniedTest = + assert(not(createAllowed(BEACON, mountReq("bind", "/home/beacon", false, "rshared")))) + run mountPropagationRprivateAllowedTest = + assert(createAllowed(BEACON, mountReq("bind", "/home/beacon", false, "rprivate"))) + run mountNoVolumesDeniedTest = assert(not(createAllowed(TOOLS, mountReq("bind", "/home/beacon", false, "")))) + run bindAllowedTest = assert(createAllowed(BEACON, bindReq("/home/beacon", Set()))) + run bindReadOnlyVolumeRwDeniedTest = assert(not(createAllowed(BEACON, bindReq("/etc/jwt_dir", Set("rw"))))) + run bindReadOnlyVolumeNoModeDeniedTest = assert(not(createAllowed(BEACON, bindReq("/etc/jwt_dir", Set())))) + run bindReadOnlyVolumeRoAllowedTest = assert(createAllowed(BEACON, bindReq("/etc/jwt_dir", Set("ro")))) + run bindReadOnlyVolumeRoZAllowedTest = assert(createAllowed(BEACON, bindReq("/etc/jwt_dir", Set("ro", "Z")))) + run bindReadOnlyVolumeRoRwDeniedTest = assert(not(createAllowed(BEACON, bindReq("/etc/jwt_dir", Set("ro", "rw"))))) + run bindRsharedDeniedTest = assert(not(createAllowed(BEACON, bindReq("/home/beacon", Set("rshared"))))) + run bindUnlistedDeniedTest = assert(not(createAllowed(BEACON, bindReq("/", Set("ro"))))) + run bindNoVolumesDeniedTest = assert(not(createAllowed(TOOLS, bindReq("/home/beacon", Set())))) + + run createSoundTest = + assert(and { + createChecksEveryField, + daemonSeesOnlyAllowlistedHostConfig, + daemonMountsOnlyWhitelisted, + daemonAttachesOnlyForcedNetwork, + }) +} + +// All three before #88: only the Binds host paths were checked, and not at +// all for a policy with no volumes; the mutator did not force Privileged +// without a container_config. +module create_pre88 { + import create_model(LEGACY_CREATE = true).* + + run pre88UnsoundTest = + assert(and { + not(daemonSeesOnlyAllowlistedHostConfig), + not(daemonMountsOnlyWhitelisted), + // The reproductions in #88. + createAllowed(BEACON, mountReq("bind", "/", false, "")), + createAllowed(BEACON, bindReq("/etc/jwt_dir", Set("rw"))), + createAllowed(BEACON, bindReq("/etc/jwt_dir", Set())), + createAllowed(BEACON, one("CapAdd", "SYS_ADMIN")), + createAllowed(BEACON, one("PidMode", "host")), + // A policy with no volumes let any bind through. + createAllowed(TOOLS, bindReq("/", Set())), + // Without a container_config, Privileged reached the daemon. + createAllowed(TOOLS, one("Privileged", "true")) and daemonPrivileged(TOOLS, one("Privileged", "true")), + // A zero-valued case variant cleared a forced field. + createAllowed(BEACON, one("securityopt", "null")), + // NetworkEndpointsGate (#87) read only the exact key, so a variant + // attached another network. + not(daemonAttachesOnlyForcedNetwork), + createAllowed(BEACON, withVariantEndpoints("evil-net")), + }) +} + // ─── Module: docker_socket_policy ─────────────────────────────────────── module docker_socket_policy { import policy.* + // The create check after #88: createAllowed, daemonFields, daemonMounts. + import create_model(LEGACY_CREATE = false).* // ─── Image Reference Types ────────────────────────────────────────── // @@ -215,21 +634,14 @@ module docker_socket_policy { volumes: Set[str], envVars: Set[str], flags: Set[str], - networkMode: str + networkMode: str, + // The HostConfig fields and host mounts the daemon read (#88). + hostConfig: Set[Field], + hostMounts: Set[HostMount] } pure def setState(c: Container, s: ContainerState): Container = { - { - name: c.name, - serviceName: c.serviceName, - imageRef: c.imageRef, - state: s, - privileged: c.privileged, - volumes: c.volumes, - envVars: c.envVars, - flags: c.flags, - networkMode: c.networkMode - } + { ...c, state: s } } // ─── Policy Constants ───────────────────────────────────────────────── @@ -250,6 +662,7 @@ module docker_socket_policy { user: "2001:2001" }, volumes: Set("/home/beacon", "/etc/jwt_dir"), + readOnlyVolumes: Set("/etc/jwt_dir"), envFile: "/etc/docker/env/beacon.env", privileged: false, allowedCliFlags: Set("--rcConfig", "--logLevel", "--dataDir", "--network"), @@ -275,6 +688,7 @@ module docker_socket_policy { user: "2002:2002" }, volumes: Set("/home/validator"), + readOnlyVolumes: Set(), envFile: "/etc/docker/env/validator.env", privileged: false, allowedCliFlags: Set("--rcConfig", "--logLevel", "--dataDir", "--network"), @@ -284,7 +698,9 @@ module docker_socket_policy { deniedFlags: Set("--privileged", "--volume", "--mount", "--cap-add", "--cap-drop", "--device", "--security-opt", "--user", "--network") - } + }, + // No volumes and no container_config (#88). + TOOLS ) // ─── State Variables ────────────────────────────────────────────────── @@ -346,7 +762,7 @@ module docker_socket_policy { action createContainer( name: str, imageRef: ImageRef, flags: Set[str], volumes: Set[str], envVars: Set[str], - privileged: bool + hc: CreateReq ): bool = { if (imageRef.kind == "valid") { nondet matched = oneOf(policies.filter(p => imageNameAllowed(imageRef.imageName, p))) @@ -354,7 +770,8 @@ module docker_socket_policy { flags.forall(f => flagAllowed(f, matched)), volumes.forall(v => volumeAllowed(v, matched)), envAllowed(envVars, matched), - not(privileged) and not(matched.privileged), + not(matched.privileged), + createAllowed(matched, hc), containers' = containers.union( Set({ @@ -362,11 +779,13 @@ module docker_socket_policy { serviceName: matched.serviceName, imageRef: imageRef, state: Running, - privileged: false, + privileged: daemonPrivileged(matched, hc), volumes: volumes.intersect(matched.volumes), envVars: if (matched.envFile != "") Set() else envVars, flags: flags.intersect(matched.allowedCliFlags), - networkMode: matched.containerConfig.networkMode + networkMode: matched.containerConfig.networkMode, + hostConfig: daemonFields(matched, hc), + hostMounts: daemonMounts(hc) }) ), proxyRunning' = proxyRunning @@ -471,34 +890,63 @@ module docker_socket_policy { createContainer("beacon-0", { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, Set("--rcConfig", "--dataDir"), - Set("/home/beacon"), Set(), false), + Set("/home/beacon"), Set(), NO_HOST_CONFIG), createContainer("beacon-1", { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, Set("--rcConfig", "--logLevel"), - Set("/home/beacon"), Set(), false), + Set("/home/beacon"), Set(), NO_HOST_CONFIG), createContainer("beacon-2", { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, Set("--network", "--rcConfig"), - Set("/home/beacon"), Set(), false), + Set("/home/beacon"), Set(), NO_HOST_CONFIG), createContainer("validator-0", { kind: "valid", imageName: "chainsafe/lodestar", tag: "validator", digest: "" }, Set("--dataDir", "/data/validator"), - Set("/home/validator"), Set(), false), + Set("/home/validator"), Set(), NO_HOST_CONFIG), createContainer("validator-1", { kind: "valid", imageName: "chainsafe/lodestar", tag: "validator", digest: "" }, Set("--dataDir", "/data/validator"), - Set("/home/validator"), Set(), false), + Set("/home/validator"), Set(), NO_HOST_CONFIG), createContainer("validator-2", { kind: "valid", imageName: "chainsafe/lodestar", tag: "validator", digest: "" }, Set("--dataDir", "/data/validator"), - Set("/home/validator"), Set(), false), + Set("/home/validator"), Set(), NO_HOST_CONFIG), // --- Rejected create attempts (always fail, test coverage) --- createContainer("evil", { kind: "invalidTag", imageName: "attacker/malware", tag: "latest", digest: "" }, - Set("--privileged"), Set(), Set(), true), + Set("--privileged"), Set(), Set(), one("Privileged", "true")), createContainer("bad-digest", { kind: "invalidDigest", imageName: "", tag: "", digest: "sha256:badbadbad" }, - Set(), Set(), Set(), false), + Set(), Set(), Set(), NO_HOST_CONFIG), + // --- HostConfig (#88): allowed, with Privileged neutralised --- + createContainer("beacon-privileged", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), one("Privileged", "true")), + createContainer("tools-privileged", + { kind: "valid", imageName: "example/tools", tag: "latest", digest: "" }, + Set(), Set(), Set(), one("Privileged", "true")), + createContainer("beacon-jwt-ro", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), bindReq("/etc/jwt_dir", Set("ro"))), + // --- HostConfig (#88): rejected --- + createContainer("mount-root", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), mountReq("bind", "/", false, "")), + createContainer("jwt-rw", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), bindReq("/etc/jwt_dir", Set("rw"))), + createContainer("cap-add", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), one("CapAdd", "SYS_ADMIN")), + createContainer("pid-host", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), one("PidMode", "host")), + createContainer("lower-privileged", + { kind: "valid", imageName: "chainsafe/lodestar", tag: "beacon", digest: "" }, + Set("--rcConfig"), Set("/home/beacon"), Set(), one("privileged", "true")), + createContainer("tools-bind", + { kind: "valid", imageName: "example/tools", tag: "latest", digest: "" }, + Set(), Set(), Set(), bindReq("/home/beacon", Set())), // --- Lifecycle actions (conditional on container existence) --- startContainer("beacon-0"), stopContainer("beacon-0"), @@ -522,6 +970,28 @@ module docker_socket_policy { val noPrivilegedAccess = containers.forall(c => not(c.privileged)) + // Every HostConfig field the daemon read is one the policy allows, with an + // allowed value (#88). + val hostConfigInAllowlist = containers.forall(c => + policies.exists(p => + p.serviceName == c.serviceName + and c.hostConfig.forall(f => valueAllowed(p, f.key, f.value)) + ) + ) + + // Every host path the daemon mounted is a policy volume, never writable + // when the volume is read-only, and never propagating (#88). + val hostMountsInWhitelist = containers.forall(c => + policies.exists(p => + p.serviceName == c.serviceName + and c.hostMounts.forall(m => and { + p.volumes.contains(m.source), + m.writable implies not(p.readOnlyVolumes.contains(m.source)), + m.privatePropagation, + }) + ) + ) + // Every container's network mode is exactly what its matched policy // configures — the caller cannot override it via the create request. // (Not a hardcoded "host" check: the security property is that the @@ -608,7 +1078,9 @@ module docker_socket_policy { imagesAlwaysAllowed, validImagesOnly, envOnlyFromFile, - proxyLives + proxyLives, + hostConfigInAllowlist, + hostMountsInWhitelist } val allInvariants = allP0Invariants.and(all { From 6dadfe43203c15623f85b83be11bf9a2c09c2d6f Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 09:00:27 -0400 Subject: [PATCH 2/5] test: pin the HostConfig default-deny, Mounts and bind modes (#88) The same 131 rows, by name, in Go (TestHostConfigRows), Rust (test_host_config_rows) and TypeScript: every must-be-zero field with a non-zero value, every any-value field, zero values, the namespace modes, SecurityOpt, MaskedPaths [], case variants of field names (top level, HostConfig, Mounts, NetworkingConfig), Mounts types, sources, ReadOnly and propagation, bind options and read-only volumes, Privileged forced false with an empty container_config, and the bodies Docker CLI 29 and community.docker.docker_container send. The Rust and TypeScript tests that pinned "no volumes allows any bind" and "no container_config leaves the body alone" now pin the opposite. deploy/test.sh gains 17 checks against the real daemon: the #88 reproductions (Mounts bind of /, the read-only volume bound rw or with no mode, CapAdd, PidMode host, Devices, GroupAdd), MaskedPaths [], an rshared bind, a volume mount, a lower-case privileged, an extra lower-case image next to Image (before the fix the daemon ran it: 201), a lower-case endpointsconfig next to EndpointsConfig (NetworkEndpointsGate reads only the exact key), and creates that must still pass (read-only binds and mounts, the Docker CLI 29 body). On the pre-fix code (main with #87 and #91), 73 rows fail in Go, 74 in Rust and TypeScript (the extra one is the old Rust/TS bind message without "for service"); the Rust mutator panics on a non-object HostConfig and the TypeScript one throws. Co-Authored-By: Claude Opus 5.5 --- deploy/test.sh | 92 +- go/internal/middleware/hostconfig_test.go | 220 +++++ rs/src/middleware.rs | 1028 ++++++++++++++++++++- ts/src/middleware.test.ts | 981 +++++++++++++++++++- 4 files changed, 2311 insertions(+), 10 deletions(-) create mode 100644 go/internal/middleware/hostconfig_test.go diff --git a/deploy/test.sh b/deploy/test.sh index c304df0..397bb10 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -2,9 +2,9 @@ # Integration tests for docker-socket-policy. # Sends HTTP requests directly to the proxy's Docker API and checks status codes, # and the deny message on every expected proxy 403. -# The proxy's RegistryGate, MountSourceGate, EnvFileGate, CmdGate, -# NetworkEndpointsGate and ContainerConfigMutator are all exercised, the last -# two by starting real containers (#87); exec and attach are denied by the +# The proxy's RegistryGate, MountSourceGate, EnvFileGate, HostConfigGate (#88), +# CmdGate, NetworkEndpointsGate and ContainerConfigMutator are all exercised, +# the last two by starting real containers (#87); exec and attach are denied by the # router, protocol upgrades by the handler (#76), non-read methods on the read # endpoints by the router and CONNECT by the handler (#78), and every write on a # second proxy run with --readonly (#75). @@ -272,6 +272,92 @@ fi S=$(post_json '{"Image":"chainsafe/lodestar:beacon","HostConfig":{"Binds":["/tmp:/tmp"]},"Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") check_denied "create with unlisted volume -> 403" "$S" "$BODY" 'volume mount "/tmp" is not in the whitelist' +# ─── HostConfig default-deny (#88) ─────────────────────── + +# Before #88 only the host path of HostConfig.Binds was checked, so each of +# these reached the daemon unchanged. /etc/jwt_dir is read_write: false in +# deploy/config/beacon.yaml. The allowed creates reach the daemon, which +# answers 404 (image not present) or 201: anything but a proxy 403. +echo "" +echo "--- HostConfig default-deny ---" + +# check_not_denied DESC ACTUAL_STATUS [ALSO_OK] +# ALSO_OK is one more daemon status to accept: a Mounts bind whose source is +# missing on the daemon's host gets 400 from the daemon, not from the proxy. +check_not_denied() { + if [ "$2" = "201" ] || [ "$2" = "404" ] || { [ -n "${3:-}" ] && [ "$2" = "$3" ]; }; then + echo " PASS: $1 -> $2 (not 403)" + PASS=$((PASS+1)) + else + echo " FAIL: $1 (expected 201|404, got $2: $(head -c 200 "$BODY"))" + FAIL=$((FAIL+1)) + fi +} + +# create_hc HOSTCONFIG_JSON: a create for the beacon policy with this HostConfig. +create_hc() { + post_json "{\"Image\":\"chainsafe/lodestar:beacon\",\"Cmd\":[\"--rcConfig\",\"/data/config.yml\"],\"HostConfig\":$1}" "$PROXY/containers/create" +} + +S=$(create_hc '{"Mounts":[{"Type":"bind","Source":"/","Target":"/host"}]}') +check_denied "create with Mounts bind of / -> 403" "$S" "$BODY" 'mount source "/" is not in the whitelist' + +S=$(create_hc '{"Mounts":[{"Type":"volume","Source":"dsp-test","Target":"/v"}]}') +check_denied "create with a volume-type mount -> 403" "$S" "$BODY" 'mount type "volume" is not allowed' + +S=$(create_hc '{"Mounts":[{"Type":"bind","Source":"/etc/jwt_dir","Target":"/jwtdir"}]}') +check_denied "create with read-only volume in Mounts without ReadOnly -> 403" "$S" "$BODY" 'mount of "/etc/jwt_dir" must be read-only' + +S=$(create_hc '{"Mounts":[{"Type":"bind","Source":"/etc/jwt_dir","Target":"/jwtdir","ReadOnly":true}]}') +check_not_denied "create with read-only volume in Mounts, ReadOnly: true" "$S" 400 + +S=$(create_hc '{"Binds":["/etc/jwt_dir:/jwtdir:rw"]}') +check_denied "create with read-only volume bound :rw -> 403" "$S" "$BODY" 'volume mount "/etc/jwt_dir" must be read-only (:ro)' + +S=$(create_hc '{"Binds":["/etc/jwt_dir:/jwtdir"]}') +check_denied "create with read-only volume bound with no mode -> 403" "$S" "$BODY" 'volume mount "/etc/jwt_dir" must be read-only (:ro)' + +S=$(create_hc '{"Binds":["/etc/jwt_dir:/jwtdir:ro"]}') +check_not_denied "create with read-only volume bound :ro" "$S" + +S=$(create_hc '{"Binds":["/home/beacon:/data:rshared"]}') +check_denied "create with an rshared bind -> 403" "$S" "$BODY" 'bind option "rshared" on volume mount "/home/beacon" is not allowed' + +S=$(create_hc '{"CapAdd":["SYS_ADMIN"]}') +check_denied "create with CapAdd -> 403" "$S" "$BODY" 'HostConfig.CapAdd is not allowed for service beacon' + +S=$(create_hc '{"PidMode":"host"}') +check_denied "create with PidMode host -> 403" "$S" "$BODY" 'HostConfig.PidMode value "host" is not allowed' + +S=$(create_hc '{"Devices":[{"PathOnHost":"/dev/null","PathInContainer":"/dev/x","CgroupPermissions":"rwm"}]}') +check_denied "create with Devices -> 403" "$S" "$BODY" 'HostConfig.Devices is not allowed' + +S=$(create_hc '{"GroupAdd":["0"]}') +check_denied "create with GroupAdd -> 403" "$S" "$BODY" 'HostConfig.GroupAdd is not allowed' + +S=$(create_hc '{"MaskedPaths":[]}') +check_denied "create with MaskedPaths [] -> 403" "$S" "$BODY" 'HostConfig.MaskedPaths is not allowed' + +# The daemon matches field names without regard to case, so this would set +# Privileged after the mutator's "Privileged": false. +S=$(create_hc '{"privileged":true}') +check_denied "create with lower-case privileged -> 403" "$S" "$BODY" 'HostConfig.privileged is not allowed' + +# At the top level: the router picks the policy and RegistryGate checks the +# image from "Image", but the daemon would run the one under "image". +S=$(post_json '{"Image":"chainsafe/lodestar:beacon","image":"alpine:latest","Cmd":["--rcConfig","/data/config.yml"]}' "$PROXY/containers/create") +check_denied "create with an extra lower-case image -> 403" "$S" "$BODY" 'image is not allowed for service beacon (the daemon reads it as Image)' + +# The same inside NetworkingConfig: NetworkEndpointsGate (#87) reads only +# EndpointsConfig, and the daemon would attach the network under +# endpointsconfig as well. +S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"],"NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}' "$PROXY/containers/create") +check_denied "create with lower-case endpointsconfig -> 403" "$S" "$BODY" 'NetworkingConfig.endpointsconfig is not allowed' + +# The body Docker CLI 29 sends for `docker create `. +S=$(post_json '{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["--rcConfig","/data/config.yml"],"Image":"chainsafe/lodestar:beacon","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{}}}}' "$PROXY/v1.52/containers/create") +check_not_denied "create with the Docker CLI 29 default body" "$S" + # ─── Inline env vars (env_file is set in policy) ───────── echo "" diff --git a/go/internal/middleware/hostconfig_test.go b/go/internal/middleware/hostconfig_test.go new file mode 100644 index 0000000..052e54c --- /dev/null +++ b/go/internal/middleware/hostconfig_test.go @@ -0,0 +1,220 @@ +package middleware + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/ChainSafe/docker-socket-policy/go/internal/policy" +) + +// HostConfig default-deny, Mounts and bind-mode rows (#88). The same rows, by +// name, are in rs/src/middleware.rs and ts/src/middleware.test.ts. +// +// Policies: "std" has volumes /data (read_write) and /ro (read-only) and +// forces network_mode host; "secopt" is "std" plus security_options +// [no-new-privileges:true]; "bare" has no volumes and no container_config. +// want is "" for an allowed create, or a substring of the deny reason. fwd, +// when set, lists HostConfig fields the forwarded body must hold. +type hostConfigRow struct { + name string + policy string + body string + want string + fwd string +} + +func hostConfigRowPolicy(name string) *policy.Policy { + p := &policy.Policy{ + ServiceName: "svc", + AllowedImagePrefixes: []string{"chainsafe/lodestar"}, + } + if name == "bare" { + return p + } + p.Volumes = []policy.Volume{ + {HostPath: "/data", ContainerPath: "/data", ReadWrite: true}, + {HostPath: "/ro", ContainerPath: "/ro", ReadWrite: false}, + } + p.ContainerConfig.NetworkMode = "host" + if name == "secopt" { + p.ContainerConfig.SecurityOptions = []string{"no-new-privileges:true"} + } + return p +} + +func TestHostConfigRows(t *testing.T) { + for _, row := range hostConfigRows { + t.Run(row.name, func(t *testing.T) { + var body map[string]interface{} + if err := json.Unmarshal([]byte(row.body), &body); err != nil { + t.Fatalf("bad row body: %v", err) + } + result := NewChain().Execute(testRequest("POST", "/containers/create"), hostConfigRowPolicy(row.policy), body) + if row.want == "" { + if !result.Allowed { + t.Fatalf("expected allow, got: %s", result.Reason) + } + } else { + if result.Allowed { + t.Fatalf("expected deny with %q, got allow", row.want) + } + if !strings.Contains(result.Reason, row.want) { + t.Fatalf("expected deny with %q, got: %s", row.want, result.Reason) + } + return + } + if row.fwd == "" { + return + } + var want, forwarded map[string]interface{} + if err := json.Unmarshal([]byte(row.fwd), &want); err != nil { + t.Fatalf("bad row fwd: %v", err) + } + if err := json.Unmarshal(result.ModifiedBody, &forwarded); err != nil { + t.Fatalf("forwarded body: %v", err) + } + hc, _ := forwarded["HostConfig"].(map[string]interface{}) + for k, v := range want { + if !reflect.DeepEqual(hc[k], v) { + t.Fatalf("forwarded HostConfig.%s = %v, want %v", k, hc[k], v) + } + } + }) + } +} + +var hostConfigRows = []hostConfigRow{ + {name: "capAddDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":["SYS_ADMIN"]}}`, want: "HostConfig.CapAdd is not allowed for service svc", fwd: ``}, + {name: "devicesDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Devices":[{"PathOnHost":"/dev/sda","PathInContainer":"/dev/sda","CgroupPermissions":"rwm"}]}}`, want: "HostConfig.Devices is not allowed for service svc", fwd: ``}, + {name: "deviceRequestsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceRequests":[{"Driver":"","Count":-1,"Capabilities":[["gpu"]]}]}}`, want: "HostConfig.DeviceRequests is not allowed for service svc", fwd: ``}, + {name: "deviceCgroupRulesDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceCgroupRules":["b 8:* rmw"]}}`, want: "HostConfig.DeviceCgroupRules is not allowed for service svc", fwd: ``}, + {name: "groupAddDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"GroupAdd":["docker"]}}`, want: "HostConfig.GroupAdd is not allowed for service svc", fwd: ``}, + {name: "volumesFromDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumesFrom":["other"]}}`, want: "HostConfig.VolumesFrom is not allowed for service svc", fwd: ``}, + {name: "linksDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Links":["other:alias"]}}`, want: "HostConfig.Links is not allowed for service svc", fwd: ``}, + {name: "sysctlsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Sysctls":{"kernel.shm_rmid_forced":"1"}}}`, want: "HostConfig.Sysctls is not allowed for service svc", fwd: ``}, + {name: "cgroupParentDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupParent":"/"}}`, want: "HostConfig.CgroupParent is not allowed for service svc", fwd: ``}, + {name: "runtimeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Runtime":"other"}}`, want: "HostConfig.Runtime is not allowed for service svc", fwd: ``}, + {name: "volumeDriverDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumeDriver":"local"}}`, want: "HostConfig.VolumeDriver is not allowed for service svc", fwd: ``}, + {name: "oomKillDisableDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomKillDisable":true}}`, want: "HostConfig.OomKillDisable is not allowed for service svc", fwd: ``}, + {name: "annotationsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Annotations":{"a":"b"}}}`, want: "HostConfig.Annotations is not allowed for service svc", fwd: ``}, + {name: "storageOptDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"StorageOpt":{"size":"1G"}}}`, want: "HostConfig.StorageOpt is not allowed for service svc", fwd: ``}, + {name: "blkioDeviceReadBpsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioDeviceReadBps":[{"Path":"/dev/sda","Rate":1}]}}`, want: "HostConfig.BlkioDeviceReadBps is not allowed for service svc", fwd: ``}, + {name: "cpuRealtimeRuntimeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuRealtimeRuntime":1000}}`, want: "HostConfig.CpuRealtimeRuntime is not allowed for service svc", fwd: ``}, + {name: "unknownFieldDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"FutureField":true}}`, want: "HostConfig.FutureField is not allowed for service svc", fwd: ``}, + {name: "maskedPathsEmptyDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":[]}}`, want: "HostConfig.MaskedPaths is not allowed for service svc", fwd: ``}, + {name: "readonlyPathsEmptyDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyPaths":[]}}`, want: "HostConfig.ReadonlyPaths is not allowed for service svc", fwd: ``}, + {name: "maskedPathsListDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":["/proc/kcore"]}}`, want: "HostConfig.MaskedPaths is not allowed for service svc", fwd: ``}, + {name: "zeroValuesAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":null,"Devices":[],"GroupAdd":null,"Sysctls":{},"CgroupParent":"","OomKillDisable":false,"Runtime":"","VolumeDriver":"","FutureField":0,"MaskedPaths":null,"ReadonlyPaths":null,"PidMode":"","IpcMode":"","SecurityOpt":null,"OomScoreAdj":0,"Privileged":false}}`, want: "", fwd: ``}, + {name: "pidModeHostDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"host"}}`, want: "HostConfig.PidMode value \"host\" is not allowed for service svc", fwd: ``}, + {name: "pidModeContainerDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"container:abc"}}`, want: "HostConfig.PidMode value \"container:abc\" is not allowed", fwd: ``}, + {name: "ipcModeHostDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"host"}}`, want: "HostConfig.IpcMode value \"host\" is not allowed", fwd: ``}, + {name: "ipcModeContainerDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"container:abc"}}`, want: "HostConfig.IpcMode value \"container:abc\" is not allowed", fwd: ``}, + {name: "utsModeHostDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"UTSMode":"host"}}`, want: "HostConfig.UTSMode value \"host\" is not allowed", fwd: ``}, + {name: "usernsModeHostDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"UsernsMode":"host"}}`, want: "HostConfig.UsernsMode value \"host\" is not allowed", fwd: ``}, + {name: "cgroupnsModeHostDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"host"}}`, want: "HostConfig.CgroupnsMode value \"host\" is not allowed", fwd: ``}, + {name: "cgroupContainerDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Cgroup":"container:abc"}}`, want: "HostConfig.Cgroup value \"container:abc\" is not allowed", fwd: ``}, + {name: "isolationHypervDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"hyperv"}}`, want: "HostConfig.Isolation value \"hyperv\" is not allowed", fwd: ``}, + {name: "ipcModePrivateAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"private"}}`, want: "", fwd: ``}, + {name: "ipcModeShareableAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"shareable"}}`, want: "", fwd: ``}, + {name: "ipcModeNoneAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"none"}}`, want: "", fwd: ``}, + {name: "cgroupnsModePrivateAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"private"}}`, want: "", fwd: ``}, + {name: "isolationDefaultAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"default"}}`, want: "", fwd: ``}, + {name: "oomScoreAdjNegativeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":-1000}}`, want: "HostConfig.OomScoreAdj value -1000 is not allowed", fwd: ``}, + {name: "oomScoreAdjPositiveAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":500}}`, want: "", fwd: ``}, + {name: "securityOptSeccompUnconfinedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}`, want: "HostConfig.SecurityOpt entry \"seccomp=unconfined\" is not allowed", fwd: ``}, + {name: "securityOptApparmorUnconfinedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["apparmor=unconfined"]}}`, want: "HostConfig.SecurityOpt entry \"apparmor=unconfined\" is not allowed", fwd: ``}, + {name: "securityOptLabelDisableDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["label=disable"]}}`, want: "HostConfig.SecurityOpt entry \"label=disable\" is not allowed", fwd: ``}, + {name: "securityOptNoNewPrivilegesFalseDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:false"]}}`, want: "HostConfig.SecurityOpt entry \"no-new-privileges:false\" is not allowed", fwd: ``}, + {name: "securityOptNoNewPrivilegesAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges"]}}`, want: "", fwd: ``}, + {name: "securityOptNoNewPrivilegesTrueAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:true"]}}`, want: "", fwd: ``}, + {name: "securityOptOverwrittenByPolicy", policy: "secopt", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}`, want: "", fwd: `{"SecurityOpt":["no-new-privileges:true"]}`}, + {name: "caseVariantPrivilegedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"privileged":true}}`, want: "HostConfig.privileged is not allowed for service svc (the daemon reads it as HostConfig.Privileged)", fwd: ``}, + {name: "caseVariantZeroSecurityOptDenied", policy: "secopt", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"securityopt":null}}`, want: "HostConfig.securityopt is not allowed", fwd: ``}, + {name: "caseVariantBindsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"binds":["/:/host"]}}`, want: "HostConfig.binds is not allowed", fwd: ``}, + {name: "caseVariantLongSDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"\u017fecurityOpt":["seccomp=unconfined"]}}`, want: "(the daemon reads it as HostConfig.SecurityOpt)", fwd: ``}, + {name: "caseVariantKelvinDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Lin\u212as":["other:alias"]}}`, want: "(the daemon reads it as HostConfig.Links)", fwd: ``}, + {name: "topLevelHostConfigVariantDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","hostConfig":{"Privileged":true}}`, want: "hostConfig is not allowed for service svc (the daemon reads it as HostConfig)", fwd: ``}, + {name: "topLevelUserVariantDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","user":"root"}`, want: "user is not allowed for service svc (the daemon reads it as User)", fwd: ``}, + {name: "topLevelImageVariantDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","image":"alpine:3.20"}`, want: "image is not allowed for service svc (the daemon reads it as Image)", fwd: ``}, + {name: "networkingConfigVariantDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}`, want: "NetworkingConfig.endpointsconfig is not allowed for service svc (the daemon reads it as NetworkingConfig.EndpointsConfig)", fwd: ``}, + {name: "networkingConfigVariantBareDenied", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}`, want: "NetworkingConfig.endpointsconfig is not allowed for service svc", fwd: ``}, + {name: "networkingConfigVariantLongSDenied", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"Endpoint\u017fConfig":{"evil-net":{}}}}`, want: "(the daemon reads it as NetworkingConfig.EndpointsConfig)", fwd: ``}, + {name: "networkingConfigUnforcedAllowed", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{},"other":{}}}}`, want: "", fwd: ``}, + {name: "hostConfigNotObjectDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":"x"}`, want: "HostConfig must be an object", fwd: ``}, + {name: "capDropAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapDrop":["ALL"]}}`, want: "", fwd: ``}, + {name: "readonlyRootfsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyRootfs":true}}`, want: "", fwd: ``}, + {name: "initAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Init":true}}`, want: "", fwd: ``}, + {name: "autoRemoveAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"AutoRemove":true}}`, want: "", fwd: ``}, + {name: "consoleSizeAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ConsoleSize":[24,80]}}`, want: "", fwd: ``}, + {name: "shmSizeAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ShmSize":67108864}}`, want: "", fwd: ``}, + {name: "portBindingsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"PortBindings":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"8080"}]}}}`, want: "", fwd: ``}, + {name: "publishAllPortsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"PublishAllPorts":true}}`, want: "", fwd: ``}, + {name: "dnsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Dns":["1.1.1.1"]}}`, want: "", fwd: ``}, + {name: "dnsOptionsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsOptions":["ndots:1"]}}`, want: "", fwd: ``}, + {name: "dnsSearchAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsSearch":["example.com"]}}`, want: "", fwd: ``}, + {name: "extraHostsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ExtraHosts":["host:10.0.0.1"]}}`, want: "", fwd: ``}, + {name: "tmpfsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Tmpfs":{"/run":"rw,noexec"}}}`, want: "", fwd: ``}, + {name: "ulimitsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Ulimits":[{"Name":"nofile","Soft":1024,"Hard":2048}]}}`, want: "", fwd: ``}, + {name: "memoryAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Memory":268435456}}`, want: "", fwd: ``}, + {name: "memoryReservationAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemoryReservation":134217728}}`, want: "", fwd: ``}, + {name: "memorySwapAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwap":-1}}`, want: "", fwd: ``}, + {name: "memorySwappinessAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwappiness":-1}}`, want: "", fwd: ``}, + {name: "nanoCpusAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"NanoCpus":500000000}}`, want: "", fwd: ``}, + {name: "cpuSharesAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuShares":512}}`, want: "", fwd: ``}, + {name: "cpuPeriodAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuPeriod":100000}}`, want: "", fwd: ``}, + {name: "cpuQuotaAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuQuota":50000}}`, want: "", fwd: ``}, + {name: "cpusetCpusAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetCpus":"0-1"}}`, want: "", fwd: ``}, + {name: "cpusetMemsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetMems":"0"}}`, want: "", fwd: ``}, + {name: "pidsLimitAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidsLimit":100}}`, want: "", fwd: ``}, + {name: "blkioWeightAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioWeight":10}}`, want: "", fwd: ``}, + {name: "containerIdFileAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"ContainerIDFile":"/tmp/cid"}}`, want: "", fwd: ``}, + {name: "networkModeAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"NetworkMode":"bridge"}}`, want: "", fwd: ``}, + {name: "restartPolicyAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"RestartPolicy":{"Name":"always","MaximumRetryCount":0}}}`, want: "", fwd: ``}, + {name: "logConfigAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"LogConfig":{"Type":"json-file","Config":{"max-size":"10m"}}}}`, want: "", fwd: ``}, + {name: "privilegedNeutralised", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}`, want: "", fwd: `{"Privileged":false}`}, + {name: "privilegedNeutralisedEmptyContainerConfig", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}`, want: "", fwd: `{"Privileged":false}`}, + {name: "emptyContainerConfigForcesPrivilegedFalse", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1"}`, want: "", fwd: `{"Privileged":false}`}, + {name: "mountBindRootDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/","Target":"/host"}]}}`, want: "mount source \"/\" is not in the whitelist for service svc", fwd: ``}, + {name: "mountBindAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}`, want: "", fwd: ``}, + {name: "mountBindReadOnlyVolumeWritableDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro"}]}}`, want: "mount of \"/ro\" must be read-only (ReadOnly: true) for service svc", fwd: ``}, + {name: "mountBindReadOnlyVolumeReadOnlyFalseDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":false}]}}`, want: "mount of \"/ro\" must be read-only", fwd: ``}, + {name: "mountBindReadOnlyVolumeReadOnlyAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true}]}}`, want: "", fwd: ``}, + {name: "mountVolumeTypeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"volume","Source":"myvol","Target":"/v"}]}}`, want: "mount type \"volume\" is not allowed for service svc", fwd: ``}, + {name: "mountImageTypeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"image","Source":"alpine:3.20","Target":"/i"}]}}`, want: "mount type \"image\" is not allowed", fwd: ``}, + {name: "mountNpipeTypeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"npipe","Source":"//./pipe/docker_engine","Target":"/p"}]}}`, want: "mount type \"npipe\" is not allowed", fwd: ``}, + {name: "mountMissingTypeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Source":"/data","Target":"/data"}]}}`, want: "mount type \"\" is not allowed", fwd: ``}, + {name: "mountTmpfsAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/run","TmpfsOptions":{"SizeBytes":1048576}}]}}`, want: "", fwd: ``}, + {name: "mountTmpfsWithSourceDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Source":"/","Target":"/run"}]}}`, want: "tmpfs mount must not have a Source", fwd: ``}, + {name: "mountPropagationRsharedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rshared"}}]}}`, want: "mount propagation \"rshared\" is not allowed", fwd: ``}, + {name: "mountPropagationSlaveDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"slave"}}]}}`, want: "mount propagation \"slave\" is not allowed", fwd: ``}, + {name: "mountPropagationRprivateAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rprivate"}}]}}`, want: "", fwd: ``}, + {name: "mountCreateMountpointAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"CreateMountpoint":true}}]}}`, want: "", fwd: ``}, + {name: "mountReadOnlyNonRecursiveDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true,"BindOptions":{"ReadOnlyNonRecursive":true}}]}}`, want: "HostConfig.Mounts.BindOptions.ReadOnlyNonRecursive is not allowed", fwd: ``}, + {name: "mountVolumeOptionsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","VolumeOptions":{"NoCopy":true}}]}}`, want: "HostConfig.Mounts.VolumeOptions is not allowed", fwd: ``}, + {name: "mountCaseVariantDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/x","type":"bind","source":"/"}]}}`, want: "HostConfig.Mounts.source is not allowed", fwd: ``}, + {name: "mountNoVolumesDenied", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}`, want: "mount source \"/data\" is not in the whitelist", fwd: ``}, + {name: "mountsNotListDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":{}}}`, want: "HostConfig.Mounts must be a list", fwd: ``}, + {name: "bindAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}`, want: "", fwd: ``}, + {name: "bindReadOnlyVolumeRwDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:rw"]}}`, want: "volume mount \"/ro\" must be read-only (:ro) for service svc", fwd: ``}, + {name: "bindReadOnlyVolumeNoModeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro"]}}`, want: "volume mount \"/ro\" must be read-only (:ro)", fwd: ``}, + {name: "bindReadOnlyVolumeRoAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro"]}}`, want: "", fwd: ``}, + {name: "bindReadOnlyVolumeRoZAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,Z"]}}`, want: "", fwd: ``}, + {name: "bindReadOnlyVolumeRoRwDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,rw"]}}`, want: "volume mount \"/ro\" must be read-only (:ro)", fwd: ``}, + {name: "bindRsharedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rshared"]}}`, want: "bind option \"rshared\" on volume mount \"/data\" is not allowed for service svc", fwd: ``}, + {name: "bindRslaveDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rslave"]}}`, want: "bind option \"rslave\" on volume mount \"/data\" is not allowed", fwd: ``}, + {name: "bindConsistencyModeDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:cached"]}}`, want: "bind option \"cached\" on volume mount \"/data\" is not allowed", fwd: ``}, + {name: "bindRprivateAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rprivate"]}}`, want: "", fwd: ``}, + {name: "bindPrivateZAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:z,private"]}}`, want: "", fwd: ``}, + {name: "bindNocopyAllowed", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:nocopy"]}}`, want: "", fwd: ``}, + {name: "bindUnlistedDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/etc:/etc:ro"]}}`, want: "volume mount \"/etc\" is not in the whitelist for service svc", fwd: ``}, + {name: "bindNoVolumesDenied", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}`, want: "volume mount \"/data\" is not in the whitelist", fwd: ``}, + {name: "bindNotStringDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":[1]}}`, want: "HostConfig.Binds must be a list of strings", fwd: ``}, + {name: "bindTooManyPartsDenied", policy: "std", body: `{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:ro:x"]}}`, want: "bind \"/data:/data:ro:x\" is not a valid bind", fwd: ``}, + {name: "topLevelVolumesNoVolumesDenied", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","Volumes":{"/data":{}}}`, want: "volume \"/data\" is not in the whitelist", fwd: ``}, + {name: "topLevelVolumesEmptyAllowed", policy: "bare", body: `{"Image":"chainsafe/lodestar:v1","Volumes":{}}`, want: "", fwd: ``}, + {name: "dockerCliCreateBodyAllowed", policy: "std", body: `{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}`, want: "", fwd: ``}, + {name: "dockerCliCreateBodyBareAllowed", policy: "bare", body: `{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}`, want: "", fwd: ``}, + {name: "ansibleCreateBodyAllowed", policy: "secopt", body: `{"Image":"chainsafe/lodestar:v1","Cmd":["beacon"],"Env":[],"Labels":{"app":"beacon"},"HostConfig":{"Binds":["/data:/data:rw","/ro:/ro:ro"],"NetworkMode":"host","RestartPolicy":{"Name":"unless-stopped","MaximumRetryCount":0},"LogConfig":{"Type":"json-file","Config":{"max-size":"100m"}},"SecurityOpt":["no-new-privileges:true"],"Init":false,"AutoRemove":false,"Privileged":false,"ReadonlyRootfs":false,"CapDrop":["ALL"],"Memory":0}}`, want: "", fwd: ``}, +} diff --git a/rs/src/middleware.rs b/rs/src/middleware.rs index ffccf36..4262204 100644 --- a/rs/src/middleware.rs +++ b/rs/src/middleware.rs @@ -495,12 +495,15 @@ mod tests { #[test] fn test_mount_source_gate_no_volumes_in_policy() { + // A policy with no volumes allows no binds (#88); before #88 the gate + // returned early and let every bind through. let gate = MountSourceGate; let policy = make_policy("test", vec!["alpine"]); let body = body_from_json(serde_json::json!({ "HostConfig": {"Binds": ["/data:/data"]} })); - assert!(gate.check("POST", "/containers/create", &policy, &body).is_ok()); + let result = gate.check("POST", "/containers/create", &policy, &body); + assert!(result.unwrap_err().contains("not in the whitelist")); } #[test] @@ -672,11 +675,17 @@ mod tests { #[test] fn test_container_config_mutator_noop_when_no_config() { + // Without container_config the mutator still forces Privileged to + // false, and sets nothing else (#88). let mutator = ContainerConfigMutator; let policy = make_policy("test", vec!["alpine"]); let mut body = HashMap::new(); mutator.mutate(&policy, &mut body); - assert!(body.is_empty()); + assert_eq!(body.len(), 1); + assert_eq!( + body.get("HostConfig").unwrap(), + &serde_json::json!({"Privileged": false}) + ); } #[test] @@ -1190,4 +1199,1019 @@ mod tests { mismatches.join("\n") ); } + // HostConfig default-deny, Mounts and bind-mode rows (#88). The same rows, + // by name, are in go/internal/middleware/hostconfig_test.go and + // ts/src/middleware.test.ts. + // + // Policies: "std" has volumes /data (read_write) and /ro (read-only) and + // forces network_mode host; "secopt" is "std" plus security_options + // [no-new-privileges:true]; "bare" has no volumes and no + // container_config. `want` is "" for an allowed create, or a substring of + // the deny reason. `fwd`, when set, lists HostConfig fields the forwarded + // body must hold. + struct HostConfigRow { + name: &'static str, + policy: &'static str, + body: &'static str, + want: &'static str, + fwd: &'static str, + } + + fn host_config_row_policy(name: &str) -> Policy { + let mut p = make_policy("svc", vec!["chainsafe/lodestar"]); + if name == "bare" { + return p; + } + p.volumes = Some(vec![ + Volume { + host_path: "/data".into(), + container_path: "/data".into(), + read_write: true, + }, + Volume { + host_path: "/ro".into(), + container_path: "/ro".into(), + read_write: false, + }, + ]); + p.container_config = Some(ContainerConfig { + network_mode: Some("host".into()), + restart_policy: None, + security_options: if name == "secopt" { + Some(vec!["no-new-privileges:true".into()]) + } else { + None + }, + user: None, + log_driver: None, + log_options: None, + }); + p + } + + #[test] + fn test_host_config_rows() { + let chain = Chain::new(); + let mut failures = Vec::new(); + for row in HOST_CONFIG_ROWS { + let body: HashMap = serde_json::from_str(row.body).unwrap(); + let result = chain.execute("POST", "/containers/create", &host_config_row_policy(row.policy), &body); + if row.want.is_empty() { + if !result.allowed { + failures.push(format!("{}: expected allow, got: {}", row.name, result.reason)); + continue; + } + } else { + if result.allowed || !result.reason.contains(row.want) { + failures.push(format!( + "{}: expected deny with {:?}, got allowed={} {}", + row.name, row.want, result.allowed, result.reason + )); + } + continue; + } + if row.fwd.is_empty() { + continue; + } + let want: serde_json::Map = serde_json::from_str(row.fwd).unwrap(); + let forwarded: serde_json::Value = serde_json::from_slice(&result.modified_body.unwrap()).unwrap(); + for (k, v) in &want { + if forwarded["HostConfig"].get(k) != Some(v) { + failures.push(format!( + "{}: forwarded HostConfig.{} = {:?}, want {}", + row.name, + k, + forwarded["HostConfig"].get(k), + v + )); + } + } + } + assert!( + failures.is_empty(), + "{} rows failed:\n{}", + failures.len(), + failures.join("\n") + ); + } + + const HOST_CONFIG_ROWS: &[HostConfigRow] = &[ + HostConfigRow { + name: "capAddDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":["SYS_ADMIN"]}}"##, + want: "HostConfig.CapAdd is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "devicesDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Devices":[{"PathOnHost":"/dev/sda","PathInContainer":"/dev/sda","CgroupPermissions":"rwm"}]}}"##, + want: "HostConfig.Devices is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "deviceRequestsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceRequests":[{"Driver":"","Count":-1,"Capabilities":[["gpu"]]}]}}"##, + want: "HostConfig.DeviceRequests is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "deviceCgroupRulesDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceCgroupRules":["b 8:* rmw"]}}"##, + want: "HostConfig.DeviceCgroupRules is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "groupAddDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"GroupAdd":["docker"]}}"##, + want: "HostConfig.GroupAdd is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "volumesFromDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumesFrom":["other"]}}"##, + want: "HostConfig.VolumesFrom is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "linksDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Links":["other:alias"]}}"##, + want: "HostConfig.Links is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "sysctlsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Sysctls":{"kernel.shm_rmid_forced":"1"}}}"##, + want: "HostConfig.Sysctls is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "cgroupParentDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupParent":"/"}}"##, + want: "HostConfig.CgroupParent is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "runtimeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Runtime":"other"}}"##, + want: "HostConfig.Runtime is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "volumeDriverDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumeDriver":"local"}}"##, + want: "HostConfig.VolumeDriver is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "oomKillDisableDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomKillDisable":true}}"##, + want: "HostConfig.OomKillDisable is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "annotationsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Annotations":{"a":"b"}}}"##, + want: "HostConfig.Annotations is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "storageOptDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"StorageOpt":{"size":"1G"}}}"##, + want: "HostConfig.StorageOpt is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "blkioDeviceReadBpsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioDeviceReadBps":[{"Path":"/dev/sda","Rate":1}]}}"##, + want: "HostConfig.BlkioDeviceReadBps is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "cpuRealtimeRuntimeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuRealtimeRuntime":1000}}"##, + want: "HostConfig.CpuRealtimeRuntime is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "unknownFieldDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"FutureField":true}}"##, + want: "HostConfig.FutureField is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "maskedPathsEmptyDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":[]}}"##, + want: "HostConfig.MaskedPaths is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "readonlyPathsEmptyDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyPaths":[]}}"##, + want: "HostConfig.ReadonlyPaths is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "maskedPathsListDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":["/proc/kcore"]}}"##, + want: "HostConfig.MaskedPaths is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "zeroValuesAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":null,"Devices":[],"GroupAdd":null,"Sysctls":{},"CgroupParent":"","OomKillDisable":false,"Runtime":"","VolumeDriver":"","FutureField":0,"MaskedPaths":null,"ReadonlyPaths":null,"PidMode":"","IpcMode":"","SecurityOpt":null,"OomScoreAdj":0,"Privileged":false}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "pidModeHostDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"host"}}"##, + want: "HostConfig.PidMode value \"host\" is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "pidModeContainerDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"container:abc"}}"##, + want: "HostConfig.PidMode value \"container:abc\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "ipcModeHostDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"host"}}"##, + want: "HostConfig.IpcMode value \"host\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "ipcModeContainerDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"container:abc"}}"##, + want: "HostConfig.IpcMode value \"container:abc\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "utsModeHostDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"UTSMode":"host"}}"##, + want: "HostConfig.UTSMode value \"host\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "usernsModeHostDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"UsernsMode":"host"}}"##, + want: "HostConfig.UsernsMode value \"host\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "cgroupnsModeHostDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"host"}}"##, + want: "HostConfig.CgroupnsMode value \"host\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "cgroupContainerDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Cgroup":"container:abc"}}"##, + want: "HostConfig.Cgroup value \"container:abc\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "isolationHypervDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"hyperv"}}"##, + want: "HostConfig.Isolation value \"hyperv\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "ipcModePrivateAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"private"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "ipcModeShareableAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"shareable"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "ipcModeNoneAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"none"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cgroupnsModePrivateAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"private"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "isolationDefaultAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"default"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "oomScoreAdjNegativeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":-1000}}"##, + want: "HostConfig.OomScoreAdj value -1000 is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "oomScoreAdjPositiveAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":500}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptSeccompUnconfinedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}"##, + want: "HostConfig.SecurityOpt entry \"seccomp=unconfined\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptApparmorUnconfinedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["apparmor=unconfined"]}}"##, + want: "HostConfig.SecurityOpt entry \"apparmor=unconfined\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptLabelDisableDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["label=disable"]}}"##, + want: "HostConfig.SecurityOpt entry \"label=disable\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptNoNewPrivilegesFalseDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:false"]}}"##, + want: "HostConfig.SecurityOpt entry \"no-new-privileges:false\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptNoNewPrivilegesAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptNoNewPrivilegesTrueAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:true"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "securityOptOverwrittenByPolicy", + policy: "secopt", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}"##, + want: "", + fwd: r##"{"SecurityOpt":["no-new-privileges:true"]}"##, + }, + HostConfigRow { + name: "caseVariantPrivilegedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"privileged":true}}"##, + want: "HostConfig.privileged is not allowed for service svc (the daemon reads it as HostConfig.Privileged)", + fwd: r##""##, + }, + HostConfigRow { + name: "caseVariantZeroSecurityOptDenied", + policy: "secopt", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"securityopt":null}}"##, + want: "HostConfig.securityopt is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "caseVariantBindsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"binds":["/:/host"]}}"##, + want: "HostConfig.binds is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "caseVariantLongSDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"\u017fecurityOpt":["seccomp=unconfined"]}}"##, + want: "(the daemon reads it as HostConfig.SecurityOpt)", + fwd: r##""##, + }, + HostConfigRow { + name: "caseVariantKelvinDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Lin\u212as":["other:alias"]}}"##, + want: "(the daemon reads it as HostConfig.Links)", + fwd: r##""##, + }, + HostConfigRow { + name: "topLevelHostConfigVariantDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","hostConfig":{"Privileged":true}}"##, + want: "hostConfig is not allowed for service svc (the daemon reads it as HostConfig)", + fwd: r##""##, + }, + HostConfigRow { + name: "topLevelUserVariantDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","user":"root"}"##, + want: "user is not allowed for service svc (the daemon reads it as User)", + fwd: r##""##, + }, + HostConfigRow { + name: "topLevelImageVariantDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","image":"alpine:3.20"}"##, + want: "image is not allowed for service svc (the daemon reads it as Image)", + fwd: r##""##, + }, + HostConfigRow { + name: "networkingConfigVariantDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}"##, + want: "NetworkingConfig.endpointsconfig is not allowed for service svc (the daemon reads it as NetworkingConfig.EndpointsConfig)", + fwd: r##""##, + }, + HostConfigRow { + name: "networkingConfigVariantBareDenied", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}"##, + want: "NetworkingConfig.endpointsconfig is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "networkingConfigVariantLongSDenied", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"Endpoint\u017fConfig":{"evil-net":{}}}}"##, + want: "(the daemon reads it as NetworkingConfig.EndpointsConfig)", + fwd: r##""##, + }, + HostConfigRow { + name: "networkingConfigUnforcedAllowed", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{},"other":{}}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "hostConfigNotObjectDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":"x"}"##, + want: "HostConfig must be an object", + fwd: r##""##, + }, + HostConfigRow { + name: "capDropAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapDrop":["ALL"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "readonlyRootfsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyRootfs":true}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "initAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Init":true}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "autoRemoveAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"AutoRemove":true}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "consoleSizeAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ConsoleSize":[24,80]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "shmSizeAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ShmSize":67108864}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "portBindingsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"PortBindings":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"8080"}]}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "publishAllPortsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"PublishAllPorts":true}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "dnsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Dns":["1.1.1.1"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "dnsOptionsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsOptions":["ndots:1"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "dnsSearchAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsSearch":["example.com"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "extraHostsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ExtraHosts":["host:10.0.0.1"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "tmpfsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Tmpfs":{"/run":"rw,noexec"}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "ulimitsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Ulimits":[{"Name":"nofile","Soft":1024,"Hard":2048}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "memoryAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Memory":268435456}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "memoryReservationAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemoryReservation":134217728}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "memorySwapAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwap":-1}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "memorySwappinessAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwappiness":-1}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "nanoCpusAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"NanoCpus":500000000}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cpuSharesAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuShares":512}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cpuPeriodAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuPeriod":100000}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cpuQuotaAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuQuota":50000}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cpusetCpusAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetCpus":"0-1"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "cpusetMemsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetMems":"0"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "pidsLimitAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidsLimit":100}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "blkioWeightAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioWeight":10}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "containerIdFileAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"ContainerIDFile":"/tmp/cid"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "networkModeAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"NetworkMode":"bridge"}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "restartPolicyAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"RestartPolicy":{"Name":"always","MaximumRetryCount":0}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "logConfigAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"LogConfig":{"Type":"json-file","Config":{"max-size":"10m"}}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "privilegedNeutralised", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}"##, + want: "", + fwd: r##"{"Privileged":false}"##, + }, + HostConfigRow { + name: "privilegedNeutralisedEmptyContainerConfig", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}"##, + want: "", + fwd: r##"{"Privileged":false}"##, + }, + HostConfigRow { + name: "emptyContainerConfigForcesPrivilegedFalse", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1"}"##, + want: "", + fwd: r##"{"Privileged":false}"##, + }, + HostConfigRow { + name: "mountBindRootDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/","Target":"/host"}]}}"##, + want: "mount source \"/\" is not in the whitelist for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "mountBindAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "mountBindReadOnlyVolumeWritableDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro"}]}}"##, + want: "mount of \"/ro\" must be read-only (ReadOnly: true) for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "mountBindReadOnlyVolumeReadOnlyFalseDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":false}]}}"##, + want: "mount of \"/ro\" must be read-only", + fwd: r##""##, + }, + HostConfigRow { + name: "mountBindReadOnlyVolumeReadOnlyAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "mountVolumeTypeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"volume","Source":"myvol","Target":"/v"}]}}"##, + want: "mount type \"volume\" is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "mountImageTypeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"image","Source":"alpine:3.20","Target":"/i"}]}}"##, + want: "mount type \"image\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountNpipeTypeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"npipe","Source":"//./pipe/docker_engine","Target":"/p"}]}}"##, + want: "mount type \"npipe\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountMissingTypeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Source":"/data","Target":"/data"}]}}"##, + want: "mount type \"\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountTmpfsAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/run","TmpfsOptions":{"SizeBytes":1048576}}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "mountTmpfsWithSourceDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Source":"/","Target":"/run"}]}}"##, + want: "tmpfs mount must not have a Source", + fwd: r##""##, + }, + HostConfigRow { + name: "mountPropagationRsharedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rshared"}}]}}"##, + want: "mount propagation \"rshared\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountPropagationSlaveDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"slave"}}]}}"##, + want: "mount propagation \"slave\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountPropagationRprivateAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rprivate"}}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "mountCreateMountpointAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"CreateMountpoint":true}}]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "mountReadOnlyNonRecursiveDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true,"BindOptions":{"ReadOnlyNonRecursive":true}}]}}"##, + want: "HostConfig.Mounts.BindOptions.ReadOnlyNonRecursive is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountVolumeOptionsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","VolumeOptions":{"NoCopy":true}}]}}"##, + want: "HostConfig.Mounts.VolumeOptions is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountCaseVariantDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/x","type":"bind","source":"/"}]}}"##, + want: "HostConfig.Mounts.source is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "mountNoVolumesDenied", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}"##, + want: "mount source \"/data\" is not in the whitelist", + fwd: r##""##, + }, + HostConfigRow { + name: "mountsNotListDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":{}}}"##, + want: "HostConfig.Mounts must be a list", + fwd: r##""##, + }, + HostConfigRow { + name: "bindAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindReadOnlyVolumeRwDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:rw"]}}"##, + want: "volume mount \"/ro\" must be read-only (:ro) for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "bindReadOnlyVolumeNoModeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro"]}}"##, + want: "volume mount \"/ro\" must be read-only (:ro)", + fwd: r##""##, + }, + HostConfigRow { + name: "bindReadOnlyVolumeRoAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindReadOnlyVolumeRoZAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,Z"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindReadOnlyVolumeRoRwDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,rw"]}}"##, + want: "volume mount \"/ro\" must be read-only (:ro)", + fwd: r##""##, + }, + HostConfigRow { + name: "bindRsharedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rshared"]}}"##, + want: "bind option \"rshared\" on volume mount \"/data\" is not allowed for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "bindRslaveDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rslave"]}}"##, + want: "bind option \"rslave\" on volume mount \"/data\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "bindConsistencyModeDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:cached"]}}"##, + want: "bind option \"cached\" on volume mount \"/data\" is not allowed", + fwd: r##""##, + }, + HostConfigRow { + name: "bindRprivateAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rprivate"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindPrivateZAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:z,private"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindNocopyAllowed", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:nocopy"]}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "bindUnlistedDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/etc:/etc:ro"]}}"##, + want: "volume mount \"/etc\" is not in the whitelist for service svc", + fwd: r##""##, + }, + HostConfigRow { + name: "bindNoVolumesDenied", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}"##, + want: "volume mount \"/data\" is not in the whitelist", + fwd: r##""##, + }, + HostConfigRow { + name: "bindNotStringDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":[1]}}"##, + want: "HostConfig.Binds must be a list of strings", + fwd: r##""##, + }, + HostConfigRow { + name: "bindTooManyPartsDenied", + policy: "std", + body: r##"{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:ro:x"]}}"##, + want: "bind \"/data:/data:ro:x\" is not a valid bind", + fwd: r##""##, + }, + HostConfigRow { + name: "topLevelVolumesNoVolumesDenied", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","Volumes":{"/data":{}}}"##, + want: "volume \"/data\" is not in the whitelist", + fwd: r##""##, + }, + HostConfigRow { + name: "topLevelVolumesEmptyAllowed", + policy: "bare", + body: r##"{"Image":"chainsafe/lodestar:v1","Volumes":{}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "dockerCliCreateBodyAllowed", + policy: "std", + body: r##"{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "dockerCliCreateBodyBareAllowed", + policy: "bare", + body: r##"{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}"##, + want: "", + fwd: r##""##, + }, + HostConfigRow { + name: "ansibleCreateBodyAllowed", + policy: "secopt", + body: r##"{"Image":"chainsafe/lodestar:v1","Cmd":["beacon"],"Env":[],"Labels":{"app":"beacon"},"HostConfig":{"Binds":["/data:/data:rw","/ro:/ro:ro"],"NetworkMode":"host","RestartPolicy":{"Name":"unless-stopped","MaximumRetryCount":0},"LogConfig":{"Type":"json-file","Config":{"max-size":"100m"}},"SecurityOpt":["no-new-privileges:true"],"Init":false,"AutoRemove":false,"Privileged":false,"ReadonlyRootfs":false,"CapDrop":["ALL"],"Memory":0}}"##, + want: "", + fwd: r##""##, + }, + ]; } diff --git a/ts/src/middleware.test.ts b/ts/src/middleware.test.ts index 9d3cece..6c7b06a 100644 --- a/ts/src/middleware.test.ts +++ b/ts/src/middleware.test.ts @@ -107,11 +107,11 @@ describe("MountSourceGate", () => { assert.ok(result?.includes("not in the whitelist")); }); - it("returns null when no volumes in policy", () => { - assert.equal( - gate.check("POST", "/containers/create", makePolicy(), { HostConfig: { Binds: ["/data:/data"] } }), - null, - ); + it("denies binds when no volumes in policy", () => { + // A policy with no volumes allows no binds (#88); before #88 the gate + // returned early and let every bind through. + const result = gate.check("POST", "/containers/create", makePolicy(), { HostConfig: { Binds: ["/data:/data"] } }); + assert.ok(result?.includes("not in the whitelist")); }); it("returns null when no Binds", () => { @@ -370,3 +370,974 @@ describe("RegistryGate reference parsing (#84)", () => { assert.deepEqual(mismatches, []); }); }); + +// HostConfig default-deny, Mounts and bind-mode rows (#88). The same rows, by +// name, are in go/internal/middleware/hostconfig_test.go and +// rs/src/middleware.rs. +// +// Policies: "std" has volumes /data (read_write) and /ro (read-only) and +// forces network_mode host; "secopt" is "std" plus security_options +// [no-new-privileges:true]; "bare" has no volumes and no container_config. +// want is "" for an allowed create, or a substring of the deny reason. fwd, +// when set, lists HostConfig fields the forwarded body must hold. +interface HostConfigRow { + name: string; + policy: string; + body: string; + want: string; + fwd: string; +} + +function hostConfigRowPolicy(name: string): Policy { + const p: Policy = { service_name: "svc", allowed_image_prefixes: ["chainsafe/lodestar"] }; + if (name === "bare") return p; + p.volumes = [ + { host_path: "/data", container_path: "/data", read_write: true }, + { host_path: "/ro", container_path: "/ro", read_write: false }, + ]; + p.container_config = { network_mode: "host" }; + if (name === "secopt") p.container_config.security_options = ["no-new-privileges:true"]; + return p; +} + +const hostConfigRows: HostConfigRow[] = [ + { + name: "capAddDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":["SYS_ADMIN"]}}`, + want: "HostConfig.CapAdd is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "devicesDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Devices":[{"PathOnHost":"/dev/sda","PathInContainer":"/dev/sda","CgroupPermissions":"rwm"}]}}`, + want: "HostConfig.Devices is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "deviceRequestsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceRequests":[{"Driver":"","Count":-1,"Capabilities":[["gpu"]]}]}}`, + want: "HostConfig.DeviceRequests is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "deviceCgroupRulesDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"DeviceCgroupRules":["b 8:* rmw"]}}`, + want: "HostConfig.DeviceCgroupRules is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "groupAddDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"GroupAdd":["docker"]}}`, + want: "HostConfig.GroupAdd is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "volumesFromDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumesFrom":["other"]}}`, + want: "HostConfig.VolumesFrom is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "linksDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Links":["other:alias"]}}`, + want: "HostConfig.Links is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "sysctlsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Sysctls":{"kernel.shm_rmid_forced":"1"}}}`, + want: "HostConfig.Sysctls is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "cgroupParentDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupParent":"/"}}`, + want: "HostConfig.CgroupParent is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "runtimeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Runtime":"other"}}`, + want: "HostConfig.Runtime is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "volumeDriverDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"VolumeDriver":"local"}}`, + want: "HostConfig.VolumeDriver is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "oomKillDisableDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomKillDisable":true}}`, + want: "HostConfig.OomKillDisable is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "annotationsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Annotations":{"a":"b"}}}`, + want: "HostConfig.Annotations is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "storageOptDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"StorageOpt":{"size":"1G"}}}`, + want: "HostConfig.StorageOpt is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "blkioDeviceReadBpsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioDeviceReadBps":[{"Path":"/dev/sda","Rate":1}]}}`, + want: "HostConfig.BlkioDeviceReadBps is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "cpuRealtimeRuntimeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuRealtimeRuntime":1000}}`, + want: "HostConfig.CpuRealtimeRuntime is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "unknownFieldDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"FutureField":true}}`, + want: "HostConfig.FutureField is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "maskedPathsEmptyDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":[]}}`, + want: "HostConfig.MaskedPaths is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "readonlyPathsEmptyDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyPaths":[]}}`, + want: "HostConfig.ReadonlyPaths is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "maskedPathsListDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"MaskedPaths":["/proc/kcore"]}}`, + want: "HostConfig.MaskedPaths is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "zeroValuesAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapAdd":null,"Devices":[],"GroupAdd":null,"Sysctls":{},"CgroupParent":"","OomKillDisable":false,"Runtime":"","VolumeDriver":"","FutureField":0,"MaskedPaths":null,"ReadonlyPaths":null,"PidMode":"","IpcMode":"","SecurityOpt":null,"OomScoreAdj":0,"Privileged":false}}`, + want: "", + fwd: String.raw``, + }, + { + name: "pidModeHostDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"host"}}`, + want: 'HostConfig.PidMode value "host" is not allowed for service svc', + fwd: String.raw``, + }, + { + name: "pidModeContainerDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidMode":"container:abc"}}`, + want: 'HostConfig.PidMode value "container:abc" is not allowed', + fwd: String.raw``, + }, + { + name: "ipcModeHostDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"host"}}`, + want: 'HostConfig.IpcMode value "host" is not allowed', + fwd: String.raw``, + }, + { + name: "ipcModeContainerDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"container:abc"}}`, + want: 'HostConfig.IpcMode value "container:abc" is not allowed', + fwd: String.raw``, + }, + { + name: "utsModeHostDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"UTSMode":"host"}}`, + want: 'HostConfig.UTSMode value "host" is not allowed', + fwd: String.raw``, + }, + { + name: "usernsModeHostDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"UsernsMode":"host"}}`, + want: 'HostConfig.UsernsMode value "host" is not allowed', + fwd: String.raw``, + }, + { + name: "cgroupnsModeHostDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"host"}}`, + want: 'HostConfig.CgroupnsMode value "host" is not allowed', + fwd: String.raw``, + }, + { + name: "cgroupContainerDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Cgroup":"container:abc"}}`, + want: 'HostConfig.Cgroup value "container:abc" is not allowed', + fwd: String.raw``, + }, + { + name: "isolationHypervDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"hyperv"}}`, + want: 'HostConfig.Isolation value "hyperv" is not allowed', + fwd: String.raw``, + }, + { + name: "ipcModePrivateAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"private"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "ipcModeShareableAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"shareable"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "ipcModeNoneAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"IpcMode":"none"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cgroupnsModePrivateAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CgroupnsMode":"private"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "isolationDefaultAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Isolation":"default"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "oomScoreAdjNegativeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":-1000}}`, + want: "HostConfig.OomScoreAdj value -1000 is not allowed", + fwd: String.raw``, + }, + { + name: "oomScoreAdjPositiveAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"OomScoreAdj":500}}`, + want: "", + fwd: String.raw``, + }, + { + name: "securityOptSeccompUnconfinedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}`, + want: 'HostConfig.SecurityOpt entry "seccomp=unconfined" is not allowed', + fwd: String.raw``, + }, + { + name: "securityOptApparmorUnconfinedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["apparmor=unconfined"]}}`, + want: 'HostConfig.SecurityOpt entry "apparmor=unconfined" is not allowed', + fwd: String.raw``, + }, + { + name: "securityOptLabelDisableDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["label=disable"]}}`, + want: 'HostConfig.SecurityOpt entry "label=disable" is not allowed', + fwd: String.raw``, + }, + { + name: "securityOptNoNewPrivilegesFalseDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:false"]}}`, + want: 'HostConfig.SecurityOpt entry "no-new-privileges:false" is not allowed', + fwd: String.raw``, + }, + { + name: "securityOptNoNewPrivilegesAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "securityOptNoNewPrivilegesTrueAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["no-new-privileges:true"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "securityOptOverwrittenByPolicy", + policy: "secopt", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"SecurityOpt":["seccomp=unconfined"]}}`, + want: "", + fwd: String.raw`{"SecurityOpt":["no-new-privileges:true"]}`, + }, + { + name: "caseVariantPrivilegedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"privileged":true}}`, + want: "HostConfig.privileged is not allowed for service svc (the daemon reads it as HostConfig.Privileged)", + fwd: String.raw``, + }, + { + name: "caseVariantZeroSecurityOptDenied", + policy: "secopt", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"securityopt":null}}`, + want: "HostConfig.securityopt is not allowed", + fwd: String.raw``, + }, + { + name: "caseVariantBindsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"binds":["/:/host"]}}`, + want: "HostConfig.binds is not allowed", + fwd: String.raw``, + }, + { + name: "caseVariantLongSDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"\u017fecurityOpt":["seccomp=unconfined"]}}`, + want: "(the daemon reads it as HostConfig.SecurityOpt)", + fwd: String.raw``, + }, + { + name: "caseVariantKelvinDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Lin\u212as":["other:alias"]}}`, + want: "(the daemon reads it as HostConfig.Links)", + fwd: String.raw``, + }, + { + name: "topLevelHostConfigVariantDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","hostConfig":{"Privileged":true}}`, + want: "hostConfig is not allowed for service svc (the daemon reads it as HostConfig)", + fwd: String.raw``, + }, + { + name: "topLevelUserVariantDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","user":"root"}`, + want: "user is not allowed for service svc (the daemon reads it as User)", + fwd: String.raw``, + }, + { + name: "topLevelImageVariantDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","image":"alpine:3.20"}`, + want: "image is not allowed for service svc (the daemon reads it as Image)", + fwd: String.raw``, + }, + { + name: "networkingConfigVariantDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}`, + want: "NetworkingConfig.endpointsconfig is not allowed for service svc (the daemon reads it as NetworkingConfig.EndpointsConfig)", + fwd: String.raw``, + }, + { + name: "networkingConfigVariantBareDenied", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}`, + want: "NetworkingConfig.endpointsconfig is not allowed for service svc", + fwd: String.raw``, + }, + { + name: "networkingConfigVariantLongSDenied", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"Endpoint\u017fConfig":{"evil-net":{}}}}`, + want: "(the daemon reads it as NetworkingConfig.EndpointsConfig)", + fwd: String.raw``, + }, + { + name: "networkingConfigUnforcedAllowed", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","NetworkingConfig":{"EndpointsConfig":{"default":{},"other":{}}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "hostConfigNotObjectDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":"x"}`, + want: "HostConfig must be an object", + fwd: String.raw``, + }, + { + name: "capDropAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CapDrop":["ALL"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "readonlyRootfsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ReadonlyRootfs":true}}`, + want: "", + fwd: String.raw``, + }, + { + name: "initAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Init":true}}`, + want: "", + fwd: String.raw``, + }, + { + name: "autoRemoveAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"AutoRemove":true}}`, + want: "", + fwd: String.raw``, + }, + { + name: "consoleSizeAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ConsoleSize":[24,80]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "shmSizeAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ShmSize":67108864}}`, + want: "", + fwd: String.raw``, + }, + { + name: "portBindingsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"PortBindings":{"8080/tcp":[{"HostIp":"127.0.0.1","HostPort":"8080"}]}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "publishAllPortsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"PublishAllPorts":true}}`, + want: "", + fwd: String.raw``, + }, + { + name: "dnsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Dns":["1.1.1.1"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "dnsOptionsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsOptions":["ndots:1"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "dnsSearchAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"DnsSearch":["example.com"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "extraHostsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ExtraHosts":["host:10.0.0.1"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "tmpfsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Tmpfs":{"/run":"rw,noexec"}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "ulimitsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Ulimits":[{"Name":"nofile","Soft":1024,"Hard":2048}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "memoryAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Memory":268435456}}`, + want: "", + fwd: String.raw``, + }, + { + name: "memoryReservationAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemoryReservation":134217728}}`, + want: "", + fwd: String.raw``, + }, + { + name: "memorySwapAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwap":-1}}`, + want: "", + fwd: String.raw``, + }, + { + name: "memorySwappinessAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"MemorySwappiness":-1}}`, + want: "", + fwd: String.raw``, + }, + { + name: "nanoCpusAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"NanoCpus":500000000}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cpuSharesAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuShares":512}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cpuPeriodAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuPeriod":100000}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cpuQuotaAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpuQuota":50000}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cpusetCpusAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetCpus":"0-1"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "cpusetMemsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"CpusetMems":"0"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "pidsLimitAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"PidsLimit":100}}`, + want: "", + fwd: String.raw``, + }, + { + name: "blkioWeightAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"BlkioWeight":10}}`, + want: "", + fwd: String.raw``, + }, + { + name: "containerIdFileAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"ContainerIDFile":"/tmp/cid"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "networkModeAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"NetworkMode":"bridge"}}`, + want: "", + fwd: String.raw``, + }, + { + name: "restartPolicyAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"RestartPolicy":{"Name":"always","MaximumRetryCount":0}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "logConfigAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"LogConfig":{"Type":"json-file","Config":{"max-size":"10m"}}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "privilegedNeutralised", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}`, + want: "", + fwd: String.raw`{"Privileged":false}`, + }, + { + name: "privilegedNeutralisedEmptyContainerConfig", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Privileged":true}}`, + want: "", + fwd: String.raw`{"Privileged":false}`, + }, + { + name: "emptyContainerConfigForcesPrivilegedFalse", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1"}`, + want: "", + fwd: String.raw`{"Privileged":false}`, + }, + { + name: "mountBindRootDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/","Target":"/host"}]}}`, + want: 'mount source "/" is not in the whitelist for service svc', + fwd: String.raw``, + }, + { + name: "mountBindAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "mountBindReadOnlyVolumeWritableDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro"}]}}`, + want: 'mount of "/ro" must be read-only (ReadOnly: true) for service svc', + fwd: String.raw``, + }, + { + name: "mountBindReadOnlyVolumeReadOnlyFalseDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":false}]}}`, + want: 'mount of "/ro" must be read-only', + fwd: String.raw``, + }, + { + name: "mountBindReadOnlyVolumeReadOnlyAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "mountVolumeTypeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"volume","Source":"myvol","Target":"/v"}]}}`, + want: 'mount type "volume" is not allowed for service svc', + fwd: String.raw``, + }, + { + name: "mountImageTypeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"image","Source":"alpine:3.20","Target":"/i"}]}}`, + want: 'mount type "image" is not allowed', + fwd: String.raw``, + }, + { + name: "mountNpipeTypeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"npipe","Source":"//./pipe/docker_engine","Target":"/p"}]}}`, + want: 'mount type "npipe" is not allowed', + fwd: String.raw``, + }, + { + name: "mountMissingTypeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Source":"/data","Target":"/data"}]}}`, + want: 'mount type "" is not allowed', + fwd: String.raw``, + }, + { + name: "mountTmpfsAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/run","TmpfsOptions":{"SizeBytes":1048576}}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "mountTmpfsWithSourceDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Source":"/","Target":"/run"}]}}`, + want: "tmpfs mount must not have a Source", + fwd: String.raw``, + }, + { + name: "mountPropagationRsharedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rshared"}}]}}`, + want: 'mount propagation "rshared" is not allowed', + fwd: String.raw``, + }, + { + name: "mountPropagationSlaveDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"slave"}}]}}`, + want: 'mount propagation "slave" is not allowed', + fwd: String.raw``, + }, + { + name: "mountPropagationRprivateAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"Propagation":"rprivate"}}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "mountCreateMountpointAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","BindOptions":{"CreateMountpoint":true}}]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "mountReadOnlyNonRecursiveDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/ro","Target":"/ro","ReadOnly":true,"BindOptions":{"ReadOnlyNonRecursive":true}}]}}`, + want: "HostConfig.Mounts.BindOptions.ReadOnlyNonRecursive is not allowed", + fwd: String.raw``, + }, + { + name: "mountVolumeOptionsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data","VolumeOptions":{"NoCopy":true}}]}}`, + want: "HostConfig.Mounts.VolumeOptions is not allowed", + fwd: String.raw``, + }, + { + name: "mountCaseVariantDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"tmpfs","Target":"/x","type":"bind","source":"/"}]}}`, + want: "HostConfig.Mounts.source is not allowed", + fwd: String.raw``, + }, + { + name: "mountNoVolumesDenied", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":[{"Type":"bind","Source":"/data","Target":"/data"}]}}`, + want: 'mount source "/data" is not in the whitelist', + fwd: String.raw``, + }, + { + name: "mountsNotListDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Mounts":{}}}`, + want: "HostConfig.Mounts must be a list", + fwd: String.raw``, + }, + { + name: "bindAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindReadOnlyVolumeRwDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:rw"]}}`, + want: 'volume mount "/ro" must be read-only (:ro) for service svc', + fwd: String.raw``, + }, + { + name: "bindReadOnlyVolumeNoModeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro"]}}`, + want: 'volume mount "/ro" must be read-only (:ro)', + fwd: String.raw``, + }, + { + name: "bindReadOnlyVolumeRoAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindReadOnlyVolumeRoZAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,Z"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindReadOnlyVolumeRoRwDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/ro:/ro:ro,rw"]}}`, + want: 'volume mount "/ro" must be read-only (:ro)', + fwd: String.raw``, + }, + { + name: "bindRsharedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rshared"]}}`, + want: 'bind option "rshared" on volume mount "/data" is not allowed for service svc', + fwd: String.raw``, + }, + { + name: "bindRslaveDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rslave"]}}`, + want: 'bind option "rslave" on volume mount "/data" is not allowed', + fwd: String.raw``, + }, + { + name: "bindConsistencyModeDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:cached"]}}`, + want: 'bind option "cached" on volume mount "/data" is not allowed', + fwd: String.raw``, + }, + { + name: "bindRprivateAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:rw,rprivate"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindPrivateZAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:z,private"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindNocopyAllowed", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:nocopy"]}}`, + want: "", + fwd: String.raw``, + }, + { + name: "bindUnlistedDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/etc:/etc:ro"]}}`, + want: 'volume mount "/etc" is not in the whitelist for service svc', + fwd: String.raw``, + }, + { + name: "bindNoVolumesDenied", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data"]}}`, + want: 'volume mount "/data" is not in the whitelist', + fwd: String.raw``, + }, + { + name: "bindNotStringDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":[1]}}`, + want: "HostConfig.Binds must be a list of strings", + fwd: String.raw``, + }, + { + name: "bindTooManyPartsDenied", + policy: "std", + body: String.raw`{"Image":"chainsafe/lodestar:v1","HostConfig":{"Binds":["/data:/data:ro:x"]}}`, + want: 'bind "/data:/data:ro:x" is not a valid bind', + fwd: String.raw``, + }, + { + name: "topLevelVolumesNoVolumesDenied", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","Volumes":{"/data":{}}}`, + want: 'volume "/data" is not in the whitelist', + fwd: String.raw``, + }, + { + name: "topLevelVolumesEmptyAllowed", + policy: "bare", + body: String.raw`{"Image":"chainsafe/lodestar:v1","Volumes":{}}`, + want: "", + fwd: String.raw``, + }, + { + name: "dockerCliCreateBodyAllowed", + policy: "std", + body: String.raw`{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "dockerCliCreateBodyBareAllowed", + policy: "bare", + body: String.raw`{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["true"],"Image":"chainsafe/lodestar:v1","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{"IPAMConfig":null,"Links":null,"Aliases":null,"DriverOpts":null,"GwPriority":0,"NetworkID":"","EndpointID":"","Gateway":"","IPAddress":"","MacAddress":"","IPPrefixLen":0,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"DNSNames":null}}}}`, + want: "", + fwd: String.raw``, + }, + { + name: "ansibleCreateBodyAllowed", + policy: "secopt", + body: String.raw`{"Image":"chainsafe/lodestar:v1","Cmd":["beacon"],"Env":[],"Labels":{"app":"beacon"},"HostConfig":{"Binds":["/data:/data:rw","/ro:/ro:ro"],"NetworkMode":"host","RestartPolicy":{"Name":"unless-stopped","MaximumRetryCount":0},"LogConfig":{"Type":"json-file","Config":{"max-size":"100m"}},"SecurityOpt":["no-new-privileges:true"],"Init":false,"AutoRemove":false,"Privileged":false,"ReadonlyRootfs":false,"CapDrop":["ALL"],"Memory":0}}`, + want: "", + fwd: String.raw``, + }, +]; + +describe("HostConfig rows (#88)", () => { + for (const row of hostConfigRows) { + it(row.name, () => { + const body = JSON.parse(row.body) as Record; + const result = new Chain().execute("POST", "/containers/create", hostConfigRowPolicy(row.policy), body); + if (row.want !== "") { + assert.equal(result.allowed, false, `expected deny with ${row.want}`); + assert.ok(result.reason.includes(row.want), `expected deny with ${row.want}, got: ${result.reason}`); + return; + } + assert.ok(result.allowed, `expected allow, got: ${result.reason}`); + if (row.fwd === "") return; + const want = JSON.parse(row.fwd) as Record; + const forwarded = JSON.parse(result.modifiedBody!.toString("utf-8")) as Record; + const hc = forwarded["HostConfig"] as Record; + for (const [k, v] of Object.entries(want)) { + assert.deepEqual(hc[k], v, `forwarded HostConfig.${k}`); + } + }); + } +}); From 51511fc80f426bc714fdead855772ddf3e4bc77e Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 09:02:06 -0400 Subject: [PATCH 3/5] fix!: default-deny HostConfig on create; check Mounts and read-only binds (#88) On POST /containers/create only the host paths of HostConfig.Binds were checked. Mounts, bind modes and every other HostConfig field reached the daemon unchanged, so a caller could bind / with --mount, write to a read_write: false volume, or add capabilities, devices, groups and host namespaces. In all three: - HostConfigGate (new, after EnvFileGate, on the mutated body): a key outside the allowlist must be absent or zero; MaskedPaths and ReadonlyPaths must be absent or null. NetworkMode, RestartPolicy, LogConfig, CapDrop, resource limits and similar take any value; SecurityOpt entries must be in the policy's security_options or be no-new-privileges[:true]; PidMode, UTSMode, UsernsMode, Cgroup must be ""; IpcMode, CgroupnsMode, Isolation have short allowlists; OomScoreAdj must be >= 0. - The daemon matches JSON field names without regard to case and keeps the last match, so "privileged": true overrode the forced "Privileged": false (also before this change). A key that folds to a known field name but is spelled differently is denied, at the top level, in HostConfig, in Mounts entries and BindOptions, and in NetworkingConfig (NetworkEndpointsGate, #87, reads EndpointsConfig only; the daemon would attach the networks under endpointsconfig too). - MountSourceGate: a policy with no volumes allows no binds; Binds options must be ro, rw, z, Z, nocopy, private or rprivate, and a read_write: false volume needs ro; Mounts entries must be bind (Source a policy volume, ReadOnly for read-only volumes, private propagation) or tmpfs without a Source. - ContainerConfigMutator forces Privileged: false on every create, also without a container_config, and leaves a non-object HostConfig for the gate to deny (Rust used to panic on it, TypeScript to throw). Rust now applies log_driver/log_options like Go and TypeScript. BREAKING CHANGE: creates that set a HostConfig field outside the allowlist, use Mounts other than allowed bind and tmpfs mounts, bind a read_write: false volume without :ro, use shared/slave propagation, or bind anything under a policy without volumes are now denied with 403. Co-Authored-By: Claude Opus 5.5 --- go/internal/middleware/chain.go | 1 + go/internal/middleware/containerconfig.go | 20 +- go/internal/middleware/hostconfig.go | 257 +++++++++ go/internal/middleware/mountsource.go | 202 ++++++- rs/src/middleware.rs | 624 +++++++++++++++++++++- ts/src/middleware.ts | 473 +++++++++++++++- 6 files changed, 1497 insertions(+), 80 deletions(-) create mode 100644 go/internal/middleware/hostconfig.go diff --git a/go/internal/middleware/chain.go b/go/internal/middleware/chain.go index 55f8378..b7c5ea6 100644 --- a/go/internal/middleware/chain.go +++ b/go/internal/middleware/chain.go @@ -32,6 +32,7 @@ func NewChain() *Chain { c.gates = append(c.gates, &RegistryGate{}) c.gates = append(c.gates, &MountSourceGate{}) c.gates = append(c.gates, &EnvFileGate{}) + c.gates = append(c.gates, &HostConfigGate{}) c.gates = append(c.gates, &CmdGate{}) c.gates = append(c.gates, &NetworkEndpointsGate{}) diff --git a/go/internal/middleware/containerconfig.go b/go/internal/middleware/containerconfig.go index e6e6a91..aadff84 100644 --- a/go/internal/middleware/containerconfig.go +++ b/go/internal/middleware/containerconfig.go @@ -6,14 +6,20 @@ import ( "github.com/ChainSafe/docker-socket-policy/go/internal/policy" ) +// ContainerConfigMutator overwrites the fields the policy's container_config +// sets. It forces HostConfig.Privileged to false on every create, even when +// container_config is empty (#88). A HostConfig that is not an object is left +// alone for HostConfigGate to deny. type ContainerConfigMutator struct{} func (m *ContainerConfigMutator) Mutate(r *http.Request, p *policy.Policy, body map[string]interface{}) { cc := p.ContainerConfig - if cc.NetworkMode == "" && cc.RestartPolicy == "" && len(cc.SecurityOptions) == 0 && cc.User == "" && cc.LogDriver == "" { - return - } + if raw, ok := body["HostConfig"]; ok && raw != nil { + if _, isObj := raw.(map[string]interface{}); !isObj { + return + } + } hostConfig, _ := body["HostConfig"].(map[string]interface{}) if hostConfig == nil { hostConfig = make(map[string]interface{}) @@ -32,7 +38,13 @@ func (m *ContainerConfigMutator) Mutate(r *http.Request, p *policy.Policy, body } if len(cc.SecurityOptions) > 0 { - hostConfig["SecurityOpt"] = cc.SecurityOptions + // Stored as a generic JSON list, like a parsed body, so that + // HostConfigGate reads it the same way. + opts := make([]interface{}, len(cc.SecurityOptions)) + for i, o := range cc.SecurityOptions { + opts[i] = o + } + hostConfig["SecurityOpt"] = opts } hostConfig["Privileged"] = false diff --git a/go/internal/middleware/hostconfig.go b/go/internal/middleware/hostconfig.go new file mode 100644 index 0000000..0670907 --- /dev/null +++ b/go/internal/middleware/hostconfig.go @@ -0,0 +1,257 @@ +package middleware + +import ( + "fmt" + "net/http" + "sort" + "strings" + "unicode/utf8" + + "github.com/ChainSafe/docker-socket-policy/go/internal/policy" +) + +// HostConfigGate is the default-deny check on HostConfig (#88). It runs after +// ContainerConfigMutator, on the body that is forwarded, so values the policy +// forces (Privileged, NetworkMode, SecurityOpt, ...) are what it sees. +// +// A key in hostConfigAnyValue may hold any value. A key in hostConfigRules has +// its own rule. Binds and Mounts are checked by MountSourceGate. Every other +// key, known or not, must be absent or hold a zero value (null, false, 0, "", +// [] or {}); MaskedPaths and ReadonlyPaths must be absent or null, because an +// empty list unmasks /proc. +// +// The daemon matches JSON keys without regard to case, and the last match +// wins, so "privileged": true would set Privileged and "securityopt": null +// would clear a forced SecurityOpt. A key that folds to a known field name but +// is not spelled exactly like it is denied, here and at the top level. +type HostConfigGate struct{} + +// hostConfigFields is every HostConfig field of the Docker Engine API (v1.44 +// and later, including the embedded Resources fields), plus a few removed +// ones. It is used only to catch case variants of field names. +var hostConfigFields = []string{ + "Binds", "ContainerIDFile", "LogConfig", "NetworkMode", "PortBindings", + "RestartPolicy", "AutoRemove", "VolumeDriver", "VolumesFrom", "Mounts", + "ConsoleSize", "Annotations", "CapAdd", "CapDrop", "CgroupnsMode", "Dns", + "DnsOptions", "DnsSearch", "ExtraHosts", "GroupAdd", "IpcMode", "Cgroup", + "Links", "OomScoreAdj", "PidMode", "Privileged", "PublishAllPorts", + "ReadonlyRootfs", "SecurityOpt", "StorageOpt", "Tmpfs", "UTSMode", + "UsernsMode", "ShmSize", "Sysctls", "Runtime", "Isolation", "MaskedPaths", + "ReadonlyPaths", "Init", "Capabilities", + "CpuShares", "Memory", "CgroupParent", "BlkioWeight", "BlkioWeightDevice", + "BlkioDeviceReadBps", "BlkioDeviceWriteBps", "BlkioDeviceReadIOps", + "BlkioDeviceWriteIOps", "CpuPeriod", "CpuQuota", "CpuRealtimePeriod", + "CpuRealtimeRuntime", "CpusetCpus", "CpusetMems", "Devices", + "DeviceCgroupRules", "DeviceRequests", "KernelMemory", "KernelMemoryTCP", + "MemoryReservation", "MemorySwap", "MemorySwappiness", "NanoCpus", + "OomKillDisable", "PidsLimit", "Ulimits", "CpuCount", "CpuPercent", + "IOMaximumIOps", "IOMaximumBandwidth", +} + +// createBodyFields is every top-level field of the create body (the container +// Config, HostConfig and NetworkingConfig), used to catch case variants. +var createBodyFields = []string{ + "Hostname", "Domainname", "User", "AttachStdin", "AttachStdout", + "AttachStderr", "ExposedPorts", "Tty", "OpenStdin", "StdinOnce", "Env", + "Cmd", "Healthcheck", "ArgsEscaped", "Image", "Volumes", "WorkingDir", + "Entrypoint", "NetworkDisabled", "MacAddress", "OnBuild", "Labels", + "StopSignal", "StopTimeout", "Shell", "HostConfig", "NetworkingConfig", +} + +// networkingConfigFields is every field of NetworkingConfig. +var networkingConfigFields = []string{"EndpointsConfig"} + +// hostConfigAnyValue may hold any value. NetworkMode, RestartPolicy and +// LogConfig are overwritten by the mutator when the policy sets them. +var hostConfigAnyValue = map[string]bool{ + "NetworkMode": true, "RestartPolicy": true, "LogConfig": true, + "CapDrop": true, "ReadonlyRootfs": true, "Init": true, "AutoRemove": true, + "ConsoleSize": true, "ShmSize": true, "PortBindings": true, + "PublishAllPorts": true, "Dns": true, "DnsOptions": true, "DnsSearch": true, + "ExtraHosts": true, "Tmpfs": true, "Ulimits": true, "Memory": true, + "MemoryReservation": true, "MemorySwap": true, "MemorySwappiness": true, + "NanoCpus": true, "CpuShares": true, "CpuPeriod": true, "CpuQuota": true, + "CpusetCpus": true, "CpusetMems": true, "PidsLimit": true, + "BlkioWeight": true, "ContainerIDFile": true, +} + +// hostConfigModes lists the values a namespace field may take; "" is always +// allowed. "host" and "container:" are denied. +var hostConfigModes = map[string][]string{ + "PidMode": {}, + "IpcMode": {"private", "shareable", "none"}, + "UTSMode": {}, + "UsernsMode": {}, + "CgroupnsMode": {"private"}, + "Cgroup": {}, + "Isolation": {"default"}, +} + +var noNewPrivileges = map[string]bool{"no-new-privileges": true, "no-new-privileges:true": true} + +func (g *HostConfigGate) Check(r *http.Request, p *policy.Policy, body map[string]interface{}) error { + if err := checkFieldCase("", body, createBodyFields, p); err != nil { + return err + } + // NetworkEndpointsGate and the mutator read NetworkingConfig.EndpointsConfig + // by its exact name; the daemon would also read "endpointsconfig". + if nc, isObj := body["NetworkingConfig"].(map[string]interface{}); isObj { + if err := checkFieldCase("NetworkingConfig.", nc, networkingConfigFields, p); err != nil { + return err + } + } + + raw, ok := body["HostConfig"] + if !ok || raw == nil { + return nil + } + hc, ok := raw.(map[string]interface{}) + if !ok { + return fmt.Errorf("HostConfig must be an object for service %s", p.ServiceName) + } + if err := checkFieldCase("HostConfig.", hc, hostConfigFields, p); err != nil { + return err + } + + for _, key := range sortedKeys(hc) { + value := hc[key] + switch { + case key == "Binds" || key == "Mounts": + // MountSourceGate. + case hostConfigAnyValue[key]: + case key == "MaskedPaths" || key == "ReadonlyPaths": + if value != nil { + return fmt.Errorf("HostConfig.%s is not allowed for service %s", key, p.ServiceName) + } + case key == "SecurityOpt": + if err := checkSecurityOpt(value, p); err != nil { + return err + } + case key == "OomScoreAdj": + if value == nil { + continue + } + n, isNum := value.(float64) + if !isNum || n < 0 { + return fmt.Errorf("HostConfig.OomScoreAdj value %v is not allowed for service %s", value, p.ServiceName) + } + default: + if modes, isMode := hostConfigModes[key]; isMode { + if err := checkMode(key, value, modes, p); err != nil { + return err + } + continue + } + if !isZeroValue(value) { + return fmt.Errorf("HostConfig.%s is not allowed for service %s", key, p.ServiceName) + } + } + } + return nil +} + +func checkSecurityOpt(value interface{}, p *policy.Policy) error { + if value == nil { + return nil + } + list, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("HostConfig.SecurityOpt must be a list for service %s", p.ServiceName) + } + for _, e := range list { + s, isStr := e.(string) + if !isStr { + return fmt.Errorf("HostConfig.SecurityOpt entry %v is not allowed for service %s", e, p.ServiceName) + } + if noNewPrivileges[s] || containsString(p.ContainerConfig.SecurityOptions, s) { + continue + } + return fmt.Errorf("HostConfig.SecurityOpt entry %q is not allowed for service %s", s, p.ServiceName) + } + return nil +} + +func checkMode(key string, value interface{}, allowed []string, p *policy.Policy) error { + if value == nil { + return nil + } + s, ok := value.(string) + if ok && (s == "" || containsString(allowed, s)) { + return nil + } + if ok { + return fmt.Errorf("HostConfig.%s value %q is not allowed for service %s", key, s, p.ServiceName) + } + return fmt.Errorf("HostConfig.%s value %v is not allowed for service %s", key, value, p.ServiceName) +} + +// checkFieldCase denies a key that the daemon would read as one of fields but +// that is not spelled exactly like it. +func checkFieldCase(prefix string, obj map[string]interface{}, fields []string, p *policy.Policy) error { + for _, key := range sortedKeys(obj) { + folded := foldKey(key) + for _, f := range fields { + if key != f && folded == foldKey(f) { + return fmt.Errorf("%s%s is not allowed for service %s (the daemon reads it as %s%s)", prefix, key, p.ServiceName, prefix, f) + } + } + } + return nil +} + +// foldKey folds a key the way the daemon's JSON decoder matches field names: +// ASCII case, plus the Kelvin sign (U+212A) as k and the long s (U+017F) as s. +func foldKey(s string) string { + var b strings.Builder + for len(s) > 0 { + r, size := utf8.DecodeRuneInString(s) + s = s[size:] + switch { + case r >= 'A' && r <= 'Z': + r += 'a' - 'A' + case r == 'K': + r = 'k' + case r == 'ſ': + r = 's' + } + b.WriteRune(r) + } + return b.String() +} + +// isZeroValue reports null, false, 0, "", [] and {}. +func isZeroValue(v interface{}) bool { + switch x := v.(type) { + case nil: + return true + case bool: + return !x + case float64: + return x == 0 + case string: + return x == "" + case []interface{}: + return len(x) == 0 + case map[string]interface{}: + return len(x) == 0 + } + return false +} + +func containsString(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/go/internal/middleware/mountsource.go b/go/internal/middleware/mountsource.go index f443fea..783464f 100644 --- a/go/internal/middleware/mountsource.go +++ b/go/internal/middleware/mountsource.go @@ -3,50 +3,196 @@ package middleware import ( "fmt" "net/http" + "strings" "github.com/ChainSafe/docker-socket-policy/go/internal/policy" ) +// MountSourceGate checks every host path a create request mounts against the +// policy's volumes: HostConfig.Binds, HostConfig.Mounts and the top-level +// Volumes keys. A policy with no volumes allows no binds. A volume with +// read_write: false must be mounted read-only (#88). type MountSourceGate struct{} -func (g *MountSourceGate) Check(r *http.Request, p *policy.Policy, body map[string]interface{}) error { - if len(p.Volumes) == 0 { - return nil - } +// bindModes are the bind options allowed in a Binds entry. The propagation +// modes shared, rshared, slave and rslave are not among them. +var bindModes = map[string]bool{ + "ro": true, "rw": true, "z": true, "Z": true, "nocopy": true, + "private": true, "rprivate": true, +} + +var mountFields = []string{ + "Type", "Source", "Target", "ReadOnly", "Consistency", "BindOptions", + "VolumeOptions", "ImageOptions", "TmpfsOptions", "ClusterOptions", +} + +// mountAnyValue may hold any value in a Mounts entry; Type, Source, ReadOnly +// and BindOptions have their own rules, and every other key must be zero. +var mountAnyValue = map[string]bool{"Target": true, "Consistency": true, "TmpfsOptions": true} + +var bindOptionFields = []string{ + "Propagation", "NonRecursive", "CreateMountpoint", "ReadOnlyNonRecursive", + "ReadOnlyForceRecursive", +} + +// bindOptionAnyValue may hold any value in BindOptions; Propagation has its +// own rule, and every other key, ReadOnlyNonRecursive included, must be zero. +var bindOptionAnyValue = map[string]bool{"NonRecursive": true, "CreateMountpoint": true, "ReadOnlyForceRecursive": true} +func (g *MountSourceGate) Check(r *http.Request, p *policy.Policy, body map[string]interface{}) error { hostConfig, _ := body["HostConfig"].(map[string]interface{}) if hostConfig != nil { - binds, _ := hostConfig["Binds"].([]interface{}) - for _, b := range binds { - bindStr, ok := b.(string) - if !ok { - continue - } - hostPath := extractHostPath(bindStr) - if !isVolumeAllowed(hostPath, p.Volumes) { - return fmt.Errorf("volume mount %q is not in the whitelist for service %s", hostPath, p.ServiceName) - } + if err := checkBinds(hostConfig["Binds"], p); err != nil { + return err + } + if err := checkMounts(hostConfig["Mounts"], p); err != nil { + return err } } volumes, _ := body["Volumes"].(map[string]interface{}) - if volumes != nil { - for hostPath := range volumes { - if !isVolumeAllowed(hostPath, p.Volumes) { - return fmt.Errorf("volume %q is not in the whitelist for service %s", hostPath, p.ServiceName) + for _, hostPath := range sortedKeys(volumes) { + if findVolume(hostPath, p.Volumes) == nil { + return fmt.Errorf("volume %q is not in the whitelist for service %s", hostPath, p.ServiceName) + } + } + + return nil +} + +func checkBinds(raw interface{}, p *policy.Policy) error { + if raw == nil { + return nil + } + binds, ok := raw.([]interface{}) + if !ok { + return fmt.Errorf("HostConfig.Binds must be a list of strings for service %s", p.ServiceName) + } + for _, b := range binds { + bindStr, ok := b.(string) + if !ok { + return fmt.Errorf("HostConfig.Binds must be a list of strings for service %s", p.ServiceName) + } + parts := splitBind(bindStr) + if len(parts) == 0 || len(parts) > 3 { + return fmt.Errorf("bind %q is not a valid bind for service %s", bindStr, p.ServiceName) + } + hostPath := parts[0] + vol := findVolume(hostPath, p.Volumes) + if vol == nil { + return fmt.Errorf("volume mount %q is not in the whitelist for service %s", hostPath, p.ServiceName) + } + var opts []string + if len(parts) == 3 && parts[2] != "" { + opts = strings.Split(parts[2], ",") + } + for _, o := range opts { + if !bindModes[o] { + return fmt.Errorf("bind option %q on volume mount %q is not allowed for service %s", o, hostPath, p.ServiceName) } } + if !vol.ReadWrite && (!containsString(opts, "ro") || containsString(opts, "rw")) { + return fmt.Errorf("volume mount %q must be read-only (:ro) for service %s", hostPath, p.ServiceName) + } } + return nil +} +func checkMounts(raw interface{}, p *policy.Policy) error { + if raw == nil { + return nil + } + mounts, ok := raw.([]interface{}) + if !ok { + return fmt.Errorf("HostConfig.Mounts must be a list for service %s", p.ServiceName) + } + for _, m := range mounts { + mount, ok := m.(map[string]interface{}) + if !ok { + return fmt.Errorf("HostConfig.Mounts entry must be an object for service %s", p.ServiceName) + } + if err := checkMount(mount, p); err != nil { + return err + } + } return nil } -func extractHostPath(bind string) string { - parts := splitBind(bind) - if len(parts) >= 1 { - return parts[0] +func checkMount(mount map[string]interface{}, p *policy.Policy) error { + if err := checkFieldCase("HostConfig.Mounts.", mount, mountFields, p); err != nil { + return err + } + for _, key := range sortedKeys(mount) { + switch { + case key == "Type" || key == "Source" || key == "ReadOnly": + case key == "BindOptions": + if err := checkBindOptions(mount[key], p); err != nil { + return err + } + case mountAnyValue[key]: + default: + if !isZeroValue(mount[key]) { + return fmt.Errorf("HostConfig.Mounts.%s is not allowed for service %s", key, p.ServiceName) + } + } + } + + mountType, _ := mount["Type"].(string) + source, _ := mount["Source"].(string) + switch mountType { + case "tmpfs": + if !isZeroValue(mount["Source"]) { + return fmt.Errorf("tmpfs mount must not have a Source for service %s", p.ServiceName) + } + return nil + case "bind": + vol := findVolume(source, p.Volumes) + if vol == nil { + return fmt.Errorf("mount source %q is not in the whitelist for service %s", source, p.ServiceName) + } + readOnly, _ := mount["ReadOnly"].(bool) + if !vol.ReadWrite && !readOnly { + return fmt.Errorf("mount of %q must be read-only (ReadOnly: true) for service %s", source, p.ServiceName) + } + return nil + default: + return fmt.Errorf("mount type %q is not allowed for service %s", mountType, p.ServiceName) } - return bind +} + +func checkBindOptions(raw interface{}, p *policy.Policy) error { + if raw == nil { + return nil + } + opts, ok := raw.(map[string]interface{}) + if !ok { + return fmt.Errorf("HostConfig.Mounts.BindOptions must be an object for service %s", p.ServiceName) + } + if err := checkFieldCase("HostConfig.Mounts.BindOptions.", opts, bindOptionFields, p); err != nil { + return err + } + for _, key := range sortedKeys(opts) { + value := opts[key] + switch { + case key == "Propagation": + if value == nil { + continue + } + s, isStr := value.(string) + if !isStr { + return fmt.Errorf("mount propagation %v is not allowed for service %s", value, p.ServiceName) + } + if s != "" && s != "private" && s != "rprivate" { + return fmt.Errorf("mount propagation %q is not allowed for service %s", s, p.ServiceName) + } + case bindOptionAnyValue[key]: + default: + if !isZeroValue(value) { + return fmt.Errorf("HostConfig.Mounts.BindOptions.%s is not allowed for service %s", key, p.ServiceName) + } + } + } + return nil } func splitBind(bind string) []string { @@ -79,11 +225,11 @@ func splitBind(bind string) []string { return parts } -func isVolumeAllowed(hostPath string, volumes []policy.Volume) bool { - for _, v := range volumes { - if v.HostPath == hostPath { - return true +func findVolume(hostPath string, volumes []policy.Volume) *policy.Volume { + for i := range volumes { + if volumes[i].HostPath == hostPath { + return &volumes[i] } } - return false + return nil } diff --git a/rs/src/middleware.rs b/rs/src/middleware.rs index 4262204..d8621a9 100644 --- a/rs/src/middleware.rs +++ b/rs/src/middleware.rs @@ -37,6 +37,7 @@ impl Chain { gates.push(Box::new(RegistryGate)); gates.push(Box::new(MountSourceGate)); gates.push(Box::new(EnvFileGate)); + gates.push(Box::new(HostConfigGate)); gates.push(Box::new(CmdGate)); gates.push(Box::new(NetworkEndpointsGate)); @@ -109,7 +110,225 @@ impl Gate for RegistryGate { } } +type JsonObject = serde_json::Map; + +/// MountSourceGate checks every host path a create request mounts against the +/// policy's volumes: `HostConfig.Binds`, `HostConfig.Mounts` and the top-level +/// `Volumes` keys. A policy with no volumes allows no binds. A volume with +/// `read_write: false` must be mounted read-only (#88). pub struct MountSourceGate; + +/// The bind options allowed in a `Binds` entry. The propagation modes +/// shared, rshared, slave and rslave are not among them. +const BIND_MODES: &[&str] = &["ro", "rw", "z", "Z", "nocopy", "private", "rprivate"]; + +const MOUNT_FIELDS: &[&str] = &[ + "Type", + "Source", + "Target", + "ReadOnly", + "Consistency", + "BindOptions", + "VolumeOptions", + "ImageOptions", + "TmpfsOptions", + "ClusterOptions", +]; + +/// May hold any value in a `Mounts` entry; Type, Source, ReadOnly and +/// BindOptions have their own rules, and every other key must be zero. +const MOUNT_ANY_VALUE: &[&str] = &["Target", "Consistency", "TmpfsOptions"]; + +const BIND_OPTION_FIELDS: &[&str] = &[ + "Propagation", + "NonRecursive", + "CreateMountpoint", + "ReadOnlyNonRecursive", + "ReadOnlyForceRecursive", +]; + +/// May hold any value in `BindOptions`; Propagation has its own rule, and +/// every other key, ReadOnlyNonRecursive included, must be zero. +const BIND_OPTION_ANY_VALUE: &[&str] = &["NonRecursive", "CreateMountpoint", "ReadOnlyForceRecursive"]; + +fn find_volume<'a>(host_path: &str, policy: &'a Policy) -> Option<&'a crate::policy::Volume> { + policy.volumes.as_ref()?.iter().find(|v| v.host_path == host_path) +} + +fn check_binds(raw: Option<&serde_json::Value>, policy: &Policy) -> std::result::Result<(), String> { + let raw = match raw { + None | Some(serde_json::Value::Null) => return Ok(()), + Some(r) => r, + }; + let not_list = || { + format!( + "HostConfig.Binds must be a list of strings for service {}", + policy.service_name + ) + }; + let binds = raw.as_array().ok_or_else(not_list)?; + for bind in binds { + let bind_str = bind.as_str().ok_or_else(not_list)?; + let parts: Vec<&str> = bind_str.split(':').collect(); + if bind_str.is_empty() || parts.len() > 3 { + return Err(format!( + "bind {:?} is not a valid bind for service {}", + bind_str, policy.service_name + )); + } + let host_path = parts[0]; + let vol = find_volume(host_path, policy).ok_or_else(|| { + format!( + "volume mount {:?} is not in the whitelist for service {}", + host_path, policy.service_name + ) + })?; + let opts: Vec<&str> = if parts.len() == 3 && !parts[2].is_empty() { + parts[2].split(',').collect() + } else { + Vec::new() + }; + for o in &opts { + if !BIND_MODES.contains(o) { + return Err(format!( + "bind option {:?} on volume mount {:?} is not allowed for service {}", + o, host_path, policy.service_name + )); + } + } + if !vol.read_write && (!opts.contains(&"ro") || opts.contains(&"rw")) { + return Err(format!( + "volume mount {:?} must be read-only (:ro) for service {}", + host_path, policy.service_name + )); + } + } + Ok(()) +} + +fn check_mounts(raw: Option<&serde_json::Value>, policy: &Policy) -> std::result::Result<(), String> { + let raw = match raw { + None | Some(serde_json::Value::Null) => return Ok(()), + Some(r) => r, + }; + let mounts = raw + .as_array() + .ok_or_else(|| format!("HostConfig.Mounts must be a list for service {}", policy.service_name))?; + for m in mounts { + let mount = m.as_object().ok_or_else(|| { + format!( + "HostConfig.Mounts entry must be an object for service {}", + policy.service_name + ) + })?; + check_mount(mount, policy)?; + } + Ok(()) +} + +fn check_mount(mount: &JsonObject, policy: &Policy) -> std::result::Result<(), String> { + check_field_case("HostConfig.Mounts.", mount.keys(), MOUNT_FIELDS, policy)?; + for key in sorted_keys(mount.keys()) { + let value = &mount[key.as_str()]; + match key.as_str() { + "Type" | "Source" | "ReadOnly" => {} + "BindOptions" => check_bind_options(value, policy)?, + k if MOUNT_ANY_VALUE.contains(&k) => {} + k => { + if !is_zero_value(value) { + return Err(format!( + "HostConfig.Mounts.{} is not allowed for service {}", + k, policy.service_name + )); + } + } + } + } + + let mount_type = mount.get("Type").and_then(|v| v.as_str()).unwrap_or(""); + let source = mount.get("Source").and_then(|v| v.as_str()).unwrap_or(""); + match mount_type { + "tmpfs" => { + if !mount.get("Source").map_or(true, is_zero_value) { + return Err(format!( + "tmpfs mount must not have a Source for service {}", + policy.service_name + )); + } + Ok(()) + } + "bind" => { + let vol = find_volume(source, policy).ok_or_else(|| { + format!( + "mount source {:?} is not in the whitelist for service {}", + source, policy.service_name + ) + })?; + let read_only = mount.get("ReadOnly").and_then(|v| v.as_bool()).unwrap_or(false); + if !vol.read_write && !read_only { + return Err(format!( + "mount of {:?} must be read-only (ReadOnly: true) for service {}", + source, policy.service_name + )); + } + Ok(()) + } + t => Err(format!( + "mount type {:?} is not allowed for service {}", + t, policy.service_name + )), + } +} + +fn check_bind_options(raw: &serde_json::Value, policy: &Policy) -> std::result::Result<(), String> { + if raw.is_null() { + return Ok(()); + } + let opts = raw.as_object().ok_or_else(|| { + format!( + "HostConfig.Mounts.BindOptions must be an object for service {}", + policy.service_name + ) + })?; + check_field_case( + "HostConfig.Mounts.BindOptions.", + opts.keys(), + BIND_OPTION_FIELDS, + policy, + )?; + for key in sorted_keys(opts.keys()) { + let value = &opts[key.as_str()]; + match key.as_str() { + "Propagation" => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.is_empty() || s == "private" || s == "rprivate" => {} + serde_json::Value::String(s) => { + return Err(format!( + "mount propagation {:?} is not allowed for service {}", + s, policy.service_name + )) + } + v => { + return Err(format!( + "mount propagation {} is not allowed for service {}", + v, policy.service_name + )) + } + }, + k if BIND_OPTION_ANY_VALUE.contains(&k) => {} + k => { + if !is_zero_value(value) { + return Err(format!( + "HostConfig.Mounts.BindOptions.{} is not allowed for service {}", + k, policy.service_name + )); + } + } + } + } + Ok(()) +} + impl Gate for MountSourceGate { fn check( &self, @@ -118,27 +337,18 @@ impl Gate for MountSourceGate { policy: &Policy, body: &HashMap, ) -> std::result::Result<(), String> { - let volumes = match &policy.volumes { - Some(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - if let Some(host_config) = body.get("HostConfig").and_then(|v| v.as_object()) { - if let Some(binds) = host_config.get("Binds").and_then(|v| v.as_array()) { - for bind in binds { - let bind_str = bind.as_str().unwrap_or(""); - let host_path = bind_str.split(':').next().unwrap_or(bind_str); - if !volumes.iter().any(|v| v.host_path == host_path) { - return Err(format!("volume mount {:?} is not in the whitelist", host_path)); - } - } - } + check_binds(host_config.get("Binds"), policy)?; + check_mounts(host_config.get("Mounts"), policy)?; } if let Some(body_volumes) = body.get("Volumes").and_then(|v| v.as_object()) { - for host_path in body_volumes.keys() { - if !volumes.iter().any(|v| v.host_path == *host_path) { - return Err(format!("volume {:?} is not in the whitelist", host_path)); + for host_path in sorted_keys(body_volumes.keys()) { + if find_volume(&host_path, policy).is_none() { + return Err(format!( + "volume {:?} is not in the whitelist for service {}", + host_path, policy.service_name + )); } } } @@ -147,6 +357,358 @@ impl Gate for MountSourceGate { } } +/// HostConfigGate is the default-deny check on `HostConfig` (#88). It runs +/// after ContainerConfigMutator, on the body that is forwarded, so values the +/// policy forces (Privileged, NetworkMode, SecurityOpt, ...) are what it sees. +/// +/// A key in `HOST_CONFIG_ANY_VALUE` may hold any value. SecurityOpt, +/// OomScoreAdj and the namespace modes have their own rules. Binds and Mounts +/// are checked by MountSourceGate. Every other key, known or not, must be +/// absent or hold a zero value (null, false, 0, "", [] or {}); MaskedPaths and +/// ReadonlyPaths must be absent or null, because an empty list unmasks /proc. +/// +/// The daemon matches JSON keys without regard to case, and the last match +/// wins, so `"privileged": true` would set Privileged and `"securityopt": null` +/// would clear a forced SecurityOpt. A key that folds to a known field name +/// but is not spelled exactly like it is denied, here and at the top level. +pub struct HostConfigGate; + +/// Every HostConfig field of the Docker Engine API (v1.44 and later, +/// including the embedded Resources fields), plus a few removed ones. Used +/// only to catch case variants of field names. +const HOST_CONFIG_FIELDS: &[&str] = &[ + "Binds", + "ContainerIDFile", + "LogConfig", + "NetworkMode", + "PortBindings", + "RestartPolicy", + "AutoRemove", + "VolumeDriver", + "VolumesFrom", + "Mounts", + "ConsoleSize", + "Annotations", + "CapAdd", + "CapDrop", + "CgroupnsMode", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "GroupAdd", + "IpcMode", + "Cgroup", + "Links", + "OomScoreAdj", + "PidMode", + "Privileged", + "PublishAllPorts", + "ReadonlyRootfs", + "SecurityOpt", + "StorageOpt", + "Tmpfs", + "UTSMode", + "UsernsMode", + "ShmSize", + "Sysctls", + "Runtime", + "Isolation", + "MaskedPaths", + "ReadonlyPaths", + "Init", + "Capabilities", + "CpuShares", + "Memory", + "CgroupParent", + "BlkioWeight", + "BlkioWeightDevice", + "BlkioDeviceReadBps", + "BlkioDeviceWriteBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteIOps", + "CpuPeriod", + "CpuQuota", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "CpusetCpus", + "CpusetMems", + "Devices", + "DeviceCgroupRules", + "DeviceRequests", + "KernelMemory", + "KernelMemoryTCP", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "NanoCpus", + "OomKillDisable", + "PidsLimit", + "Ulimits", + "CpuCount", + "CpuPercent", + "IOMaximumIOps", + "IOMaximumBandwidth", +]; + +/// Every top-level field of the create body (the container Config, +/// HostConfig and NetworkingConfig), used to catch case variants. +const CREATE_BODY_FIELDS: &[&str] = &[ + "Hostname", + "Domainname", + "User", + "AttachStdin", + "AttachStdout", + "AttachStderr", + "ExposedPorts", + "Tty", + "OpenStdin", + "StdinOnce", + "Env", + "Cmd", + "Healthcheck", + "ArgsEscaped", + "Image", + "Volumes", + "WorkingDir", + "Entrypoint", + "NetworkDisabled", + "MacAddress", + "OnBuild", + "Labels", + "StopSignal", + "StopTimeout", + "Shell", + "HostConfig", + "NetworkingConfig", +]; + +/// Every field of NetworkingConfig. +const NETWORKING_CONFIG_FIELDS: &[&str] = &["EndpointsConfig"]; + +/// May hold any value. NetworkMode, RestartPolicy and LogConfig are +/// overwritten by the mutator when the policy sets them. +const HOST_CONFIG_ANY_VALUE: &[&str] = &[ + "NetworkMode", + "RestartPolicy", + "LogConfig", + "CapDrop", + "ReadonlyRootfs", + "Init", + "AutoRemove", + "ConsoleSize", + "ShmSize", + "PortBindings", + "PublishAllPorts", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "Tmpfs", + "Ulimits", + "Memory", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "NanoCpus", + "CpuShares", + "CpuPeriod", + "CpuQuota", + "CpusetCpus", + "CpusetMems", + "PidsLimit", + "BlkioWeight", + "ContainerIDFile", +]; + +/// The values a namespace field may take besides "". "host" and +/// "container:" are denied. +fn host_config_modes(key: &str) -> Option<&'static [&'static str]> { + match key { + "PidMode" | "UTSMode" | "UsernsMode" | "Cgroup" => Some(&[]), + "IpcMode" => Some(&["private", "shareable", "none"]), + "CgroupnsMode" => Some(&["private"]), + "Isolation" => Some(&["default"]), + _ => None, + } +} + +const NO_NEW_PRIVILEGES: &[&str] = &["no-new-privileges", "no-new-privileges:true"]; + +/// Folds a key the way the daemon's JSON decoder matches field names: ASCII +/// case, plus the Kelvin sign (U+212A) as k and the long s (U+017F) as s. +fn fold_key(s: &str) -> String { + s.chars() + .map(|c| match c { + '\u{212a}' => 'k', + '\u{17f}' => 's', + c => c.to_ascii_lowercase(), + }) + .collect() +} + +fn sorted_keys<'a, I: Iterator>(keys: I) -> Vec { + let mut v: Vec = keys.cloned().collect(); + v.sort(); + v +} + +/// Denies a key that the daemon would read as one of `fields` but that is +/// not spelled exactly like it. +fn check_field_case<'a, I: Iterator>( + prefix: &str, + keys: I, + fields: &[&str], + policy: &Policy, +) -> std::result::Result<(), String> { + for key in sorted_keys(keys) { + let folded = fold_key(&key); + for f in fields { + if key != *f && folded == fold_key(f) { + return Err(format!( + "{}{} is not allowed for service {} (the daemon reads it as {}{})", + prefix, key, policy.service_name, prefix, f + )); + } + } + } + Ok(()) +} + +/// null, false, 0, "", [] and {}. +fn is_zero_value(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::Null => true, + serde_json::Value::Bool(b) => !b, + serde_json::Value::Number(n) => n.as_f64() == Some(0.0), + serde_json::Value::String(s) => s.is_empty(), + serde_json::Value::Array(a) => a.is_empty(), + serde_json::Value::Object(o) => o.is_empty(), + } +} + +fn check_security_opt(value: &serde_json::Value, policy: &Policy) -> std::result::Result<(), String> { + if value.is_null() { + return Ok(()); + } + let list = value.as_array().ok_or_else(|| { + format!( + "HostConfig.SecurityOpt must be a list for service {}", + policy.service_name + ) + })?; + let policy_opts = policy + .container_config + .as_ref() + .and_then(|c| c.security_options.as_ref()); + for e in list { + let s = match e.as_str() { + Some(s) => s, + None => { + return Err(format!( + "HostConfig.SecurityOpt entry {} is not allowed for service {}", + e, policy.service_name + )) + } + }; + if NO_NEW_PRIVILEGES.contains(&s) || policy_opts.is_some_and(|o| o.iter().any(|x| x == s)) { + continue; + } + return Err(format!( + "HostConfig.SecurityOpt entry {:?} is not allowed for service {}", + s, policy.service_name + )); + } + Ok(()) +} + +fn check_mode( + key: &str, + value: &serde_json::Value, + allowed: &[&str], + policy: &Policy, +) -> std::result::Result<(), String> { + match value { + serde_json::Value::Null => Ok(()), + serde_json::Value::String(s) if s.is_empty() || allowed.contains(&s.as_str()) => Ok(()), + serde_json::Value::String(s) => Err(format!( + "HostConfig.{} value {:?} is not allowed for service {}", + key, s, policy.service_name + )), + v => Err(format!( + "HostConfig.{} value {} is not allowed for service {}", + key, v, policy.service_name + )), + } +} + +impl Gate for HostConfigGate { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { + check_field_case("", body.keys(), CREATE_BODY_FIELDS, policy)?; + // NetworkEndpointsGate and the mutator read + // NetworkingConfig.EndpointsConfig by its exact name; the daemon would + // also read "endpointsconfig". + if let Some(nc) = body.get("NetworkingConfig").and_then(|v| v.as_object()) { + check_field_case("NetworkingConfig.", nc.keys(), NETWORKING_CONFIG_FIELDS, policy)?; + } + + let hc = match body.get("HostConfig") { + None | Some(serde_json::Value::Null) => return Ok(()), + Some(serde_json::Value::Object(hc)) => hc, + Some(_) => { + return Err(format!( + "HostConfig must be an object for service {}", + policy.service_name + )) + } + }; + check_field_case("HostConfig.", hc.keys(), HOST_CONFIG_FIELDS, policy)?; + + for key in sorted_keys(hc.keys()) { + let value = &hc[key.as_str()]; + match key.as_str() { + // MountSourceGate. + "Binds" | "Mounts" => {} + k if HOST_CONFIG_ANY_VALUE.contains(&k) => {} + "MaskedPaths" | "ReadonlyPaths" => { + if !value.is_null() { + return Err(format!( + "HostConfig.{} is not allowed for service {}", + key, policy.service_name + )); + } + } + "SecurityOpt" => check_security_opt(value, policy)?, + "OomScoreAdj" => { + if !value.is_null() && !value.as_f64().is_some_and(|n| n >= 0.0) { + return Err(format!( + "HostConfig.OomScoreAdj value {} is not allowed for service {}", + value, policy.service_name + )); + } + } + k => { + if let Some(modes) = host_config_modes(k) { + check_mode(k, value, modes, policy)?; + } else if !is_zero_value(value) { + return Err(format!( + "HostConfig.{} is not allowed for service {}", + k, policy.service_name + )); + } + } + } + } + Ok(()) + } +} + pub struct EnvFileGate; impl Gate for EnvFileGate { fn check( @@ -365,19 +927,30 @@ fn force_endpoints(mode: &str, body: &mut HashMap) { } } +/// ContainerConfigMutator overwrites the fields the policy's +/// `container_config` sets. It forces `HostConfig.Privileged` to false on +/// every create, even without a `container_config` (#88). A `HostConfig` that +/// is not an object is left alone for HostConfigGate to deny. pub struct ContainerConfigMutator; impl Mutator for ContainerConfigMutator { fn mutate(&self, policy: &Policy, body: &mut HashMap) { + let host_config = body + .entry("HostConfig".to_string()) + .or_insert_with(|| serde_json::Value::Object(Default::default())); + if host_config.is_null() { + *host_config = serde_json::Value::Object(Default::default()); + } + let hc = match host_config.as_object_mut() { + Some(hc) => hc, + None => return, + }; + hc.insert("Privileged".to_string(), serde_json::Value::Bool(false)); + let cc = match &policy.container_config { Some(c) => c, None => return, }; - let host_config = body - .entry("HostConfig".to_string()) - .or_insert_with(|| serde_json::Value::Object(Default::default())); - let hc = host_config.as_object_mut().unwrap(); - if let Some(nm) = &cc.network_mode { hc.insert("NetworkMode".to_string(), serde_json::Value::String(nm.clone())); } @@ -391,7 +964,12 @@ impl Mutator for ContainerConfigMutator { serde_json::Value::Array(so.iter().map(|s| serde_json::Value::String(s.clone())).collect()), ); } - hc.insert("Privileged".to_string(), serde_json::Value::Bool(false)); + if let Some(driver) = &cc.log_driver { + hc.insert( + "LogConfig".to_string(), + serde_json::json!({"Type": driver, "Config": cc.log_options.clone().unwrap_or_default()}), + ); + } if let Some(u) = &cc.user { body.insert("User".to_string(), serde_json::Value::String(u.clone())); } diff --git a/ts/src/middleware.ts b/ts/src/middleware.ts index 1196386..dd00d2e 100644 --- a/ts/src/middleware.ts +++ b/ts/src/middleware.ts @@ -20,6 +20,7 @@ export class Chain { this.gates.push(new RegistryGate()); this.gates.push(new MountSourceGate()); this.gates.push(new EnvFileGate()); + this.gates.push(new HostConfigGate()); this.gates.push(new CmdGate()); this.gates.push(new NetworkEndpointsGate()); } @@ -66,33 +67,453 @@ export class RegistryGate implements Gate { } } +type JsonObject = Record; + +function isObject(v: unknown): v is JsonObject { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +// Keys in byte order, so that the first deny is the same one Go and Rust report. +function sortedKeys(obj: JsonObject): string[] { + return Object.keys(obj).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +// null, false, 0, "", [] and {}. +function isZeroValue(v: unknown): boolean { + if (v === null || v === undefined || v === false || v === 0 || v === "") return true; + if (Array.isArray(v)) return v.length === 0; + if (isObject(v)) return Object.keys(v).length === 0; + return false; +} + +// Folds a key the way the daemon's JSON decoder matches field names: ASCII +// case, plus the Kelvin sign (U+212A) as k and the long s (U+017F) as s. +function foldKey(s: string): string { + let out = ""; + for (const c of s) { + if (c >= "A" && c <= "Z") out += c.toLowerCase(); + else if (c === "K") out += "k"; + else if (c === "ſ") out += "s"; + else out += c; + } + return out; +} + +// Denies a key that the daemon would read as one of fields but that is not +// spelled exactly like it. +function checkFieldCase(prefix: string, obj: JsonObject, fields: readonly string[], policy: Policy): string | null { + for (const key of sortedKeys(obj)) { + const folded = foldKey(key); + for (const f of fields) { + if (key !== f && folded === foldKey(f)) { + return `${prefix}${key} is not allowed for service ${policy.service_name} (the daemon reads it as ${prefix}${f})`; + } + } + } + return null; +} + +// Go's %q / Rust's {:?} for the strings in deny messages. +function quote(s: string): string { + return JSON.stringify(s); +} + +// The bind options allowed in a Binds entry. The propagation modes shared, +// rshared, slave and rslave are not among them. +const BIND_MODES = new Set(["ro", "rw", "z", "Z", "nocopy", "private", "rprivate"]); + +const MOUNT_FIELDS = [ + "Type", + "Source", + "Target", + "ReadOnly", + "Consistency", + "BindOptions", + "VolumeOptions", + "ImageOptions", + "TmpfsOptions", + "ClusterOptions", +] as const; + +// May hold any value in a Mounts entry; Type, Source, ReadOnly and +// BindOptions have their own rules, and every other key must be zero. +const MOUNT_ANY_VALUE = new Set(["Target", "Consistency", "TmpfsOptions"]); + +const BIND_OPTION_FIELDS = [ + "Propagation", + "NonRecursive", + "CreateMountpoint", + "ReadOnlyNonRecursive", + "ReadOnlyForceRecursive", +] as const; + +// May hold any value in BindOptions; Propagation has its own rule, and every +// other key, ReadOnlyNonRecursive included, must be zero. +const BIND_OPTION_ANY_VALUE = new Set(["NonRecursive", "CreateMountpoint", "ReadOnlyForceRecursive"]); + +function findVolume(hostPath: string, policy: Policy) { + return policy.volumes?.find((v) => v.host_path === hostPath); +} + +function checkBinds(raw: unknown, policy: Policy): string | null { + if (raw === undefined || raw === null) return null; + const notList = `HostConfig.Binds must be a list of strings for service ${policy.service_name}`; + if (!Array.isArray(raw)) return notList; + for (const bind of raw) { + if (typeof bind !== "string") return notList; + const parts = bind.split(":"); + if (bind === "" || parts.length > 3) { + return `bind ${quote(bind)} is not a valid bind for service ${policy.service_name}`; + } + const hostPath = parts[0]; + const vol = findVolume(hostPath, policy); + if (!vol) { + return `volume mount ${quote(hostPath)} is not in the whitelist for service ${policy.service_name}`; + } + const opts = parts.length === 3 && parts[2] !== "" ? parts[2].split(",") : []; + for (const o of opts) { + if (!BIND_MODES.has(o)) { + return `bind option ${quote(o)} on volume mount ${quote(hostPath)} is not allowed for service ${policy.service_name}`; + } + } + if (!vol.read_write && (!opts.includes("ro") || opts.includes("rw"))) { + return `volume mount ${quote(hostPath)} must be read-only (:ro) for service ${policy.service_name}`; + } + } + return null; +} + +function checkMounts(raw: unknown, policy: Policy): string | null { + if (raw === undefined || raw === null) return null; + if (!Array.isArray(raw)) return `HostConfig.Mounts must be a list for service ${policy.service_name}`; + for (const m of raw) { + if (!isObject(m)) return `HostConfig.Mounts entry must be an object for service ${policy.service_name}`; + const err = checkMount(m, policy); + if (err !== null) return err; + } + return null; +} + +function checkMount(mount: JsonObject, policy: Policy): string | null { + const caseErr = checkFieldCase("HostConfig.Mounts.", mount, MOUNT_FIELDS, policy); + if (caseErr !== null) return caseErr; + for (const key of sortedKeys(mount)) { + if (key === "Type" || key === "Source" || key === "ReadOnly" || MOUNT_ANY_VALUE.has(key)) continue; + if (key === "BindOptions") { + const err = checkBindOptions(mount[key], policy); + if (err !== null) return err; + continue; + } + if (!isZeroValue(mount[key])) { + return `HostConfig.Mounts.${key} is not allowed for service ${policy.service_name}`; + } + } + + const mountType = typeof mount["Type"] === "string" ? mount["Type"] : ""; + const source = typeof mount["Source"] === "string" ? mount["Source"] : ""; + if (mountType === "tmpfs") { + if (!isZeroValue(mount["Source"])) { + return `tmpfs mount must not have a Source for service ${policy.service_name}`; + } + return null; + } + if (mountType === "bind") { + const vol = findVolume(source, policy); + if (!vol) { + return `mount source ${quote(source)} is not in the whitelist for service ${policy.service_name}`; + } + if (!vol.read_write && mount["ReadOnly"] !== true) { + return `mount of ${quote(source)} must be read-only (ReadOnly: true) for service ${policy.service_name}`; + } + return null; + } + return `mount type ${quote(mountType)} is not allowed for service ${policy.service_name}`; +} + +function checkBindOptions(raw: unknown, policy: Policy): string | null { + if (raw === undefined || raw === null) return null; + if (!isObject(raw)) return `HostConfig.Mounts.BindOptions must be an object for service ${policy.service_name}`; + const caseErr = checkFieldCase("HostConfig.Mounts.BindOptions.", raw, BIND_OPTION_FIELDS, policy); + if (caseErr !== null) return caseErr; + for (const key of sortedKeys(raw)) { + const value = raw[key]; + if (key === "Propagation") { + if (value === null) continue; + if (typeof value !== "string") { + return `mount propagation ${JSON.stringify(value)} is not allowed for service ${policy.service_name}`; + } + if (value !== "" && value !== "private" && value !== "rprivate") { + return `mount propagation ${quote(value)} is not allowed for service ${policy.service_name}`; + } + continue; + } + if (BIND_OPTION_ANY_VALUE.has(key)) continue; + if (!isZeroValue(value)) { + return `HostConfig.Mounts.BindOptions.${key} is not allowed for service ${policy.service_name}`; + } + } + return null; +} + +// MountSourceGate checks every host path a create request mounts against the +// policy's volumes: HostConfig.Binds, HostConfig.Mounts and the top-level +// Volumes keys. A policy with no volumes allows no binds. A volume with +// read_write: false must be mounted read-only (#88). export class MountSourceGate implements Gate { check(_method: string, _path: string, policy: Policy, body: Record): string | null { - const volumes = policy.volumes; - if (!volumes?.length) return null; + const hostConfig = body["HostConfig"]; + if (isObject(hostConfig)) { + const err = checkBinds(hostConfig["Binds"], policy) ?? checkMounts(hostConfig["Mounts"], policy); + if (err !== null) return err; + } - const hostConfig = body["HostConfig"] as Record | undefined; - if (hostConfig) { - const binds = hostConfig["Binds"] as string[] | undefined; - if (binds) { - for (const bind of binds) { - const hostPath = bind.split(":")[0]; - if (!volumes.some((v) => v.host_path === hostPath)) { - return `volume mount "${hostPath}" is not in the whitelist`; - } + const topVolumes = body["Volumes"]; + if (isObject(topVolumes)) { + for (const hostPath of sortedKeys(topVolumes)) { + if (!findVolume(hostPath, policy)) { + return `volume ${quote(hostPath)} is not in the whitelist for service ${policy.service_name}`; } } } - const topVolumes = body["Volumes"] as Record | undefined; - if (topVolumes) { - for (const hostPath of Object.keys(topVolumes)) { - if (!volumes.some((v) => v.host_path === hostPath)) { - return `volume "${hostPath}" is not in the whitelist`; + return null; + } +} + +// Every HostConfig field of the Docker Engine API (v1.44 and later, including +// the embedded Resources fields), plus a few removed ones. Used only to catch +// case variants of field names. +const HOST_CONFIG_FIELDS = [ + "Binds", + "ContainerIDFile", + "LogConfig", + "NetworkMode", + "PortBindings", + "RestartPolicy", + "AutoRemove", + "VolumeDriver", + "VolumesFrom", + "Mounts", + "ConsoleSize", + "Annotations", + "CapAdd", + "CapDrop", + "CgroupnsMode", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "GroupAdd", + "IpcMode", + "Cgroup", + "Links", + "OomScoreAdj", + "PidMode", + "Privileged", + "PublishAllPorts", + "ReadonlyRootfs", + "SecurityOpt", + "StorageOpt", + "Tmpfs", + "UTSMode", + "UsernsMode", + "ShmSize", + "Sysctls", + "Runtime", + "Isolation", + "MaskedPaths", + "ReadonlyPaths", + "Init", + "Capabilities", + "CpuShares", + "Memory", + "CgroupParent", + "BlkioWeight", + "BlkioWeightDevice", + "BlkioDeviceReadBps", + "BlkioDeviceWriteBps", + "BlkioDeviceReadIOps", + "BlkioDeviceWriteIOps", + "CpuPeriod", + "CpuQuota", + "CpuRealtimePeriod", + "CpuRealtimeRuntime", + "CpusetCpus", + "CpusetMems", + "Devices", + "DeviceCgroupRules", + "DeviceRequests", + "KernelMemory", + "KernelMemoryTCP", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "NanoCpus", + "OomKillDisable", + "PidsLimit", + "Ulimits", + "CpuCount", + "CpuPercent", + "IOMaximumIOps", + "IOMaximumBandwidth", +] as const; + +// Every top-level field of the create body (the container Config, HostConfig +// and NetworkingConfig), used to catch case variants. +const CREATE_BODY_FIELDS = [ + "Hostname", + "Domainname", + "User", + "AttachStdin", + "AttachStdout", + "AttachStderr", + "ExposedPorts", + "Tty", + "OpenStdin", + "StdinOnce", + "Env", + "Cmd", + "Healthcheck", + "ArgsEscaped", + "Image", + "Volumes", + "WorkingDir", + "Entrypoint", + "NetworkDisabled", + "MacAddress", + "OnBuild", + "Labels", + "StopSignal", + "StopTimeout", + "Shell", + "HostConfig", + "NetworkingConfig", +] as const; + +// Every field of NetworkingConfig. +const NETWORKING_CONFIG_FIELDS = ["EndpointsConfig"] as const; + +// May hold any value. NetworkMode, RestartPolicy and LogConfig are +// overwritten by the mutator when the policy sets them. +const HOST_CONFIG_ANY_VALUE = new Set([ + "NetworkMode", + "RestartPolicy", + "LogConfig", + "CapDrop", + "ReadonlyRootfs", + "Init", + "AutoRemove", + "ConsoleSize", + "ShmSize", + "PortBindings", + "PublishAllPorts", + "Dns", + "DnsOptions", + "DnsSearch", + "ExtraHosts", + "Tmpfs", + "Ulimits", + "Memory", + "MemoryReservation", + "MemorySwap", + "MemorySwappiness", + "NanoCpus", + "CpuShares", + "CpuPeriod", + "CpuQuota", + "CpusetCpus", + "CpusetMems", + "PidsLimit", + "BlkioWeight", + "ContainerIDFile", +]); + +// The values a namespace field may take besides "". "host" and +// "container:" are denied. +const HOST_CONFIG_MODES = new Map([ + ["PidMode", []], + ["IpcMode", ["private", "shareable", "none"]], + ["UTSMode", []], + ["UsernsMode", []], + ["CgroupnsMode", ["private"]], + ["Cgroup", []], + ["Isolation", ["default"]], +]); + +const NO_NEW_PRIVILEGES = new Set(["no-new-privileges", "no-new-privileges:true"]); + +function checkSecurityOpt(value: unknown, policy: Policy): string | null { + if (value === null) return null; + if (!Array.isArray(value)) return `HostConfig.SecurityOpt must be a list for service ${policy.service_name}`; + for (const e of value) { + if (typeof e !== "string") { + return `HostConfig.SecurityOpt entry ${JSON.stringify(e)} is not allowed for service ${policy.service_name}`; + } + if (NO_NEW_PRIVILEGES.has(e) || policy.container_config?.security_options?.includes(e)) continue; + return `HostConfig.SecurityOpt entry ${quote(e)} is not allowed for service ${policy.service_name}`; + } + return null; +} + +// HostConfigGate is the default-deny check on HostConfig (#88). It runs after +// ContainerConfigMutator, on the body that is forwarded, so values the policy +// forces (Privileged, NetworkMode, SecurityOpt, ...) are what it sees. +// +// A key in HOST_CONFIG_ANY_VALUE may hold any value. SecurityOpt, OomScoreAdj +// and the namespace modes have their own rules. Binds and Mounts are checked +// by MountSourceGate. Every other key, known or not, must be absent or hold a +// zero value (null, false, 0, "", [] or {}); MaskedPaths and ReadonlyPaths +// must be absent or null, because an empty list unmasks /proc. +// +// The daemon matches JSON keys without regard to case, and the last match +// wins, so "privileged": true would set Privileged and "securityopt": null +// would clear a forced SecurityOpt. A key that folds to a known field name but +// is not spelled exactly like it is denied, here and at the top level. +export class HostConfigGate implements Gate { + check(_method: string, _path: string, policy: Policy, body: Record): string | null { + const topErr = checkFieldCase("", body, CREATE_BODY_FIELDS, policy); + if (topErr !== null) return topErr; + // NetworkEndpointsGate and the mutator read NetworkingConfig.EndpointsConfig + // by its exact name; the daemon would also read "endpointsconfig". + const nc = body["NetworkingConfig"]; + if (isObject(nc)) { + const ncErr = checkFieldCase("NetworkingConfig.", nc, NETWORKING_CONFIG_FIELDS, policy); + if (ncErr !== null) return ncErr; + } + + const hc = body["HostConfig"]; + if (hc === undefined || hc === null) return null; + if (!isObject(hc)) return `HostConfig must be an object for service ${policy.service_name}`; + const caseErr = checkFieldCase("HostConfig.", hc, HOST_CONFIG_FIELDS, policy); + if (caseErr !== null) return caseErr; + + for (const key of sortedKeys(hc)) { + const value = hc[key]; + // Binds and Mounts: MountSourceGate. + if (key === "Binds" || key === "Mounts" || HOST_CONFIG_ANY_VALUE.has(key)) continue; + if (key === "MaskedPaths" || key === "ReadonlyPaths") { + if (value !== null) return `HostConfig.${key} is not allowed for service ${policy.service_name}`; + continue; + } + if (key === "SecurityOpt") { + const err = checkSecurityOpt(value, policy); + if (err !== null) return err; + continue; + } + if (key === "OomScoreAdj") { + if (value !== null && !(typeof value === "number" && value >= 0)) { + return `HostConfig.OomScoreAdj value ${JSON.stringify(value)} is not allowed for service ${policy.service_name}`; } + continue; } + const modes = HOST_CONFIG_MODES.get(key); + if (modes !== undefined) { + if (value === null || value === "" || (typeof value === "string" && modes.includes(value))) continue; + const shown = typeof value === "string" ? quote(value) : JSON.stringify(value); + return `HostConfig.${key} value ${shown} is not allowed for service ${policy.service_name}`; + } + if (!isZeroValue(value)) return `HostConfig.${key} is not allowed for service ${policy.service_name}`; } - return null; } } @@ -164,10 +585,6 @@ export class CmdGate implements Gate { } } -function isObject(v: unknown): v is Record { - return typeof v === "object" && v !== null && !Array.isArray(v); -} - /** NetworkingConfig.EndpointsConfig of a create body: null when absent or null, "malformed" when not an object. */ function endpointsConfig(body: Record): Record | null | "malformed" { const nc = body["NetworkingConfig"]; @@ -230,18 +647,24 @@ function forceEndpoints(mode: string, body: Record): void { body["NetworkingConfig"] = nc; } +// ContainerConfigMutator overwrites the fields the policy's container_config +// sets. It forces HostConfig.Privileged to false on every create, even without +// a container_config (#88). A HostConfig that is not an object is left alone +// for HostConfigGate to deny. export class ContainerConfigMutator implements Mutator { mutate(policy: Policy, body: Record): void { + const existing = body["HostConfig"]; + if (existing !== undefined && existing !== null && !isObject(existing)) return; + const hostConfig: JsonObject = isObject(existing) ? existing : {}; + body["HostConfig"] = hostConfig; + hostConfig["Privileged"] = false; + const cc = policy.container_config; if (!cc) return; - const hostConfig = (body["HostConfig"] as Record) ?? {}; - body["HostConfig"] = hostConfig; - if (cc.network_mode) hostConfig["NetworkMode"] = cc.network_mode; if (cc.restart_policy) hostConfig["RestartPolicy"] = { Name: cc.restart_policy }; if (cc.security_options?.length) hostConfig["SecurityOpt"] = cc.security_options; - hostConfig["Privileged"] = false; if (cc.user) body["User"] = cc.user; if (cc.log_driver) { hostConfig["LogConfig"] = { Type: cc.log_driver, Config: cc.log_options ?? {} }; From 600c1da571df3522e52c0c8edca40ce176db9b86 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 09:02:11 -0400 Subject: [PATCH 4/5] docs: HostConfig rules, read_write enforcement and counts (#88) README: a HostConfig rules table, read_write enforcement in the policy schema, HostConfigGate in the pipeline, the trust model and the formal verification summary. spec/README.md: the create model, its rows and instances, the new P0 invariants and attack scenarios. AGENTS.md and CONTRIBUTING.md: the new gate and test counts. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 ++++++------- CONTRIBUTING.md | 2 +- README.md | 55 +++++++++++++++++++++++++++++++++++++++++-------- spec/README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f482e1..046fce5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ deploy/ — Docker Compose + integration tests ## Architecture (same across all 3 languages) - `policy/` — Policy types + Manager (loads YAML from config dir) -- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, NetworkEndpointsGate, ContainerConfigMutator (`--readonly` and exec are denied by the router) +- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, HostConfigGate, CmdGate, NetworkEndpointsGate, ContainerConfigMutator (`--readonly` and exec are denied by the router) - `proxy/` — Router (default-deny), Handler (orchestrates chain), Transport (forwards to Docker socket) - `audit/` — JSON-structured logging @@ -39,17 +39,17 @@ deploy/ — Docker Compose + integration tests - Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml) ## Test Coverage -- Go: 118 unit tests (main/listener: 24, policy: 11, middleware: 27, proxy: 52, audit: 4) -- Rust: 145 unit tests (main/listener: 23, policy: 13, middleware: 43, proxy: 47, handler: 13, audit: 4, transport: 2) -- TypeScript: 169 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 34, proxy: 35, policy: 12, handler: 17, shutdown: 5, transport: 5, audit: 4) -- Integration, per implementation: 77 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) -- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`; and, for the image pull, `pull`, `pull_pre83`, `pull_body_ignored` and `pull_pre84`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`) +- Go: 119 unit tests (main/listener: 24, policy: 11, middleware: 28, proxy: 52, audit: 4); the middleware count includes `TestHostConfigRows`, whose 131 #88 rows run as subtests +- Rust: 146 unit tests (main/listener: 23, policy: 13, middleware: 44, proxy: 47, handler: 13, audit: 4, transport: 2); `test_host_config_rows` runs the 131 #88 rows +- TypeScript: 300 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 165 incl. the 131 #88 rows, proxy: 35, policy: 12, handler: 17, shutdown: 5, transport: 5, audit: 4) +- Integration, per implementation: 94 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose) +- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`; and, for the image pull, `pull`, `pull_pre83`, `pull_body_ignored` and `pull_pre84`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`, `create` and `create_pre88`) ## Test Conventions - Go: stdlib `testing` package, `go test ./...` - Rust: `#[cfg(test)]` inline modules, `cargo test` - TypeScript: `node:test` framework, `npm run build && node --test dist/*.test.js` -- Integration: `make test-integration` (77 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose +- Integration: `make test-integration` (94 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose ## Contribution Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e0d90c..8005ffb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ To bypass a hook once, pass `--no-verify` to `git commit` or `git push`. CI runs All three languages implement the same architecture: - `policy/` — Policy types + Manager (loads YAML) -- `middleware/` — Gate trait + Chain (4 gates + 1 mutator: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, ContainerConfigMutator; `--readonly` and exec are denied by the router) +- `middleware/` — Gate trait + Chain (5 gates + 1 mutator: RegistryGate, MountSourceGate, EnvFileGate, HostConfigGate, CmdGate, ContainerConfigMutator; `--readonly` and exec are denied by the router) - `proxy/` — Router (default-deny) + Handler + Transport Changes should be applied to **all three implementations** unless they are language-specific (e.g., CLI flag parsing). diff --git a/README.md b/README.md index f392234..de02ba3 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,9 @@ All three implementations expose the same API surface, share the same [Quint spe | Language | Directory | Tests | Stack | |----------|-----------|-------|-------| -| Go | [go/](go/) | 118 unit + 77 integration + 15 socket | stdlib net/http + yaml.v3 | -| Rust | [rs/](rs/) | 145 unit + 77 integration + 15 socket | tokio, hyper, serde, clap | -| TypeScript | [ts/](ts/) | 169 unit (1 skipped) + 77 integration + 15 socket | Node 22 ESM, built-in http | +| Go | [go/](go/) | 119 unit + 94 integration + 15 socket | stdlib net/http + yaml.v3 | +| Rust | [rs/](rs/) | 146 unit + 94 integration + 15 socket | tokio, hyper, serde, clap | +| TypeScript | [ts/](ts/) | 300 unit (1 skipped) + 94 integration + 15 socket | Node 22 ESM, built-in http | ### Build All @@ -159,6 +159,9 @@ volumes: - host_path: /home/beacon container_path: /data read_write: true + - host_path: /etc/jwt_dir + container_path: /jwtdir + read_write: false # must be mounted read-only: -v /etc/jwt_dir:/jwtdir:ro env_file: /home/beacon/beacon.env allowed_cli_flags: - --rcConfig @@ -169,6 +172,14 @@ denied_flags: - --cap-add ``` +`volumes` is the whole list of host paths a container may mount, through `-v` +(`HostConfig.Binds`) or `--mount type=bind` (`HostConfig.Mounts`). A policy +without `volumes` allows no bind mounts. `read_write: false` (the default when +the key is left out in Go and TypeScript; Rust requires the key) is enforced: +the bind must carry `:ro`, or the mount `ReadOnly: true`, and a bind without a +mode is read-write in Docker, so it is denied. See +[Container create: HostConfig rules](#container-create-hostconfig-rules). + ### Use the Proxy ```bash @@ -186,22 +197,47 @@ docker rm beacon docker exec -it beacon bash # denied: exec not allowed docker run --privileged alpine sh # denied: privileged containers blocked docker pull attacker/malware:latest # denied: image not in allowlist +docker run --cap-add SYS_ADMIN --pid host chainsafe/lodestar:next # denied: HostConfig.CapAdd +docker run --mount type=bind,src=/,dst=/host chainsafe/lodestar:next # denied: mount source not in volumes ``` ## Middleware Pipeline | Middleware | Type | What it checks | |------------|------|----------------| -| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy` from policy, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` | +| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy`, `log_driver` from policy, `HostConfig.Privileged: false` on every create, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` | | RegistryGate | Gate | Validates the normalised image ref against `allowed_image_prefixes`, then `image_tag_pattern` and `image_digest_allowed` | -| MountSourceGate | Gate | Validates volume binds against whitelist | +| MountSourceGate | Gate | Validates `HostConfig.Binds`, `HostConfig.Mounts` and top-level `Volumes` against `volumes`, including bind options and `read_write: false` | | EnvFileGate | Gate | Strips `Env` field from create body; env must come from locked `env_file` | +| HostConfigGate | Gate | Default-deny on `HostConfig`: a field outside the allowlist must be absent or zero ([#88](https://github.com/ChainSafe/docker-socket-policy/issues/88)) | | CmdGate | Gate | Validates each CLI flag in `Cmd` array against allowlist + denylist | | NetworkEndpointsGate | Gate | With a forced `network_mode`, denies `NetworkingConfig.EndpointsConfig` entries for other networks | When a policy forces `network_mode` M, the endpoint entries in `NetworkingConfig.EndpointsConfig` must agree with it, or the daemon creates the container and then refuses to start it with `network default not found`: without `--network`, the Docker CLI sends an entry for `default` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Entries may be keyed `default` or M; any other key is denied with `network "" is not allowed for service ; policy forces network_mode `, since it would attach the container to a network the policy does not allow. For `host`, `none` and `container:` the proxy then forwards an empty `EndpointsConfig`; for `bridge` or a named network it forwards exactly one entry, keyed M, with the settings (aliases, for example) of the caller's M entry, else of its `default` entry. A `NetworkingConfig` that is not a JSON object is denied. Without a forced `network_mode`, `NetworkingConfig` is forwarded as sent. -The chain runs only on container create. `--readonly` is not a gate: the router denies every request that is not `GET` or `HEAD`, on every path, before any other check ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)). Attach is denied by the router for every method, so a websocket attach (`GET /containers/{name}/attach/ws`) cannot reach a container's stdin, with or without `--readonly` ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)). +The chain runs only on container create. The mutator runs first, so the gates check the body that is forwarded to the daemon. `--readonly` is not a gate: the router denies every request that is not `GET` or `HEAD`, on every path, before any other check ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)). Attach is denied by the router for every method, so a websocket attach (`GET /containers/{name}/attach/ws`) cannot reach a container's stdin, with or without `--readonly` ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)). + +### Container create: HostConfig rules + +`HostConfig` is default-deny ([#88](https://github.com/ChainSafe/docker-socket-policy/issues/88)). Before #88 only the host paths in `HostConfig.Binds` were checked, so `--mount`, a bind mode, `--cap-add`, `--pid host`, `--device` and every other `HostConfig` field reached the daemon unchanged. Now each key is checked against this table, after the mutator has run; any key not listed, including fields the Engine API adds later, must be absent or hold a zero value (`null`, `false`, `0`, `""`, `[]` or `{}`), or the create is denied with 403 `HostConfig. is not allowed for service `. The body a plain `docker create` or `docker run` sends passes: the CLI sends most fields with zero values. + +| `HostConfig` key | Rule | +|------------------|------| +| `Binds` | Host path must be a policy `volumes` entry. Bind options: `ro`, `rw`, `z`, `Z`, `nocopy`, `private`, `rprivate`; `shared`, `rshared`, `slave`, `rslave` and anything else are denied. A `read_write: false` volume needs `ro` (no mode means read-write). | +| `Mounts` | `Type: bind`: `Source` must be a policy `volumes` entry; `ReadOnly: true` for a `read_write: false` volume; `BindOptions.Propagation` empty, `private` or `rprivate`; `BindOptions.ReadOnlyNonRecursive` must be zero. `Type: tmpfs`: allowed, without a `Source`. Every other type (`volume`, `image`, `npipe`, `cluster`) is denied. Other entry keys besides `Target`, `Consistency` and `TmpfsOptions` must be zero. | +| `NetworkMode`, `RestartPolicy`, `LogConfig` | Any value; overwritten when the policy sets `network_mode`, `restart_policy` or `log_driver`. | +| `SecurityOpt` | Each entry must be in the policy's `security_options` or be `no-new-privileges` / `no-new-privileges:true`. Overwritten when the policy sets `security_options`. | +| `Privileged` | Forced to `false` by the mutator on every create, so `true` is neutralised, not denied. | +| `PidMode`, `UTSMode`, `UsernsMode`, `Cgroup` | `""` only. | +| `IpcMode` | `""`, `private`, `shareable` or `none`. | +| `CgroupnsMode` | `""` or `private`. | +| `Isolation` | `""` or `default`. | +| `OomScoreAdj` | `0` or more. | +| `MaskedPaths`, `ReadonlyPaths` | Absent or `null` only: an empty list unmasks `/proc` (`--security-opt systempaths=unconfined`). | +| `CapDrop`, `ReadonlyRootfs`, `Init`, `AutoRemove`, `ConsoleSize`, `ShmSize`, `PortBindings`, `PublishAllPorts`, `Dns`, `DnsOptions`, `DnsSearch`, `ExtraHosts`, `Tmpfs`, `Ulimits`, `Memory`, `MemoryReservation`, `MemorySwap`, `MemorySwappiness`, `NanoCpus`, `CpuShares`, `CpuPeriod`, `CpuQuota`, `CpusetCpus`, `CpusetMems`, `PidsLimit`, `BlkioWeight`, `ContainerIDFile` | Any value. | +| Everything else: `CapAdd`, `Devices`, `DeviceRequests`, `DeviceCgroupRules`, `GroupAdd`, `VolumesFrom`, `Links`, `Sysctls`, `CgroupParent`, `Runtime`, `VolumeDriver`, `OomKillDisable`, `Annotations`, `StorageOpt`, the other resource fields, and unknown keys | Absent or zero. | + +The daemon matches JSON field names without regard to case, and the last match wins, so `"privileged": true` would set `Privileged` after the mutator's `"Privileged": false`, and `"securityopt": null` would clear a forced `SecurityOpt`. A key that the daemon would read as a known field but that is not spelled exactly like it (`privileged`, `hostConfig`, `user`, `image`, …) is denied, at the top level of the body, in `HostConfig`, in `Mounts` entries and their `BindOptions`, and in `NetworkingConfig`, whether or not the policy forces `network_mode`: `NetworkEndpointsGate` reads `EndpointsConfig` only, and the daemon would attach the networks under `endpointsconfig` too. Inside `RestartPolicy` and `LogConfig` the spelling does not matter: the mutator replaces the whole object when the policy sets it, and otherwise any value is allowed; no gate reads `Healthcheck` or the settings inside an endpoint entry. The policy's `ports` field is parsed but not enforced; `PortBindings` is not restricted by it. ## Endpoint Access @@ -388,7 +424,8 @@ proxy's `--config-dir`. A policy is a per-service *template*, chosen by the `Image` of the request. It fixes what a container from that image may look like: volumes, network mode, -env file, user, CLI flags. A policy says nothing about *who* may ask for it. A +env file, user, CLI flags, and no other host access (`HostConfig` is +default-deny, #88). A policy says nothing about *who* may ask for it. A caller who names `chainsafe/lodestar` gets the policy for that image, whichever service the caller belongs to. This is intended, not a gap: callers on a shared socket are all the same caller as far as the kernel can tell (for @@ -456,7 +493,7 @@ not at the socket. ## Formal Verification -This project includes a [Quint](https://quint-lang.org/) formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 9 invariants on every state transition. A second module, `spec/listener.qnt`, models listening-socket startup (group selection, existing-path checks, the single-instance lock) with 6 more invariants. A third module, `spec/router.qnt`, models the router's denies for writes under `--readonly` ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)), percent-encoded paths ([#53](https://github.com/ChainSafe/docker-socket-policy/issues/53)), empty interior segments ([#55](https://github.com/ChainSafe/docker-socket-policy/issues/55)), dot segments ([#70](https://github.com/ChainSafe/docker-socket-policy/issues/70)), exec ([#49](https://github.com/ChainSafe/docker-socket-policy/issues/49)) and attach ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)), the GET/HEAD-only read passthrough ([#78](https://github.com/ChainSafe/docker-socket-policy/issues/78)), the `POST /containers/create` route, container-name extraction and the container-lifecycle branch, not the full routing table ([#24](https://github.com/ChainSafe/docker-socket-policy/issues/24), [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). Its 12 properties are checked exhaustively over the methods `GET`, `HEAD`, `POST`, `DELETE` and every path of 1 to 3 segments over the model's word set; exec is denied by the router, not by a middleware gate. +This project includes a [Quint](https://quint-lang.org/) formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 11 invariants on every state transition. The same file models the create check on `HostConfig` ([#88](https://github.com/ChainSafe/docker-socket-policy/issues/88)): the default-deny field rules, `Mounts`, bind options and read-only volumes, and the case-insensitive field names the daemon accepts; its properties are checked exhaustively over every single-field, single-bind and single-mount request of the model, and instance `create_pre88` shows that they failed before the fix. A second module, `spec/listener.qnt`, models listening-socket startup (group selection, existing-path checks, the single-instance lock) with 6 more invariants. A third module, `spec/router.qnt`, models the router's denies for writes under `--readonly` ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)), percent-encoded paths ([#53](https://github.com/ChainSafe/docker-socket-policy/issues/53)), empty interior segments ([#55](https://github.com/ChainSafe/docker-socket-policy/issues/55)), dot segments ([#70](https://github.com/ChainSafe/docker-socket-policy/issues/70)), exec ([#49](https://github.com/ChainSafe/docker-socket-policy/issues/49)) and attach ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)), the GET/HEAD-only read passthrough ([#78](https://github.com/ChainSafe/docker-socket-policy/issues/78)), the `POST /containers/create` route, container-name extraction and the container-lifecycle branch, not the full routing table ([#24](https://github.com/ChainSafe/docker-socket-policy/issues/24), [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). Its 12 properties are checked exhaustively over the methods `GET`, `HEAD`, `POST`, `DELETE` and every path of 1 to 3 segments over the model's word set; exec is denied by the router, not by a middleware gate. The request-handling spec also has a module, `network_endpoints_model`, for the endpoint entries under a forced `network_mode` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Its 3 properties (extra networks are denied, an allowed create names no network but the forced one, and every allowed create starts) are checked over five network modes and every endpoint map over seven keys; `network_endpoints_pre87` shows all three failing before the fix. @@ -466,7 +503,7 @@ The CI pipeline runs verification on every push and PR. A violation blocks the b make typecheck # Quint type-check (proves type safety) make verify # Random-simulation verification (default evaluator) make verify BACKEND=rust # Same, using the faster Rust backend -make test-spec # Quint `run` tests for listener.qnt, router.qnt and network_endpoints (one per table row) +make test-spec # Quint `run` tests for listener.qnt, router.qnt, network_endpoints and the create model (one per table row) make validate # All checks: typecheck + verify + go vet + go test ``` diff --git a/spec/README.md b/spec/README.md index 7a07493..56309ab 100644 --- a/spec/README.md +++ b/spec/README.md @@ -6,7 +6,7 @@ This directory contains a [Quint](https://quint-lang.org/) formal specification | 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. Module `network_endpoints_model` models `NetworkingConfig.EndpointsConfig` under a forced `network_mode` (#87), one `run` test per table row, instances `network_endpoints` (all three after #87) and `network_endpoints_pre87` | +| `docker_socket_policy.qnt` | Request-handling spec: policy types, state machine, endpoint routing table, 11 invariants (8 P0 / 3 P1), 6 attack scenario simulations. Module `network_endpoints_model` models `NetworkingConfig.EndpointsConfig` under a forced `network_mode` (#87), one `run` test per table row, instances `network_endpoints` (all three after #87) and `network_endpoints_pre87`. Also the create check on `HostConfig` (module `create_model`, #88): the default-deny field rules, `Mounts`, bind options, read-only volumes and case-insensitive field names, with one `run` test per table row. Instances `create` (all three after #88) and `create_pre88` (all three before #88); the state machine uses the `create` rules | | `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 | | `router.qnt` | The percent-encoded path deny, the empty interior segment deny, the dot segment deny, the segment-exact exec deny and `POST /containers/create` route, the segment-exact attach deny, the GET/HEAD-only read passthrough with `/_ping` standing for the read endpoints, container-name extraction and the container-lifecycle branch of the router only, not the full router. The daemon's percent-decoding as a fixed table. One `run` test per table row. Instances `router` (Go, TypeScript, and Rust after #48, #53, #49, #55, #76, #78 and #70), `router_pre48` (Rust's extraction rule before #48), `router_pre53` (Rust and TypeScript before #53, routing on the raw path), `router_pre55` (all three before #55, letting empty interior segments through), `router_readonly` (as `router`, with `--readonly`, #75), `router_pre76` (all three before #76, letting attach through), `router_pre78` (all three before #78, allowing the read endpoints for every method) and `router_pre70` (all three before #70, letting `.` and `..` segments through). Every instance includes the #49 exec and create rule, so the pre instances isolate one earlier rule each rather than being full snapshots. Module `pull_model` models the `POST /images/create` branch: which of the body and the query names the image, and image-name normalisation as a fixed table. Instances `pull` (all three after #83 and #84), `pull_pre83` (all three before #83, reading `fromImage` from the body), `pull_body_ignored` (the first #83 fix, which forwarded a pull's body) and `pull_pre84` (all three before #84, matching the spelling as sent) | @@ -52,6 +52,9 @@ quint test spec/router.qnt --main=pull_pre84 # Network endpoints under a forced network_mode (#87): the table tests on both instances quint test spec/docker_socket_policy.qnt --main=network_endpoints quint test spec/docker_socket_policy.qnt --main=network_endpoints_pre87 +# Create model (#88): the table tests, and the pre-fix witness +quint test spec/docker_socket_policy.qnt --main=create +quint test spec/docker_socket_policy.qnt --main=create_pre88 # Formal model-checking via Apalache (exhaustive, requires Java) quint verify --max-steps=10 --invariants allInvariants spec/docker_socket_policy.qnt @@ -63,12 +66,14 @@ quint verify --max-steps=10 --invariants allInvariants spec/docker_socket_policy | Invariant | What It Checks | Guard / Mutator | |-----------|---------------|-----------------| -| `noPrivilegedAccess` | No created container has `privileged=true` | ContainerConfigMutator sets `privileged=false` | +| `noPrivilegedAccess` | No created container has `privileged=true` | ContainerConfigMutator sets `privileged=false` on every create (#88: also without a `container_config`) | | `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 | +| `hostConfigInAllowlist` | Every `HostConfig` field the daemon read is allowed, with an allowed value (#88) | HostConfigGate → `createAllowed()` | +| `hostMountsInWhitelist` | Every host path the daemon mounted is a policy volume, read-only when the volume is, and never propagating (#88) | MountSourceGate → `createAllowed()` | ### P1 Security Invariants (Should Never Fail) @@ -230,6 +235,43 @@ Each implementation's router tests use the same row names in comments (added wit | `namedOwnKept` | `mynet` | `{"mynet": {Aliases}}` | `{"mynet": {Aliases}}` | | `namedExtraDenied` | `mynet` | `{"default": {}, "other": {}}` | deny | | `namedOwnOverDefault` | `mynet` | `{"default": {Aliases}, "mynet": {}}` | `{"mynet": {}}` | +### Container create: HostConfig (`create_model` in `docker_socket_policy.qnt`) + +The create check of [#88](https://github.com/ChainSafe/docker-socket-policy/issues/88): ContainerConfigMutator, then MountSourceGate and HostConfigGate on the mutated body. `LEGACY_CREATE = true` models all three before #88; `false` all three after. Four properties are checked exhaustively over both model policies (`BEACON`: volumes `/home/beacon` and read-only `/etc/jwt_dir`, a full `container_config`; `TOOLS`: no volumes, no `container_config`) and every single-field, single-bind and single-mount request over the model's atoms, plus every `NetworkingConfig` holding the CLI's `default` entry and one more entry, under `EndpointsConfig` or `endpointsconfig`: + +| Property | Claim | +|----------|-------| +| `createChecksEveryField` | The rule: an allowed create has only exactly spelled field names, allowed values after the mutator, and allowed binds and mounts. Close to a tautology on `create`. | +| `daemonSeesOnlyAllowlistedHostConfig` | What the daemon reads (fields under their canonical names) is allowlisted with allowed values, `Privileged` is zero, and every value the policy forces (`NetworkMode`, `SecurityOpt`) is the forced one. Does not mention the gate's rules or the flag. | +| `daemonMountsOnlyWhitelisted` | Every host path the daemon mounts, from `Binds` or `Mounts`, is a policy volume, is not writable when the volume is read-only, and does not propagate. | +| `daemonAttachesOnlyForcedNetwork` | When the policy forces `network_mode`, the daemon attaches no network but the mode (or the CLI's `default`), reading `EndpointsConfig` under any spelling. `NetworkEndpointsGate` (#87, in both instances) reads the exact key only. | + +`create` runs `createSoundTest` (all four hold) and the rows below; `create_pre88` runs `pre88UnsoundTest`, which asserts that the three security properties fail and that the #88 reproductions were allowed: a `Mounts` bind of `/`, a `:rw` and a mode-less bind of the read-only volume, `CapAdd`, `PidMode: host`, any bind on a policy with no volumes, `Privileged: true` reaching the daemon on a policy with no `container_config`, a zero-valued `securityopt` clearing a forced `SecurityOpt`, and an `endpointsconfig` entry attaching `evil-net` under a forced `host`. The deny rows run on `create` only. + +Rows, with the names the language tables use (the language tables have more rows, one per field): + +| Row (`run Test`) | Request | Outcome | +|-----|---------|---------| +| `capAddDenied`, `devicesDenied`, `groupAddDenied`, `unknownFieldDenied` | a non-zero field outside the allowlist | deny | +| `maskedPathsEmptyDenied` | `MaskedPaths: []` | deny | +| `pidModeHostDenied`, `pidModeContainerDenied`, `ipcModeHostDenied` | `host` / `container:` namespace modes | deny | +| `ipcModePrivateAllowed` | `IpcMode: private` | allow | +| `oomScoreAdjNegativeDenied` / `oomScoreAdjPositiveAllowed` | `OomScoreAdj` -1000 / 500 | deny / allow | +| `capDropAllowed`, `memorySwappinessAllowed`, `networkModeAllowed` | any-value fields | allow | +| `zeroValuesAllowed` | denied fields with zero values | allow | +| `securityOptSeccompUnconfinedDenied` / `securityOptNoNewPrivilegesAllowed` | `SecurityOpt` on a policy without `security_options` | deny / allow | +| `securityOptOverwrittenByPolicy` | `SecurityOpt: [seccomp=unconfined]` on `BEACON` | allow; the daemon reads the policy's list | +| `caseVariantPrivilegedDenied`, `caseVariantZeroSecurityOptDenied` | `privileged: true`, `securityopt: null` | deny | +| `networkingConfigVariantDenied` / `networkingConfigVariantBareDenied` | `NetworkingConfig` with `endpointsconfig: {evil-net}` on `BEACON` (forces `host`) / `TOOLS` | deny | +| `privilegedNeutralised`, `privilegedNeutralisedEmptyContainerConfig` | `Privileged: true` on `BEACON` / `TOOLS` | allow; the daemon reads `false` | +| `mountBindRootDenied`, `mountVolumeTypeDenied`, `mountTmpfsWithSourceDenied`, `mountPropagationRsharedDenied`, `mountNoVolumesDenied` | `Mounts` | deny | +| `mountBindReadOnlyVolumeWritableDenied` / `mountBindReadOnlyVolumeReadOnlyAllowed` | bind of `/etc/jwt_dir`, `ReadOnly` false / true | deny / allow | +| `mountBindAllowed`, `mountTmpfsAllowed`, `mountPropagationRprivateAllowed` | `Mounts` | allow | +| `bindReadOnlyVolumeRwDenied`, `bindReadOnlyVolumeNoModeDenied`, `bindReadOnlyVolumeRoRwDenied` | `/etc/jwt_dir:…:rw`, no mode, `ro,rw` | deny | +| `bindReadOnlyVolumeRoAllowed`, `bindReadOnlyVolumeRoZAllowed`, `bindAllowed` | `:ro`, `:ro,Z`, a read-write volume | allow | +| `bindRsharedDenied`, `bindUnlistedDenied`, `bindNoVolumesDenied` | `Binds` | deny | + +The state machine imports `create_model(LEGACY_CREATE = false)`: `createContainer` takes the request's `HostConfig`, guards on `createAllowed`, and records what the daemon read; the P0 invariants `hostConfigInAllowlist` and `hostMountsInWhitelist` check it on every step of `make verify`, and `noPrivilegedAccess` now reads `Privileged` from it instead of rejecting privileged requests (the implementations neutralise them). ### Image pull (`router.qnt`, `pull_model`) @@ -290,6 +332,8 @@ Two invariants are structurally tautological within the Quint model — they can - **Network endpoints are a pure function, not part of the state machine (#87).** `createContainer` records the forced `networkMode` but takes no `NetworkingConfig`, and nothing in the state machine models the daemon's start, so the endpoint rule sits in its own module beside it and is checked by `quint test`, not by `make verify`. When the policy does not force `network_mode`, `NetworkingConfig` is forwarded untouched; neither the model nor the implementations check it then. Whether the daemon accepts the settings kept on a re-keyed entry (aliases on the default `bridge` network, for example) is the daemon's decision; the model fixes only the keys. `extraNetworkDenied` and `onlyForcedNetwork` are close to tautologies on `network_endpoints`, since `handleEndpoints` tests the key set directly; `network_endpoints_pre87` shows they are not vacuous. Dropping the extra keys instead of denying would keep `onlyForcedNetwork` and fail `extraNetworkDenied`; that was rejected, because the caller asked for a network the policy does not allow and should be told. A malformed `NetworkingConfig` or `EndpointsConfig` (not a JSON object) is denied by the implementations under a forced mode; the model's maps cannot express it, so the language tests pin it. - **Image references are tokens, normalisation is a table (#84).** Quint cannot split a string, so `pull_model` cannot parse `[registry/]repository[:tag][@digest]`. `CANONICAL` lists the spellings in the universe and the repository each one names; the implementations compute the same thing with one parser per language, used for the request's image (create `Image` and pull `fromImage`), for the registry gate and for each policy prefix at load time. The parser's rules (the tag is after the last `:` that follows the last `/`; the first component is a registry when it contains `.` or `:` or is `localhost`; `docker.io`, `index.docker.io` and `registry-1.docker.io` are dropped, and so is `library/` before a single-component name; only the registry is lower-cased, and upper case in the path is invalid) are pinned by the same normalisation table in the Go, Rust and TypeScript tests, not by the model. So are the namespace-boundary prefix match (`prefix` or `prefix/…`), which the model reduces to equality, and the tag and digest rules. +- **The create model is a small universe (#88).** Values are atoms (`"null"`, `"zero"`, `"[]"` and a few non-zero ones), a list field holds one entry, and the field names stand for the classes in the README's HostConfig table: `CapAdd`, `Devices`, `GroupAdd` for the must-be-zero fields, `FutureField` for a field the proxy does not know, `CapDrop` and `MemorySwappiness` for the any-value fields, `PidMode` and `IpcMode` for the namespace modes. Case folding is a fixed table (`CASE_FOLD`), as percent-decoding is in `router.qnt`: Quint cannot fold strings. The implementations fold ASCII case plus the Kelvin sign and the long s, as Go's `encoding/json` does, and check the top level of the body, `Mounts` entries and `BindOptions` too; the language tables cover those (`caseVariantLongSDenied`, `caseVariantKelvinDenied`, `topLevel*VariantDenied`, `mountCaseVariantDenied`, `networkingConfigVariantLongSDenied`). The other nested objects need no check: the mutator replaces `RestartPolicy` and `LogConfig` whole when the policy sets them and they are any-value otherwise, and no gate reads `Healthcheck` or an endpoint entry's settings by key. Dropping the `NetworkingConfig` variant check from `createAllowed` makes the two `networkingConfigVariant*` rows and `createSoundTest` fail. Requests are single-field, single-bind or single-mount; a request that combines a canonical field with a variant needs no separate case, because the daemon may keep either value and the property checks every value it may read. Bind parsing (`src:dst:opts`, the escaped colon in Go), the zero-value test on JSON types and the type checks (a `Binds` entry that is not a string) are language-only rows. +- **Mutating the create rules makes the rows fail.** Dropping the case-variant check from `fieldOk` makes `caseVariantZeroSecurityOptDenied` and `createSoundTest` fail (`caseVariantPrivilegedDenied` still passes: `privileged` is not an allowlisted name, so the zero-value rule denies its `true`). Dropping the read-only clause from `mountOk` makes `mountBindReadOnlyVolumeWritableDenied` and `createSoundTest` fail. - **Listener fault bias.** `step` crashes 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. ### Attack Scenarios Prevented by Invariants @@ -303,6 +347,10 @@ Two invariants are structurally tautological within the Quint model — they can | 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` | +| Host Root Mount | `createContainer` with `Mounts: [{Type: bind, Source: /}]` | `hostMountsInWhitelist` — `/` is not a policy volume (#88) | +| Read-only Volume Written | `createContainer` with `Binds: [/etc/jwt_dir:/jwtdir:rw]` | `hostMountsInWhitelist` — the volume is `read_write: false` (#88) | +| Capabilities / Host PID | `createContainer` with `CapAdd: [SYS_ADMIN]` or `PidMode: host` | `hostConfigInAllowlist` — default-deny on `HostConfig` (#88) | +| Case-variant Field | `createContainer` with `privileged: true` | `noPrivilegedAccess`, `hostConfigInAllowlist` — the variant is denied (#88) | ## State Machine @@ -318,7 +366,8 @@ Two invariants are structurally tautological within the Quint model — they can │ │ │ createContainer(name, imageRef, ...) │ │ │ guards: nondet, imageNameAllowed, flagAllowed, │ - │ │ volumeAllowed, envAllowed, not(priv) │ + │ │ volumeAllowed, envAllowed, │ + │ │ createAllowed (HostConfig, #88) │ │ │ ValidImage only (invalid rejected) │ │ │ mutators: enforcedPrivileged, networkMode, │ │ │ effectiveFlags, effectiveVolumes, │ From 539287b62e6337c59caf47d594beb8fd0f1c53b7 Mon Sep 17 00:00:00 2001 From: Adrian Bienkowski Date: Wed, 14 Oct 2026 11:05:23 -0400 Subject: [PATCH 5/5] test: send the Docker CLI 29 create body on API v1.45 (#88) The CI runner's daemon supports API 1.48 at most, so /v1.52/ got a 400 before the proxy's decision mattered. The body is unchanged. Co-Authored-By: Claude Opus 5.5 --- deploy/test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/test.sh b/deploy/test.sh index 397bb10..252c0f9 100755 --- a/deploy/test.sh +++ b/deploy/test.sh @@ -354,8 +354,9 @@ check_denied "create with an extra lower-case image -> 403" "$S" "$BODY" 'image S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/config.yml"],"NetworkingConfig":{"EndpointsConfig":{"default":{}},"endpointsconfig":{"evil-net":{}}}}' "$PROXY/containers/create") check_denied "create with lower-case endpointsconfig -> 403" "$S" "$BODY" 'NetworkingConfig.endpointsconfig is not allowed' -# The body Docker CLI 29 sends for `docker create `. -S=$(post_json '{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["--rcConfig","/data/config.yml"],"Image":"chainsafe/lodestar:beacon","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{}}}}' "$PROXY/v1.52/containers/create") +# The body Docker CLI 29 sends for `docker create `, sent on API +# v1.45 like the rest of the suite: the CI runner's daemon is older than CLI 29. +S=$(post_json '{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":true,"AttachStderr":true,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":null,"Cmd":["--rcConfig","/data/config.yml"],"Image":"chainsafe/lodestar:beacon","Volumes":{},"WorkingDir":"","Entrypoint":null,"Labels":{},"HostConfig":{"Binds":null,"ContainerIDFile":"","LogConfig":{"Type":"","Config":{}},"NetworkMode":"default","PortBindings":{},"RestartPolicy":{"Name":"no","MaximumRetryCount":0},"AutoRemove":false,"VolumeDriver":"","VolumesFrom":null,"ConsoleSize":[0,0],"CapAdd":null,"CapDrop":null,"CgroupnsMode":"","Dns":null,"DnsOptions":[],"DnsSearch":[],"ExtraHosts":null,"GroupAdd":null,"IpcMode":"","Cgroup":"","Links":null,"OomScoreAdj":0,"PidMode":"","Privileged":false,"PublishAllPorts":false,"ReadonlyRootfs":false,"SecurityOpt":null,"UTSMode":"","UsernsMode":"","ShmSize":0,"Isolation":"","CpuShares":0,"Memory":0,"NanoCpus":0,"CgroupParent":"","BlkioWeight":0,"BlkioWeightDevice":[],"BlkioDeviceReadBps":[],"BlkioDeviceWriteBps":[],"BlkioDeviceReadIOps":[],"BlkioDeviceWriteIOps":[],"CpuPeriod":0,"CpuQuota":0,"CpuRealtimePeriod":0,"CpuRealtimeRuntime":0,"CpusetCpus":"","CpusetMems":"","Devices":[],"DeviceCgroupRules":null,"DeviceRequests":null,"MemoryReservation":0,"MemorySwap":0,"MemorySwappiness":-1,"OomKillDisable":false,"PidsLimit":0,"Ulimits":null,"CpuCount":0,"CpuPercent":0,"IOMaximumIOps":0,"IOMaximumBandwidth":0,"MaskedPaths":null,"ReadonlyPaths":null},"NetworkingConfig":{"EndpointsConfig":{"default":{}}}}' "$PROXY/v1.45/containers/create") check_not_denied "create with the Docker CLI 29 default body" "$S" # ─── Inline env vars (env_file is set in policy) ─────────