Skip to Content
OpenVAPTAlpha
Pre-release. Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. Free for your own use, 100% at your own risk. This website collects no data. Read MoreRead Less
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
Pre-release. OpenVAPT has no Stable release yet. Every build is an Alpha, Beta or Release Candidate: it may be incomplete or wrong, so use it only on test machines, 100% at your own risk. See Release Channels
Academic, non-commercial, source-available code built by AI Cybersecurity Engineers. These are AI systems, not people or registered engineers. Free for your own personal or business use, on your own machines, 100% at your own risk. Not for sale. Not for providing services. This website collects no data. Read the Acknowledgements
HighHOST-CTR-014Secure ConfigurationServer

containerd Remote API or Podman Unconfined Defaults

Checks the containerd and Podman configuration files for a TCP API without TLS and for unconfined seccomp or disabled labelling defaults.

Why It Matters
containerd's gRPC API is root on the host, like the Docker API; a tcp_address without TLS exposes it to the network. Podman's containers.conf sets the defaults every container inherits: an unconfined seccomp profile or disabled SELinux labelling removes the isolation for all of them.
How It Detects
Reads /etc/containerd/config.toml ([grpc] tcp_address set without tcp_tls_cert) and /etc/containers/containers.conf plus /usr/share/containers/containers.conf (seccomp_profile unconfined, label false, or apparmor_profile unconfined in [containers]).
How to Fix
containerd: remove tcp_address or set tcp_tls_cert and tcp_tls_key, then restart containerd. Podman: delete seccomp_profile = "unconfined", set label = true and apparmor_profile = "container-default" in containers.conf.
How to Verify
grep -E 'tcp_address|tcp_tls' /etc/containerd/config.toml ; grep -E 'seccomp_profile|^label|apparmor_profile' /etc/containers/containers.conf
CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
Control Category
Secure Configuration: CIS Controls v8 4; NIST CSF 2.0 PR.PS
Fix Effort
Medium
CIS Controls v8 Safeguard
4.1
NIST CSF 2.0 Subcategory
PR.PS-01
CWE
CWE-306, CWE-1188
NIST SP 800-53 Rev. 5
AC-3, SC-8, CM-6
References
containerd documentation, Operations (configuration)
containers.conf(5), Podman configuration
NIST SP 800-190, Application Container Security Guide
Learn the theory behind this check: P9 Containers and Kubernetes, A5 Container Runtime Hardening Depth.

Source Code

The complete file that defines this check. Run it with openvapt scan --only HOST-CTR-014, or read it offline with openvapt explain HOST-CTR-014.

internal/checks/host/containers_depth.go · Lines 1-695View on GitHub at This Commit
1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io/fs"
9 "path/filepath"
10 "regexp"
11 "sort"
12 "strings"
13
14 "github.com/openvapt/openvapt/internal/check"
15 "github.com/openvapt/openvapt/internal/sys"
16)
17
18// Deeper Docker, containerd and Podman checks (HOST-CTR-005 and later). They
19// read /etc/docker/daemon.json, each container's hostconfig.json and
20// config.v2.json under /var/lib/docker (root only), the Docker socket's
21// metadata, /etc/group and the containerd and Podman configuration files. No
22// container command is run: docker, podman and ctr are not on the read-only
23// allow-list, so everything here comes from files. CIS Docker Benchmark
24// recommendations are referred to by number only and paraphrased.
25
26var (
27 refDockerLogging = check.Ref{Title: "Docker documentation, Configure logging drivers", URL: "https://docs.docker.com/engine/logging/configure/"}
28 refDockerdRef = check.Ref{Title: "Docker documentation, dockerd daemon reference", URL: "https://docs.docker.com/reference/cli/dockerd/"}
29 refDockerRootless = check.Ref{Title: "Docker documentation, Rootless mode", URL: "https://docs.docker.com/engine/security/rootless/"}
30 refDockerSeccomp = check.Ref{Title: "Docker documentation, Seccomp security profiles", URL: "https://docs.docker.com/engine/security/seccomp/"}
31 refDockerLimits = check.Ref{Title: "Docker documentation, Resource constraints", URL: "https://docs.docker.com/engine/containers/resource_constraints/"}
32 refDockerTrust = check.Ref{Title: "Docker documentation, Content trust in Docker", URL: "https://docs.docker.com/engine/security/trust/"}
33 refContainerdOps = check.Ref{Title: "containerd documentation, Operations (configuration)", URL: "https://github.com/containerd/containerd/blob/main/docs/ops.md"}
34 refPodmanConf = check.Ref{Title: "containers.conf(5), Podman configuration", URL: "https://github.com/containers/common/blob/main/docs/containers.conf.5.md"}
35 refOWASPDockerCS = check.Ref{Title: "OWASP Docker Security Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html"}
36 refCISDockerBench = check.Ref{Title: "CIS Docker Benchmark (referenced by recommendation number)", URL: "https://www.cisecurity.org/benchmark/docker"}
37)
38
39// dockerDaemonMore is the second subset of daemon.json these checks read.
40type dockerDaemonMore struct {
41 LogDriver string `json:"log-driver"`
42 LogOpts map[string]string `json:"log-opts"`
43 UserlandProxy *bool `json:"userland-proxy"`
44 DefaultUlimits map[string]interface{} `json:"default-ulimits"`
45 InsecureRegs []string `json:"insecure-registries"`
46 Debug bool `json:"debug"`
47 Experimental bool `json:"experimental"`
48 SeccompProfile string `json:"seccomp-profile"`
49 SelinuxEnabled bool `json:"selinux-enabled"`
50}
51
52// readDaemonMore returns the parsed daemon.json, whether Docker seems to be
53// installed at all, and whether the file was readable and valid.
54func readDaemonMore(ctx context.Context, s sys.System) (d dockerDaemonMore, installed, ok bool) {
55 t, complete, found := readText(ctx, s, "/etc/docker/daemon.json")
56 if !found {
57 _, err := s.Stat("/var/lib/docker")
58 return d, err == nil, false
59 }
60 if !complete || json.Unmarshal([]byte(t), &d) != nil {
61 return d, true, false
62 }
63 return d, true, true
64}
65
66// hostConfigMore is the second subset of hostconfig.json these checks read.
67type hostConfigMore struct {
68 SecurityOpt []string `json:"SecurityOpt"`
69 ReadonlyRootfs bool `json:"ReadonlyRootfs"`
70 Memory int64 `json:"Memory"`
71 PidsLimit *int64 `json:"PidsLimit"`
72 LogConfig struct {
73 Type string `json:"Type"`
74 Config map[string]string `json:"Config"`
75 } `json:"LogConfig"`
76}
77
78// containerUser is the subset of config.v2.json these checks read.
79type containerUser struct {
80 Config struct {
81 User string `json:"User"`
82 } `json:"Config"`
83}
84
85type containerInfo struct {
86 id string
87 host hostConfigMore
88 user string
89 // userKnown is false when config.v2.json could not be read.
90 userKnown bool
91}
92
93// containersMore reads hostconfig.json and config.v2.json for every
94// container (needs root). Container ids are shortened to 12 characters.
95func containersMore(ctx context.Context, s sys.System) ([]containerInfo, bool) {
96 paths, _ := s.Glob("/var/lib/docker/containers/*/hostconfig.json")
97 sort.Strings(paths)
98 var out []containerInfo
99 complete := true
100 for _, p := range paths {
101 r, err := s.ReadFile(ctx, p)
102 if err != nil {
103 complete = false
104 continue
105 }
106 var hc hostConfigMore
107 if json.Unmarshal(r.Data, &hc) != nil || !r.Complete {
108 complete = false
109 continue
110 }
111 dir := filepath.Dir(p)
112 id := filepath.Base(dir)
113 if len(id) > 12 {
114 id = id[:12]
115 }
116 ci := containerInfo{id: id, host: hc}
117 if cr, err := s.ReadFile(ctx, dir+"/config.v2.json"); err == nil && cr.Complete {
118 var cu containerUser
119 if json.Unmarshal(cr.Data, &cu) == nil {
120 ci.user = cu.Config.User
121 ci.userKnown = true
122 }
123 }
124 out = append(out, ci)
125 }
126 return out, complete
127}
128
129// containerCheck wraps the "no containers / unreadable" handling shared by
130// the per-container checks.
131func containerCheck(ctx context.Context, s sys.System, judge func(containerInfo) []string, failMsg, passMsg string) []check.Result {
132 cs, complete := containersMore(ctx, s)
133 if len(cs) == 0 {
134 if !complete {
135 return []check.Result{check.IncompleteR("containers", "Container configuration could not be read (run as root)")}
136 }
137 return []check.Result{check.SkipR("containers", "No Docker containers found")}
138 }
139 var bad []string
140 for _, c := range cs {
141 if why := judge(c); len(why) > 0 {
142 bad = append(bad, c.id+": "+strings.Join(why, ", "))
143 }
144 }
145 if len(bad) > 0 {
146 return []check.Result{check.FailR("containers", failMsg, bad...)}
147 }
148 if !complete {
149 return []check.Result{check.IncompleteR("containers", "Some container configurations could not be read")}
150 }
151 return []check.Result{check.PassR("containers", fmt.Sprintf("%d container(s) checked; %s", len(cs), passMsg))}
152}
153
154// groupMembers returns the members of a group from /etc/group, by name:
155// the explicit member list plus any /etc/passwd account whose primary group
156// is that group.
157func groupMembers(ctx context.Context, s sys.System, group string) ([]string, bool) {
158 gt, _, ok := readText(ctx, s, "/etc/group")
159 if !ok {
160 return nil, false
161 }
162 gid := ""
163 set := map[string]bool{}
164 for _, line := range strings.Split(gt, "\n") {
165 f := strings.Split(line, ":")
166 if len(f) >= 4 && f[0] == group {
167 gid = f[2]
168 for _, m := range strings.Split(f[3], ",") {
169 if m = strings.TrimSpace(m); m != "" {
170 set[m] = true
171 }
172 }
173 }
174 }
175 if gid == "" {
176 return nil, true
177 }
178 if pt, _, ok := readText(ctx, s, "/etc/passwd"); ok {
179 for _, line := range strings.Split(pt, "\n") {
180 f := strings.Split(line, ":")
181 if len(f) >= 4 && f[3] == gid && f[0] != "root" {
182 set[f[0]] = true
183 }
184 }
185 }
186 out := make([]string, 0, len(set))
187 for m := range set {
188 out = append(out, m)
189 }
190 sort.Strings(out)
191 return out, true
192}
193
194var tomlKV = regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*(.+?)\s*$`)
195
196// tomlValues returns a flat key to value map for a simple TOML file,
197// prefixing keys with their [section]. Comments and quotes are stripped. It
198// is a small scanner for flat configuration, not a full parser.
199func tomlValues(text string) map[string]string {
200 out := map[string]string{}
201 section := ""
202 for _, line := range strings.Split(text, "\n") {
203 t := strings.TrimSpace(line)
204 if t == "" || strings.HasPrefix(t, "#") {
205 continue
206 }
207 if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
208 section = strings.Trim(t, "[]") + "."
209 continue
210 }
211 if m := tomlKV.FindStringSubmatch(t); m != nil {
212 v := m[2]
213 if i := strings.Index(v, " #"); i >= 0 {
214 v = v[:i]
215 }
216 out[section+m[1]] = strings.Trim(strings.TrimSpace(v), `"'`)
217 }
218 }
219 return out
220}
221
222func init() {
223 check.Register(&check.Check{
224 ID: "HOST-CTR-005", Title: "Docker Container Logs Have No Size Limit", Domain: check.DomainHost,
225 Severity: check.Low, Category: check.CatLogging, Effort: check.EffortLow,
226 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L",
227 Summary: "Checks whether the Docker daemon's default log driver caps log file size and rotation.",
228 Rationale: "With the default json-file driver and no max-size, a chatty or attacked container can fill the disk with its own logs and take the host and every other container down. Bounded, rotated logs also keep evidence manageable for investigations.",
229 Detection: "Reads /etc/docker/daemon.json. Fails when log-driver is json-file or unset and log-opts lacks max-size, or when a container's LogConfig uses json-file without max-size. Drivers that ship logs elsewhere (journald, syslog, fluentd, awslogs) pass.",
230 Fix: "Add to /etc/docker/daemon.json: \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"10m\", \"max-file\": \"3\"} (or use journald) and restart Docker; recreate containers that set their own LogConfig.",
231 Verify: "docker info --format '{{.LoggingDriver}}' ; grep -A3 log-opts /etc/docker/daemon.json",
232 Refs: []check.Ref{refDockerLogging, refNIST190, refCISDockerBench},
233 Standards: check.Standards{CWE: []string{"CWE-779", "CWE-770"}, NIST: []string{"AU-4", "SC-5", "CM-6"}, CISTopic: "CIS Docker Benchmark 2.12 (centralised and bounded container logging)"},
234 Run: func(ctx context.Context, s sys.System) []check.Result {
235 d, installed, ok := readDaemonMore(ctx, s)
236 if !installed {
237 return []check.Result{check.SkipR("docker", "Docker is not installed")}
238 }
239 if !ok {
240 if _, err := s.Stat("/etc/docker/daemon.json"); err != nil {
241 return []check.Result{check.FailR("/etc/docker/daemon.json", "No daemon.json: json-file logging with no size limit is the default")}
242 }
243 return []check.Result{check.IncompleteR("/etc/docker/daemon.json", "daemon.json could not be parsed")}
244 }
245 var bad []string
246 drv := strings.ToLower(d.LogDriver)
247 if (drv == "" || drv == "json-file" || drv == "local") && d.LogOpts["max-size"] == "" {
248 bad = append(bad, "daemon log-driver "+orElse(drv, "json-file (default)")+" has no max-size")
249 }
250 cs, _ := containersMore(ctx, s)
251 for _, c := range cs {
252 lt := strings.ToLower(c.host.LogConfig.Type)
253 if lt == "json-file" && c.host.LogConfig.Config["max-size"] == "" && d.LogOpts["max-size"] == "" {
254 bad = append(bad, c.id+": json-file logging without max-size")
255 }
256 }
257 if len(bad) > 0 {
258 return []check.Result{check.FailR("/etc/docker/daemon.json", "Container logs are unbounded", bad...)}
259 }
260 return []check.Result{check.PassR("/etc/docker/daemon.json", "Container logging is bounded or shipped to a log service", "log-driver "+orElse(drv, "json-file")+", max-size "+orElse(d.LogOpts["max-size"], "n/a"))}
261 },
262 })
263
264 check.Register(&check.Check{
265 ID: "HOST-CTR-006", Title: "Docker Userland Proxy Enabled", Domain: check.DomainHost,
266 Severity: check.Low, Category: check.CatConfig, Effort: check.EffortLow,
267 CVSS: "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L",
268 Summary: "Checks whether the Docker daemon still uses the docker-proxy userland process for published ports.",
269 Rationale: "The userland proxy is a root process per published port that exists only for an old loopback edge case. Hairpin NAT does the same job in the kernel with less code running as root and fewer processes to crash or exploit.",
270 Detection: "Reads /etc/docker/daemon.json and fails unless userland-proxy is false.",
271 Fix: "Add \"userland-proxy\": false to /etc/docker/daemon.json and restart Docker; test that published ports still answer from the host itself.",
272 Verify: "ps -C docker-proxy should list nothing after containers are recreated.",
273 Refs: []check.Ref{refDockerdRef, refCISDockerBench},
274 Standards: check.Standards{CWE: []string{"CWE-1188"}, NIST: []string{"CM-7", "CM-6"}, CISTopic: "CIS Docker Benchmark 2.14 (disable the userland proxy)"},
275 Run: func(ctx context.Context, s sys.System) []check.Result {
276 d, installed, ok := readDaemonMore(ctx, s)
277 if !installed {
278 return []check.Result{check.SkipR("docker", "Docker is not installed")}
279 }
280 if !ok {
281 if _, err := s.Stat("/etc/docker/daemon.json"); err != nil {
282 return []check.Result{check.FailR("/etc/docker/daemon.json", "No daemon.json: the userland proxy is on by default")}
283 }
284 return []check.Result{check.IncompleteR("/etc/docker/daemon.json", "daemon.json could not be parsed")}
285 }
286 if d.UserlandProxy == nil || *d.UserlandProxy {
287 return []check.Result{check.FailR("/etc/docker/daemon.json", "userland-proxy is not false")}
288 }
289 return []check.Result{check.PassR("/etc/docker/daemon.json", "userland-proxy is false")}
290 },
291 })
292
293 check.Register(&check.Check{
294 ID: "HOST-CTR-007", Title: "Docker Default Ulimits Not Set", Domain: check.DomainHost,
295 Severity: check.Low, Category: check.CatConfig, Effort: check.EffortLow,
296 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L",
297 Summary: "Checks whether the Docker daemon sets default ulimits (open files and processes) for containers.",
298 Rationale: "Without default limits a container inherits the daemon's generous limits and can exhaust file descriptors or spawn processes until the host starves. Daemon-wide defaults protect every container that forgets to set its own.",
299 Detection: "Reads /etc/docker/daemon.json and fails unless default-ulimits contains at least nofile and nproc.",
300 Fix: "Add to /etc/docker/daemon.json: \"default-ulimits\": {\"nofile\": {\"Name\": \"nofile\", \"Hard\": 65536, \"Soft\": 32768}, \"nproc\": {\"Name\": \"nproc\", \"Hard\": 4096, \"Soft\": 2048}} and restart Docker; tune the numbers to your workloads.",
301 Verify: "docker info | grep -A3 -i ulimit or docker run --rm alpine sh -c 'ulimit -n; ulimit -u' (your own test container)",
302 Refs: []check.Ref{refDockerdRef, refCISDockerBench},
303 Standards: check.Standards{CWE: []string{"CWE-770"}, NIST: []string{"SC-6", "CM-6"}, CISTopic: "CIS Docker Benchmark 2.7 (default ulimit)"},
304 Run: func(ctx context.Context, s sys.System) []check.Result {
305 d, installed, ok := readDaemonMore(ctx, s)
306 if !installed {
307 return []check.Result{check.SkipR("docker", "Docker is not installed")}
308 }
309 if !ok {
310 if _, err := s.Stat("/etc/docker/daemon.json"); err != nil {
311 return []check.Result{check.FailR("/etc/docker/daemon.json", "No daemon.json: no default ulimits are set")}
312 }
313 return []check.Result{check.IncompleteR("/etc/docker/daemon.json", "daemon.json could not be parsed")}
314 }
315 var miss []string
316 for _, k := range []string{"nofile", "nproc"} {
317 if _, ok := d.DefaultUlimits[k]; !ok {
318 miss = append(miss, "default-ulimits has no "+k)
319 }
320 }
321 if len(miss) > 0 {
322 return []check.Result{check.FailR("/etc/docker/daemon.json", "Default ulimits are not set", miss...)}
323 }
324 return []check.Result{check.PassR("/etc/docker/daemon.json", "default-ulimits sets nofile and nproc")}
325 },
326 })
327
328 check.Register(&check.Check{
329 ID: "HOST-CTR-008", Title: "Docker Socket Accessible to Non-Administrators", Domain: check.DomainHost,
330 Severity: check.High, Category: check.CatAccess, Effort: check.EffortLow,
331 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
332 Summary: "Checks the Docker socket's permissions and who belongs to the docker group.",
333 Rationale: "Whoever can write to /var/run/docker.sock is root: one command starts a privileged container with the host's disk mounted. Members of the docker group have that power without sudo, logging or a password prompt, so the group should be empty or hold only administrators.",
334 Detection: "Stats /var/run/docker.sock (or /run/docker.sock) and fails when others can read or write it, or when it is not owned by root. Reads /etc/group and /etc/passwd and lists every member of the docker group as evidence; a non-empty group fails.",
335 Fix: "chmod 660 /var/run/docker.sock && chown root:docker /var/run/docker.sock (the daemon recreates it with these defaults). Remove users from the docker group (gpasswd -d <user> docker) and give administrators sudo docker, or move to rootless Docker.",
336 Verify: "stat -c '%a %U:%G %n' /var/run/docker.sock ; getent group docker",
337 Refs: []check.Ref{refDockerSock, refOWASPDockerCS, refCISDockerBench},
338 Standards: check.Standards{CWE: []string{"CWE-732", "CWE-250"}, NIST: []string{"AC-3", "AC-6", "CM-6"}, CISTopic: "CIS Docker Benchmark 1.2.2 and 3.15 (only trusted users control the daemon; socket ownership and permissions)"},
339 Run: func(ctx context.Context, s sys.System) []check.Result {
340 var sock sys.FileInfo
341 found := false
342 for _, p := range []string{"/var/run/docker.sock", "/run/docker.sock"} {
343 if st, err := s.Stat(p); err == nil {
344 sock, found = st, true
345 break
346 }
347 }
348 if !found {
349 if _, err := s.Stat("/var/lib/docker"); err != nil {
350 return []check.Result{check.SkipR("docker", "Docker is not installed")}
351 }
352 return []check.Result{check.IncompleteR("/var/run/docker.sock", "Docker socket not found (daemon stopped or rootless)")}
353 }
354 var bad []string
355 if sock.Mode.Perm()&0o006 != 0 {
356 bad = append(bad, fmt.Sprintf("%s mode %s is accessible to all users", sock.Path, perm(sock.Mode)))
357 }
358 if sock.UID > 0 {
359 bad = append(bad, fmt.Sprintf("%s is owned by uid %d, not root", sock.Path, sock.UID))
360 }
361 members, ok := groupMembers(ctx, s, "docker")
362 if !ok {
363 return []check.Result{check.IncompleteR(sock.Path, "/etc/group could not be read")}
364 }
365 if len(members) > 0 {
366 bad = append(bad, "docker group members (root-equivalent): "+strings.Join(members, ", "))
367 }
368 if len(bad) > 0 {
369 return []check.Result{check.FailR(sock.Path, "Docker socket is reachable by non-administrators", bad...)}
370 }
371 return []check.Result{check.PassR(sock.Path, "Docker socket is root-owned, mode "+perm(sock.Mode)+", and the docker group is empty")}
372 },
373 })
374
375 check.Register(&check.Check{
376 ID: "HOST-CTR-009", Title: "Docker Not Running in Rootless Mode", Domain: check.DomainHost,
377 Severity: check.Info, Category: check.CatConfig, Effort: check.EffortHigh,
378 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:N",
379 Summary: "Reports whether the Docker daemon runs as root or in rootless mode. Informational.",
380 Rationale: "In rootless mode the daemon and every container run inside a user namespace as an ordinary account, so a container breakout lands in that account rather than in root. It is not possible for every workload (some networking and storage features differ), so this is information for a design decision, not a failure.",
381 Detection: "Looks for a rootless indicator: a per-user socket at /run/user/*/docker.sock, a per-user daemon configuration at /home/*/.config/docker/daemon.json, or a user systemd unit for docker. Reports root mode when only the system daemon is present.",
382 Fix: "Where the workloads allow it, install rootless Docker (dockerd-rootless-setuptool.sh install) for the service account and disable the system daemon; otherwise keep userns-remap (HOST-CTR-002) as the fallback.",
383 Verify: "docker info --format '{{.SecurityOptions}}' lists name=rootless.",
384 Refs: []check.Ref{refDockerRootless, refNIST190},
385 Standards: check.Standards{CWE: []string{"CWE-250"}, NIST: []string{"AC-6", "SC-39"}, CISTopic: "CIS Docker Benchmark 2.18 (run the daemon in rootless mode)"},
386 Run: func(ctx context.Context, s sys.System) []check.Result {
387 _, installed, _ := readDaemonMore(ctx, s)
388 var hints []string
389 for _, pat := range []string{"/run/user/*/docker.sock", "/home/*/.config/docker/daemon.json", "/home/*/.config/systemd/user/docker.service", "/root/.config/docker/daemon.json"} {
390 m, _ := s.Glob(pat)
391 hints = append(hints, m...)
392 }
393 for _, p := range []string{"/run/user/1000/docker.sock", "/usr/bin/dockerd-rootless.sh"} {
394 if _, err := s.Stat(p); err == nil {
395 hints = append(hints, p)
396 }
397 }
398 sort.Strings(hints)
399 if len(hints) > 0 {
400 return []check.Result{check.PassR("docker", "A rootless Docker installation is present", hints...)}
401 }
402 if !installed {
403 return []check.Result{check.SkipR("docker", "Docker is not installed")}
404 }
405 return []check.Result{check.FailR("docker", "Docker runs as the root daemon only; rootless mode is not in use")}
406 },
407 })
408
409 check.Register(&check.Check{
410 ID: "HOST-CTR-010", Title: "Docker Daemon Allows Insecure Registries or Debug Features", Domain: check.DomainHost,
411 Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortLow,
412 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
413 Summary: "Checks daemon.json for insecure-registries, debug mode and experimental features.",
414 Rationale: "An insecure registry is pulled over plain HTTP or with an unverified certificate, so anyone on the path can swap the image for a malicious one. Debug logging and experimental features widen the daemon's attack surface and leak internal detail into logs.",
415 Detection: "Reads /etc/docker/daemon.json and the ExecStart lines of docker.service and its drop-ins (docker.service.d/*.conf under /etc/systemd/system, /run/systemd/system, /usr/lib/systemd/system and /lib/systemd/system). Fails when insecure-registries is non-empty, debug or experimental is true, or the dockerd command line carries --insecure-registry, --debug (-D) or --experimental. Incomplete when daemon.json exists but cannot be read or parsed, or a unit file cannot be read.",
416 Fix: "Remove insecure-registries (serve the registry over TLS with a trusted certificate, or add its CA to /etc/docker/certs.d/<registry>/ca.crt), set \"debug\": false and \"experimental\": false in daemon.json, and remove any --insecure-registry, --debug (-D) or --experimental flag from the dockerd ExecStart in docker.service drop-ins (then systemctl daemon-reload). Restart Docker in a maintenance window: running containers stop unless live-restore is on.",
417 Verify: "docker info | grep -Ei 'insecure|debug|experimental'",
418 Refs: []check.Ref{refDockerdRef, refNIST190, refCISDockerBench},
419 Standards: check.Standards{CWE: []string{"CWE-494", "CWE-1188"}, NIST: []string{"SI-7", "CM-7"}, CISTopic: "CIS Docker Benchmark 2.4 and 2.10 (no insecure registries; no experimental features)"},
420 Run: func(ctx context.Context, s sys.System) []check.Result {
421 d, installed, ok := readDaemonMore(ctx, s)
422 if !installed {
423 return []check.Result{check.SkipR("docker", "Docker is not installed")}
424 }
425 bad, unitIncomplete := dockerdUnitFlags(ctx, s)
426 jsonNote := ""
427 if ok {
428 for _, r := range d.InsecureRegs {
429 bad = append(bad, "daemon.json insecure-registries: "+r)
430 }
431 if d.Debug {
432 bad = append(bad, "daemon.json debug is true")
433 }
434 if d.Experimental {
435 bad = append(bad, "daemon.json experimental is true")
436 }
437 } else if _, err := s.Stat("/etc/docker/daemon.json"); errors.Is(err, fs.ErrNotExist) {
438 jsonNote = "none"
439 } else {
440 jsonNote = "daemon.json exists but could not be read or parsed"
441 }
442 if len(bad) > 0 {
443 return []check.Result{check.FailR("docker daemon", "Docker daemon allows insecure registries or debug features", bad...)}
444 }
445 if jsonNote != "" && jsonNote != "none" {
446 return []check.Result{check.IncompleteR("/etc/docker/daemon.json", "The daemon settings could not be confirmed: "+jsonNote)}
447 }
448 if len(unitIncomplete) > 0 {
449 return []check.Result{check.IncompleteR("docker.service", "A docker.service unit or drop-in could not be read, so dockerd command-line flags could not be confirmed", unitIncomplete...)}
450 }
451 msg := "No insecure registries, debug or experimental settings in daemon.json or on the dockerd command line"
452 if jsonNote == "none" {
453 msg = "No daemon.json, and no insecure registries, debug or experimental flags on the dockerd command line"
454 }
455 return []check.Result{check.PassR("docker daemon", msg)}
456 },
457 })
458
459 check.Register(&check.Check{
460 ID: "HOST-CTR-011", Title: "Seccomp or AppArmor Disabled for Containers", Domain: check.DomainHost,
461 Severity: check.High, Category: check.CatConfig, Effort: check.EffortMedium,
462 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
463 Summary: "Looks for the daemon or individual containers turning off the default seccomp profile, AppArmor or SELinux labelling.",
464 Rationale: "Docker's default seccomp profile blocks around 40 dangerous system calls and AppArmor or SELinux confines what a container's root can touch on the host. Running unconfined removes the main barriers between a compromised container and the kernel.",
465 Detection: "Reads /etc/docker/daemon.json (seccomp-profile unconfined) and each container's hostconfig.json SecurityOpt (needs root) for seccomp=unconfined, seccomp:unconfined, apparmor=unconfined or label=disable.",
466 Fix: "Remove --security-opt seccomp=unconfined and apparmor=unconfined from the container definitions and recreate them; if an application needs one extra system call, write a custom seccomp profile that adds only that call. Delete seccomp-profile from daemon.json.",
467 Verify: "docker inspect --format '{{.Name}} {{.HostConfig.SecurityOpt}}' $(docker ps -aq)",
468 Refs: []check.Ref{refDockerSeccomp, {Title: "Docker documentation, AppArmor security profiles", URL: "https://docs.docker.com/engine/security/apparmor/"}, refNIST190},
469 Standards: check.Standards{CWE: []string{"CWE-653", "CWE-250"}, NIST: []string{"AC-6", "SC-39", "CM-7"}, CISTopic: "CIS Docker Benchmark 5.1 and 5.22 (AppArmor profile; seccomp not unconfined)"},
470 Run: func(ctx context.Context, s sys.System) []check.Result {
471 d, installed, _ := readDaemonMore(ctx, s)
472 if !installed {
473 return []check.Result{check.SkipR("docker", "Docker is not installed")}
474 }
475 var daemonBad []string
476 if strings.EqualFold(strings.TrimSpace(d.SeccompProfile), "unconfined") {
477 daemonBad = append(daemonBad, "daemon.json seccomp-profile is unconfined")
478 }
479 unconfined := regexp.MustCompile(`(?i)^(seccomp[=:]unconfined|apparmor[=:]unconfined|label[=:]disable|label[=:]type:spc_t)$`)
480 res := containerCheck(ctx, s, func(c containerInfo) []string {
481 var why []string
482 for _, o := range c.host.SecurityOpt {
483 if unconfined.MatchString(strings.TrimSpace(o)) {
484 why = append(why, o)
485 }
486 }
487 return why
488 }, "Containers run without seccomp, AppArmor or SELinux confinement", "all keep the default confinement")
489 if len(daemonBad) > 0 {
490 if res[0].Status == check.Fail {
491 res[0].Evidence = append(daemonBad, res[0].Evidence...)
492 return res
493 }
494 return []check.Result{check.FailR("/etc/docker/daemon.json", "Docker daemon disables the default seccomp profile", daemonBad...)}
495 }
496 return res
497 },
498 })
499
500 check.Register(&check.Check{
501 ID: "HOST-CTR-012", Title: "Containers Run Without Memory or Process Limits", Domain: check.DomainHost,
502 Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortMedium,
503 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H",
504 Summary: "Looks for containers with no memory limit and no PID limit.",
505 Rationale: "A container with no limits can use all the host's memory or fork until the kernel runs out of process slots, taking the host and every neighbour down (a fork bomb or a memory leak is enough). Limits turn that into a problem for one container.",
506 Detection: "Reads Memory and PidsLimit from each container's hostconfig.json (needs root) and fails when both are zero or unset.",
507 Fix: "Recreate each container with --memory (for example 512m) and --pids-limit (for example 256), or set them in the compose file under deploy.resources.limits / pids_limit.",
508 Verify: "docker inspect --format '{{.Name}} mem={{.HostConfig.Memory}} pids={{.HostConfig.PidsLimit}}' $(docker ps -aq)",
509 Refs: []check.Ref{refDockerLimits, refNIST190, refCISDockerBench},
510 Standards: check.Standards{CWE: []string{"CWE-770"}, NIST: []string{"SC-6", "SC-5"}, CISTopic: "CIS Docker Benchmark 5.11 and 5.29 (memory limit; PIDs cgroup limit)"},
511 Run: func(ctx context.Context, s sys.System) []check.Result {
512 return containerCheck(ctx, s, func(c containerInfo) []string {
513 var why []string
514 if c.host.Memory <= 0 {
515 why = append(why, "no memory limit")
516 }
517 if c.host.PidsLimit == nil || *c.host.PidsLimit <= 0 {
518 why = append(why, "no PID limit")
519 }
520 if len(why) == 2 {
521 return why
522 }
523 return nil
524 }, "Containers have neither a memory nor a PID limit", "each has a memory or PID limit")
525 },
526 })
527
528 check.Register(&check.Check{
529 ID: "HOST-CTR-013", Title: "Containers Run as Root With a Writable Root Filesystem", Domain: check.DomainHost,
530 Severity: check.Medium, Category: check.CatConfig, Effort: check.EffortMedium,
531 CVSS: "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L",
532 Summary: "Looks for containers whose process runs as root and whose root filesystem is writable.",
533 Rationale: "Root inside the container is the first half of most container escapes, and a writable root filesystem lets an attacker install tools and persist. A non-root user plus --read-only with a few tmpfs mounts removes both at once for most services.",
534 Detection: "Reads Config.User from config.v2.json and ReadonlyRootfs from hostconfig.json (needs root) and fails when the user is empty, root or 0 and the root filesystem is writable.",
535 Fix: "Add USER <uid> to the image or run with --user 1000:1000, and start the container with --read-only plus --tmpfs /tmp for scratch space; recreate the container.",
536 Verify: "docker inspect --format '{{.Name}} user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}}' $(docker ps -aq)",
537 Refs: []check.Ref{refOWASPDockerCS, refNIST190, refCISDockerBench},
538 Standards: check.Standards{CWE: []string{"CWE-250", "CWE-732"}, NIST: []string{"AC-6", "CM-7"}, CISTopic: "CIS Docker Benchmark 4.1 and 5.13 (non-root user; read-only root filesystem)"},
539 Run: func(ctx context.Context, s sys.System) []check.Result {
540 return containerCheck(ctx, s, func(c containerInfo) []string {
541 if !c.userKnown {
542 return nil
543 }
544 u := strings.TrimSpace(strings.SplitN(c.user, ":", 2)[0])
545 if (u == "" || u == "root" || u == "0") && !c.host.ReadonlyRootfs {
546 return []string{"runs as root with a writable root filesystem"}
547 }
548 return nil
549 }, "Containers run as root on a writable root filesystem", "none run as root on a writable root filesystem")
550 },
551 })
552
553 check.Register(&check.Check{
554 ID: "HOST-CTR-014", Title: "containerd Remote API or Podman Unconfined Defaults", Domain: check.DomainHost,
555 Severity: check.High, Category: check.CatConfig, Effort: check.EffortMedium,
556 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
557 Summary: "Checks the containerd and Podman configuration files for a TCP API without TLS and for unconfined seccomp or disabled labelling defaults.",
558 Rationale: "containerd's gRPC API is root on the host, like the Docker API; a tcp_address without TLS exposes it to the network. Podman's containers.conf sets the defaults every container inherits: an unconfined seccomp profile or disabled SELinux labelling removes the isolation for all of them.",
559 Detection: "Reads /etc/containerd/config.toml ([grpc] tcp_address set without tcp_tls_cert) and /etc/containers/containers.conf plus /usr/share/containers/containers.conf (seccomp_profile unconfined, label false, or apparmor_profile unconfined in [containers]).",
560 Fix: "containerd: remove tcp_address or set tcp_tls_cert and tcp_tls_key, then restart containerd. Podman: delete seccomp_profile = \"unconfined\", set label = true and apparmor_profile = \"container-default\" in containers.conf.",
561 Verify: "grep -E 'tcp_address|tcp_tls' /etc/containerd/config.toml ; grep -E 'seccomp_profile|^label|apparmor_profile' /etc/containers/containers.conf",
562 Refs: []check.Ref{refContainerdOps, refPodmanConf, refNIST190},
563 Standards: check.Standards{CWE: []string{"CWE-306", "CWE-1188"}, NIST: []string{"AC-3", "SC-8", "CM-6"}},
564 Run: func(ctx context.Context, s sys.System) []check.Result {
565 var out []check.Result
566 if t, _, ok := readText(ctx, s, "/etc/containerd/config.toml"); ok {
567 v := tomlValues(t)
568 addr := v["grpc.tcp_address"]
569 if addr != "" && v["grpc.tcp_tls_cert"] == "" {
570 out = append(out, check.FailR("/etc/containerd/config.toml", "containerd listens on TCP without TLS", "grpc tcp_address = "+addr))
571 } else {
572 out = append(out, check.PassR("/etc/containerd/config.toml", "containerd has no unauthenticated TCP API"))
573 }
574 }
575 for _, p := range []string{"/etc/containers/containers.conf", "/usr/share/containers/containers.conf"} {
576 t, _, ok := readText(ctx, s, p)
577 if !ok {
578 continue
579 }
580 v := tomlValues(t)
581 var bad []string
582 if strings.EqualFold(v["containers.seccomp_profile"], "unconfined") {
583 bad = append(bad, "seccomp_profile = unconfined")
584 }
585 if strings.EqualFold(v["containers.label"], "false") {
586 bad = append(bad, "label = false")
587 }
588 if strings.EqualFold(v["containers.apparmor_profile"], "unconfined") {
589 bad = append(bad, "apparmor_profile = unconfined")
590 }
591 if len(bad) > 0 {
592 out = append(out, check.FailR(p, "Podman defaults disable container confinement", bad...))
593 } else {
594 out = append(out, check.PassR(p, "Podman defaults keep seccomp, AppArmor and labelling on"))
595 }
596 break
597 }
598 if len(out) == 0 {
599 return []check.Result{check.SkipR("containerd/podman", "No containerd or Podman configuration found")}
600 }
601 return out
602 },
603 })
604
605 check.Register(&check.Check{
606 ID: "HOST-CTR-015", Title: "Docker Content Trust Not Enabled", Domain: check.DomainHost,
607 Severity: check.Low, Category: check.CatConfig, Effort: check.EffortMedium,
608 CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N",
609 Summary: "Checks whether DOCKER_CONTENT_TRUST=1 is set system-wide so that only signed images are pulled.",
610 Rationale: "Without content trust, docker pull accepts whatever the registry or a tampering proxy returns for a tag. Signature verification makes a substituted image fail to pull instead of running as root on your host.",
611 Detection: "Reads /etc/environment, /etc/profile, /etc/profile.d/*.sh and the Docker service drop-ins for DOCKER_CONTENT_TRUST=1. Fails when Docker is installed and the variable is not set anywhere system-wide.",
612 Fix: "Add DOCKER_CONTENT_TRUST=1 to /etc/environment (or a file in /etc/profile.d) and sign your own images with docker trust sign; images without signatures then need an explicit --disable-content-trust for a deliberate exception.",
613 Verify: "grep -r DOCKER_CONTENT_TRUST /etc/environment /etc/profile.d",
614 Refs: []check.Ref{refDockerTrust, refCISDockerBench},
615 Standards: check.Standards{CWE: []string{"CWE-494", "CWE-347"}, NIST: []string{"SI-7", "CM-14"}, CISTopic: "CIS Docker Benchmark 4.5 (enable content trust)"},
616 Run: func(ctx context.Context, s sys.System) []check.Result {
617 _, installed, _ := readDaemonMore(ctx, s)
618 if !installed {
619 return []check.Result{check.SkipR("docker", "Docker is not installed")}
620 }
621 files := []string{"/etc/environment", "/etc/profile"}
622 for _, pat := range []string{"/etc/profile.d/*.sh", "/etc/systemd/system/docker.service.d/*.conf"} {
623 m, _ := s.Glob(pat)
624 files = append(files, m...)
625 }
626 re := regexp.MustCompile(`(?m)^\s*(export\s+)?(Environment=)?"?DOCKER_CONTENT_TRUST"?=\"?1\"?`)
627 for _, p := range files {
628 if t, _, ok := readText(ctx, s, p); ok && re.MatchString(t) {
629 return []check.Result{check.PassR(p, "DOCKER_CONTENT_TRUST=1 is set system-wide")}
630 }
631 }
632 return []check.Result{check.FailR("/etc/environment", "DOCKER_CONTENT_TRUST is not set system-wide; unsigned images are accepted")}
633 },
634 })
635}
636
637func orElse(v, def string) string {
638 if v == "" {
639 return def
640 }
641 return v
642}
643
644// dockerdUnitFlags reads the effective dockerd ExecStart from docker.service
645// and its drop-ins and returns the insecure flags it carries, plus the unit
646// files that exist but could not be read. An empty ExecStart= resets the
647// list, as systemd does.
648func dockerdUnitFlags(ctx context.Context, s sys.System) (bad, unreadable []string) {
649 dirs := []string{"/lib/systemd/system", "/usr/lib/systemd/system", "/run/systemd/system", "/etc/systemd/system"}
650 var paths []string
651 for _, d := range dirs {
652 if _, err := s.Stat(d + "/docker.service"); err == nil {
653 paths = append(paths, d+"/docker.service")
654 }
655 }
656 for _, d := range dirs {
657 g, _ := s.Glob(d + "/docker.service.d/*.conf")
658 sort.Strings(g)
659 paths = append(paths, g...)
660 }
661 type line struct{ src, cmd string }
662 var exec []line
663 for _, p := range paths {
664 text, st := readState(ctx, s, p)
665 if st != fileRead {
666 if st != fileMissing {
667 unreadable = append(unreadable, p)
668 }
669 continue
670 }
671 for _, l := range strings.Split(text, "\n") {
672 k, v, ok := strings.Cut(strings.TrimSpace(l), "=")
673 if !ok || strings.TrimSpace(k) != "ExecStart" {
674 continue
675 }
676 if v = strings.TrimSpace(v); v == "" {
677 exec = nil
678 continue
679 }
680 exec = append(exec, line{p, v})
681 }
682 }
683 for _, e := range exec {
684 f := strings.Fields(e.cmd)
685 for i, a := range f {
686 switch {
687 case a == "--insecure-registry" && i+1 < len(f):
688 bad = append(bad, e.src+": --insecure-registry "+f[i+1])
689 case strings.HasPrefix(a, "--insecure-registry="), a == "-D", a == "--debug", a == "--debug=true", a == "--experimental", a == "--experimental=true":
690 bad = append(bad, e.src+": "+a)
691 }
692 }
693 }
694 return bad, unreadable
695}

Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.

Cite This Page

If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.

APA 7
OpenVAPT. (2026). HOST-CTR-014 containerd Remote API or Podman Unconfined Defaults (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-CTR-014.html

In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.

BibTeX
@misc{openvapt_host_ctr_014,
  author       = {{OpenVAPT}},
  title        = {{HOST-CTR-014 containerd Remote API or Podman Unconfined Defaults}},
  year         = {2026},
  howpublished = {\url{https://openvapt.com/checks/HOST-CTR-014.html}},
  note         = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}

Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.

Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.