Containers Run Without Memory or Process Limits
Looks for containers with no memory limit and no PID limit.
- Why It Matters
- 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.
- How It Detects
- Reads Memory and PidsLimit from each container's hostconfig.json (needs root) and fails when both are zero or unset.
- How to 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.
- How to Verify
docker inspect --format '{{.Name}} mem={{.HostConfig.Memory}} pids={{.HostConfig.PidsLimit}}' $(docker ps -aq)- CVSS v3.1 Vector
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/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-770
- NIST SP 800-53 Rev. 5
- SC-6, SC-5
- Benchmark Topic
- CIS Docker Benchmark 5.11 and 5.29 (memory limit; PIDs cgroup limit) (topic only, no benchmark text)
- References
- Docker documentation, Resource constraints
NIST SP 800-190, Application Container Security Guide
CIS Docker Benchmark (referenced by recommendation number)
Source Code
The complete file that defines this check. Run it with openvapt scan --only HOST-CTR-012, or read it offline with openvapt explain HOST-CTR-012.
internal/checks/host/containers_depth.go · Lines 1-695View on GitHub at This Commit
| 1 | package host |
| 2 | |
| 3 | import ( |
| 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 | |
| 26 | var ( |
| 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. |
| 40 | type 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. |
| 54 | func 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. |
| 67 | type 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. |
| 79 | type containerUser struct { |
| 80 | Config struct { |
| 81 | User string `json:"User"` |
| 82 | } `json:"Config"` |
| 83 | } |
| 84 | |
| 85 | type 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. |
| 95 | func 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. |
| 131 | func 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. |
| 157 | func 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 | |
| 194 | var 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. |
| 199 | func 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 | |
| 222 | func 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 | |
| 637 | func 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. |
| 648 | func 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.
OpenVAPT. (2026). HOST-CTR-012 Containers Run Without Memory or Process Limits (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/HOST-CTR-012.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.
@misc{openvapt_host_ctr_012,
author = {{OpenVAPT}},
title = {{HOST-CTR-012 Containers Run Without Memory or Process Limits}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/HOST-CTR-012.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.