Files
privatae-bastion/client-go/claims_static_test.go
T
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

1449 lines
55 KiB
Go

package main
// Source-level checks behind the README's security model. Each one reads the
// package's own non-test sources, for every platform at once (build tags are
// not applied), and compares what it finds with a table written here. A new
// listener, route, program or write in one of these places fails the test
// until the table is changed with it, which is the point: the change then
// arrives with a reviewer looking at the claim it touches.
//
// The behaviour behind the same claims (what a listener refuses, what an op
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"net"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"testing"
)
// srcPkg is the package's non-test sources, parsed once per test.
type srcPkg struct {
fset *token.FileSet
files map[string]*ast.File // base name -> file
funcs map[string]*ast.FuncDecl
// methods maps a method name to every receiver type that declares it.
methods map[string][]string
}
func parseSources(t *testing.T) *srcPkg {
t.Helper()
names, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
p := &srcPkg{fset: token.NewFileSet(), files: map[string]*ast.File{},
funcs: map[string]*ast.FuncDecl{}, methods: map[string][]string{}}
for _, n := range names {
if strings.HasSuffix(n, "_test.go") {
continue
}
f, err := parser.ParseFile(p.fset, n, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatalf("parse %s: %v", n, err)
}
p.addFile(n, f)
}
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
}
return p
}
// addFile registers one parsed file and its functions. A test that proves a
// check can fail adds a synthetic file through here, so it is indexed exactly
// as a real one would be.
func (p *srcPkg) addFile(n string, f *ast.File) {
p.files[n] = f
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(fd)
// The same name in two platform files (processIsRoot, …) is kept
// once per file so neither body is lost to the other.
if _, dup := p.funcs[key]; dup {
key = key + "@" + n
}
p.funcs[key] = fd
if fd.Recv != nil {
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
}
}
}
// funcKeysIn is the p.funcs key of every function declared in file n.
func (p *srcPkg) funcKeysIn(n string) []string {
var out []string
for _, d := range p.files[n].Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
}
key := funcKey(fd)
if p.funcs[key] != fd {
key = key + "@" + n
}
out = append(out, key)
}
return out
}
// funcKey is "name" for a function and "Type.name" for a method.
func funcKey(fd *ast.FuncDecl) string {
if fd.Recv == nil || len(fd.Recv.List) == 0 {
return fd.Name.Name
}
typ := fd.Recv.List[0].Type
if s, ok := typ.(*ast.StarExpr); ok {
typ = s.X
}
if g, ok := typ.(*ast.IndexExpr); ok {
typ = g.X
}
if id, ok := typ.(*ast.Ident); ok {
return id.Name + "." + fd.Name.Name
}
return fd.Name.Name
}
// eachCall visits every call in the package with the file and the name of the
// function it sits in ("" at package level).
func (p *srcPkg) eachCall(visit func(file, fn string, call *ast.CallExpr)) {
names := make([]string, 0, len(p.files))
for n := range p.files {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
for _, d := range p.files[n].Decls {
fn := ""
if fd, ok := d.(*ast.FuncDecl); ok {
fn = funcKey(fd)
}
ast.Inspect(d, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
visit(n, fn, c)
}
return true
})
}
}
}
// selName returns (receiver text, selector) for a call like a.b.C(…).
func selName(c *ast.CallExpr) (string, string) {
s, ok := c.Fun.(*ast.SelectorExpr)
if !ok {
return "", ""
}
return exprText(s.X), s.Sel.Name
}
func exprText(e ast.Expr) string {
switch v := e.(type) {
case *ast.Ident:
return v.Name
case *ast.SelectorExpr:
return exprText(v.X) + "." + v.Sel.Name
case *ast.CallExpr:
return exprText(v.Fun) + "()"
case *ast.StarExpr:
return "*" + exprText(v.X)
case *ast.ParenExpr:
return exprText(v.X)
case *ast.CompositeLit:
return exprText(v.Type) + "{}"
case *ast.UnaryExpr:
return v.Op.String() + exprText(v.X)
case *ast.BasicLit:
return v.Value
case *ast.IndexExpr:
return exprText(v.X) + "[" + exprText(v.Index) + "]"
case *ast.SliceExpr:
return exprText(v.X) + "[:]"
}
return "?"
}
// stringLits returns every string literal inside e, unquoted.
func stringLits(e ast.Node) []string {
var out []string
ast.Inspect(e, func(x ast.Node) bool {
if b, ok := x.(*ast.BasicLit); ok && b.Kind == token.STRING {
if s, err := strconv.Unquote(b.Value); err == nil {
out = append(out, s)
}
}
return true
})
return out
}
// netstackParams names, per function, the parameters typed *netstack.Net: the
// tunnel's own network stack. A listener opened on one of them is a listener
// inside the tunnel.
func (p *srcPkg) netstackParams() map[string]map[string]bool {
out := map[string]map[string]bool{}
for key, fd := range p.funcs {
for _, f := range fd.Type.Params.List {
st, ok := f.Type.(*ast.StarExpr)
if !ok || exprText(st.X) != "netstack.Net" {
continue
}
for _, n := range f.Names {
if out[key] == nil {
out[key] = map[string]bool{}
}
out[key][n.Name] = true
}
}
}
return out
}
// listenSelectors are the calls that open something that accepts: a socket
// listener, a packet socket, or a server that makes its own listener.
var listenSelectors = map[string]bool{
"Listen": true, "ListenTCP": true, "ListenUDP": true, "ListenPacket": true,
"ListenUnix": true, "ListenUnixgram": true, "ListenIP": true, "ListenMulticastUDP": true,
"ListenAndServe": true, "ListenAndServeTLS": true,
"ListenTCPAddrPort": true, "ListenUDPAddrPort": true, "ListenPing": true,
"CreateNamedPipe": true,
}
type listenSite struct {
file, fn, call string
lits []string
}
func (p *srcPkg) listenSites() []listenSite {
var out []listenSite
p.eachCall(func(file, fn string, c *ast.CallExpr) {
recv, sel := selName(c)
if !listenSelectors[sel] {
return
}
var lits []string
for _, a := range c.Args {
lits = append(lits, stringLits(a)...)
}
out = append(out, listenSite{file: file, fn: fn, call: recv + "." + sel, lits: lits})
})
return out
}
// Inside the tunnel, only gated listeners. Every listener opened on the
// tunnel's network stack is one of the four gated forwards, the stack itself
// is created in one place, and the only other thing done with it is dialling
// out to the host. A fifth listener, or the stack handed to any other
// function, fails here.
func TestOnlyTheGatedForwardsListenInsideTheTunnel(t *testing.T) {
p := parseSources(t)
params := p.netstackParams()
want := map[string]bool{
"run.go:serveOllamaForward": true, // :11434, inference family
"run.go:serveLLMForward": true, // :18080, inference family
"pii.go:servePIIForward": true, // :18081, inference family
"service.go:serveServiceForwards": true, // :18100-18199, services family + reach grant
"coder_forward.go:serveCoderForwards": true, // :18200-18299, mode:coder, owner's tunnel address only
}
got := map[string]bool{}
sites := map[string]int{}
for _, s := range p.listenSites() {
recv := strings.SplitN(s.call, ".", 2)[0]
if params[s.fn][recv] {
got[s.file+":"+s.fn] = true
sites[s.file+":"+s.fn]++
}
}
for k, n := range sites {
if n != 1 {
t.Errorf("%s has %d call sites that listen on the tunnel's stack; each gated forward has one (serveServiceForwards' one site opens a listener per slot)", k, n)
}
}
for k := range got {
if !want[k] {
t.Errorf("%s opens a listener on the tunnel's stack; every tunnel listener must be a gated forward listed here", k)
}
}
for k := range want {
if !got[k] {
t.Errorf("%s no longer listens on the tunnel's stack — update this table and the README with it", k)
}
}
// Only those four functions (and runPairing, which builds the stack) may
// hold it at all.
holders := map[string]bool{}
for fn := range params {
holders[fn] = true
}
for _, fn := range []string{"serveOllamaForward", "serveLLMForward", "servePIIForward", "serveServiceForwards", "serveCoderForwards"} {
delete(holders, fn)
}
for fn := range holders {
t.Errorf("%s takes the tunnel's network stack as a parameter; only the gated forwards may", fn)
}
var creators []string
p.eachCall(func(file, fn string, c *ast.CallExpr) {
if recv, sel := selName(c); recv == "netstack" && sel == "CreateNetTUN" {
creators = append(creators, file+":"+fn)
}
})
if len(creators) != 1 || creators[0] != "run.go:runPairing" {
t.Fatalf("the tunnel's stack must be created in runPairing alone, found %v", creators)
}
// In runPairing the stack is passed to the four forwards and otherwise
// used only to dial the host.
rp := p.funcs["runPairing"]
passedTo := map[string]bool{}
ast.Inspect(rp.Body, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.CallExpr:
for _, a := range v.Args {
if id, ok := a.(*ast.Ident); ok && id.Name == "tnet" {
passedTo[exprText(v.Fun)] = true
}
}
if recv, sel := selName(v); recv == "tnet" && sel != "DialContext" {
t.Errorf("runPairing calls tnet.%s; the stack is only dialled out of", sel)
}
case *ast.AssignStmt:
for _, r := range v.Rhs {
if id, ok := r.(*ast.Ident); ok && id.Name == "tnet" {
t.Errorf("runPairing copies the tunnel's stack into another variable")
}
}
}
return true
})
for fn := range passedTo {
switch fn {
case "serveOllamaForward", "serveLLMForward", "servePIIForward", "serveServiceForwards", "serveCoderForwards":
default:
t.Errorf("runPairing hands the tunnel's stack to %s", fn)
}
}
// The ports: the README names them, the heartbeat reports them.
if coderPortBase != 18200 || coderPortMax != 18299 {
t.Errorf("coder tunnel ports moved: %d-%d", coderPortBase, coderPortMax)
}
if llmForwardPort != 18080 || piiForwardPort != 18081 || servicePortBase != 18100 || servicePortMax != 18199 {
t.Errorf("tunnel ports moved: llm %d, pii %d, service %d-%d", llmForwardPort, piiForwardPort, servicePortBase, servicePortMax)
}
}
// The bastion dials out. Every listener this package opens on the machine's
// own interfaces is in this table, with what it binds and how many call sites
// open it. (The WireGuard device's own UDP socket is opened inside the
// wireguard module, outside this package, and is the tunnel's outbound
// transport: it is not in this table.) Loopback literals are checked in the
// source; the per-pairing addresses are checked where they are allocated
// (TestListenAddressesAreOnlyEverAllocatedOnLoopback); the memory-mirror
// forwarder's two binds and its source check are TestMirrorPeerAllowed's.
func TestEveryListenerThisPackageOpensIsInTheTable(t *testing.T) {
p := parseSources(t)
params := p.netstackParams()
type rule struct {
prefix string // the network literal, when the call names one ("" = none required)
sites int // call sites in that function; a second one is a second listener
why string
}
want := map[string]rule{
"run.go:runPairing:net.Listen": {"tcp", 1, "git proxy on cfg.GitProxyAddr (loopback, allocated by nextGitProxyAddr)"},
"run.go:runPairing:srv.ListenAndServe": {"", 1, "local control port on cfg.ListenAddr (loopback, allocated by nextListenAddr)"},
// The same two for a pairing with a Host on this machine (no tunnel).
"run.go:runUnixPairing:net.Listen": {"tcp", 1, "git proxy on cfg.GitProxyAddr (loopback, allocated by nextGitProxyAddr)"},
"run.go:runUnixPairing:srv.ListenAndServe": {"", 1, "local control port on cfg.ListenAddr (loopback, allocated by nextListenAddr)"},
"ui.go:startUIServer:net.Listen": {"", 1, "the console, 127.0.0.1 only"},
"contextui.go:cmdContextUI:net.Listen": {"", 1, "the context page, 127.0.0.1 only"},
"service_mirror.go:mirrorPushBridge:net.Listen": {"", 1, "the mirror push bridge, 127.0.0.1:0"},
"mirror.go:serveMirrorForward:net.Listen": {"", 1, "memory-mirror forwarder: loopback and the replica network's gateway, source-checked"},
"contextsock_unix.go:contextSockListen:net.Listen": {"unix", 1, "a unix socket file, not a network port"},
"ops_action_linux.go:opActionProbeUnit:net.Listen": {"tcp", 1, "the action-op probe, 127.0.0.1:0, open while one probe unit dials it"},
"coders_linux.go:coderListenUnix:net.Listen": {"unix", 1, "a coder run's egress.sock, a unix socket file in its run dir, not a network port"},
"coders_other.go:coderListenUnix:net.Listen": {"unix", 1, "the same, off Linux (coder mode refuses there)"},
// The one listener on every address, by design: the door a Host that
// this server can't reach connects to (hostdoor.go). Bound only while
// such a pairing is served or a pairing window is open; it keeps only
// connections whose certificate verifies under a served pairing's
// pinned CA, and forwards nothing (TestTheDoorRefusesAnythingButItsPinnedHost).
"hostdoor.go:listenDoor:lc.Listen": {"tcp", 1, "the door your Host connects to, every address, TCP 8812 (docs/host-connects.md)"},
// Beside a Windows system service: the tray's own approvals window,
// and the service's approvals pipe (approvalpipe.go), which takes no
// remote client (PIPE_REJECT_REMOTE_CLIENTS) and, by its ACL, only
// interactive users; TestTheApprovalPipeTakesLocalInteractiveClientsOnly.
"tray_pipe_windows.go:startTrayPage:net.Listen": {"tcp", 1, "the tray's approvals window, 127.0.0.1 only"},
"approvalpipe_windows.go:approvalPipeServer.create:windows.CreateNamedPipe": {"", 1, "the approvals pipe, a local named pipe, not a network port"},
}
loopbackOnly := map[string]bool{
"ui.go:startUIServer:net.Listen": true,
"contextui.go:cmdContextUI:net.Listen": true,
"tray_pipe_windows.go:startTrayPage:net.Listen": true,
"service_mirror.go:mirrorPushBridge:net.Listen": true,
"ops_action_linux.go:opActionProbeUnit:net.Listen": true,
}
seen := map[string]int{}
for _, s := range p.listenSites() {
recv := strings.SplitN(s.call, ".", 2)[0]
if params[s.fn][recv] {
continue // inside the tunnel: TestOnlyTheGatedForwardsListenInsideTheTunnel
}
key := s.file + ":" + s.fn + ":" + s.call
seen[key]++
r, ok := want[key]
if !ok {
t.Errorf("%s opens a listener that is not in this table (literals %q). A new listener must bind loopback; add it here with what it binds", key, s.lits)
continue
}
if r.prefix != "" && (len(s.lits) == 0 || s.lits[0] != r.prefix) {
t.Errorf("%s: want network %q, got %q", key, r.prefix, s.lits)
}
if loopbackOnly[key] {
found := false
for _, l := range s.lits {
switch {
case loopbackLiteral(l):
found = true
case l == "tcp" || l == "tcp4" || l == "tcp6":
default:
t.Errorf("%s binds %q; it must bind loopback", key, l)
}
}
if !found {
t.Errorf("%s: no loopback address in the bind (%q)", key, s.lits)
}
}
}
for k, r := range want {
if seen[k] == 0 {
t.Errorf("%s is in the table but no longer listens; take it out", k)
} else if seen[k] != r.sites {
t.Errorf("%s opens a listener at %d call sites, the table says %d; each one is a listener to account for", k, seen[k], r.sites)
}
}
// The mirror forwarder binds exactly two addresses: the gateway it was
// given and loopback.
var mirrorAddrs []string
ast.Inspect(p.funcs["serveMirrorForward"].Body, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
if recv, sel := selName(c); recv == "net" && sel == "JoinHostPort" {
mirrorAddrs = append(mirrorAddrs, exprText(c.Args[0]))
}
}
return true
})
sort.Strings(mirrorAddrs)
if strings.Join(mirrorAddrs, ",") != `"127.0.0.1",gw` {
t.Errorf("the mirror forwarder must bind the replica gateway and loopback only, binds %v", mirrorAddrs)
}
}
// loopbackLiteral reports whether a string literal in a bind names loopback:
// a host:port on 127.0.0.1 or [::1], or the bare host handed to JoinHostPort.
func loopbackLiteral(l string) bool {
return l == "127.0.0.1" || l == "::1" || strings.HasPrefix(l, "127.0.0.1:") || strings.HasPrefix(l, "[::1]:")
}
// The control and git ports are whatever the pairing file says, so the
// claim rests on what writes those fields: the allocators, and a re-pair
// that keeps the old value. Nothing else in the code may set them.
func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
p := parseSources(t)
allowed := map[string]bool{
"nextListenAddr()": true,
"nextGitProxyAddr()": true,
"existing.ListenAddr": true,
"existing.GitProxyAddr": true,
"cfg.GitProxyAddr": true, // run.go writes the allocated value back to disk
}
for name, f := range p.files {
ast.Inspect(f, func(x ast.Node) bool {
check := func(field string, rhs ast.Expr) {
if field != "ListenAddr" && field != "GitProxyAddr" {
return
}
if !allowed[exprText(rhs)] {
t.Errorf("%s sets %s from %s; only the loopback allocators may", name, field, exprText(rhs))
}
}
switch v := x.(type) {
case *ast.CompositeLit:
if exprText(v.Type) != "Config" && exprText(v.Type) != "&Config" {
return true
}
for _, e := range v.Elts {
if kv, ok := e.(*ast.KeyValueExpr); ok {
if k, ok := kv.Key.(*ast.Ident); ok {
check(k.Name, kv.Value)
}
}
}
case *ast.AssignStmt:
for i, l := range v.Lhs {
if s, ok := l.(*ast.SelectorExpr); ok && i < len(v.Rhs) {
check(s.Sel.Name, v.Rhs[i])
}
}
}
return true
})
}
// And the allocators hand out loopback, however many pairings exist.
var cfgs []*Config
for i := 0; i < 40; i++ {
c := &Config{ListenAddr: nextListenAddr(cfgs)}
c.GitProxyAddr = nextGitProxyAddr(append(cfgs, c))
for _, a := range []string{c.ListenAddr, c.GitProxyAddr} {
if !strings.HasPrefix(a, "127.0.0.1:") {
t.Fatalf("allocated %q; every local port must be on 127.0.0.1", a)
}
}
cfgs = append(cfgs, c)
}
}
// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
// the binary registers, on any listener, is in this table; none of them, and
// no op name on the management lane, names MCP; no string in the code is a
// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
// carried on; and no dependency is an MCP library.
//
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
// string containing "/mcp" at all. The connection-config reader
// (mcpconfig.go) has to name the files agent clients declare their tool
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
// protocol instead of the word (mcpReaderViolations):
//
// - no literal anywhere is a URL or route with an mcp path segment;
// - the config basenames and keys may appear in mcpconfig.go and nowhere
// else, so a second file that starts reading (or writing) them is a
// change a reviewer sees here;
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
// can reach through this package's call graph, net.Dial or net.Listen,
// net/http, os/exec, TLS dialling or the websocket client. The reader is
// handed its process list by agents.go; it starts and dials nothing.
//
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
// fail.
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
p := parseSources(t)
// A route served by more than one surface names each file, "a.go|b.go":
// the tray's page (uitray.go) answers a few of the console's own paths.
want := map[string]string{
// run.go: the loopback control port, one per pairing.
"/healthz": "run.go", "/git-credential": "run.go", "/dcn/propose": "run.go",
// The local console (ui*.go), loopback only.
"/assets/": "ui.go", "/auth/ticket": "ui.go", "/signout": "ui.go",
"/fragments/status": "ui.go", "/given": "ui.go", "/given/switch": "ui.go",
"/fragments/given": "ui.go", "/approve": "ui.go|uitray.go", "/deny": "ui.go|uitray.go", "/": "ui.go",
"/agents": "uisanction.go", "/fragments/agents": "uisanction.go",
"/agents/mark": "uisanction.go", "/agents/unmark": "uisanction.go",
"/fragments/context": "uicontext.go", "/context/scan": "uicontext.go",
"/context/stop": "uicontext.go", "/context/ask": "uicontext.go",
"/policy": "uipolicy.go", "/fragments/policy": "uipolicy.go",
"/fragments/home": "uihome.go", "/agent": "uihome.go", "/machine": "uihome.go",
"/agent/stop": "uihome.go", "/agent/mark": "uihome.go", "/activity": "uihome.go",
"/fragments/machine/schedules": "uimachine.go", "/fragments/machine/tools": "uimachine.go",
"/fragments/machine/baseline": "uimachine.go", "/machine/compare": "uimachine.go",
"/machine/baseline": "uimachine.go",
"/machine/report": "uimachine.go",
"/setup/host/disconnect": "uisetup.go", "/setup/host/release": "uisetup.go",
"/approvals/machine/approve": "uihome.go", "/approvals/machine/deny": "uihome.go",
"/approvals": "uidevice.go|uitray.go", "/fragments/approvals": "uidevice.go|uitray.go",
"/fragments/approvals-news": "uidevice.go|uitray.go", "/device/approve": "uidevice.go",
"/device/deny": "uidevice.go", "/device/approve-shown": "uidevice.go",
"/diskmap": "uidiskmap.go", "/fragments/diskmap": "uidiskmap.go",
"/diskmap/scan": "uidiskmap.go", "/diskmap/stop": "uidiskmap.go",
"/setup": "uisetup.go", "/setup/pair": "uisetup.go",
"/setup/stepup/start": "uisetup.go", "/setup/stepup/qr": "uisetup.go",
"/setup/stepup/key": "uisetup.go", "/setup/stepup/confirm": "uisetup.go",
"/setup/folder/check": "uisetup.go", "/setup/folder/propose": "uisetup.go",
"/setup/anchor/choose": "uisetup.go", "/setup/owner/name": "uisetup.go",
}
got := map[string]bool{}
p.eachCall(func(file, fn string, c *ast.CallExpr) {
_, sel := selName(c)
if sel != "HandleFunc" && sel != "Handle" || len(c.Args) != 2 {
return
}
lits := stringLits(c.Args[0])
if len(lits) != 1 {
t.Errorf("%s:%s registers a route that is not a string literal (%s); every route must be readable here", file, fn, exprText(c.Args[0]))
return
}
route := lits[0]
got[route] = true
if f, ok := want[route]; !ok {
t.Errorf("%s:%s registers %q, which is not in this table", file, fn, route)
} else if !strings.Contains("|"+f+"|", "|"+file+"|") && route != "/" {
t.Errorf("%q is registered in %s, the table says %s", route, file, f)
}
if strings.Contains(strings.ToLower(route), "mcp") {
t.Errorf("%s:%s registers an MCP route %q", file, fn, route)
}
})
for r := range want {
if !got[r] {
t.Errorf("%q is in the table but no longer registered; take it out", r)
}
}
for _, v := range mcpReaderViolations(p) {
t.Error(v)
}
for name, f := range p.files {
for _, imp := range f.Imports {
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
t.Errorf("%s imports %s", name, imp.Path.Value)
}
}
}
mod, err := os.ReadFile("go.mod")
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(mod)), "mcp") {
t.Errorf("go.mod names an MCP module:\n%s", mod)
}
for _, op := range mgmtOpVocabulary() {
if strings.Contains(strings.ToLower(op), "mcp") {
t.Errorf("the management lane answers an op named %q", op)
}
}
}
// mcpConfigNames are the file basenames and keys agent clients declare tool
// servers under. Only mcpconfig.go may name them. settings.json and
// config.toml are left out on purpose: many programs use those names (the
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
// about tool servers; the keys beside them do.
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
"mcpservers", "context_servers", "mcp_servers",
// The clients' own directories, which name a settings.json or
// config.toml as surely as the basename would.
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
// mcpURLOrRoute reports whether a string literal is a URL or route that
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
// /mcp-proxy) except a client's config FILE name: /mcp.json and
// /mcp_config.json.
func mcpURLOrRoute(l string) bool {
l = strings.ToLower(l)
if i := strings.Index(l, "://"); i >= 0 {
rest := l[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
return strings.Contains(rest[j:], "/mcp")
}
return false
}
for i := 0; ; {
j := strings.Index(l[i:], "/mcp")
if j < 0 {
return false
}
rest := l[i+j+len("/mcp"):]
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
return true
}
i += j + len("/mcp")
}
}
// mcpReaderSink: a package-level function or type that opens a connection,
// resolves a name, or starts a process — named as a call or as a value.
func mcpReaderSink(recv, sel string) bool {
switch {
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
return true
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
return true
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
return true
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
return true
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
return true
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
return true
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
// A DLL loaded by name reaches any function in it, past every check here.
return true
case recv == "registry" && sel == "OpenRemoteKey":
return true
}
return false
}
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
// whatever the receiver is called — d.Dial on a net.Dialer variable,
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
// purpose: it only applies to code the reader can reach.
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
"CombinedOutput": true, "Connect": true}
// mcpReaderAllowedImports is every package a config-reader file may import:
// parsing, strings, and opening files. Anything else is a change a reviewer
// has to see here first. x/sys/windows and its registry package are the
// Windows open (CreateFile and the handle's own type, attributes and owner)
// and the profile list it checks owners against; each can also start a
// process, open a socket, load a DLL or open a remote registry, and those
// calls are sinks in mcpReaderSink like their unix equivalents.
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
"golang.org/x/sys/windows/registry": true}
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
// The http, exec and websocket packages also export constants and types
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
// their functions count here.
func mcpValueSink(recv, sel string) bool {
switch recv {
case "http", "exec", "websocket", "rpc", "jsonrpc":
switch sel {
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
return true
}
return false
}
return mcpReaderSink(recv, sel)
}
// mcpSinksIn lists the network and process calls inside one declaration.
func mcpSinksIn(n ast.Node) []string {
var out []string
called := map[*ast.SelectorExpr]bool{}
ast.Inspect(n, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
called[sel] = true
}
}
return true
})
ast.Inspect(n, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.SelectorExpr:
recv := exprText(v.X)
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
out = append(out, recv+"."+v.Sel.Name)
}
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
}
}
return true
})
return out
}
// mcpReaderViolations is every way the package breaks the guarantee above.
func mcpReaderViolations(p *srcPkg) []string {
var out []string
names := make([]string, 0, len(p.files))
for n := range p.files {
names = append(names, n)
}
sort.Strings(names)
var readerFiles []string
for _, name := range names {
if strings.HasPrefix(name, "mcpconfig") {
readerFiles = append(readerFiles, name)
}
for _, s := range stringLits(p.files[name]) {
l := strings.ToLower(s)
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
}
// The protocol's namespace. The reader may name the npm scope
// its reference servers are published under, to recognise one
// running; nothing else may name it at all.
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
}
if name == "mcpconfig.go" {
continue
}
for _, b := range mcpConfigNames {
if strings.Contains(l, b) {
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
}
}
}
}
if len(readerFiles) == 0 {
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
}
var roots []string
for _, name := range readerFiles {
for _, imp := range p.files[name].Imports {
path, _ := strconv.Unquote(imp.Path.Value)
if !mcpReaderAllowedImports[path] {
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
}
}
roots = append(roots, p.funcKeysIn(name)...)
}
// Package-level variables are nodes too ("var:name"): a function held in
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
// reads the variable, and a reader file's own variables are roots, since
// their initialisers run with the package.
g := p.callGraph()
vars := map[string][]ast.Expr{}
for _, name := range names {
for _, d := range p.files[name].Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, sp := range gd.Specs {
vs := sp.(*ast.ValueSpec)
for _, id := range vs.Names {
key := "var:" + id.Name
vars[key] = append(vars[key], vs.Values...)
if strings.HasPrefix(name, "mcpconfig") {
roots = append(roots, key)
}
}
}
}
}
refs := func(n ast.Node) []string {
var out []string
ast.Inspect(n, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok {
if _, isVar := vars["var:"+id.Name]; isVar {
out = append(out, "var:"+id.Name)
}
if _, isFn := p.funcs[id.Name]; isFn {
out = append(out, id.Name)
}
}
return true
})
return out
}
sinks := map[string][]string{}
for key, fd := range p.funcs {
g[key] = append(g[key], refs(fd)...)
if s := mcpSinksIn(fd); len(s) > 0 {
sinks[key] = s
}
}
for key, vals := range vars {
for _, v := range vals {
g[key] = append(g[key], refs(v)...)
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
}
}
seen := reach(g, roots, nil)
for fn, path := range seen {
if what := sinks[fn]; len(what) > 0 {
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
fn, strings.Join(what, ", "), path))
}
}
sort.Strings(out)
return out
}
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
// network package, reaches a dialler through another function, or a second
// file that names a client's config file or an /mcp URL: each one is added
// as a synthetic source and must be reported. The real tree must report
// nothing, and the scan must have found sinks and roots to compare, so a
// broken extraction cannot pass by finding nothing.
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
clean := parseSources(t)
if v := mcpReaderViolations(clean); len(v) != 0 {
t.Fatalf("the real tree must be clean: %v", v)
}
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
}
add := func(p *srcPkg, name, src string) {
t.Helper()
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
p.addFile(name, f)
}
cases := []struct {
name, want string
files map[string]string
}{
{"the reader execs", "can reach mcpSneakRun", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader imports net/http", "imports net/http", map[string]string{
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
{"a route", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
{"the reader resolves a name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
}
for _, c := range cases {
p := parseSources(t)
for n, src := range c.files {
add(p, n, src)
}
found := false
for _, v := range mcpReaderViolations(p) {
if strings.Contains(v, c.want) {
found = true
}
}
if !found {
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
}
}
// A config file name is not a URL path, and a file in the reader may
// name one.
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
if mcpURLOrRoute(ok) {
t.Errorf("%q read as an MCP URL or route", ok)
}
}
}
// callGraph maps each function to what it calls or refers to, by name.
// A method call through a value is resolved to every method of that name in
// the package, so the graph over-approximates: a path it cannot rule out is
// treated as a path. A method passed as a value without being called is not
// followed; none of the handlers below does that.
func (p *srcPkg) callGraph() map[string][]string {
g := map[string][]string{}
// The per-file duplicates ("name@file"), by name, built once: looking
// them up by prefix for every identifier made this graph cost seconds.
dups := map[string][]string{}
for k := range p.funcs {
if i := strings.IndexByte(k, '@'); i >= 0 {
dups[k[:i]] = append(dups[k[:i]], k)
}
}
for key, fd := range p.funcs {
if fd.Body == nil {
continue
}
var out []string
ast.Inspect(fd.Body, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.Ident:
if _, ok := p.funcs[v.Name]; ok {
out = append(out, v.Name)
}
out = append(out, dups[v.Name]...)
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
out = append(out, p.methods[sel.Sel.Name]...)
}
}
return true
})
g[key] = out
}
return g
}
// reach returns every function reachable from roots, with the path to it.
func reach(g map[string][]string, roots []string, stop map[string]bool) map[string]string {
seen := map[string]string{}
var queue []string
for _, r := range roots {
seen[r] = r
queue = append(queue, r)
}
for len(queue) > 0 {
n := queue[0]
queue = queue[1:]
if stop[n] {
continue
}
for _, m := range g[n] {
if _, ok := seen[m]; !ok {
seen[m] = seen[n] + " -> " + m
queue = append(queue, m)
}
}
}
return seen
}
// handlerFuncName is the Go function a handlerOps entry dispatches to.
func (p *srcPkg) handlerFuncs(t *testing.T) map[string]string {
t.Helper()
out := map[string]string{}
for _, f := range p.files {
ast.Inspect(f, func(x ast.Node) bool {
vs, ok := x.(*ast.ValueSpec)
if !ok || len(vs.Names) != 1 || vs.Names[0].Name != "handlerOps" {
return true
}
cl := vs.Values[0].(*ast.CompositeLit)
for _, e := range cl.Elts {
kv := e.(*ast.KeyValueExpr)
op := exprText(kv.Key)
if b, ok := kv.Key.(*ast.BasicLit); ok {
op, _ = strconv.Unquote(b.Value)
}
h := kv.Value.(*ast.CompositeLit)
out[op] = exprText(h.Elts[len(h.Elts)-1])
}
return false
})
}
if len(out) != len(handlerOps) {
t.Fatalf("read %d handler entries from the source, the table has %d", len(out), len(handlerOps))
}
return out
}
// execSites names the functions that start a process directly.
func (p *srcPkg) execSites() map[string]string {
out := map[string]string{}
p.eachCall(func(file, fn string, c *ast.CallExpr) {
recv, sel := selName(c)
if (recv == "exec" && (sel == "Command" || sel == "CommandContext")) ||
(recv == "os" && sel == "StartProcess") ||
(recv == "syscall" && (sel == "Exec" || sel == "ForkExec" || sel == "StartProcess")) {
out[fn] = file
}
})
return out
}
// shellPrograms are the command interpreters a "no shell" claim rules out.
var shellPrograms = map[string]bool{
"sh": true, "/bin/sh": true, "bash": true, "/bin/bash": true, "zsh": true, "dash": true,
"cmd": true, "cmd.exe": true, "powershell": true, "powershell.exe": true, "pwsh": true,
}
// Fixed argv, and no shell in file handlers. The file ops are Go handlers:
// nothing any of them can reach runs a shell, and the one process any of
// them can start is the local search agent that dcn_grant_sync brings up for
// a store granted "search" — a Python interpreter found on PATH running the
// signed agent. ctx_extract is the one context op that runs a program, a
// named tool from a fixed table (TestCtxExtractRefusesAnUnknownTool), and is
// left out on purpose.
func TestFileHandlersStartNoShell(t *testing.T) {
p := parseSources(t)
g := p.callGraph()
h := p.handlerFuncs(t)
var roots []string
for op, fn := range h {
if (strings.HasPrefix(op, "ctx_") && op != "ctx_extract") || strings.HasPrefix(op, "dcn_") ||
op == "backup_fetch" || op == "backup_push" || op == "backup_reseal" || op == "backup_list" {
roots = append(roots, fn)
}
}
sort.Strings(roots)
if len(roots) < 10 {
t.Fatalf("found only %d file handlers (%v); the table read went wrong", len(roots), roots)
}
// The management lane's framing and audit are shared by every op and
// write the bastion's own records; they are checked where they live.
stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true}
seen := reach(g, roots, stop)
agent := map[string]bool{"faPython": true, "faSignManifest": true, "faRunOnce": true}
starters := p.execSites()
for fn, file := range starters {
path, ok := seen[fn]
if !ok {
continue
}
if !agent[fn] {
t.Errorf("a file handler can reach %s (%s), which starts a process: %s", fn, file, path)
}
}
for fn := range agent {
if _, ok := starters[fn]; !ok {
t.Errorf("%s no longer starts a process; take it out of this test", fn)
}
}
// Nothing a file handler can reach names a shell as the program.
p.eachCall(func(file, fn string, c *ast.CallExpr) {
if _, ok := seen[fn]; !ok {
return
}
recv, sel := selName(c)
if recv != "exec" || (sel != "Command" && sel != "CommandContext") {
return
}
prog := c.Args[0]
if sel == "CommandContext" {
prog = c.Args[1]
}
for _, l := range stringLits(prog) {
if shellPrograms[l] {
t.Errorf("%s:%s runs %s, reachable from a file handler", file, fn, l)
}
}
})
}
// Fixed argv: the one allowlisted op has one fixed command line, and the
// place that runs it builds the command from the table alone.
func TestTheAllowlistedOpRunsItsTableArgvOnly(t *testing.T) {
if len(p8AllowedOps) != 1 || strings.Join(p8AllowedOps["uptime"], " ") != "uptime" {
t.Fatalf("the allowlisted argv table changed: %v — an op that takes arguments must validate them per op, and this test with it", p8AllowedOps)
}
p := parseSources(t)
var n int
p.eachCall(func(file, fn string, c *ast.CallExpr) {
if file != "mgmt.go" {
return
}
if recv, sel := selName(c); recv == "exec" && (sel == "Command" || sel == "CommandContext") {
n++
for _, a := range c.Args[1:] {
txt := exprText(a)
if !strings.HasPrefix(txt, "argv") {
t.Errorf("mgmt.go:%s builds a command from %s; only the table's argv may reach it", fn, txt)
}
}
ast.Inspect(c, func(x ast.Node) bool {
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "Args" {
t.Errorf("mgmt.go:%s passes %s to a command", fn, exprText(s))
}
return true
})
}
})
if n != 1 {
t.Fatalf("mgmt.go starts %d commands; the allowlisted op is the one", n)
}
}
// Observation changes nothing on the machine. What an observe or computer
// op can reach writes nothing through Go and signals nothing, apart from the
// bastion's own records of the op (the audit line and the observe journal,
// machinestore.go), and runs only the programs pinned here, each with the
// arguments that make it a read.
func TestObserveOpsWriteNothing(t *testing.T) {
p := parseSources(t)
g := p.callGraph()
h := p.handlerFuncs(t)
var roots []string
for op, fn := range h {
if strings.HasPrefix(op, "observe:") || strings.HasPrefix(op, "computer:") {
roots = append(roots, fn)
}
}
if len(roots) != 18 {
t.Fatalf("expected the 15 observe and 3 computer handlers, found %d", len(roots))
}
stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true,
"storeRecordObserve": true}
seen := reach(g, roots, stop)
writes := map[string]bool{
"os.WriteFile": true, "os.Create": true, "os.Remove": true, "os.RemoveAll": true,
"os.Rename": true, "os.Mkdir": true, "os.MkdirAll": true, "os.MkdirTemp": true, "os.CreateTemp": true,
"os.Chmod": true, "os.Chown": true, "os.Lchown": true, "os.Chtimes": true,
"os.Symlink": true, "os.Link": true, "os.Truncate": true,
"syscall.Kill": true, "unix.Kill": true, "syscall.Mount": true, "unix.Mount": true,
"syscall.Unlink": true, "unix.Unlink": true, "syscall.Reboot": true, "unix.Reboot": true,
}
// wrapper -> index of the program argument
runners := map[string]int{"observeExec": 1, "textOf": 1, "runFixed": 0, "runTimed": 0, "runFixedEnv": 1}
programs := map[string]bool{}
for fn, path := range seen {
fd := p.funcs[fn]
if fd == nil || fd.Body == nil {
continue
}
ast.Inspect(fd.Body, func(x ast.Node) bool {
c, ok := x.(*ast.CallExpr)
if !ok {
return true
}
recv, sel := selName(c)
if writes[recv+"."+sel] {
t.Errorf("%s calls %s.%s, reachable from an observe op: %s", fn, recv, sel, path)
}
if recv == "os" && sel == "OpenFile" && len(c.Args) > 1 && exprText(c.Args[1]) != "os.O_RDONLY" {
t.Errorf("%s opens a file for writing, reachable from an observe op: %s", fn, path)
}
if sel == "Kill" || sel == "Signal" {
t.Errorf("%s signals a process (%s.%s), reachable from an observe op: %s", fn, recv, sel, path)
}
at, ok := runners[exprText(c.Fun)]
if !ok || len(c.Args) <= at {
return true
}
if _, self := runners[fn]; self {
return true // the wrapper passing its own argument on
}
var argv []string
for _, a := range c.Args[at:] {
l := stringLits(a)
if len(l) == 1 {
argv = append(argv, l[0])
} else {
argv = append(argv, "<"+exprText(a)+">")
}
}
if len(argv[0]) > 40 {
argv[0] = argv[0][:40]
}
for i := range argv {
if len(argv[i]) > 40 {
argv[i] = argv[i][:40] + "…"
}
}
programs[strings.Join(argv, " ")] = true
return true
})
}
var got []string
for k := range programs {
got = append(got, k)
}
sort.Strings(got)
want := []string{
"apt-get -s upgrade", // -s: simulate, changes nothing
"df -P",
"dmesg -T",
"iptables -S",
"journalctl -n1 -o short-unix --no-pager",
// observe:logins: sshd's lines over a window of whole hours.
"journalctl <sinceArg> -o short-iso --no-pager -q --lines=50000 _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0",
// observe:journal: one unit this machine's own lists name, over minutes.
"journalctl <unitArg> <sinceArg> -o short-iso --no-pager -q --lines=5000",
"kubectl --kubeconfig <observeKubeconfig> get nodes -o json",
"kubectl --kubeconfig <observeKubeconfig> get pods -A -o json",
"nft list chain inet pubwall input",
"nft list ruleset",
"nft list table inet pubwall",
"osascript -e <windowsScript>", // lists windows (TestNoInputInjection reads the script)
"osascript -l JavaScript -e <darwinTreeScript>", // reads one app's accessibility tree
"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <windowsTreeEncoded>",
"python3 -c <linuxTreeScript>",
"ss -tlnpH",
"ss -ulnpH",
"system_profiler SPApplicationsDataType -json",
"systemctl is-active fail2ban",
"systemctl list-timers --all --no-legend --plain",
"systemctl list-unit-files --no-legend --plain", // observe:journal's unit check
"systemctl list-units --all --no-legend --plain",
"systemctl list-units --failed --no-legend --plain",
"ufw status",
"ufw status verbose",
"wmctrl -lp",
"xprop -root _NET_ACTIVE_WINDOW",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("the programs an observe or computer op runs changed. Every one must be a read; "+
"review the change and update this table.\n got: %q\nwant: %q", got, want)
}
}
// No input injection lives in the bastion. No program that types, clicks
// or moves a pointer is run, no Windows input API is loaded, and none of the
// scripts handed to an interpreter asks for a keystroke, a click or an
// accessibility action — on any platform.
func TestNoInputInjection(t *testing.T) {
p := parseSources(t)
injectors := map[string]bool{"xdotool": true, "ydotool": true, "wtype": true, "xte": true,
"cliclick": true, "nircmd": true, "AutoHotkey": true, "autohotkey": true}
runners := map[string]int{"observeExec": 1, "textOf": 1, "runFixed": 0, "runTimed": 0, "runFixedEnv": 1}
inputAPIs := map[string]bool{"SendInput": true, "keybd_event": true, "mouse_event": true,
"SetCursorPos": true, "BlockInput": true, "SendKeys": true}
p.eachCall(func(file, fn string, c *ast.CallExpr) {
recv, sel := selName(c)
at := -1
switch {
case recv == "exec" && sel == "Command":
at = 0
case recv == "exec" && sel == "CommandContext":
at = 1
default:
if i, ok := runners[exprText(c.Fun)]; ok {
at = i
}
}
if at >= 0 && len(c.Args) > at {
for _, l := range stringLits(c.Args[at]) {
if injectors[filepath.Base(l)] {
t.Errorf("%s:%s runs %s, an input-injection tool", file, fn, l)
}
}
}
if sel == "NewProc" || sel == "FindProc" || sel == "MustFindProc" {
for _, l := range stringLits(c) {
if inputAPIs[l] {
t.Errorf("%s:%s loads %s, an input-injection API", file, fn, l)
}
}
}
})
verbs := []string{"keystroke", "key code", "click at", "click button", "click menu",
".Invoke()", "InvokePattern", ".SetValue(", "SendKeys", "do_action", "doAction",
"generate_keyboard_event", "generate_mouse_event", "pyautogui", "pynput", "XTest",
"CGEventPost", "/dev/uinput"}
// The scripts live in the computer_* files; agents.go names injection
// tools too, as things to DETECT on the machine, which is not running them.
for name, f := range p.files {
if !strings.HasPrefix(name, "computer") && !strings.HasPrefix(name, "observe") {
continue
}
for _, s := range stringLits(f) {
for _, v := range verbs {
if strings.Contains(s, v) {
t.Errorf("%s carries %q in a string: an input-injection call", name, v)
}
}
}
}
for name, f := range p.files {
ast.Inspect(f, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok && (inputAPIs[id.Name] || id.Name == "CGEventPost") {
t.Errorf("%s refers to %s, an input-injection API", name, id.Name)
}
return true
})
}
}
// Scoped tunnel, on the path a rotation takes. Rotation and failover reach the
// live device through package variables, so their tests can drive a probe
// without a WireGuard peer (rotate.go). The scope claim is tested on swapPeer
// itself (claims_tunnel_test.go), so it holds for the running code only while
// those variables are swapPeer and its siblings: declared with them, and
// assigned or addressed nowhere outside tests.
func TestARotationReachesTheDeviceOnlyThroughSwapPeer(t *testing.T) {
p := parseSources(t)
declared := map[string]string{
"livePeerSwap": "swapPeer",
"liveHandshake": "handshakeWithin",
"liveLastHandshake": "return lastHandshake(…)",
"dialForRouteCheck": "net.DialTimeout",
}
initText := func(e ast.Expr) string {
// liveLastHandshake adapts lastHandshake's interface parameter to the
// device type; anything more in that literal is a different function.
if fl, ok := e.(*ast.FuncLit); ok && len(fl.Body.List) == 1 {
if r, ok := fl.Body.List[0].(*ast.ReturnStmt); ok && len(r.Results) == 1 {
if c, ok := r.Results[0].(*ast.CallExpr); ok && exprText(c.Fun) == "lastHandshake" {
return "return lastHandshake(…)"
}
}
}
return exprText(e)
}
seen := map[string]int{}
for name, f := range p.files {
for _, d := range f.Decls {
g, ok := d.(*ast.GenDecl)
if !ok || g.Tok != token.VAR {
continue
}
for _, s := range g.Specs {
vs := s.(*ast.ValueSpec)
for i, n := range vs.Names {
want, ok := declared[n.Name]
if !ok {
continue
}
seen[n.Name]++
if i >= len(vs.Values) || initText(vs.Values[i]) != want {
t.Errorf("%s declares %s, which must start as %s", name, n.Name, want)
}
}
}
}
ast.Inspect(f, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.AssignStmt:
for _, l := range v.Lhs {
if id, ok := l.(*ast.Ident); ok && declared[id.Name] != "" {
t.Errorf("%s:%d assigns %s; only a test may replace it",
name, p.fset.Position(v.Pos()).Line, id.Name)
}
}
case *ast.UnaryExpr:
if id, ok := v.X.(*ast.Ident); ok && v.Op == token.AND && declared[id.Name] != "" {
t.Errorf("%s:%d takes the address of %s, which would let it be replaced",
name, p.fset.Position(v.Pos()).Line, id.Name)
}
}
return true
})
}
for n := range declared {
if seen[n] != 1 {
t.Errorf("%s is declared %d times; the table expects it once", n, seen[n])
}
}
// And what the running code holds when this test runs: no earlier test left
// a stub behind.
same := func(a, b any) bool { return reflect.ValueOf(a).Pointer() == reflect.ValueOf(b).Pointer() }
if !same(livePeerSwap, swapPeer) || !same(liveHandshake, handshakeWithin) || !same(dialForRouteCheck, net.DialTimeout) {
t.Error("a rotation seam does not hold the live function; a test replaced it and did not put it back")
}
}