package hostsecurity import ( "context" "errors" "fmt" "os" "os/exec" "regexp" "sort" "strings" "time" ) type PackageUpdate struct { Name string `json:"name"` Arch string `json:"arch,omitempty"` CurrentVersion string `json:"current_version,omitempty"` NewVersion string `json:"new_version,omitempty"` Repository string `json:"repository,omitempty"` Source string `json:"source"` } type PackageReport struct { OS OSInfo `json:"os"` PackageManager string `json:"package_manager"` AURHelper string `json:"aur_helper,omitempty"` AURUser string `json:"aur_user,omitempty"` CanInspect bool `json:"can_inspect"` CanManage bool `json:"can_manage"` Updates []PackageUpdate `json:"updates"` AURUpdates []PackageUpdate `json:"aur_updates,omitempty"` Warnings []string `json:"warnings,omitempty"` CheckedAt time.Time `json:"checked_at"` } type PackageUpgradeInput struct { Scope string `json:"scope"` } var ( packageArrowRE = regexp.MustCompile(`^([^\s]+)\s+([^\s]+)\s+->\s+([^\s]+)$`) apkUpdateRE = regexp.MustCompile(`^(.+)-([0-9][^\s]*)\s+<\s+([^\s]+)$`) ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) ) func (s *Service) requirePackageInspection(ctx context.Context) error { if !s.cfg.Enabled { return errors.New("host security layer is disabled") } caps := s.capabilities(ctx) if !caps.ExecutorAvailable || !caps.TargetVerified { if caps.Reason != "" { return errors.New(caps.Reason) } return errors.New("host namespace executor unavailable") } if s.packageManager() == "" { return errors.New("no supported host package manager detected") } return nil } func (s *Service) PackageUpdates(ctx context.Context) (PackageReport, error) { s.mu.Lock() defer s.mu.Unlock() if err := s.requirePackageInspection(ctx); err != nil { return PackageReport{}, err } pm := s.packageManager() report := PackageReport{ OS: s.osInfo(), PackageManager: pm, AURHelper: s.aurHelper(), AURUser: s.cfg.AURUser, CanInspect: true, CanManage: false, Updates: []PackageUpdate{}, CheckedAt: time.Now().UTC(), } caps := s.capabilities(ctx) report.CanManage = caps.AllowChanges && caps.AllowPackageManagement && caps.ExecutorAvailable && caps.TargetVerified && caps.HostRootWritable x, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() var out string var code int var err error switch pm { case "apt": out, err = s.hostCommand(x, nil, "apt-get", "-s", "dist-upgrade") if err == nil { report.Updates = parseAPTUpdates(out) } case "dnf", "yum": out, code, err = s.hostCommandExit(x, nil, pm, "check-update", "-q") if err == nil && code != 0 && code != 100 { err = fmt.Errorf("%s check-update exited with status %d: %s", pm, code, strings.TrimSpace(out)) } if err == nil { report.Updates = parseRPMUpdates(out) } case "zypper": out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "list-updates") if err == nil { report.Updates = parseZypperUpdates(out) } case "apk": out, err = s.hostCommand(x, nil, "apk", "version", "-l", "<") if err == nil { report.Updates = parseAPKUpdates(out) } case "pacman": if s.hostBinaryExists("/usr/bin/checkupdates", "/bin/checkupdates") { out, code, err = s.hostCommandExit(x, nil, "checkupdates", "--nocolor") if err == nil && code != 0 && code != 2 { err = fmt.Errorf("checkupdates exited with status %d: %s", code, strings.TrimSpace(out)) } } else { out, code, err = s.hostCommandExit(x, nil, "pacman", "-Qu") if err == nil && code != 0 && code != 1 { err = fmt.Errorf("pacman -Qu exited with status %d: %s", code, strings.TrimSpace(out)) } report.Warnings = append(report.Warnings, "pacman-contrib/checkupdates ist nicht installiert; die Anzeige basiert auf der zuletzt synchronisierten Pacman-Datenbank. Dockwatch führt absichtlich kein isoliertes pacman -Sy aus.") } if err == nil { report.Updates = parseArrowUpdates(out, "repo") } } if err != nil { return PackageReport{}, err } if pm == "pacman" && report.AURHelper != "" { if !s.hostBinaryExists("/usr/bin/runuser", "/bin/runuser", "/usr/sbin/runuser") { report.Warnings = append(report.Warnings, "AUR-Helper erkannt, aber runuser fehlt auf dem Host; AUR-Aktionen sind nicht verfügbar.") } else if strings.TrimSpace(s.cfg.AURUser) == "" { report.Warnings = append(report.Warnings, "AUR-Helper erkannt, aber HOST_AUR_USER ist nicht gesetzt. AUR-Updates werden deshalb nicht abgefragt oder installiert.") } else if _, err := s.aurUser(); err != nil { report.Warnings = append(report.Warnings, "AUR-Benutzer ist nicht verwendbar: "+err.Error()) } else { aurOut, aurCode, aurErr := s.hostCommandExit(x, nil, s.aurCommand(report.AURHelper, "-Qua")...) if aurErr == nil && aurCode != 0 && aurCode != 1 { aurErr = fmt.Errorf("%s -Qua exited with status %d: %s", report.AURHelper, aurCode, strings.TrimSpace(aurOut)) } if aurErr != nil { report.Warnings = append(report.Warnings, "AUR-Updateprüfung fehlgeschlagen: "+aurErr.Error()) } else { report.AURUpdates = parseArrowUpdates(aurOut, "aur") } } } sort.Slice(report.Updates, func(i, j int) bool { return report.Updates[i].Name < report.Updates[j].Name }) sort.Slice(report.AURUpdates, func(i, j int) bool { return report.AURUpdates[i].Name < report.AURUpdates[j].Name }) return report, nil } func (s *Service) RefreshPackageMetadata(ctx context.Context) (PolicyResult, error) { s.mu.Lock() defer s.mu.Unlock() if err := s.requirePackages(ctx); err != nil { return PolicyResult{}, err } pm := s.packageManager() x, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() var out string var err error switch pm { case "apt": out, err = s.hostCommand(x, nil, "apt-get", "update") case "dnf": out, err = s.hostCommand(x, nil, "dnf", "makecache", "--refresh") case "yum": out, err = s.hostCommand(x, nil, "yum", "makecache", "-y") case "zypper": out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "refresh") case "apk": out, err = s.hostCommand(x, nil, "apk", "update") case "pacman": if !s.hostBinaryExists("/usr/bin/checkupdates", "/bin/checkupdates") { return PolicyResult{}, errors.New("pacman-contrib/checkupdates ist erforderlich, damit Dockwatch Paketlisten ohne riskantes isoliertes pacman -Sy aktualisieren kann") } var code int out, code, err = s.hostCommandExit(x, nil, "checkupdates", "--nocolor") if err == nil && code != 0 && code != 2 { err = fmt.Errorf("checkupdates exited with status %d: %s", code, strings.TrimSpace(out)) } if err == nil { return PolicyResult{OK: true, Message: "Arch-Paketlisten wurden sicher in der temporären checkupdates-Datenbank aktualisiert.", Output: limit(out, 12000)}, nil } default: return PolicyResult{}, errors.New("unsupported package manager") } if err != nil { return PolicyResult{}, err } return PolicyResult{OK: true, Message: "Paketmetadaten wurden aktualisiert.", Output: limit(out, 12000)}, nil } func (s *Service) UpgradePackages(ctx context.Context, in PackageUpgradeInput) (PolicyResult, error) { s.mu.Lock() defer s.mu.Unlock() if err := s.requirePackages(ctx); err != nil { return PolicyResult{}, err } scope := strings.ToLower(strings.TrimSpace(in.Scope)) if scope == "" { scope = "system" } if scope != "system" && scope != "aur" && scope != "all" { return PolicyResult{}, errors.New("scope must be system, aur, or all") } pm := s.packageManager() if scope == "aur" && pm != "pacman" { return PolicyResult{}, errors.New("AUR updates are only available on pacman-based hosts") } x, cancel := context.WithTimeout(ctx, 45*time.Minute) defer cancel() var out string var err error switch pm { case "apt": if scope == "aur" { return PolicyResult{}, errors.New("AUR is not available on apt hosts") } if v, e := s.hostCommand(x, nil, "apt-get", "update"); e != nil { return PolicyResult{}, e } else { out = v } v, e := s.hostCommand(x, nil, "env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "dist-upgrade", "-y", "--no-remove") if e != nil { return PolicyResult{}, e } out += "\n" + v case "dnf": if scope == "aur" { return PolicyResult{}, errors.New("AUR is not available on dnf hosts") } out, err = s.hostCommand(x, nil, "dnf", "upgrade", "--refresh", "-y") case "yum": if scope == "aur" { return PolicyResult{}, errors.New("AUR is not available on yum hosts") } out, err = s.hostCommand(x, nil, "yum", "update", "-y") case "zypper": if scope == "aur" { return PolicyResult{}, errors.New("AUR is not available on zypper hosts") } out, err = s.hostCommand(x, nil, "zypper", "--non-interactive", "update") case "apk": if scope == "aur" { return PolicyResult{}, errors.New("AUR is not available on apk hosts") } out, err = s.hostCommand(x, nil, "apk", "upgrade") case "pacman": helper := s.aurHelper() if scope == "system" || (scope == "all" && helper == "") { out, err = s.hostCommand(x, nil, "pacman", "-Syu", "--noconfirm") } else { if helper == "" { return PolicyResult{}, errors.New("no supported AUR helper detected (paru or yay)") } if _, e := s.aurUser(); e != nil { return PolicyResult{}, e } op := "-Sua" if scope == "all" { op = "-Syu" } helperArgs := []string{op, "--noconfirm", "--sudoflags=-n"} if helper == "paru" { helperArgs = append(helperArgs, "--skipreview", "--noupgrademenu") } else { helperArgs = append(helperArgs, "--cleanmenu=false", "--diffmenu=false", "--editmenu=false", "--answerupgrade=None") } out, err = s.hostCommand(x, nil, s.aurCommand(helper, helperArgs...)...) } default: return PolicyResult{}, errors.New("unsupported package manager") } if err != nil { return PolicyResult{}, err } return PolicyResult{OK: true, Message: "System-Updates wurden installiert.", Output: limit(out, 20000)}, nil } func (s *Service) hostCommandExit(ctx context.Context, stdin []byte, args ...string) (string, int, error) { if len(args) == 0 { return "", -1, errors.New("host command is empty") } base := []string{"-t", fmt.Sprintf("%d", s.cfg.HostPID), "-m", "-u", "-i", "-n", "-p", "-r", "--"} base = append(base, args...) cmd := exec.CommandContext(ctx, "nsenter", base...) if stdin != nil { cmd.Stdin = strings.NewReader(string(stdin)) } b, err := cmd.CombinedOutput() out := strings.TrimSpace(string(b)) if err == nil { return out, 0, nil } var exitErr *exec.ExitError if errors.As(err, &exitErr) { return out, exitErr.ExitCode(), nil } return out, -1, err } func (s *Service) hostBinaryExists(paths ...string) bool { for _, p := range paths { if fileExists(s.hostPath(p)) { return true } } return false } func (s *Service) aurHelper() string { if s.packageManager() != "pacman" { return "" } if s.hostBinaryExists("/usr/bin/paru", "/usr/local/bin/paru") { return "paru" } if s.hostBinaryExists("/usr/bin/yay", "/usr/local/bin/yay") { return "yay" } return "" } type aurUserInfo struct { Name string Home string } func (s *Service) aurUser() (aurUserInfo, error) { name := strings.TrimSpace(s.cfg.AURUser) if name == "" { return aurUserInfo{}, errors.New("HOST_AUR_USER is not configured") } if !safeNameRE.MatchString(name) || name == "root" { return aurUserInfo{}, errors.New("HOST_AUR_USER must name a non-root local user") } b, err := os.ReadFile(s.hostPath("/etc/passwd")) if err != nil { return aurUserInfo{}, fmt.Errorf("read host passwd: %w", err) } for _, line := range strings.Split(string(b), "\n") { parts := strings.Split(line, ":") if len(parts) >= 6 && parts[0] == name { if parts[2] == "0" { return aurUserInfo{}, errors.New("HOST_AUR_USER must not be root") } return aurUserInfo{Name: name, Home: parts[5]}, nil } } return aurUserInfo{}, fmt.Errorf("HOST_AUR_USER %q does not exist on the host", name) } func (s *Service) aurCommand(helper string, args ...string) []string { u, err := s.aurUser() if err != nil { return []string{"false"} } cmd := []string{"runuser", "-u", u.Name, "--", "env", "HOME=" + u.Home, "USER=" + u.Name, "LOGNAME=" + u.Name, "TERM=dumb", "NO_COLOR=1", helper} return append(cmd, args...) } func parseAPTUpdates(out string) []PackageUpdate { rows := []PackageUpdate{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "Inst ") { continue } fields := strings.Fields(line) if len(fields) < 4 { continue } name := fields[1] oldStart := strings.Index(line, "[") oldEnd := strings.Index(line, "]") newStart := strings.Index(line, "(") newEnd := strings.Index(line, ")") if oldStart < 0 || oldEnd <= oldStart || newStart < 0 || newEnd <= newStart || oldStart > newStart { continue } oldV := strings.TrimSpace(line[oldStart+1 : oldEnd]) inside := strings.Fields(line[newStart+1 : newEnd]) if len(inside) == 0 { continue } newV := inside[0] repo := "" if len(inside) > 1 { repo = strings.Join(inside[1:], " ") } rows = append(rows, PackageUpdate{Name: name, CurrentVersion: oldV, NewVersion: newV, Repository: repo, Source: "repo"}) } return rows } func parseRPMUpdates(out string) []PackageUpdate { rows := []PackageUpdate{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Obsoleting") || strings.HasPrefix(line, "Security:") { continue } f := strings.Fields(line) if len(f) < 2 || strings.Contains(f[0], ":") || !strings.Contains(f[0], ".") { continue } name, arch := f[0], "" if i := strings.LastIndex(name, "."); i > 0 { arch = name[i+1:] name = name[:i] } repo := "" if len(f) > 2 { repo = f[2] } rows = append(rows, PackageUpdate{Name: name, Arch: arch, NewVersion: f[1], Repository: repo, Source: "repo"}) } return rows } func parseArrowUpdates(out, source string) []PackageUpdate { rows := []PackageUpdate{} for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(ansiEscapeRE.ReplaceAllString(line, "")) m := packageArrowRE.FindStringSubmatch(line) if len(m) != 4 { continue } rows = append(rows, PackageUpdate{Name: m[1], CurrentVersion: m[2], NewVersion: m[3], Source: source}) } return rows } func parseZypperUpdates(out string) []PackageUpdate { rows := []PackageUpdate{} for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, "|") || strings.Contains(line, "---") { continue } parts := strings.Split(line, "|") for i := range parts { parts[i] = strings.TrimSpace(parts[i]) } if len(parts) < 5 { continue } offset := len(parts) - 5 if offset < 0 || offset+4 >= len(parts) || parts[offset] == "Repository" || parts[offset+1] == "Name" || parts[offset+1] == "" { continue } rows = append(rows, PackageUpdate{Name: parts[offset+1], CurrentVersion: parts[offset+2], NewVersion: parts[offset+3], Repository: parts[offset], Arch: parts[offset+4], Source: "repo"}) } return rows } func parseAPKUpdates(out string) []PackageUpdate { rows := []PackageUpdate{} for _, line := range strings.Split(out, "\n") { m := apkUpdateRE.FindStringSubmatch(strings.TrimSpace(line)) if len(m) != 4 { continue } rows = append(rows, PackageUpdate{Name: m[1], CurrentVersion: m[2], NewVersion: m[3], Source: "repo"}) } return rows }