1406 lines
49 KiB
Go
1406 lines
49 KiB
Go
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
|
|
}
|