bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// The connection watch's decisions (rootexec_watch.go), without the kernel:
|
||||
// which destinations count as outside, what the J frame carries, and that a
|
||||
// command whose connections could not be watched is never reported clean.
|
||||
// The kernel half is rootexec_watch_linux_test.go, in a private network
|
||||
// namespace.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// No test in this package touches this machine's nftables or cgroups unless
|
||||
// it says so: the default watch here records nothing and creates nothing. The
|
||||
// kernel tests put the real one back, inside their own network namespace.
|
||||
func init() {
|
||||
rootExecWatchStart = func(string) *rootExecWatch { return &rootExecWatch{fd: -1} }
|
||||
rootExecWatchStop = func(w *rootExecWatch) ([]rootExecConn, string) { return nil, w.Err }
|
||||
rootExecWatchKill = func(*rootExecWatch) {}
|
||||
}
|
||||
|
||||
// When a wall stops holding mid-run, the command's whole cgroup is killed as
|
||||
// well as its process group, so a child that left the group (setsid, a double
|
||||
// fork) does not keep running as root after a revoke. A command that ends by
|
||||
// itself is never killed that way: what it left running lives on, as before.
|
||||
func TestRootExecWallStopKillsTheCommandsCgroup(t *testing.T) {
|
||||
old := rootExecRecheck
|
||||
rootExecRecheck = 50 * time.Millisecond
|
||||
oldStart, oldKill := rootExecWatchStart, rootExecWatchKill
|
||||
t.Cleanup(func() { rootExecRecheck, rootExecWatchStart, rootExecWatchKill = old, oldStart, oldKill })
|
||||
var mine *rootExecWatch
|
||||
rootExecWatchStart = func(string) *rootExecWatch { mine = &rootExecWatch{fd: -1}; return mine }
|
||||
var killed []*rootExecWatch
|
||||
var mu sync.Mutex
|
||||
rootExecWatchKill = func(w *rootExecWatch) { mu.Lock(); killed = append(killed, w); mu.Unlock() }
|
||||
|
||||
env := rootExecBox(t, true)
|
||||
withFakeExec(t, "", "", 0, false)
|
||||
j := runWatched(t, env, "exec-cg-clean")
|
||||
if j["exit"] != float64(0) {
|
||||
t.Fatalf("the clean command: %v", j)
|
||||
}
|
||||
mu.Lock()
|
||||
n := len(killed)
|
||||
mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatal("a command that ended by itself must not have its cgroup killed")
|
||||
}
|
||||
|
||||
fe := withFakeExec(t, "started\n", "", 0, true)
|
||||
ws := sessionDialAs(t, env, tAnchor)
|
||||
sendExec(t, ws, "exec-cg-fall", "setsid sleep 999 & sleep 999", 600, 0)
|
||||
for i := 0; i < 300 && len(fe.ran()) == 0; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if _, err := rootExecRevoke(env.DcnDir, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readUntil(t, ws, 'X', 3*time.Second)
|
||||
if string(got['X']) != "126" {
|
||||
t.Fatalf("stopped by the revoke: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(killed) != 1 || killed[0] != mine {
|
||||
t.Fatalf("the revoke must kill this command's own cgroup once: %v (watch %p)", killed, mine)
|
||||
}
|
||||
}
|
||||
|
||||
// withWatchResult stands in a watch that saw conns, or could not watch (why).
|
||||
func withWatchResult(t *testing.T, conns []rootExecConn, why string) {
|
||||
t.Helper()
|
||||
oldStart, oldStop := rootExecWatchStart, rootExecWatchStop
|
||||
rootExecWatchStart = func(string) *rootExecWatch { return &rootExecWatch{fd: -1, Err: why} }
|
||||
rootExecWatchStop = func(w *rootExecWatch) ([]rootExecConn, string) { return conns, w.Err }
|
||||
t.Cleanup(func() { rootExecWatchStart, rootExecWatchStop = oldStart, oldStop })
|
||||
}
|
||||
|
||||
func writeOwnHosts(t *testing.T, body string) {
|
||||
t.Helper()
|
||||
p, err := rootExecOwnHostsPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func withResolver(t *testing.T, m map[string][]string) {
|
||||
t.Helper()
|
||||
old := rootExecResolve
|
||||
rootExecResolve = func(_ context.Context, name string) ([]netip.Addr, error) {
|
||||
var out []netip.Addr
|
||||
for _, s := range m[name] {
|
||||
out = append(out, netip.MustParseAddr(s))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("no such host")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
t.Cleanup(func() { rootExecResolve = old })
|
||||
}
|
||||
|
||||
// runWatched sends one command through the handler and returns the raw J.
|
||||
func runWatched(t *testing.T, env sessionEnv, sid string) map[string]any {
|
||||
t.Helper()
|
||||
ws := sessionDialAs(t, env, tAnchor)
|
||||
sendExec(t, ws, sid, "true", 30, 0)
|
||||
got := readUntil(t, ws, 'X', 5*time.Second)
|
||||
var j map[string]any
|
||||
if err := json.Unmarshal(got['J'], &j); err != nil {
|
||||
t.Fatalf("J: %v (X=%q E=%q)", err, got['X'], got['E'])
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// A command that reached outside reports each destination once, with its
|
||||
// count; one that reached only loopback and the platform's own hosts has no
|
||||
// untrusted_connections at all, so the frame reads as an older one did.
|
||||
//
|
||||
// MUTATION: drop UntrustedConnections from the J frame in handleRootExec.
|
||||
// RESULT: red — the outside destination is missing.
|
||||
func TestRootExecJCarriesOnlyOutsideConnections(t *testing.T) {
|
||||
env := rootExecBox(t, true)
|
||||
withFakeExec(t, "ok\n", "", 0, false)
|
||||
withResolver(t, map[string][]string{"git.example": {"198.51.100.20", "2001:db8::20"}})
|
||||
writeOwnHosts(t, `{"hosts": ["2001:db8::/32", "203.0.113.4", "git.example"]}`)
|
||||
withWatchResult(t, []rootExecConn{
|
||||
{Addr: "127.0.0.1", Proto: "tcp", Port: 5432, Count: 3},
|
||||
{Addr: "2001:db8::7", Proto: "tcp", Port: 22, Count: 1},
|
||||
{Addr: "203.0.113.4", Proto: "udp", Port: 53, Count: 2},
|
||||
{Addr: "198.51.100.20", Proto: "tcp", Port: 443, Count: 1},
|
||||
{Addr: "2001:db8::20", Proto: "tcp", Port: 443, Count: 1},
|
||||
{Addr: "192.0.2.80", Proto: "tcp", Port: 443, Count: 4},
|
||||
{Addr: "192.0.2.81", Proto: "icmp", Count: 1},
|
||||
}, "")
|
||||
j := runWatched(t, env, "exec-watch-out")
|
||||
raw, _ := json.Marshal(j["untrusted_connections"])
|
||||
var got []rootExecConn
|
||||
_ = json.Unmarshal(raw, &got)
|
||||
want := []rootExecConn{{Addr: "192.0.2.80", Proto: "tcp", Port: 443, Count: 4}, {Addr: "192.0.2.81", Proto: "icmp", Count: 1}}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("untrusted_connections = %s, want %+v", raw, want)
|
||||
}
|
||||
row := auditRow(t, env, "exec-watch-out")
|
||||
if n, _ := row["untrusted_connections"].(float64); n != 2 {
|
||||
t.Fatalf("the stop row counts the outside ones: %v", row)
|
||||
}
|
||||
if cs, _ := row["connections"].([]any); len(cs) != 7 {
|
||||
t.Fatalf("the stop row lists every connection, own ones marked: %v", row["connections"])
|
||||
}
|
||||
tr, _ := os.ReadFile(env.CtxRoot + "/sessions/exec-watch-out.log")
|
||||
if !strings.Contains(string(tr), "== connections: ") || !strings.Contains(string(tr), "tcp 192.0.2.80:443 x4 (outside)") ||
|
||||
!strings.Contains(string(tr), "(platform)") {
|
||||
t.Fatalf("the transcript records the connections:\n%s", tr)
|
||||
}
|
||||
|
||||
withWatchResult(t, []rootExecConn{{Addr: "::1", Proto: "tcp", Port: 80, Count: 1}, {Addr: "2001:db8::9", Proto: "udp", Port: 53, Count: 1}}, "")
|
||||
j = runWatched(t, env, "exec-watch-own")
|
||||
if _, ok := j["untrusted_connections"]; ok {
|
||||
t.Fatalf("only loopback and own hosts: the field must be absent, J=%v", j)
|
||||
}
|
||||
if _, ok := j["untrusted_connections_more"]; ok {
|
||||
t.Fatalf("no overflow count either: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// Output whose connections were not watched is not known to be clean: it is
|
||||
// reported with one entry that says why, so the host fences it.
|
||||
//
|
||||
// MUTATION: return nothing from rootExecClassify when unwatched != "".
|
||||
// RESULT: red — the J frame says nothing and the host would trust the output.
|
||||
func TestRootExecUnwatchedCountsAsOutside(t *testing.T) {
|
||||
env := rootExecBox(t, true)
|
||||
withFakeExec(t, "ok\n", "", 0, false)
|
||||
withWatchResult(t, nil, "nft is not installed")
|
||||
j := runWatched(t, env, "exec-watch-none")
|
||||
cs, _ := j["untrusted_connections"].([]any)
|
||||
if len(cs) != 1 {
|
||||
t.Fatalf("an unwatched command must report one untrusted entry: %v", j)
|
||||
}
|
||||
e, _ := cs[0].(map[string]any)
|
||||
if e["addr"] != "*" || !strings.Contains(fmt.Sprint(e["note"]), "nft is not installed") {
|
||||
t.Fatalf("the entry says why: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// The list's edges: empty or missing = nothing is own but loopback, a broken
|
||||
// file = empty, a name that does not resolve adds nothing, the host's list is
|
||||
// capped and the rest counted.
|
||||
func TestRootExecOwnHostsList(t *testing.T) {
|
||||
setupBox(t)
|
||||
withResolver(t, map[string][]string{"edge.example": {"203.0.113.9"}})
|
||||
own := rootExecOwnSet()
|
||||
if len(own) != 0 || !rootExecIsOwn("127.0.0.5", own) || !rootExecIsOwn("::1", own) || rootExecIsOwn("10.0.0.1", own) {
|
||||
t.Fatalf("no list: only loopback is own (%v)", own)
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": ["10.0.0.0/8", "nowhere.example", "edge.example", "::ffff:192.0.2.1"]}`)
|
||||
own = rootExecOwnSet()
|
||||
for a, want := range map[string]bool{"10.1.2.3": true, "203.0.113.9": true, "192.0.2.1": true, "::ffff:10.0.0.1": true,
|
||||
"203.0.113.10": false, "11.0.0.1": false} {
|
||||
if rootExecIsOwn(a, own) != want {
|
||||
t.Errorf("%s own=%v, want %v", a, !want, want)
|
||||
}
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": ["10.0.0.0/8",`)
|
||||
if own := rootExecOwnSet(); len(own) != 0 {
|
||||
t.Fatalf("a broken list must read as empty: %v", own)
|
||||
}
|
||||
if _, _, problem := rootExecOwnHosts(); problem == "" {
|
||||
t.Fatal("a broken list says why")
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": ["a b"]}`)
|
||||
if own := rootExecOwnSet(); len(own) != 0 {
|
||||
t.Fatalf("an entry that is not an address, CIDR or name refuses the list: %v", own)
|
||||
}
|
||||
|
||||
var many []rootExecConn
|
||||
for i := 0; i < rootExecUntrustedMax+5; i++ {
|
||||
many = append(many, rootExecConn{Addr: fmt.Sprintf("192.0.2.%d", i+1), Proto: "tcp", Port: 443, Count: 1})
|
||||
}
|
||||
u, more, all := rootExecClassify(many, "", nil, nil)
|
||||
if len(u) != rootExecUntrustedMax || more != 5 || len(all) != len(many) {
|
||||
t.Fatalf("capped at %d with the rest counted: %d %d %d", rootExecUntrustedMax, len(u), more, len(all))
|
||||
}
|
||||
}
|
||||
|
||||
// A local proxy fetches from outside for whoever connects to it, so a
|
||||
// connection to one of the list's loopback_relays (default: the common proxy
|
||||
// ports) on a loopback or local address is outside. The local DNS resolver,
|
||||
// and loopback on any other port, stay this machine's own.
|
||||
//
|
||||
// MUTATION: drop the rootExecIsRelay test from rootExecClassify.
|
||||
// RESULT: red — 127.0.0.1:3128 reads as loopback and the J frame says nothing.
|
||||
func TestRootExecLoopbackRelaysCountAsOutside(t *testing.T) {
|
||||
env := rootExecBox(t, true)
|
||||
if got := fmt.Sprint(rootExecLoopbackRelays()); got != "[1080 3128 8118 9050]" {
|
||||
t.Fatalf("no list: the common proxy ports, got %s", got)
|
||||
}
|
||||
oldLocal := rootExecLocalAddr
|
||||
rootExecLocalAddr = func(a netip.Addr) bool { return a == netip.MustParseAddr("10.9.9.1") }
|
||||
t.Cleanup(func() { rootExecLocalAddr = oldLocal })
|
||||
conns := []rootExecConn{
|
||||
{Addr: "127.0.0.1", Proto: "tcp", Port: 3128, Count: 2}, // squid on loopback
|
||||
{Addr: "10.9.9.1", Proto: "tcp", Port: 8118, Count: 1}, // a proxy on this machine's own address
|
||||
{Addr: "127.0.0.53", Proto: "udp", Port: 53, Count: 4}, // the local resolver
|
||||
{Addr: "127.0.0.1", Proto: "tcp", Port: 5433, Count: 1}, // a platform relay, not listed
|
||||
{Addr: "127.0.0.1", Proto: "udp", Port: 3128, Count: 1}, // TCP only
|
||||
{Addr: "2001:db8::7", Proto: "tcp", Port: 3128, Count: 1}, // a platform host's proxy, not local
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": ["2001:db8::/32"]}`)
|
||||
withFakeExec(t, "ok\n", "", 0, false)
|
||||
withWatchResult(t, conns, "")
|
||||
j := runWatched(t, env, "exec-watch-relay")
|
||||
u := map[string]string{}
|
||||
cs, _ := j["untrusted_connections"].([]any)
|
||||
for _, c := range cs {
|
||||
m, _ := c.(map[string]any)
|
||||
u[fmt.Sprintf("%v %v %v", m["addr"], m["proto"], m["port"])] = fmt.Sprint(m["note"])
|
||||
}
|
||||
if len(u) != 2 || !strings.Contains(u["127.0.0.1 tcp 3128"], "local relay") || u["10.9.9.1 tcp 8118"] == "" {
|
||||
t.Fatalf("the two relay connections, each with its note, and nothing else: %v", u)
|
||||
}
|
||||
|
||||
// The list sets its own ports; an empty list has none; a bad port keeps the default.
|
||||
writeOwnHosts(t, `{"hosts": [], "loopback_relays": [5433, 5433]}`)
|
||||
if got := fmt.Sprint(rootExecLoopbackRelays()); got != "[5433]" {
|
||||
t.Fatalf("listed ports: %s", got)
|
||||
}
|
||||
unt, _, _ := rootExecClassify(conns, "", nil, rootExecLoopbackRelays())
|
||||
got := map[string]bool{}
|
||||
for _, c := range unt {
|
||||
got[fmt.Sprintf("%s %s %d", c.Addr, c.Proto, c.Port)] = true
|
||||
}
|
||||
// 127.0.0.1:5433 is now the relay and 127.0.0.1:3128 plain loopback; with
|
||||
// no platform hosts listed, the other two addresses are outside anyway.
|
||||
if len(got) != 3 || !got["127.0.0.1 tcp 5433"] || got["127.0.0.1 tcp 3128"] {
|
||||
t.Fatalf("with loopback_relays [5433]: %v", got)
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": [], "loopback_relays": []}`)
|
||||
if r := rootExecLoopbackRelays(); len(r) != 0 {
|
||||
t.Fatalf("an empty list has no relays: %v", r)
|
||||
}
|
||||
writeOwnHosts(t, `{"hosts": [], "loopback_relays": [0]}`)
|
||||
if got := fmt.Sprint(rootExecLoopbackRelays()); got != "[1080 3128 8118 9050]" {
|
||||
t.Fatalf("a port out of range keeps the default: %s", got)
|
||||
}
|
||||
if !strings.Contains(rootExecOwnHostsLine(), "local relays") {
|
||||
t.Fatal("the console line names the relays")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user