Files
privatae-bastion/client-go/killtree_real_linux_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

277 lines
9.5 KiB
Go

//go:build linux
package main
import (
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// A REAL TREE, REALLY KILLED. Everything else here decides from stubs; this is
// the one test that sends SIGSTOP and SIGKILL, and only to processes it started
// itself: a shell that backgrounds two sleeps and waits. It proves the freeze
// and the kill reach real grandchildren through the real parent map.
//
// MUTATION: make osStopProcess a no-op and killProcess skip descendants.
// RESULT: red — a sleep survives.
func TestKillTreeKillsARealProcessTree(t *testing.T) {
prevStop, prevResume, prevKill, prevTree, prevID := stopProcess, resumeProcess, killProcess, processTree, procIdentity
t.Cleanup(func() {
stopProcess, resumeProcess, killProcess, processTree, procIdentity = prevStop, prevResume, prevKill, prevTree, prevID
})
// THE FENCE. Real signals, as root, on the host's own process table: a
// tree-kill with one guard removed can SIGKILL every child of pid 1 on the
// machine running the test. Here every signal goes through `fenced`, which
// refuses — and fails the test for — any pid that is not the shell this
// test started or one of its descendants at the moment of asking. A
// mutant of killTree/descendantsOf can then fail this test, never reach
// another process.
// `allowed` is fixed BEFORE killTree runs: the shell and the children it
// had then. Re-deriving it at signal time would refuse the sleeps once the
// shell is dead and they are re-parented.
var root int
allowed := map[int]bool{}
refused := []int{}
fenced := func(pid int, sig syscall.Signal) error {
if !allowed[pid] {
refused = append(refused, pid)
return syscall.EPERM
}
return syscall.Kill(pid, sig)
}
stopProcess = func(pid int) error { return fenced(pid, syscall.SIGSTOP) }
resumeProcess = func(pid int) error { return fenced(pid, syscall.SIGCONT) }
killProcess = func(pid int) error { return fenced(pid, syscall.SIGKILL) }
procIdentity = osProcIdentity
processTree = processParents
defer func() {
if len(refused) > 0 {
t.Errorf("killTree asked to signal pids outside the test's own tree: %v", refused)
}
}()
cmd := exec.Command("sh", "-c", "sleep 300 & sleep 300 & wait")
if err := cmd.Start(); err != nil {
t.Skipf("cannot start a shell here: %v", err)
}
root = cmd.Process.Pid
done := make(chan struct{})
go func() { _ = cmd.Wait(); close(done) }()
// Wait until both sleeps are the shell's children in the real parent map.
// The shell's children, read from /proc by THIS TEST, not through
// descendantsOf or processParents: the fence below is built from this list,
// and a fence built with the code under test falls when that code does (a
// mutant descendantsOf returning every pid put every pid in the fence).
var kids []int
for i := 0; i < 100; i++ {
kids = childrenFromProc(root)
if len(kids) >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
if len(kids) < 2 {
_ = syscall.Kill(root, syscall.SIGKILL)
t.Fatalf("the shell's two sleeps never appeared under pid %d (saw %v)", root, kids)
}
allowed[root] = true
for _, k := range kids {
allowed[k] = true
}
if n := killTree(root, spareFromContain); n < 3 {
t.Fatalf("killTree reached %d processes, want 3 (the shell and two sleeps)", n)
}
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the shell did not die")
}
for _, k := range kids {
// A reaped or zombie child is dead. A SIGKILL lands when the task next
// runs, so on a loaded machine a killed sleep can still read 'R' (or
// be part-way out) for a moment after the shell is gone: it is given
// five seconds to finish dying, and only one still alive then
// survived.
var stat string
for i := 0; i < 250; i++ {
s, err := readProcStat(k)
if err != nil || strings.Contains(s, ") Z ") {
stat = ""
break
}
stat = s
time.Sleep(20 * time.Millisecond)
}
if stat != "" {
t.Fatalf("sleep %d survived the tree kill: %s", k, stat)
}
}
}
// A REAL RELAYING PARENT, REALLY THAWED. The parent here does what sudo does:
// it waits on its child with WUNTRACED, and when the child is reported stopped
// it stops itself, then goes on waiting. killTree freezes and kills the child;
// with the fix the parent is continued, collects the exit, and leaves — so the
// child's pid is GONE (not a zombie) and the parent has exited. Without it the
// parent stays `T` and the child stays `Z`, which is what a machine showed
// after a real containment of an agent started through sudo.
//
// Same fence as the test above: every signal goes through `fenced`, and the
// allowed set is the parent and the child, read from /proc by this test.
//
// MUTATION: drop the thawRelayingAncestors call. RESULT: red — the parent is
// still stopped and the child is a zombie after the kill.
func TestKillTreeThawsARealRelayingParent(t *testing.T) {
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is needed to stand in for sudo here")
}
prevStop, prevResume, prevKill, prevTree, prevID := stopProcess, resumeProcess, killProcess, processTree, procIdentity
t.Cleanup(func() {
stopProcess, resumeProcess, killProcess, processTree, procIdentity = prevStop, prevResume, prevKill, prevTree, prevID
})
allowed := map[int]bool{}
refused := []int{}
fenced := func(pid int, sig syscall.Signal) error {
if !allowed[pid] {
refused = append(refused, pid)
return syscall.EPERM
}
return syscall.Kill(pid, sig)
}
stopProcess = func(pid int) error { return fenced(pid, syscall.SIGSTOP) }
resumeProcess = func(pid int) error { return fenced(pid, syscall.SIGCONT) }
var parent int
killProcess = func(pid int) error {
// The relay needs the stand-in to be scheduled between our SIGSTOP and
// SIGKILL: a kill that lands first hands it an exit instead of a stop and
// nothing relays anything. sudo has the same window. The test closes it
// by letting the kill wait until the parent has stopped, bounded.
for i := 0; i < 200 && parent != 0; i++ {
if _, stopped, ok := osProcIdentity(parent); ok && stopped {
break
}
time.Sleep(10 * time.Millisecond)
}
return fenced(pid, syscall.SIGKILL)
}
procIdentity = osProcIdentity
processTree = processParents
defer func() {
if len(refused) > 0 {
t.Errorf("killTree asked to signal pids outside the test's own tree: %v", refused)
}
}()
// The stand-in for sudo: start the child, wait for it to be reported
// STOPPED, stop ourselves the way sudo does, then wait for it to end and
// exit 0. If what it collects is an exit instead of a stop it leaves with
// status 3 — the relay never happened and the run below has to say so
// rather than pass on a parent that was never stopped.
prog := "import os,signal,subprocess,sys\n" +
"p=subprocess.Popen(['sleep','300'])\n" +
"_, st = os.waitpid(p.pid, os.WUNTRACED)\n" +
"if not os.WIFSTOPPED(st):\n" +
" sys.exit(3)\n" +
"os.kill(os.getpid(), signal.SIGSTOP)\n" +
"p.wait()\n"
cmd := exec.Command(py, "-c", prog)
if err := cmd.Start(); err != nil {
t.Skipf("cannot start python3 here: %v", err)
}
parent = cmd.Process.Pid
done := make(chan struct{})
var waitErr error
go func() { waitErr = cmd.Wait(); close(done) }()
t.Cleanup(func() {
// Only while the stand-in is still ours: once Wait has returned, the
// number may belong to someone else and is not signalled.
select {
case <-done:
default:
_ = syscall.Kill(parent, syscall.SIGCONT)
_ = syscall.Kill(parent, syscall.SIGKILL)
}
})
var kids []int
for i := 0; i < 100; i++ {
kids = childrenFromProc(parent)
if len(kids) == 1 {
break
}
time.Sleep(20 * time.Millisecond)
}
if len(kids) != 1 {
t.Fatalf("the stand-in's child never appeared under pid %d (saw %v)", parent, kids)
}
child := kids[0]
allowed[parent] = true
allowed[child] = true
if n := killTree(child, spareFromContain); n != 1 {
t.Fatalf("killTree reached %d processes, want 1 (the child)", n)
}
select {
case <-done:
case <-time.After(5 * time.Second):
stat, _ := readProcStat(parent)
t.Fatalf("the parent never left: it was not continued after its child was killed (%s)", strings.TrimSpace(stat))
}
// Continued, not killed: a thaw that SIGKILLed the parent would also make
// it leave, with the child reaped by init. Exit 0 is the stand-in reaching
// the end of its script; exit 3 is the relay never having happened, which
// is a broken test, not a passing one.
if waitErr != nil {
t.Fatalf("the parent did not exit cleanly after being continued (3 = it never saw the stop): %v", waitErr)
}
if stat, err := readProcStat(child); err == nil {
t.Fatalf("the child is still in the process table after its parent exited: %s", strings.TrimSpace(stat))
}
}
// childrenFromProc lists the direct children of parent by reading
// /proc/<pid>/stat's ppid field (after the last ')', so a name with spaces
// cannot shift it). Deliberately independent of the package's own walk.
func childrenFromProc(parent int) []int {
ents, err := os.ReadDir("/proc")
if err != nil {
return nil
}
out := []int{}
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
if err != nil {
continue
}
s := string(raw)
i := strings.LastIndex(s, ")")
if i < 0 || i+2 > len(s) {
continue
}
f := strings.Fields(s[i+2:])
if len(f) > 1 {
if pp, err := strconv.Atoi(f[1]); err == nil && pp == parent {
out = append(out, pid)
}
}
}
return out
}
func readProcStat(pid int) (string, error) {
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/stat").Output()
return string(out), err
}