bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user