Files
privatae-bastion/client-go/origin_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

260 lines
9.7 KiB
Go

package main
import (
"encoding/json"
"strings"
"testing"
)
// Origin. Each fixture is a process tree in the shape a real
// box produces; the facts function is what the platform files hand the
// shared walk, so every OS's rule is exercised from the Linux CI run.
func factsOf(m map[int]procFacts) func(int) (procFacts, bool) {
return func(pid int) (procFacts, bool) {
f, ok := m[pid]
return f, ok
}
}
func TestClassifyOrigin(t *testing.T) {
cases := []struct {
name string
tree map[int]procFacts
pid int
observed map[int]bool
extra originExtra
want string
signal string
}{
{
// The headline shape: cron -> sh -> python agent, no tty.
name: "cron job on linux",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
800: {PID: 800, PPID: 1, Comm: "cron", Unit: "cron.service"},
9001: {PID: 9001, PPID: 800, Comm: "sh"},
9002: {PID: 9002, PPID: 9001, Comm: "python3"},
},
pid: 9002, want: originScheduler, signal: "scheduler-parent",
},
{
name: "systemd timer-activated unit",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
4100: {PID: 4100, PPID: 1, Comm: "python3", Unit: "nightly-agent.service"},
},
pid: 4100,
extra: originExtra{TimerUnits: map[string]string{"nightly-agent.service": "nightly-agent.timer"}},
want: originScheduler, signal: "systemd-timer",
},
{
name: "interactive terminal",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
2000: {PID: 2000, PPID: 1, Comm: "gnome-terminal-", Interactive: true, Detail: "tty pts/0"},
2001: {PID: 2001, PPID: 2000, Comm: "bash", Interactive: true, Detail: "tty pts/0"},
2002: {PID: 2002, PPID: 2001, Comm: "claude", Interactive: true, Detail: "tty pts/0"},
},
pid: 2002, want: originInteractive, signal: "session",
},
{
// A shell under sshd has a tty too; the sshd ancestor wins.
name: "ssh session",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
700: {PID: 700, PPID: 1, Comm: "sshd", Unit: "ssh.service"},
7001: {PID: 7001, PPID: 700, Comm: "sshd-session"},
7002: {PID: 7002, PPID: 7001, Comm: "bash", Interactive: true, Detail: "tty pts/2"},
7003: {PID: 7003, PPID: 7002, Comm: "aider", Interactive: true, Detail: "tty pts/2"},
},
pid: 7003, want: originRemote, signal: "remote-parent",
},
{
name: "sub-agent forked by an observed agent",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
3000: {PID: 3000, PPID: 1, Comm: "claude", Interactive: true, Detail: "tty pts/1"},
3001: {PID: 3001, PPID: 3000, Comm: "bash", Interactive: true, Detail: "tty pts/1"},
3002: {PID: 3002, PPID: 3001, Comm: "codex", Interactive: true, Detail: "tty pts/1"},
},
pid: 3002, observed: map[int]bool{3000: true},
want: originAgent, signal: "parent-agent",
},
{
name: "systemd service, no tty",
tree: map[int]procFacts{
1: {PID: 1, Comm: "systemd"},
5000: {PID: 5000, PPID: 1, Comm: "node", Unit: "openclaw.service"},
},
pid: 5000, want: originService, signal: "service-parent",
},
{
// Windows: a task under the Schedule svchost, session 0.
name: "windows scheduled task",
tree: map[int]procFacts{
4: {PID: 4, Comm: "system"},
600: {PID: 600, PPID: 4, Comm: "wininit"},
700: {PID: 700, PPID: 600, Comm: "services"},
1500: {PID: 1500, PPID: 700, Comm: "svchost"},
8800: {PID: 8800, PPID: 1500, Comm: "node"},
},
pid: 8800,
extra: originExtra{SchedulerPIDs: map[int]bool{1500: true}},
want: originScheduler, signal: "task-scheduler",
},
{
// Windows: a service under services.exe with no scheduler pid known.
name: "windows service session 0",
tree: map[int]procFacts{
700: {PID: 700, PPID: 600, Comm: "services"},
1501: {PID: 1501, PPID: 700, Comm: "svchost"},
8801: {PID: 8801, PPID: 1501, Comm: "python"},
},
pid: 8801, want: originService, signal: "service-parent",
},
{
name: "windows desktop session",
tree: map[int]procFacts{
900: {PID: 900, PPID: 700, Comm: "explorer", Interactive: true, Detail: "session 1"},
9100: {PID: 9100, PPID: 900, Comm: "windowsterminal", Interactive: true, Detail: "session 1"},
9101: {PID: 9101, PPID: 9100, Comm: "node", Interactive: true, Detail: "session 1"},
},
pid: 9101, want: originInteractive, signal: "session",
},
{
// macOS: launchd is everyone's parent; an interval job is told by
// its executable matching the launchd inventory.
name: "launchd interval job",
tree: map[int]procFacts{
1: {PID: 1, Comm: "launchd"},
420: {PID: 420, PPID: 1, Comm: "python3", Exe: "/usr/local/bin/python3"},
},
pid: 420,
extra: originExtra{ScheduledExes: map[string]string{"/usr/local/bin/python3": "com.example.nightly"}},
want: originScheduler, signal: "scheduled-job",
},
{
name: "launchd daemon with no schedule",
tree: map[int]procFacts{
1: {PID: 1, Comm: "launchd"},
421: {PID: 421, PPID: 1, Comm: "node", Exe: "/usr/local/bin/node"},
},
pid: 421, want: originService, signal: "service-parent",
},
{
name: "unreadable process",
tree: map[int]procFacts{},
pid: 1, want: originUnknown, signal: "origin",
},
{
// An unreadable ancestor mid-walk: decide from what we hold.
name: "unreadable ancestor, no tty",
tree: map[int]procFacts{
6000: {PID: 6000, PPID: 5999, Comm: "python3"},
},
pid: 6000, want: originUnknown, signal: "origin",
},
{
name: "parent map cycle terminates",
tree: map[int]procFacts{
10: {PID: 10, PPID: 11, Comm: "a"},
11: {PID: 11, PPID: 10, Comm: "b"},
},
pid: 10, want: originUnknown, signal: "origin",
},
}
for _, c := range cases {
got, ev := classifyOrigin(c.pid, factsOf(c.tree), c.observed, c.extra)
if got != c.want {
t.Errorf("%s: origin = %q, want %q (evidence %v)", c.name, got, c.want, ev)
continue
}
if len(ev) == 0 || ev[0].Signal != c.signal {
t.Errorf("%s: evidence = %v, want first signal %q", c.name, ev, c.signal)
}
if len(ev) > 0 && ev[0].Tier == "" {
t.Errorf("%s: evidence carries no tier", c.name)
}
}
}
// ── automation kind + promotion ─────────────────────────────────────────────
func TestMatchSignatureAutomation(t *testing.T) {
cases := []struct {
argv []string
sig string
auto bool
ok bool
}{
{[]string{"claude"}, "claude-code", false, true},
{[]string{"/usr/bin/xdotool", "type", "hello"}, "xdotool", true, true},
{[]string{"chromedriver.exe", "--port=9515"}, "chromedriver", true, true},
{[]string{"node", "/app/node_modules/playwright/cli.js", "test"}, "playwright", true, true},
{[]string{"python3", "-m", "pytest", "tests/selenium_smoke.py"}, "selenium", true, true},
{[]string{"bash", "-c", "echo playwright"}, "", false, false}, // not an interpreter
{[]string{"node", "server.js"}, "", false, false},
// an agent that also drives a browser is an agent first
{[]string{"node", "/x/@anthropic-ai/claude-code/cli.js", "--playwright"}, "claude-code", false, true},
}
for _, c := range cases {
sig, auto, ok := matchSignature(c.argv)
if ok != c.ok || sig != c.sig || auto != c.auto {
t.Errorf("matchSignature(%v) = (%q,%v,%v), want (%q,%v,%v)", c.argv, sig, auto, ok, c.sig, c.auto, c.ok)
}
}
}
func TestAutomationKindAndPromotion(t *testing.T) {
byPid := map[int]agentObs{
100: {PID: 100, Signature: "xdotool", Cmdline: "xdotool type hi", raw: "xdotool type hi", automation: true},
200: {PID: 200, Signature: "playwright", Cmdline: "node playwright/cli.js", raw: "node playwright/cli.js", automation: true},
300: {PID: 300, Signature: "claude-code", Cmdline: "claude", raw: "claude"},
}
parent := func(pid int) int { return 1 }
classifyObservations(byPid, parent)
if byPid[100].Kind != kindAutomation || byPid[200].Kind != kindAutomation || byPid[300].Kind != kindAgent {
t.Fatalf("kinds after classify: %q %q %q", byPid[100].Kind, byPid[200].Kind, byPid[300].Kind)
}
// 200 talks to a model: promoted. 100 only hits an ambiguous port: stays automation.
conns := []connObs{
{PID: 200, RemoteIP: "1.2.3.4", RemotePort: 443},
{PID: 100, RemoteIP: "127.0.0.1", RemotePort: 8080},
}
cloud := map[string]string{"1.2.3.4": "api.anthropic.com"}
strong, weak := map[int]string{}, map[int]string{8080: "llama.cpp/localai"}
info := func(pid int) procDetail { return procDetail{} }
mergeConnections(byPid, conns, cloud, strong, weak, info, parent)
if byPid[200].Kind != kindAgent {
t.Errorf("playwright with model traffic should be an agent, got %q", byPid[200].Kind)
}
if byPid[100].Kind != kindAutomation || len(byPid[100].LLMEndpoints) != 1 {
t.Errorf("xdotool on an ambiguous port should stay automation with the annotation, got %q %v", byPid[100].Kind, byPid[100].LLMEndpoints)
}
// evidence tiers follow the label
ev := endpointEvidence(byPid[100].LLMEndpoints)
if len(ev) != 1 || ev[0].Tier != tierAmbiguous {
t.Errorf("ambiguous port evidence = %+v", ev)
}
ev = endpointEvidence(byPid[200].LLMEndpoints)
if len(ev) != 1 || ev[0].Tier != tierVerified || ev[0].Value != "api.anthropic.com" {
t.Errorf("cloud endpoint evidence = %+v", ev)
}
}
func TestAgentObsWireShape(t *testing.T) {
a := agentObs{PID: 1, Kind: kindAgent, Signature: "claude-code", Origin: originScheduler,
Evidence: []evidence{{Signal: "scheduler-parent", Tier: tierVerified, Value: "cron pid 800"}}, automation: true}
b, _ := json.Marshal(a)
s := string(b)
for _, want := range []string{`"origin":"scheduler"`, `"evidence":[{"signal":"scheduler-parent","tier":"verified","value":"cron pid 800"}]`} {
if !strings.Contains(s, want) {
t.Errorf("wire form lacks %s: %s", want, s)
}
}
if strings.Contains(s, "automation") || strings.Contains(s, "raw") {
t.Errorf("unexported fields leaked to the wire: %s", s)
}
}