Skip to content

Commit c4160de

Browse files
authored
Merge pull request #92 from CodesWhat/feat/swarm-user-privileges-parity
✨ feat(filter): swarm ContainerSpec User/Privileges enforcement parity
2 parents 7a41edc + e084e93 commit c4160de

10 files changed

Lines changed: 343 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Swarm service create/update now enforces the container-create identity/privilege rails.** Four opt-in `request_body.service` knobs mirror their container-create counterparts onto the swarm `ContainerSpec`, closing a bypass where a service could request a workload posture that `/containers/create` would have denied: `require_non_root_user` (`ContainerSpec.User`), `require_no_new_privileges` (`ContainerSpec.Privileges.NoNewPrivileges`), `require_readonly_rootfs` (`ContainerSpec.ReadOnly`), and `require_drop_all_capabilities` (`ContainerSpec.CapabilityDrop` must include `ALL`). All default off. The root-user check reuses the same numeric-UID parsing as container-create, so zero-padded `"00"`/`"0:0"` forms are rejected. (Swarm has no privileged mode, per-service namespace sharing, or runtime/device selection, so those container-create rails have no service equivalent.)
13+
1014
### Security
1115

1216
- **Zero-padded numeric UIDs can no longer bypass root-user enforcement.** `require_non_root_user` (container create) and `allow_root_user: false` (exec) compared the user field to the exact string `"0"`, but Docker parses `Config.User` numerically — so `"00"`, `"000"`, or `"0000:5"` all run as root while slipping past the check. Both inspectors now parse the UID and treat any value resolving to 0 as root.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ LinuxServer's socket-proxy env surface is already Tecnativa-compatible for the b
461461

462462
| Tier | Theme |
463463
|---|---|
464-
| Security hardening (v1.x) | Continued mutation-test hardening of the rule-evaluation core and config validators; swarm `ContainerSpec.User` / `Privileges` enforcement parity with container create; `HostConfig.SecurityOpt` `label=`/`systempaths=` policy evaluation (currently passed through) |
464+
| Security hardening (v1.x) | Continued mutation-test hardening of the rule-evaluation core and config validators; swarm `ContainerSpec` seccomp/AppArmor mode enforcement parity (the `Privileges.NoNewPrivileges`, `User`, `ReadOnly`, and `CapabilityDrop` rails already mirror container-create); `HostConfig.SecurityOpt` `label=`/`systempaths=` policy evaluation (currently passed through) |
465465
| Policy refinement (v1.x) | Multiple frontend listeners on the main proxy, named rule path aliases |
466466
| Internals (v1.x) | Code-review backlog: collapse the config → filter-options → policy translation layers behind a single source of truth (generated Viper defaults); allocation-free rate-limit bucket state (packed `atomic.Uint64`); profiling-gated JSON redaction fast path; clear per-profile in-flight gauges when a hot reload removes the profile |
467467
| Compliance (v1.x) | CIS Docker Benchmark control mapping, audit-ready policy templates |

app/internal/config/config.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,15 +293,19 @@ type ConfigRequestBodyConfig struct {
293293

294294
// ServiceRequestBodyConfig configures inspection for service create/update.
295295
type ServiceRequestBodyConfig struct {
296-
AllowHostNetwork bool `mapstructure:"allow_host_network"`
297-
AllowedBindMounts []string `mapstructure:"allowed_bind_mounts"`
298-
AllowAllRegistries bool `mapstructure:"allow_all_registries"`
299-
AllowOfficial bool `mapstructure:"allow_official"`
300-
AllowedRegistries []string `mapstructure:"allowed_registries"`
301-
AllowAllCapabilities bool `mapstructure:"allow_all_capabilities"`
302-
AllowedCapabilities []string `mapstructure:"allowed_capabilities"`
303-
AllowSysctls bool `mapstructure:"allow_sysctls"`
304-
ImageTrust ImageTrustConfig `mapstructure:"image_trust"`
296+
AllowHostNetwork bool `mapstructure:"allow_host_network"`
297+
AllowedBindMounts []string `mapstructure:"allowed_bind_mounts"`
298+
AllowAllRegistries bool `mapstructure:"allow_all_registries"`
299+
AllowOfficial bool `mapstructure:"allow_official"`
300+
AllowedRegistries []string `mapstructure:"allowed_registries"`
301+
AllowAllCapabilities bool `mapstructure:"allow_all_capabilities"`
302+
AllowedCapabilities []string `mapstructure:"allowed_capabilities"`
303+
AllowSysctls bool `mapstructure:"allow_sysctls"`
304+
RequireNonRootUser bool `mapstructure:"require_non_root_user"`
305+
RequireNoNewPrivileges bool `mapstructure:"require_no_new_privileges"`
306+
RequireReadonlyRootfs bool `mapstructure:"require_readonly_rootfs"`
307+
RequireDropAllCapabilities bool `mapstructure:"require_drop_all_capabilities"`
308+
ImageTrust ImageTrustConfig `mapstructure:"image_trust"`
305309
}
306310

307311
// SwarmRequestBodyConfig configures inspection for swarm writes.

app/internal/config/filter_options.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,19 @@ func (c ConfigRequestBodyConfig) ToFilterOptions() filter.ConfigOptions {
179179

180180
func (c ServiceRequestBodyConfig) ToFilterOptions() filter.ServiceOptions {
181181
return filter.ServiceOptions{
182-
AllowHostNetwork: c.AllowHostNetwork,
183-
AllowedBindMounts: c.AllowedBindMounts,
184-
AllowAllRegistries: c.AllowAllRegistries,
185-
AllowOfficial: c.AllowOfficial,
186-
AllowedRegistries: c.AllowedRegistries,
187-
AllowAllCapabilities: c.AllowAllCapabilities,
188-
AllowedCapabilities: c.AllowedCapabilities,
189-
AllowSysctls: c.AllowSysctls,
190-
ImageTrust: c.ImageTrust.toFilterOptions(),
182+
AllowHostNetwork: c.AllowHostNetwork,
183+
AllowedBindMounts: c.AllowedBindMounts,
184+
AllowAllRegistries: c.AllowAllRegistries,
185+
AllowOfficial: c.AllowOfficial,
186+
AllowedRegistries: c.AllowedRegistries,
187+
AllowAllCapabilities: c.AllowAllCapabilities,
188+
AllowedCapabilities: c.AllowedCapabilities,
189+
AllowSysctls: c.AllowSysctls,
190+
RequireNonRootUser: c.RequireNonRootUser,
191+
RequireNoNewPrivileges: c.RequireNoNewPrivileges,
192+
RequireReadonlyRootfs: c.RequireReadonlyRootfs,
193+
RequireDropAllCapabilities: c.RequireDropAllCapabilities,
194+
ImageTrust: c.ImageTrust.toFilterOptions(),
191195
}
192196
}
193197

app/internal/config/filter_options_test.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,15 @@ func TestRequestBodyConfigToFilterOptionsMapsEveryPolicy(t *testing.T) {
8282
AllowTemplateDrivers: true,
8383
},
8484
Service: ServiceRequestBodyConfig{
85-
AllowHostNetwork: true,
86-
AllowedBindMounts: []string{"/srv/services"},
87-
AllowAllRegistries: true,
88-
AllowOfficial: false,
89-
AllowedRegistries: []string{"registry.example.com"},
85+
AllowHostNetwork: true,
86+
AllowedBindMounts: []string{"/srv/services"},
87+
AllowAllRegistries: true,
88+
AllowOfficial: false,
89+
AllowedRegistries: []string{"registry.example.com"},
90+
RequireNonRootUser: true,
91+
RequireNoNewPrivileges: true,
92+
RequireReadonlyRootfs: true,
93+
RequireDropAllCapabilities: true,
9094
},
9195
Swarm: SwarmRequestBodyConfig{
9296
AllowForceNewCluster: true,
@@ -196,11 +200,15 @@ func TestRequestBodyConfigToFilterOptionsMapsEveryPolicy(t *testing.T) {
196200
AllowTemplateDrivers: true,
197201
},
198202
Service: filter.ServiceOptions{
199-
AllowHostNetwork: true,
200-
AllowedBindMounts: []string{"/srv/services"},
201-
AllowAllRegistries: true,
202-
AllowOfficial: false,
203-
AllowedRegistries: []string{"registry.example.com"},
203+
AllowHostNetwork: true,
204+
AllowedBindMounts: []string{"/srv/services"},
205+
AllowAllRegistries: true,
206+
AllowOfficial: false,
207+
AllowedRegistries: []string{"registry.example.com"},
208+
RequireNonRootUser: true,
209+
RequireNoNewPrivileges: true,
210+
RequireReadonlyRootfs: true,
211+
RequireDropAllCapabilities: true,
204212
},
205213
Swarm: filter.SwarmOptions{
206214
AllowForceNewCluster: true,

app/internal/config/load.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ func setLoadDefaults(v *viper.Viper, defaults Config) {
159159
v.SetDefault("request_body.service.allow_all_capabilities", defaults.RequestBody.Service.AllowAllCapabilities)
160160
v.SetDefault("request_body.service.allowed_capabilities", defaults.RequestBody.Service.AllowedCapabilities)
161161
v.SetDefault("request_body.service.allow_sysctls", defaults.RequestBody.Service.AllowSysctls)
162+
v.SetDefault("request_body.service.require_non_root_user", defaults.RequestBody.Service.RequireNonRootUser)
163+
v.SetDefault("request_body.service.require_no_new_privileges", defaults.RequestBody.Service.RequireNoNewPrivileges)
164+
v.SetDefault("request_body.service.require_readonly_rootfs", defaults.RequestBody.Service.RequireReadonlyRootfs)
165+
v.SetDefault("request_body.service.require_drop_all_capabilities", defaults.RequestBody.Service.RequireDropAllCapabilities)
162166
v.SetDefault("request_body.container_create.image_trust.require_rekor_inclusion", defaults.RequestBody.ContainerCreate.ImageTrust.RequireRekorInclusion)
163167
v.SetDefault("request_body.container_create.image_trust.verify_timeout", defaults.RequestBody.ContainerCreate.ImageTrust.VerifyTimeout)
164168
v.SetDefault("request_body.service.image_trust.require_rekor_inclusion", defaults.RequestBody.Service.ImageTrust.RequireRekorInclusion)

app/internal/config/load_env_defaults_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@ func TestLoadHonorsAllowSysctlsEnvVar(t *testing.T) {
1919
}
2020
}
2121

22+
func TestLoadHonorsServiceHardeningEnvVars(t *testing.T) {
23+
// The swarm-parity hardening rails are env-only unless registered in
24+
// setLoadDefaults; guard all four so a missing SetDefault can't silently
25+
// drop them.
26+
t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_NON_ROOT_USER", "true")
27+
t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_NO_NEW_PRIVILEGES", "true")
28+
t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_READONLY_ROOTFS", "true")
29+
t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_REQUIRE_DROP_ALL_CAPABILITIES", "true")
30+
31+
cfg, err := Load("/nonexistent-so-defaults-and-env-only.yaml")
32+
if err != nil {
33+
t.Fatalf("Load() = %v", err)
34+
}
35+
svc := cfg.RequestBody.Service
36+
if !svc.RequireNonRootUser {
37+
t.Error("RequireNonRootUser = false, want true from env")
38+
}
39+
if !svc.RequireNoNewPrivileges {
40+
t.Error("RequireNoNewPrivileges = false, want true from env")
41+
}
42+
if !svc.RequireReadonlyRootfs {
43+
t.Error("RequireReadonlyRootfs = false, want true from env")
44+
}
45+
if !svc.RequireDropAllCapabilities {
46+
t.Error("RequireDropAllCapabilities = false, want true from env")
47+
}
48+
}
49+
2250
func TestLoadHonorsImageTrustVerifyTimeoutEnvVar(t *testing.T) {
2351
t.Setenv("SOCKGUARD_REQUEST_BODY_CONTAINER_CREATE_IMAGE_TRUST_VERIFY_TIMEOUT", "30s")
2452
t.Setenv("SOCKGUARD_REQUEST_BODY_SERVICE_IMAGE_TRUST_VERIFY_TIMEOUT", "45s")

app/internal/filter/service.go

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,34 +28,63 @@ type ServiceOptions struct {
2828
AllowedCapabilities []string
2929
// AllowSysctls permits ContainerSpec.Sysctls; default false denies any.
3030
AllowSysctls bool
31+
// RequireNonRootUser / RequireNoNewPrivileges / RequireReadonlyRootfs /
32+
// RequireDropAllCapabilities mirror the container-create hardening rails for
33+
// swarm task containers, enforced against ContainerSpec.User,
34+
// ContainerSpec.Privileges.NoNewPrivileges, ContainerSpec.ReadOnly, and
35+
// ContainerSpec.CapabilityDrop respectively.
36+
RequireNonRootUser bool
37+
RequireNoNewPrivileges bool
38+
RequireReadonlyRootfs bool
39+
RequireDropAllCapabilities bool
3140
// ImageTrust applies cosign verification to ContainerSpec.Image, matching
3241
// the container-create path so swarm services cannot escape image trust.
3342
ImageTrust ImageTrustOptions
3443
}
3544

3645
type servicePolicy struct {
37-
allowHostNetwork bool
38-
allowedBindMounts []string
39-
imagePolicy imagePullPolicy
40-
allowAllCapabilities bool
41-
allowedCapabilities []string
42-
allowSysctls bool
43-
imageTrust imageTrustFields
46+
allowHostNetwork bool
47+
allowedBindMounts []string
48+
imagePolicy imagePullPolicy
49+
allowAllCapabilities bool
50+
allowedCapabilities []string
51+
allowSysctls bool
52+
requireNonRootUser bool
53+
requireNoNewPrivileges bool
54+
requireReadonlyRootfs bool
55+
requireDropAllCapabilities bool
56+
imageTrust imageTrustFields
4457
}
4558

4659
type serviceRequest struct {
4760
TaskTemplate struct {
48-
ContainerSpec struct {
49-
Image string `json:"Image"`
50-
Mounts []serviceMount `json:"Mounts"`
51-
CapabilityAdd []string `json:"CapabilityAdd"`
52-
CapabilityDrop []string `json:"CapabilityDrop"`
53-
Sysctls map[string]string `json:"Sysctls"`
54-
} `json:"ContainerSpec"`
61+
ContainerSpec serviceContainerSpec `json:"ContainerSpec"`
5562
} `json:"TaskTemplate"`
5663
Networks []serviceNetwork `json:"Networks"`
5764
}
5865

66+
// serviceContainerSpec mirrors the subset of Docker's swarm ContainerSpec that
67+
// Sockguard inspects. Identity/privilege fields (User, ReadOnly, Privileges,
68+
// CapabilityDrop) carry the swarm equivalents of the container-create hardening
69+
// rails so service create/update cannot bypass them.
70+
type serviceContainerSpec struct {
71+
Image string `json:"Image"`
72+
User string `json:"User"`
73+
Mounts []serviceMount `json:"Mounts"`
74+
CapabilityAdd []string `json:"CapabilityAdd"`
75+
CapabilityDrop []string `json:"CapabilityDrop"`
76+
Sysctls map[string]string `json:"Sysctls"`
77+
ReadOnly bool `json:"ReadOnly"`
78+
Privileges *serviceContainerPrivileges `json:"Privileges"`
79+
}
80+
81+
// serviceContainerPrivileges captures the swarm ContainerSpec.Privileges fields
82+
// Sockguard enforces. NoNewPrivileges is a direct boolean here, unlike the
83+
// container-create path where it is encoded as a HostConfig.SecurityOpt string.
84+
type serviceContainerPrivileges struct {
85+
NoNewPrivileges bool `json:"NoNewPrivileges"`
86+
}
87+
5988
type serviceMount struct {
6089
Type string `json:"Type"`
6190
Source string `json:"Source"`
@@ -83,13 +112,38 @@ func newServicePolicy(opts ServiceOptions) servicePolicy {
83112
AllowOfficial: opts.AllowOfficial,
84113
AllowedRegistries: opts.AllowedRegistries,
85114
}),
86-
allowAllCapabilities: opts.AllowAllCapabilities,
87-
allowedCapabilities: normalizeCapabilityList(opts.AllowedCapabilities),
88-
allowSysctls: opts.AllowSysctls,
89-
imageTrust: buildImageTrustFields(opts.ImageTrust),
115+
allowAllCapabilities: opts.AllowAllCapabilities,
116+
allowedCapabilities: normalizeCapabilityList(opts.AllowedCapabilities),
117+
allowSysctls: opts.AllowSysctls,
118+
requireNonRootUser: opts.RequireNonRootUser,
119+
requireNoNewPrivileges: opts.RequireNoNewPrivileges,
120+
requireReadonlyRootfs: opts.RequireReadonlyRootfs,
121+
requireDropAllCapabilities: opts.RequireDropAllCapabilities,
122+
imageTrust: buildImageTrustFields(opts.ImageTrust),
90123
}
91124
}
92125

126+
// denyHardeningReason enforces the swarm equivalents of the container-create
127+
// boolean rails against ContainerSpec. It reuses the same isNonRootUser and
128+
// capDropContainsAll helpers so service and container policy stay in lockstep.
129+
// NoNewPrivileges is a direct ContainerSpec.Privileges boolean rather than a
130+
// SecurityOpt string; a nil Privileges block means the flag is unset (denied).
131+
func (p servicePolicy) denyHardeningReason(spec serviceContainerSpec) string {
132+
if p.requireNoNewPrivileges && (spec.Privileges == nil || !spec.Privileges.NoNewPrivileges) {
133+
return "service denied: no-new-privileges is required (set ContainerSpec.Privileges.NoNewPrivileges to true)"
134+
}
135+
if p.requireNonRootUser && !isNonRootUser(spec.User) {
136+
return "service denied: non-root user is required (set ContainerSpec.User to a non-zero UID or non-root username)"
137+
}
138+
if p.requireReadonlyRootfs && !spec.ReadOnly {
139+
return "service denied: read-only root filesystem is required (set ContainerSpec.ReadOnly to true)"
140+
}
141+
if p.requireDropAllCapabilities && !capDropContainsAll(spec.CapabilityDrop) {
142+
return "service denied: ContainerSpec.CapabilityDrop must include \"ALL\""
143+
}
144+
return ""
145+
}
146+
93147
func (p servicePolicy) inspect(logger *slog.Logger, r *http.Request, normalizedPath string) (string, error) {
94148
if r == nil || r.Method != http.MethodPost || !isServiceWritePath(normalizedPath) || r.Body == nil {
95149
return "", nil
@@ -134,6 +188,14 @@ func (p servicePolicy) inspect(logger *slog.Logger, r *http.Request, normalizedP
134188
return fmt.Sprintf("service denied: bind mount source %q is not allowlisted", source), nil
135189
}
136190

191+
// Identity/privilege rails: ContainerSpec carries swarm equivalents of the
192+
// container-create hardening knobs (User, Privileges.NoNewPrivileges,
193+
// ReadOnly, CapabilityDrop). Enforce them so service create/update is not a
194+
// bypass of require_non_root_user and friends.
195+
if denyReason := p.denyHardeningReason(req.TaskTemplate.ContainerSpec); denyReason != "" {
196+
return denyReason, nil
197+
}
198+
137199
// Swarm task containers can grant Linux capabilities and set sysctls via
138200
// ContainerSpec, exactly like /containers/create — enforce the same rails so
139201
// service create/update is not a bypass of the container-create policy.

0 commit comments

Comments
 (0)