Files
jbergner f3a687543c
release-tag / release-image (push) Successful in 2m28s
v9.6.0
2026-09-01 22:46:20 +02:00

478 lines
15 KiB
Go

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
}