~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
384 lines · 9.6 KB Raw History
package treeaudit

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/hostsvc"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/sha256sum"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/versions"
)

const (
	VMPrefix            = hostsvc.NamePrefix + "vm-"
	MarketNetworkPrefix = hostsvc.NamePrefix + "market-network-"
	MarketNetworkVMPart = "-vm-"
	EnvUnit             = "SWC_UNIT"
	EnvInvoc            = "INVOCATION_ID"
)

var VMAssets = []string{"firecracker", "pasta"}

type Proc struct {
	PID    int    `json:"pid"`
	Exe    string `json:"exe,omitempty"`
	SHA256 string `json:"sha256,omitempty"`
	Err    string `json:"err,omitempty"`
}

type Unit struct {
	Name    string              `json:"name"`
	State   hostsvc.ActiveState `json:"state"`
	MainPID int                 `json:"mainPID"`
	Procs   []Proc              `json:"procs"`
	Err     string              `json:"err,omitempty"`
}

type Finding struct {
	Unit   string `json:"unit"`
	PID    int    `json:"pid,omitempty"`
	Exe    string `json:"exe,omitempty"`
	SHA256 string `json:"sha256,omitempty"`
	Why    string `json:"why"`
}

type Report struct {
	Backend  hostsvc.Backend `json:"backend"`
	Units    []Unit          `json:"units"`
	Findings []Finding       `json:"findings"`
}

func (r Report) Pass() bool { return len(r.Findings) == 0 }

func IsVMUnit(name string) bool {
	if strings.HasPrefix(name, VMPrefix) {
		return true
	}
	return strings.HasPrefix(name, MarketNetworkPrefix) && strings.Contains(strings.TrimPrefix(name, MarketNetworkPrefix), MarketNetworkVMPart)
}

func Judge(units []Unit, pinned map[string]string) []Finding {
	var out []Finding
	for _, u := range units {
		switch {
		case u.Err != "":
			out = append(out, Finding{Unit: u.Name, Why: "its process tree could not be read: " + u.Err})
			continue
		case u.State != hostsvc.Active:
			out = append(out, Finding{Unit: u.Name, Why: fmt.Sprintf("the unit is %s, not active: a VM unit passes only once it runs nothing but pinned assets", u.State)})
			continue
		case len(u.Procs) == 0:
			out = append(out, Finding{Unit: u.Name, Why: "the active unit has no process"})
			continue
		}
		for _, p := range u.Procs {
			switch {
			case p.Err != "":
				out = append(out, Finding{Unit: u.Name, PID: p.PID, Exe: p.Exe, Why: "the executable could not be hashed: " + p.Err})
			case pinned[p.SHA256] == "":
				out = append(out, Finding{Unit: u.Name, PID: p.PID, Exe: p.Exe, SHA256: p.SHA256,
					Why: "not a pinned VM-unit asset (" + strings.Join(VMAssets, ", ") + "): no socialweb-computer code, vmexec or trampoline shell may stay in a running VM's tree"})
			}
		}
	}
	return out
}

type Host struct {
	ProcRoot   string
	CgroupRoot string
}

func (h Host) proc() string {
	if h.ProcRoot == "" {
		return "/proc"
	}
	return h.ProcRoot
}

func (h Host) cgroup() string {
	if h.CgroupRoot == "" {
		return "/sys/fs/cgroup"
	}
	return h.CgroupRoot
}

func (h Host) CgroupMembers(mainPID int, unit string) ([]int, error) {
	if mainPID <= 0 {
		return nil, fmt.Errorf("%s has no main pid", unit)
	}
	b, err := os.ReadFile(filepath.Join(h.proc(), strconv.Itoa(mainPID), "cgroup"))
	if err != nil {
		return nil, err
	}
	var path string
	for line := range strings.SplitSeq(string(b), "\n") {
		if rest, ok := strings.CutPrefix(line, "0::"); ok {
			path = rest
		}
	}
	if path == "" {
		return nil, fmt.Errorf("pid %d has no cgroup v2 path", mainPID)
	}
	for filepath.Base(path) != unit {
		if path == "/" || path == "." {
			return nil, fmt.Errorf("the main pid %d of %s is not inside the unit's cgroup", mainPID, unit)
		}
		path = filepath.Dir(path)
	}
	var pids []int
	err = filepath.WalkDir(filepath.Join(h.cgroup(), path), func(p string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || d.Name() != "cgroup.procs" {
			return nil
		}
		raw, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		for f := range strings.FieldsSeq(string(raw)) {
			if n, err := strconv.Atoi(f); err == nil {
				pids = append(pids, n)
			}
		}
		return nil
	})
	sort.Ints(pids)
	return pids, err
}

func statPGID(b []byte) (int, bool) {
	i := bytes.LastIndexByte(b, ')')
	if i < 0 {
		return 0, false
	}
	f := strings.Fields(string(b[i+1:]))
	if len(f) < 3 || f[0] == "Z" || f[0] == "X" {
		return 0, false
	}
	n, err := strconv.Atoi(f[2])
	return n, err == nil
}

func environ(b []byte) map[string]string {
	out := map[string]string{}
	for kv := range bytes.SplitSeq(b, []byte{0}) {
		k, v, ok := strings.Cut(string(kv), "=")
		if ok {
			out[k] = v
		}
	}
	return out
}

func (h Host) ProcMembers(leader int, unit, invocation string, devsup int) ([]int, error) {
	entries, err := os.ReadDir(h.proc())
	if err != nil {
		return nil, err
	}
	var pids []int
	for _, e := range entries {
		pid, err := strconv.Atoi(e.Name())
		if err != nil || pid == devsup {
			continue
		}
		stat, err := os.ReadFile(filepath.Join(h.proc(), e.Name(), "stat"))
		if err != nil {
			continue
		}
		pgid, alive := statPGID(stat)
		if !alive {
			continue
		}
		if leader > 0 && pgid == leader {
			pids = append(pids, pid)
			continue
		}
		raw, err := os.ReadFile(filepath.Join(h.proc(), e.Name(), "environ"))
		if err != nil {
			continue
		}
		env := environ(raw)
		if env[EnvUnit] == unit && (invocation == "" || env[EnvInvoc] == invocation) {
			pids = append(pids, pid)
		}
	}
	sort.Ints(pids)
	return pids, nil
}

func (h Host) Hash(pid int) Proc {
	p := Proc{PID: pid}
	link := filepath.Join(h.proc(), strconv.Itoa(pid), "exe")
	if target, err := os.Readlink(link); err == nil {
		p.Exe = target
	}
	f, err := os.Open(link)
	if err != nil {
		p.Err = err.Error()
		return p
	}
	defer f.Close()
	sum := sha256.New()
	if _, err := io.Copy(sum, f); err != nil {
		p.Err = err.Error()
		return p
	}
	p.SHA256 = hex.EncodeToString(sum.Sum(nil))
	return p
}

type Lister interface {
	List(ctx context.Context, prefix string) ([]hostsvc.UnitStatus, error)
	Capabilities() hostsvc.Capabilities
}

func Collect(ctx context.Context, m Lister, h Host, devsup int) (Report, error) {
	r := Report{Backend: m.Capabilities().Backend}
	var statuses []hostsvc.UnitStatus
	for _, prefix := range []string{VMPrefix, MarketNetworkPrefix} {
		st, err := m.List(ctx, prefix)
		if err != nil {
			return r, err
		}
		statuses = append(statuses, st...)
	}
	sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
	for _, st := range statuses {
		if !IsVMUnit(st.Name) {
			continue
		}
		u := Unit{Name: st.Name, State: st.State, MainPID: st.MainPID}
		var pids []int
		var err error
		switch r.Backend {
		case hostsvc.BackendSystemdUser:
			pids, err = h.CgroupMembers(st.MainPID, st.Name)
		case hostsvc.BackendProc:
			pids, err = h.ProcMembers(st.MainPID, st.Name, st.InvocationID, devsup)
		default:
			err = fmt.Errorf("no tree audit for backend %q", r.Backend)
		}
		if err != nil && st.State == hostsvc.Active {
			u.Err = err.Error()
		}
		for _, pid := range pids {
			u.Procs = append(u.Procs, h.Hash(pid))
		}
		r.Units = append(r.Units, u)
	}
	return r, nil
}

func Audit(ctx context.Context, m Lister, h Host, devsup int, pinned map[string]string) (Report, error) {
	r, err := Collect(ctx, m, h, devsup)
	if err != nil {
		return r, err
	}
	r.Findings = Judge(r.Units, pinned)
	return r, nil
}

var digestDir = regexp.MustCompile(`^[0-9a-f]{64}$`)

type TreeDigest func(dir string) (string, error)

func Pinned(runtimeRoot string, pins []versions.Pin, digest TreeDigest) (map[string]string, []string) {
	out := map[string]string{}
	var notes []string
	execNames := map[string]string{}
	trees := map[string]string{}
	for _, p := range pins {
		if !isVMAsset(p.Name) {
			continue
		}
		for _, rel := range p.Executables {
			execNames[rel] = p.Name
		}
		if p.TreeSHA256 != "" {
			trees[p.TreeSHA256] = p.Name
		}
	}
	entries, err := os.ReadDir(runtimeRoot)
	if err != nil {
		return out, []string{"no runtime set under " + runtimeRoot + ": " + err.Error()}
	}
	for _, e := range entries {
		if !e.IsDir() || !digestDir.MatchString(e.Name()) {
			continue
		}
		dir := filepath.Join(runtimeRoot, e.Name())
		var found []string
		for rel := range execNames {
			if _, err := os.Stat(filepath.Join(dir, rel)); err == nil {
				found = append(found, rel)
			}
		}
		if len(found) == 0 {
			continue
		}
		if got, err := digest(dir); err != nil || got != e.Name() {
			notes = append(notes, fmt.Sprintf("%s does not verify against its name (%v); its executables are not pinned", dir, err))
			continue
		}
		asset, whole := trees[e.Name()]
		if !whole {
			for _, rel := range found {
				sum, err := sha256sum.File(filepath.Join(dir, rel))
				if err != nil {
					notes = append(notes, err.Error())
					continue
				}
				out[sum] = execNames[rel] + " " + filepath.Join(dir, rel)
			}
			continue
		}
		werr := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
			if err != nil || !d.Type().IsRegular() {
				return err
			}
			info, err := d.Info()
			if err != nil || info.Mode().Perm()&0o111 == 0 {
				return err
			}
			sum, err := sha256sum.File(p)
			if err != nil {
				return err
			}
			out[sum] = asset + " " + p
			return nil
		})
		if werr != nil {
			notes = append(notes, werr.Error())
		}
	}
	return out, notes
}

func isVMAsset(name string) bool {
	return slices.Contains(VMAssets, name)
}

func Write(w io.Writer, r Report) {
	fmt.Fprintf(w, "backend %s: %d VM unit(s)\n", r.Backend, len(r.Units))
	for _, u := range r.Units {
		fmt.Fprintf(w, "  %s %s main pid %d, %d process(es)\n", u.Name, u.State, u.MainPID, len(u.Procs))
	}
	for _, f := range r.Findings {
		fmt.Fprintf(w, "FAIL %s pid %d exe %s: %s\n", f.Unit, f.PID, f.Exe, f.Why)
	}
}