478 lines
15 KiB
Go
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
|
|
}
|