package stacks import ( "bufio" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "reflect" "regexp" "strconv" "strings" "time" ) var validHostAccountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}\$?$`) type HostAccount struct { Name string `json:"name"` UID int `json:"uid"` GID int `json:"gid"` Home string `json:"home,omitempty"` Shell string `json:"shell,omitempty"` } type HostGroup struct { Name string `json:"name"` GID int `json:"gid"` } type HostAccessStatus struct { Configured bool `json:"configured"` Available bool `json:"available"` ManagementEnabled bool `json:"management_enabled"` PermissionManagementEnabled bool `json:"permission_management_enabled"` Root string `json:"root,omitempty"` Message string `json:"message,omitempty"` } type BindMountIdentity struct { Source string `json:"source"` Destination string `json:"destination"` ReadOnly bool `json:"read_only"` OwnerUID *int `json:"owner_uid,omitempty"` OwnerGID *int `json:"owner_gid,omitempty"` OwnerUser string `json:"owner_user,omitempty"` OwnerGroup string `json:"owner_group,omitempty"` MatchesUID *bool `json:"matches_uid,omitempty"` MatchesGID *bool `json:"matches_gid,omitempty"` OwnershipNote string `json:"ownership_note,omitempty"` Mode string `json:"mode,omitempty"` StaticWritable *bool `json:"static_writable,omitempty"` WritableReason string `json:"writable_reason,omitempty"` RuntimeWritable *bool `json:"runtime_writable,omitempty"` RuntimeNote string `json:"runtime_note,omitempty"` ACLDetected bool `json:"acl_detected,omitempty"` ACLNote string `json:"acl_note,omitempty"` } type ContainerIdentityReport struct { ContainerID string `json:"container_id"` ContainerName string `json:"container_name"` Image string `json:"image"` Running bool `json:"running"` ConfiguredUser string `json:"configured_user"` EffectiveUID *int `json:"effective_uid,omitempty"` EffectiveGID *int `json:"effective_gid,omitempty"` EffectiveGroups []int `json:"effective_groups,omitempty"` BindUID *int `json:"bind_uid,omitempty"` BindGID *int `json:"bind_gid,omitempty"` BindIdentitySource string `json:"bind_identity_source,omitempty"` BindHostUser *HostAccount `json:"bind_host_user,omitempty"` BindHostGroup *HostGroup `json:"bind_host_group,omitempty"` IdentitySource string `json:"identity_source"` RunsAsRoot *bool `json:"runs_as_root,omitempty"` RootAssessment string `json:"root_assessment"` RootReasons []string `json:"root_reasons"` Recommendations []string `json:"recommendations"` Privileged bool `json:"privileged"` DockerSocketMounted bool `json:"docker_socket_mounted"` AddedCapabilities []string `json:"added_capabilities"` DeviceCount int `json:"device_count"` HostAccess HostAccessStatus `json:"host_access"` HostIDMapping string `json:"host_id_mapping"` HostUser *HostAccount `json:"host_user,omitempty"` HostGroup *HostGroup `json:"host_group,omitempty"` BindMounts []BindMountIdentity `json:"bind_mounts"` } type CreateHostUserInput struct { ContainerID string `json:"container_id"` Username string `json:"username"` GroupName string `json:"group_name"` CreateHome bool `json:"create_home"` } type CreateHostUserResult struct { CreatedUser bool `json:"created_user"` CreatedGroup bool `json:"created_group"` User HostAccount `json:"user"` Group HostGroup `json:"group"` Message string `json:"message"` } func mapObj(v any) map[string]any { m, _ := v.(map[string]any) return m } func boolVal(v any) bool { b, _ := v.(bool) return b } func stringVal(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprint(v) } func stringSlice(v any) []string { a, _ := v.([]any) out := make([]string, 0, len(a)) for _, x := range a { if s := strings.TrimSpace(stringVal(x)); s != "" { out = append(out, s) } } return out } func parseNumericUserSpec(spec string) (uid, gid *int) { spec = strings.TrimSpace(spec) if spec == "" { u, g := 0, 0 return &u, &g } parts := strings.SplitN(spec, ":", 2) if n, err := strconv.Atoi(parts[0]); err == nil && n >= 0 { u := n uid = &u } if len(parts) == 2 { if n, err := strconv.Atoi(parts[1]); err == nil && n >= 0 { g := n gid = &g } } if strings.EqualFold(parts[0], "root") { u := 0 uid = &u } if len(parts) == 2 && strings.EqualFold(parts[1], "root") { g := 0 gid = &g } return uid, gid } func dockerProc1Identity(ctx context.Context, id string) (uid, gid *int, groups []int, err error) { cctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() out, e := exec.CommandContext(cctx, "docker", "exec", id, "cat", "/proc/1/status").CombinedOutput() if e != nil { return nil, nil, nil, fmt.Errorf("read container PID 1 status: %s", fallbackOutput(out, e)) } for _, line := range strings.Split(string(out), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } switch strings.TrimSuffix(fields[0], ":") { case "Uid": if len(fields) < 3 { continue } n, e := strconv.Atoi(fields[2]) // effective UID if e == nil && n >= 0 { v := n uid = &v } case "Gid": if len(fields) < 3 { continue } n, e := strconv.Atoi(fields[2]) // effective GID if e == nil && n >= 0 { v := n gid = &v } case "Groups": for _, f := range fields[1:] { if n, e := strconv.Atoi(f); e == nil && n >= 0 { groups = append(groups, n) } } } } if uid == nil || gid == nil { return nil, nil, groups, errors.New("PID 1 status did not contain numeric effective UID/GID") } return uid, gid, groups, nil } func dockerExecID(ctx context.Context, id, flag string) (*int, error) { cctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() out, err := exec.CommandContext(cctx, "docker", "exec", id, "id", flag).CombinedOutput() if err != nil { return nil, fmt.Errorf("docker exec id %s: %s", flag, fallbackOutput(out, err)) } n, err := strconv.Atoi(strings.TrimSpace(string(out))) if err != nil || n < 0 { return nil, errors.New("container returned an invalid numeric identity") } return &n, nil } func (s *Service) hostAccessStatus() HostAccessStatus { root := strings.TrimSpace(s.hostRoot) st := HostAccessStatus{Configured: root != "", ManagementEnabled: s.allowHostUserManagement, PermissionManagementEnabled: s.allowHostPermissionManagement, Root: root} if root == "" { st.Message = "HOST_ROOT is not configured; host account and bind-mount ownership lookup is unavailable" return st } fi, err := os.Stat(filepath.Join(root, "etc", "passwd")) if err != nil || fi.IsDir() { st.Message = "configured HOST_ROOT does not expose a readable /etc/passwd" return st } st.Available = true switch { case s.allowHostUserManagement && s.allowHostPermissionManagement: st.Message = "host identity inspection, explicitly approved user creation and bind-mount permission repair are enabled" case s.allowHostUserManagement: st.Message = "host identity inspection and explicitly approved user creation are enabled; bind-mount permission repair is disabled" case s.allowHostPermissionManagement: st.Message = "host identity inspection and explicitly approved bind-mount permission repair are enabled; user creation is disabled" default: st.Message = "host identity inspection is read-only; host mutations are disabled" } return st } func readHostAccounts(root string) ([]HostAccount, []HostGroup, error) { pf, err := os.Open(filepath.Join(root, "etc", "passwd")) if err != nil { return nil, nil, err } defer pf.Close() users := []HostAccount{} sc := bufio.NewScanner(pf) for sc.Scan() { line := sc.Text() if line == "" || strings.HasPrefix(line, "#") { continue } p := strings.Split(line, ":") if len(p) < 7 { continue } uid, e1 := strconv.Atoi(p[2]) gid, e2 := strconv.Atoi(p[3]) if e1 != nil || e2 != nil { continue } users = append(users, HostAccount{Name: p[0], UID: uid, GID: gid, Home: p[5], Shell: p[6]}) } if err := sc.Err(); err != nil { return nil, nil, err } gf, err := os.Open(filepath.Join(root, "etc", "group")) if err != nil { return nil, nil, err } defer gf.Close() groups := []HostGroup{} sc = bufio.NewScanner(gf) for sc.Scan() { p := strings.Split(sc.Text(), ":") if len(p) < 3 { continue } gid, err := strconv.Atoi(p[2]) if err == nil { groups = append(groups, HostGroup{Name: p[0], GID: gid}) } } return users, groups, sc.Err() } func accountByUID(users []HostAccount, uid int) *HostAccount { for i := range users { if users[i].UID == uid { u := users[i] return &u } } return nil } func accountByName(users []HostAccount, name string) *HostAccount { for i := range users { if users[i].Name == name { u := users[i] return &u } } return nil } func groupByGID(groups []HostGroup, gid int) *HostGroup { for i := range groups { if groups[i].GID == gid { g := groups[i] return &g } } return nil } func groupByName(groups []HostGroup, name string) *HostGroup { for i := range groups { if groups[i].Name == name { g := groups[i] return &g } } return nil } func numericEnvPair(cfg map[string]any) (uid, gid *int, source string) { vals := map[string]int{} if envs, ok := cfg["Env"].([]any); ok { for _, raw := range envs { part := strings.SplitN(stringVal(raw), "=", 2) if len(part) != 2 { continue } n, err := strconv.Atoi(strings.TrimSpace(part[1])) if err == nil && n >= 0 { vals[strings.ToUpper(strings.TrimSpace(part[0]))] = n } } } for _, pair := range [][3]string{{"PUID", "PGID", "PUID/PGID"}, {"USER_ID", "GROUP_ID", "USER_ID/GROUP_ID"}} { u, uok := vals[pair[0]] g, gok := vals[pair[1]] if uok && gok { uv, gv := u, g return &uv, &gv, pair[2] + " environment" } } return nil, nil, "" } func fileOwnerIDs(fi os.FileInfo) (*int, *int) { if fi == nil || fi.Sys() == nil { return nil, nil } v := reflect.ValueOf(fi.Sys()) if v.Kind() == reflect.Pointer { if v.IsNil() { return nil, nil } v = v.Elem() } if v.Kind() != reflect.Struct { return nil, nil } read := func(name string) *int { f := v.FieldByName(name) if !f.IsValid() { return nil } var n uint64 switch f.Kind() { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n = f.Uint() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: x := f.Int() if x < 0 { return nil } n = uint64(x) default: return nil } if n > uint64(^uint(0)>>1) { return nil } x := int(n) return &x } return read("Uid"), read("Gid") } func fileDeviceID(fi os.FileInfo) *uint64 { if fi == nil || fi.Sys() == nil { return nil } v := reflect.ValueOf(fi.Sys()) if v.Kind() == reflect.Pointer { if v.IsNil() { return nil } v = v.Elem() } if v.Kind() != reflect.Struct { return nil } f := v.FieldByName("Dev") if !f.IsValid() { return nil } var n uint64 switch f.Kind() { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n = f.Uint() case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: x := f.Int() if x < 0 { return nil } n = uint64(x) default: return nil } return &n } func staticWriteAccess(fi os.FileInfo, ownerUID, ownerGID, uid, gid *int, groups []int) (*bool, string) { if fi == nil || uid == nil || gid == nil || ownerUID == nil || ownerGID == nil { return nil, "insufficient numeric identity or ownership information" } if *uid == 0 { v := true return &v, "UID 0 normally bypasses discretionary write bits; read-only mounts and LSM policies can still deny writes" } perm := fi.Mode().Perm() need := func(writeBit, execBit os.FileMode) (bool, string) { if fi.IsDir() { ok := perm&writeBit != 0 && perm&execBit != 0 return ok, map[bool]string{true: "directory write+execute bits are set", false: "directory requires both write and execute permission"}[ok] } ok := perm&writeBit != 0 return ok, map[bool]string{true: "file write bit is set", false: "file write bit is not set"}[ok] } if *uid == *ownerUID { v, reason := need(0200, 0100) return &v, "owner class: " + reason } groupMatch := *gid == *ownerGID if !groupMatch { for _, g := range groups { if g == *ownerGID { groupMatch = true break } } } if groupMatch { v, reason := need(0020, 0010) return &v, "group class: " + reason } v, reason := need(0002, 0001) return &v, "other class: " + reason } func aclInfo(ctx context.Context, path string) (bool, string) { tool, err := exec.LookPath("getfacl") if err != nil { return false, "ACL inspection unavailable (getfacl not installed)" } cctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() out, err := exec.CommandContext(cctx, tool, "-cpn", "--", path).CombinedOutput() if err != nil { return false, "ACL inspection failed" } extended := false for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "mask::") || regexp.MustCompile(`^(user|group):[0-9]+:`).MatchString(line) { extended = true break } } if extended { return true, "extended POSIX ACL detected; mode-bit write analysis is advisory" } return false, "no extended POSIX ACL detected" } func secureHostMappedPath(root, source string) (string, error) { if strings.TrimSpace(root) == "" { return "", errors.New("HOST_ROOT is not configured") } if !filepath.IsAbs(source) { return "", errors.New("Docker bind source is not an absolute host path") } root = filepath.Clean(root) rootInfo, err := os.Lstat(root) if err != nil || !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 { return "", errors.New("HOST_ROOT must be an existing real directory, not a symlink") } clean := filepath.Clean(source) if clean == string(filepath.Separator) { return "", errors.New("refusing to manage the host root directory as a bind mount") } candidate := hostMappedPath(root, clean) rel, err := filepath.Rel(root, candidate) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", errors.New("bind source escapes HOST_ROOT") } cur := root for _, part := range strings.Split(rel, string(filepath.Separator)) { if part == "" || part == "." { continue } cur = filepath.Join(cur, part) fi, err := os.Lstat(cur) if err != nil { return "", fmt.Errorf("host bind path unavailable: %w", err) } if fi.Mode()&os.ModeSymlink != 0 { return "", errors.New("refusing host permission management through symlinked bind paths") } } return candidate, nil } func statHostOwner(ctx context.Context, path string) (*int, *int, error) { select { case <-ctx.Done(): return nil, nil, ctx.Err() default: } fi, err := os.Lstat(path) if err != nil { return nil, nil, err } u, g := fileOwnerIDs(fi) if u == nil || g == nil { return nil, nil, errors.New("host filesystem does not expose numeric UID/GID") } return u, g, nil } func hostMappedPath(root, source string) string { clean := filepath.Clean(source) if filepath.IsAbs(clean) { clean = strings.TrimLeft(clean, `/\\`) } return filepath.Join(root, clean) } func (s *Service) dockerHostIDMapping(ctx context.Context, hostCfg map[string]any) string { if mode := strings.TrimSpace(stringVal(hostCfg["UsernsMode"])); mode != "" && mode != "host" { return "remapped" } s.hostMappingMu.Lock() if s.hostMapping != "" && time.Since(s.hostMappingAt) < 30*time.Second { v := s.hostMapping s.hostMappingMu.Unlock() return v } s.hostMappingMu.Unlock() cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() mapping := "unknown" out, err := exec.CommandContext(cctx, "docker", "info", "--format", "{{json .SecurityOptions}}").CombinedOutput() if err == nil { var opts []string if json.Unmarshal(out, &opts) == nil { mapping = "direct" for _, opt := range opts { x := strings.ToLower(opt) if strings.Contains(x, "rootless") || strings.Contains(x, "userns") { mapping = "remapped" break } } } } s.hostMappingMu.Lock() s.hostMapping, s.hostMappingAt = mapping, time.Now() s.hostMappingMu.Unlock() return mapping } func (s *Service) ContainerIdentity(ctx context.Context, id string) (ContainerIdentityReport, error) { var err error id, err = safeDockerPositional(id, "container id/name") if err != nil { return ContainerIdentityReport{}, err } raw, err := runDocker(ctx, "inspect", id) if err != nil { return ContainerIdentityReport{}, err } var arr []map[string]any if err := json.Unmarshal([]byte(raw), &arr); err != nil || len(arr) == 0 { return ContainerIdentityReport{}, errors.New("invalid docker inspect response") } obj := arr[0] cfg := mapObj(obj["Config"]) hostCfg := mapObj(obj["HostConfig"]) state := mapObj(obj["State"]) report := ContainerIdentityReport{ ContainerID: stringVal(obj["Id"]), ContainerName: strings.TrimPrefix(stringVal(obj["Name"]), "/"), Image: stringVal(cfg["Image"]), Running: boolVal(state["Running"]), ConfiguredUser: strings.TrimSpace(stringVal(cfg["User"])), Privileged: boolVal(hostCfg["Privileged"]), AddedCapabilities: stringSlice(hostCfg["CapAdd"]), RootReasons: []string{}, Recommendations: []string{}, BindMounts: []BindMountIdentity{}, HostAccess: s.hostAccessStatus(), IdentitySource: "container configuration", } if devs, ok := hostCfg["Devices"].([]any); ok { report.DeviceCount = len(devs) } report.HostIDMapping = s.dockerHostIDMapping(ctx, hostCfg) if report.HostIDMapping == "remapped" { report.Recommendations = append(report.Recommendations, "Docker user-namespace remapping or rootless mode is active. Container UID/GID values do not map directly to the same host IDs; automatic matching host-account creation is disabled.") } else if report.HostIDMapping == "unknown" { report.Recommendations = append(report.Recommendations, "Dockwatch could not confirm Docker's host UID mapping mode. Treat host UID/GID comparisons as advisory.") } uid, gid := parseNumericUserSpec(report.ConfiguredUser) if report.Running { if u, g, groups, e := dockerProc1Identity(ctx, id); e == nil { uid, gid = u, g report.EffectiveGroups = groups report.IdentitySource = "running PID 1 effective UID/GID (/proc/1/status)" } else { resolved := false if u, e := dockerExecID(ctx, id, "-u"); e == nil { uid = u resolved = true } if g, e := dockerExecID(ctx, id, "-g"); e == nil { gid = g resolved = true } if resolved { report.IdentitySource = "configured exec identity fallback (PID 1 identity unavailable)" report.Recommendations = append(report.Recommendations, "PID 1 effective UID/GID could not be read; the reported UID/GID falls back to the container's configured exec identity and may differ if the entrypoint drops privileges itself.") } } } report.EffectiveUID, report.EffectiveGID = uid, gid if uid != nil { r := *uid == 0 report.RunsAsRoot = &r } // Bind-mount ownership can intentionally differ from PID 1. Images such as // LinuxServer.io commonly keep PID 1 privileged while PUID/PGID select the // identity used for application data. Prefer only well-known paired hints; // otherwise fall back to the effective process identity. if bu, bg, src := numericEnvPair(cfg); bu != nil && bg != nil { report.BindUID, report.BindGID, report.BindIdentitySource = bu, bg, src } else { report.BindUID, report.BindGID = uid, gid report.BindIdentitySource = report.IdentitySource } var users []HostAccount var groups []HostGroup if report.HostAccess.Available { users, groups, _ = readHostAccounts(s.hostRoot) if uid != nil { report.HostUser = accountByUID(users, *uid) } if gid != nil { report.HostGroup = groupByGID(groups, *gid) } if report.BindUID != nil { report.BindHostUser = accountByUID(users, *report.BindUID) } if report.BindGID != nil { report.BindHostGroup = groupByGID(groups, *report.BindGID) } } if mounts, ok := obj["Mounts"].([]any); ok { for _, mv := range mounts { m := mapObj(mv) if strings.ToLower(stringVal(m["Type"])) != "bind" { continue } source := stringVal(m["Source"]) dest := stringVal(m["Destination"]) bm := BindMountIdentity{Source: source, Destination: dest, ReadOnly: !boolVal(m["RW"])} if dest == "/var/run/docker.sock" || source == "/var/run/docker.sock" || strings.HasSuffix(source, "/docker.sock") { report.DockerSocketMounted = true } if report.HostAccess.Available && source != "" { hp, safeErr := secureHostMappedPath(s.hostRoot, source) if safeErr == nil { fi, statErr := os.Lstat(hp) ou, og := fileOwnerIDs(fi) if statErr == nil && ou != nil && og != nil { bm.OwnerUID, bm.OwnerGID = ou, og bm.Mode = fmt.Sprintf("%04o", fi.Mode().Perm()) if u := accountByUID(users, *ou); u != nil { bm.OwnerUser = u.Name } if g := groupByGID(groups, *og); g != nil { bm.OwnerGroup = g.Name } if report.BindUID != nil { x := *report.BindUID == *ou bm.MatchesUID = &x } if report.BindGID != nil { x := *report.BindGID == *og bm.MatchesGID = &x } bindGroups := report.EffectiveGroups if report.BindUID != nil && report.EffectiveUID != nil && *report.BindUID != *report.EffectiveUID { bindGroups = nil } bm.StaticWritable, bm.WritableReason = staticWriteAccess(fi, ou, og, report.BindUID, report.BindGID, bindGroups) bm.ACLDetected, bm.ACLNote = aclInfo(ctx, hp) if bm.ReadOnly { v := false bm.StaticWritable = &v bm.WritableReason = "Docker mount is read-only" } if bm.MatchesUID != nil && !*bm.MatchesUID && !bm.ReadOnly { bm.OwnershipNote = "writable bind mount owner differs from the expected application UID; verify mode/ACL or repair ownership" } } else { bm.OwnershipNote = "host path ownership could not be read" } } else { bm.OwnershipNote = safeErr.Error() } } report.BindMounts = append(report.BindMounts, bm) } } switch { case report.RunsAsRoot == nil: report.RootAssessment = "unknown" report.RootReasons = append(report.RootReasons, "The effective UID could not be resolved. Distroless/stopped containers with a named USER may require manual review.") case !*report.RunsAsRoot: report.RootAssessment = "non-root" report.RootReasons = append(report.RootReasons, fmt.Sprintf("Container runs with UID %d instead of UID 0.", *uid)) case report.Privileged: report.RootAssessment = "root-high-privilege" report.RootReasons = append(report.RootReasons, "Container is privileged. Removing root without redesigning privileges is likely to break the current setup.") case report.DockerSocketMounted: report.RootAssessment = "root-review-docker-socket" report.RootReasons = append(report.RootReasons, "Docker socket is bind-mounted. Root is often used for socket access, but matching the socket GID can allow a non-root process to connect.") case report.DeviceCount > 0: report.RootAssessment = "root-review-devices" report.RootReasons = append(report.RootReasons, "Host devices are passed through. Device groups/permissions and capabilities should be checked before dropping root.") default: report.RootAssessment = "root-not-obviously-required" report.RootReasons = append(report.RootReasons, "No privileged mode, Docker socket, or passed-through host devices were detected. Static inspection cannot prove that the application itself supports non-root operation.") } if len(report.AddedCapabilities) > 0 { report.RootReasons = append(report.RootReasons, "Explicit Linux capabilities are configured: "+strings.Join(report.AddedCapabilities, ", ")+". These may replace some reasons for running as root, depending on the application.") } if envs, ok := cfg["Env"].([]any); ok { hints := []string{} for _, raw := range envs { part := strings.SplitN(stringVal(raw), "=", 2) if len(part) != 2 { continue } switch strings.ToUpper(part[0]) { case "PUID", "PGID", "UID", "GID", "USER_ID", "GROUP_ID": if _, e := strconv.Atoi(strings.TrimSpace(part[1])); e == nil { hints = append(hints, part[0]+"="+part[1]) } } } if len(hints) > 0 { report.Recommendations = append(report.Recommendations, "The container declares numeric identity-related environment variables ("+strings.Join(hints, ", ")+"). Check the image documentation: it may provide an image-specific PUID/PGID-style non-root configuration.") } } if report.BindUID != nil && *report.BindUID > 0 { if report.HostAccess.Available { if report.BindHostUser == nil { report.Recommendations = append(report.Recommendations, fmt.Sprintf("No local host account currently maps to the expected bind-mount UID %d. Docker only needs numeric ownership, but a matching host account can make administration clearer.", *report.BindUID)) } else { report.Recommendations = append(report.Recommendations, fmt.Sprintf("Bind-mount UID %d is already mapped to local user %q.", *report.BindUID, report.BindHostUser.Name)) } } else { report.Recommendations = append(report.Recommendations, "Configure HOST_ROOT read-only to compare application UID/GID with host accounts and bind-mount ownership.") } } if report.RunsAsRoot != nil && *report.RunsAsRoot { report.Recommendations = append(report.Recommendations, "Do not change Compose user automatically. Test the image with an explicit user/PUID/PGID setting and verify writable paths, capabilities and healthchecks first.") } return report, nil } type BindPermissionPreviewInput struct { ContainerID string `json:"container_id"` Destination string `json:"destination"` Recursive bool `json:"recursive"` } type BindPermissionPreview struct { ContainerID string `json:"container_id"` ContainerName string `json:"container_name"` Source string `json:"source"` Destination string `json:"destination"` ReadOnly bool `json:"read_only"` ExpectedUID *int `json:"expected_uid,omitempty"` ExpectedGID *int `json:"expected_gid,omitempty"` IdentitySource string `json:"identity_source"` HostAccess HostAccessStatus `json:"host_access"` HostUser *HostAccount `json:"host_user,omitempty"` HostGroup *HostGroup `json:"host_group,omitempty"` OwnerUID *int `json:"owner_uid,omitempty"` OwnerGID *int `json:"owner_gid,omitempty"` Mode string `json:"mode,omitempty"` StaticWritable *bool `json:"static_writable,omitempty"` WritableReason string `json:"writable_reason,omitempty"` RuntimeWritable *bool `json:"runtime_writable,omitempty"` RuntimeNote string `json:"runtime_note,omitempty"` ACLDetected bool `json:"acl_detected,omitempty"` ACLNote string `json:"acl_note,omitempty"` Recursive bool `json:"recursive"` EntriesScanned int64 `json:"entries_scanned"` FilesScanned int64 `json:"files_scanned"` DirectoriesScanned int64 `json:"directories_scanned"` SymlinksSkipped int64 `json:"symlinks_skipped"` CrossFilesystemSkipped int64 `json:"cross_filesystem_skipped"` EntriesOwnershipMismatch int64 `json:"entries_ownership_mismatch"` ScanTruncated bool `json:"scan_truncated"` CanRepair bool `json:"can_repair"` BlockedReason string `json:"blocked_reason,omitempty"` Recommendation string `json:"recommendation,omitempty"` } type RepairBindPermissionsInput struct { ContainerID string `json:"container_id"` Destination string `json:"destination"` Recursive bool `json:"recursive"` FixOwnership bool `json:"fix_ownership"` Mode string `json:"mode,omitempty"` // optional top-level chmod, e.g. 0750 } type RepairBindPermissionsResult struct { ChangedOwnership int64 `json:"changed_ownership"` ChangedMode bool `json:"changed_mode"` Before BindPermissionPreview `json:"before"` After BindPermissionPreview `json:"after"` Message string `json:"message"` } type StackBindPermissionContainer struct { Service string `json:"service"` ContainerID string `json:"container_id"` Report *ContainerIdentityReport `json:"report,omitempty"` Error string `json:"error,omitempty"` } type StackBindPermissionsReport struct { Stack string `json:"stack"` Containers []StackBindPermissionContainer `json:"containers"` } func findBindMount(report ContainerIdentityReport, destination string) (*BindMountIdentity, error) { destination = filepath.Clean(strings.TrimSpace(destination)) if destination == "." || destination == "" { return nil, errors.New("bind destination is required") } for i := range report.BindMounts { if filepath.Clean(report.BindMounts[i].Destination) == destination { m := report.BindMounts[i] return &m, nil } } return nil, errors.New("requested destination is not a bind mount of this container") } func dockerRuntimeWritable(ctx context.Context, id, destination string, uid, gid int) (*bool, string) { cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() cmd := exec.CommandContext(cctx, "docker", "exec", "--user", fmt.Sprintf("%d:%d", uid, gid), id, "sh", "-c", `test -w "$1"`, "dockwatch", destination) out, err := cmd.CombinedOutput() if err == nil { v := true return &v, "docker exec test -w succeeded for the expected numeric UID:GID (supplementary groups may differ from the application process)" } msg := strings.TrimSpace(string(out)) var ee *exec.ExitError if errors.As(err, &ee) && ee.ExitCode() == 1 && msg == "" { v := false return &v, "docker exec test -w reported the mount is not writable for the expected numeric UID:GID (supplementary groups may differ from the application process)" } if msg == "" { msg = err.Error() } return nil, "runtime write check unavailable: " + msg } var errBindScanLimit = errors.New("bind permission scan limit reached") const bindScanLimit = int64(200000) func scanBindOwnership(ctx context.Context, path string, uid, gid int, recursive bool) (entries, files, dirs, symlinks, crossFS, mismatch int64, truncated bool, err error) { rootInfo, err := os.Lstat(path) if err != nil { return 0, 0, 0, 0, 0, 0, false, err } rootDev := fileDeviceID(rootInfo) check := func(p string, fi os.FileInfo) error { select { case <-ctx.Done(): return ctx.Err() default: } if fi.Mode()&os.ModeSymlink != 0 { symlinks++ return nil } entries++ if entries > bindScanLimit { return errBindScanLimit } if fi.IsDir() { dirs++ } else { files++ } u, g := fileOwnerIDs(fi) if u == nil || g == nil { return errors.New("host filesystem does not expose numeric UID/GID") } if *u != uid || *g != gid { mismatch++ } return nil } if !recursive { e := check(path, rootInfo) return entries, files, dirs, symlinks, crossFS, mismatch, false, e } err = filepath.Walk(path, func(p string, fi os.FileInfo, e error) error { if e != nil { return e } if p != path && rootDev != nil { if dev := fileDeviceID(fi); dev != nil && *dev != *rootDev { crossFS++ if fi.IsDir() { return filepath.SkipDir } return nil } } if fi.Mode()&os.ModeSymlink != 0 { symlinks++ return nil } return check(p, fi) }) if errors.Is(err, errBindScanLimit) { return entries - 1, files, dirs, symlinks, crossFS, mismatch, true, nil } return entries, files, dirs, symlinks, crossFS, mismatch, false, err } func parsePermissionMode(v string) (os.FileMode, error) { v = strings.TrimSpace(v) if v == "" { return 0, nil } if len(v) == 4 && v[0] == '0' { v = v[1:] } if len(v) != 3 { return 0, errors.New("mode must be a three-digit octal value such as 750 or 775") } n, err := strconv.ParseUint(v, 8, 32) if err != nil || n > 0777 { return 0, errors.New("mode must be an octal value between 000 and 777") } return os.FileMode(n), nil } func (s *Service) BindPermissionPreview(ctx context.Context, in BindPermissionPreviewInput) (BindPermissionPreview, error) { report, err := s.ContainerIdentity(ctx, in.ContainerID) if err != nil { return BindPermissionPreview{}, err } mount, err := findBindMount(report, in.Destination) if err != nil { return BindPermissionPreview{}, err } out := BindPermissionPreview{ ContainerID: report.ContainerID, ContainerName: report.ContainerName, Source: mount.Source, Destination: mount.Destination, ReadOnly: mount.ReadOnly, ExpectedUID: report.BindUID, ExpectedGID: report.BindGID, IdentitySource: report.BindIdentitySource, HostAccess: report.HostAccess, HostUser: report.BindHostUser, HostGroup: report.BindHostGroup, OwnerUID: mount.OwnerUID, OwnerGID: mount.OwnerGID, Mode: mount.Mode, StaticWritable: mount.StaticWritable, WritableReason: mount.WritableReason, ACLDetected: mount.ACLDetected, ACLNote: mount.ACLNote, Recursive: in.Recursive, } if !report.HostAccess.Available { out.BlockedReason = "HOST_ROOT is not available; host ownership cannot be inspected" out.Recommendation = "Mount the host root read-only at HOST_ROOT for analysis." return out, nil } if report.BindUID == nil || report.BindGID == nil { out.BlockedReason = "expected bind-mount UID/GID could not be resolved" return out, nil } hp, err := secureHostMappedPath(s.hostRoot, mount.Source) if err != nil { out.BlockedReason = err.Error() return out, nil } fi, err := os.Lstat(hp) if err != nil { return out, err } out.Mode = fmt.Sprintf("%04o", fi.Mode().Perm()) out.OwnerUID, out.OwnerGID = fileOwnerIDs(fi) bindGroups := report.EffectiveGroups if report.EffectiveUID == nil || *report.BindUID != *report.EffectiveUID { bindGroups = nil } out.StaticWritable, out.WritableReason = staticWriteAccess(fi, out.OwnerUID, out.OwnerGID, report.BindUID, report.BindGID, bindGroups) out.ACLDetected, out.ACLNote = aclInfo(ctx, hp) if mount.ReadOnly { v := false out.StaticWritable = &v out.WritableReason = "Docker mount is read-only" } out.EntriesScanned, out.FilesScanned, out.DirectoriesScanned, out.SymlinksSkipped, out.CrossFilesystemSkipped, out.EntriesOwnershipMismatch, out.ScanTruncated, err = scanBindOwnership(ctx, hp, *report.BindUID, *report.BindGID, in.Recursive) if err != nil { return out, err } if out.ScanTruncated { out.BlockedReason = "recursive ownership scan exceeded the 200000-entry safety limit" out.Recommendation = "Narrow the bind mount or repair this very large tree deliberately on the host. Dockwatch will not perform a blind recursive chown." } if report.Running { out.RuntimeWritable, out.RuntimeNote = dockerRuntimeWritable(ctx, in.ContainerID, mount.Destination, *report.BindUID, *report.BindGID) } else { out.RuntimeNote = "container is stopped; runtime write check was skipped" } switch { case out.ScanTruncated: // Keep the safety block set above. case mount.ReadOnly: out.BlockedReason = "mount is read-only; Dockwatch never repairs ownership for :ro mounts" out.Recommendation = "No ownership repair is recommended for a read-only bind mount." case report.HostIDMapping == "remapped": out.BlockedReason = "Docker rootless/userns remapping is active; container IDs do not map directly to host IDs" out.Recommendation = "Repair ownership using the host's mapped UID/GID policy instead of same-numbered container IDs." case report.HostIDMapping != "direct": out.BlockedReason = "Dockwatch could not confirm direct container-to-host UID/GID mapping" out.Recommendation = "Resolve the Docker security-options check before allowing automatic host ownership changes." case !report.HostAccess.PermissionManagementEnabled: out.BlockedReason = "host permission management is disabled" out.Recommendation = "Set ALLOW_HOST_PERMISSION_MANAGEMENT=true and mount HOST_ROOT read-write only on trusted hosts." default: out.CanRepair = true if out.RuntimeWritable != nil && *out.RuntimeWritable { out.Recommendation = "The expected application identity can currently write this mount; ownership changes are not required for write access." } else if out.ACLDetected { out.Recommendation = "Extended ACLs are present. Review ACLs before changing ownership because mode bits alone are incomplete." } else if out.EntriesOwnershipMismatch > 0 && out.StaticWritable != nil && !*out.StaticWritable { out.Recommendation = "Ownership differs and POSIX mode bits do not grant write access. A reviewed ownership repair is likely appropriate." } else if out.EntriesOwnershipMismatch > 0 { out.Recommendation = "Ownership differs, but write access may still come from group/other bits. Change ownership only if the application requires ownership semantics." } else { out.Recommendation = "Ownership already matches the expected application UID/GID." } } return out, nil } func repairOwnership(ctx context.Context, path string, uid, gid int, recursive bool) (int64, error) { var changed int64 rootInfo, err := os.Lstat(path) if err != nil { return 0, err } rootDev := fileDeviceID(rootInfo) apply := func(p string, fi os.FileInfo) error { select { case <-ctx.Done(): return ctx.Err() default: } if fi.Mode()&os.ModeSymlink != 0 { return nil } u, g := fileOwnerIDs(fi) if u == nil || g == nil { return errors.New("host filesystem does not expose numeric UID/GID") } if *u == uid && *g == gid { return nil } if changed >= bindScanLimit { return errors.New("refusing to change more than 200000 entries in one operation") } if err := os.Chown(p, uid, gid); err != nil { return err } changed++ return nil } if !recursive { if err := apply(path, rootInfo); err != nil { return changed, err } return changed, nil } err = filepath.Walk(path, func(p string, fi os.FileInfo, err error) error { if err != nil { return err } if p != path && rootDev != nil { if dev := fileDeviceID(fi); dev != nil && *dev != *rootDev { if fi.IsDir() { return filepath.SkipDir } return nil } } return apply(p, fi) }) return changed, err } func (s *Service) RepairBindPermissions(ctx context.Context, in RepairBindPermissionsInput) (RepairBindPermissionsResult, error) { s.hostIdentityMu.Lock() defer s.hostIdentityMu.Unlock() if !s.allowHostPermissionManagement { return RepairBindPermissionsResult{}, errors.New("host permission management is disabled; set ALLOW_HOST_PERMISSION_MANAGEMENT=true explicitly") } before, err := s.BindPermissionPreview(ctx, BindPermissionPreviewInput{ContainerID: in.ContainerID, Destination: in.Destination, Recursive: in.Recursive}) if err != nil { return RepairBindPermissionsResult{}, err } if !before.CanRepair { return RepairBindPermissionsResult{}, errors.New(before.BlockedReason) } if before.ExpectedUID == nil || before.ExpectedGID == nil { return RepairBindPermissionsResult{}, errors.New("expected bind-mount UID/GID could not be resolved") } mode, err := parsePermissionMode(in.Mode) if err != nil { return RepairBindPermissionsResult{}, err } if !in.FixOwnership && strings.TrimSpace(in.Mode) == "" { return RepairBindPermissionsResult{}, errors.New("select ownership repair and/or an explicit top-level mode") } hp, err := secureHostMappedPath(s.hostRoot, before.Source) if err != nil { return RepairBindPermissionsResult{}, err } result := RepairBindPermissionsResult{Before: before} if in.FixOwnership { result.ChangedOwnership, err = repairOwnership(ctx, hp, *before.ExpectedUID, *before.ExpectedGID, in.Recursive) if err != nil { return result, fmt.Errorf("repair ownership: %w", err) } } if strings.TrimSpace(in.Mode) != "" { if err := os.Chmod(hp, mode); err != nil { return result, fmt.Errorf("chmod bind root: %w", err) } result.ChangedMode = true } result.After, err = s.BindPermissionPreview(ctx, BindPermissionPreviewInput{ContainerID: in.ContainerID, Destination: in.Destination, Recursive: in.Recursive}) if err != nil { return result, err } result.Message = fmt.Sprintf("bind mount repaired: %d ownership changes", result.ChangedOwnership) if result.ChangedMode { result.Message += "; top-level mode updated" } return result, nil } func (s *Service) stackPSAll(ctx context.Context, name string) ([]ServiceInfo, error) { out, err := s.run(ctx, name, "ps", "--all", "--format", "json") if err != nil { return nil, err } var arr []map[string]any if json.Unmarshal(out, &arr) == nil { services := make([]ServiceInfo, 0, len(arr)) for _, m := range arr { services = append(services, mapService(m)) } return services, nil } services := []ServiceInfo{} for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } var m map[string]any if json.Unmarshal([]byte(line), &m) == nil { services = append(services, mapService(m)) } } if len(services) == 0 && len(strings.TrimSpace(string(out))) > 0 { return nil, errors.New("docker compose ps returned invalid JSON") } return services, nil } func (s *Service) StackBindPermissions(ctx context.Context, name string) (StackBindPermissionsReport, error) { services, err := s.stackPSAll(ctx, name) if err != nil { return StackBindPermissionsReport{}, err } out := StackBindPermissionsReport{Stack: name, Containers: []StackBindPermissionContainer{}} for _, service := range services { item := StackBindPermissionContainer{Service: service.Service, ContainerID: service.ID} if item.ContainerID == "" { item.Error = "container is not created" out.Containers = append(out.Containers, item) continue } report, err := s.ContainerIdentity(ctx, item.ContainerID) if err != nil { item.Error = err.Error() } else { item.Report = &report } out.Containers = append(out.Containers, item) } return out, nil } func findHostTool(root string, candidates ...string) string { for _, c := range candidates { if fi, err := os.Stat(filepath.Join(root, strings.TrimPrefix(c, "/"))); err == nil && !fi.IsDir() { return c } } return "" } func runChroot(ctx context.Context, root, tool string, args ...string) error { cctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() name := "chroot" all := append([]string{root, tool}, args...) if _, err := exec.LookPath(name); err != nil { if _, busyErr := exec.LookPath("busybox"); busyErr != nil { return errors.New("host user management requires chroot or busybox in the Dockwatch runtime") } name = "busybox" all = append([]string{"chroot", root, tool}, args...) } out, err := exec.CommandContext(cctx, name, all...).CombinedOutput() if err != nil { return fmt.Errorf("host command %s: %s", tool, fallbackOutput(out, err)) } return nil } func (s *Service) CreateHostUser(ctx context.Context, in CreateHostUserInput) (CreateHostUserResult, error) { s.hostIdentityMu.Lock() defer s.hostIdentityMu.Unlock() if !s.allowHostUserManagement { return CreateHostUserResult{}, errors.New("host user management is disabled; set ALLOW_HOST_USER_MANAGEMENT=true explicitly") } if !s.hostAccessStatus().Available { return CreateHostUserResult{}, errors.New("HOST_ROOT is not available") } in.Username = strings.TrimSpace(in.Username) in.GroupName = strings.TrimSpace(in.GroupName) if !validHostAccountName.MatchString(in.Username) { return CreateHostUserResult{}, errors.New("username must be a conservative local Unix account name (lowercase letters, digits, _ and -)") } if in.GroupName != "" && !validHostAccountName.MatchString(in.GroupName) { return CreateHostUserResult{}, errors.New("invalid group name") } report, err := s.ContainerIdentity(ctx, in.ContainerID) if err != nil { return CreateHostUserResult{}, err } if report.HostIDMapping == "remapped" { return CreateHostUserResult{}, errors.New("Docker uses user-namespace remapping/rootless mode; refusing to create a same-numbered host account") } if report.HostIDMapping != "direct" { return CreateHostUserResult{}, errors.New("Dockwatch could not confirm direct container-to-host UID/GID mapping; refusing to create a host account") } if report.BindUID == nil || report.BindGID == nil { return CreateHostUserResult{}, errors.New("bind-mount UID/GID could not be resolved; refusing to guess host identity") } uid, gid := *report.BindUID, *report.BindGID if uid == 0 { return CreateHostUserResult{}, errors.New("expected bind-mount UID is 0; host root already exists and a matching account must not be created") } if uid < 0 || gid < 0 || uid > 2147483646 || gid > 2147483646 { return CreateHostUserResult{}, errors.New("container UID/GID outside supported host account range") } users, groups, err := readHostAccounts(s.hostRoot) if err != nil { return CreateHostUserResult{}, err } if existing := accountByUID(users, uid); existing != nil { g := HostGroup{GID: existing.GID} if x := groupByGID(groups, existing.GID); x != nil { g = *x } return CreateHostUserResult{User: *existing, Group: g, Message: fmt.Sprintf("host UID %d already belongs to %s; nothing changed", uid, existing.Name)}, nil } if existing := accountByName(users, in.Username); existing != nil { return CreateHostUserResult{}, fmt.Errorf("host username %q already exists with UID %d", in.Username, existing.UID) } group := groupByGID(groups, gid) createdGroup := false if group == nil { if in.GroupName == "" { in.GroupName = in.Username } if existing := groupByName(groups, in.GroupName); existing != nil { return CreateHostUserResult{}, fmt.Errorf("host group %q already exists with GID %d", in.GroupName, existing.GID) } groupadd := findHostTool(s.hostRoot, "/usr/sbin/groupadd", "/sbin/groupadd", "/usr/bin/groupadd", "/bin/groupadd") addgroup := findHostTool(s.hostRoot, "/usr/sbin/addgroup", "/sbin/addgroup", "/usr/bin/addgroup", "/bin/addgroup") switch { case groupadd != "": err = runChroot(ctx, s.hostRoot, groupadd, "-g", strconv.Itoa(gid), in.GroupName) case addgroup != "": err = runChroot(ctx, s.hostRoot, addgroup, "-g", strconv.Itoa(gid), "-S", in.GroupName) default: err = errors.New("host provides neither groupadd nor addgroup") } if err != nil { return CreateHostUserResult{}, err } createdGroup = true group = &HostGroup{Name: in.GroupName, GID: gid} } useradd := findHostTool(s.hostRoot, "/usr/sbin/useradd", "/sbin/useradd", "/usr/bin/useradd", "/bin/useradd") adduser := findHostTool(s.hostRoot, "/usr/sbin/adduser", "/sbin/adduser", "/usr/bin/adduser", "/bin/adduser") nologin := findHostTool(s.hostRoot, "/usr/sbin/nologin", "/sbin/nologin", "/bin/false") if nologin == "" { nologin = "/bin/false" } switch { case useradd != "": args := []string{"-u", strconv.Itoa(uid), "-g", strconv.Itoa(gid), "-s", nologin, "-p", "!"} if in.CreateHome { args = append(args, "-m") } else { args = append(args, "-M") } args = append(args, in.Username) err = runChroot(ctx, s.hostRoot, useradd, args...) case adduser != "": args := []string{"-D", "-u", strconv.Itoa(uid), "-G", group.Name, "-s", nologin} if !in.CreateHome { args = append(args, "-H") } args = append(args, in.Username) err = runChroot(ctx, s.hostRoot, adduser, args...) default: err = errors.New("host provides neither useradd nor adduser") } if err != nil { if createdGroup { groupdel := findHostTool(s.hostRoot, "/usr/sbin/groupdel", "/sbin/groupdel", "/usr/bin/groupdel", "/bin/groupdel") delgroup := findHostTool(s.hostRoot, "/usr/sbin/delgroup", "/sbin/delgroup", "/usr/bin/delgroup", "/bin/delgroup") if groupdel != "" { _ = runChroot(context.Background(), s.hostRoot, groupdel, group.Name) } else if delgroup != "" { _ = runChroot(context.Background(), s.hostRoot, delgroup, group.Name) } } return CreateHostUserResult{}, err } users, groups, err = readHostAccounts(s.hostRoot) if err != nil { return CreateHostUserResult{}, err } user := accountByUID(users, uid) group = groupByGID(groups, gid) if user == nil || group == nil { return CreateHostUserResult{}, errors.New("host account command succeeded but the new UID/GID could not be verified") } return CreateHostUserResult{CreatedUser: true, CreatedGroup: createdGroup, User: *user, Group: *group, Message: fmt.Sprintf("created local host user %s (%d:%d) to match container identity", user.Name, uid, gid)}, nil }