bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pure parsers and classifiers for the observe plane — NO build tag, on
|
||||
// purpose. Every platform's enumeration output is decoded here, so the Linux
|
||||
// box that runs the tests exercises the macOS and Windows paths too. The
|
||||
// platform files (agents_<goos>.go) are left holding only the calls that
|
||||
// genuinely cannot run anywhere else: reading /proc, spawning ps/lsof/ss, and
|
||||
// the Win32 syscalls. Those are a handful of lines each; everything with a
|
||||
// decision or an offset in it lives in this file, under test.
|
||||
|
||||
// binBase is filepath.Base for BOTH separator conventions, lowercased, with a
|
||||
// ".exe" suffix dropped. Deliberately not filepath.Base: that function follows
|
||||
// the separator of the machine it RUNS on, so a Windows argv would keep its
|
||||
// whole path when the same rule is exercised from a Linux test run.
|
||||
func binBase(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if i := strings.LastIndexAny(s, `/\`); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
return strings.TrimSuffix(s, ".exe")
|
||||
}
|
||||
|
||||
// matchAgentSignature: argv[0] basename exact-match, OR (only for an interpreter) a distinctive
|
||||
// script-path substring. The interpreter gate kills the "merely mentions the marker" false positive.
|
||||
func matchAgentSignature(argv []string) (string, bool) {
|
||||
sig, auto, ok := matchSignature(argv)
|
||||
if !ok || auto {
|
||||
return "", false
|
||||
}
|
||||
return sig, true
|
||||
}
|
||||
|
||||
// matchSignature is matchAgentSignature over BOTH tables: the agent tables
|
||||
// first (an agent that also drives a browser is an agent), then the
|
||||
// automation tables. `automation` reports which table answered.
|
||||
func matchSignature(argv []string) (sig string, automation bool, ok bool) {
|
||||
if len(argv) == 0 {
|
||||
return "", false, false
|
||||
}
|
||||
base := binBase(argv[0])
|
||||
if sig, ok := agentBinaries[base]; ok {
|
||||
return sig, false, true
|
||||
}
|
||||
if sig, ok := automationBinaries[base]; ok {
|
||||
return sig, true, true
|
||||
}
|
||||
if interpreters[base] {
|
||||
full := strings.ToLower(strings.Join(argv, " "))
|
||||
for _, s := range agentSubstrings {
|
||||
if strings.Contains(full, s.Sub) {
|
||||
return s.Sig, false, true
|
||||
}
|
||||
}
|
||||
for _, s := range automationSubstrings {
|
||||
if strings.Contains(full, s.Sub) {
|
||||
return s.Sig, true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// ── Kind: agent, app, or client ─────────────────────────────────────────────
|
||||
//
|
||||
// The signature tables answer "which product is this binary". They cannot
|
||||
// answer "is this an agent", because the same binary name serves two products:
|
||||
// `claude.exe` is Claude Code (an agent) AND the Claude desktop app (a chat
|
||||
// window). The desktop app is Electron, and Electron — like every Chromium
|
||||
// host — runs its renderer, GPU, network and utility work in child processes
|
||||
// launched from the SAME executable with a `--type=<role>` flag. One open app
|
||||
// therefore matched the agent table nine times, and eight of those were not
|
||||
// even the app: they were its helpers.
|
||||
//
|
||||
// Two rules, both measured on the box that produced the false alarm:
|
||||
//
|
||||
// helper — a `--type=` process WHOSE PARENT RUNS THE SAME PROGRAM (same
|
||||
// executable, or the same .app bundle on macOS). The flag alone
|
||||
// is not enough: an agent whose prompt text mentions "--type=json"
|
||||
// must stay an agent, and "add --type=x to your argv" must never be
|
||||
// a way to vanish from the report. A helper is dropped and its
|
||||
// parent remembered.
|
||||
// app — a matched process that owns helpers, or whose EXECUTABLE lives
|
||||
// inside a macOS .app bundle, is an APP: the user is running it. It
|
||||
// stays visible (what it talks to matters) but it is not an agent.
|
||||
//
|
||||
// Everything else the tables matched is an agent. A process caught by the
|
||||
// network signal alone is a client — unless the socket belongs to a helper,
|
||||
// in which case the APP that owns the helper is what talks to the model.
|
||||
|
||||
const (
|
||||
kindAgent = "agent"
|
||||
kindApp = "app"
|
||||
kindClient = "client"
|
||||
// automation: drives the machine with no model of its own —
|
||||
// a browser driver, an input injector, an RPA runtime. Inventory, never
|
||||
// an alert. Model traffic promotes it to an agent (mergeConnections).
|
||||
kindAutomation = "automation"
|
||||
)
|
||||
|
||||
// appSignatures renames a product signature when the process turns out to be
|
||||
// the interactive app rather than the agent, so the host never says
|
||||
// "claude-code" about a chat window. Unlisted products keep their name with an
|
||||
// "-app" suffix.
|
||||
var appSignatures = map[string]string{
|
||||
"claude-code": "claude-desktop",
|
||||
}
|
||||
|
||||
func appSignature(sig string) string {
|
||||
if s, ok := appSignatures[sig]; ok {
|
||||
return s
|
||||
}
|
||||
return sig + "-app"
|
||||
}
|
||||
|
||||
// argv0 is the program a command line starts with: the first token, honouring
|
||||
// a quoted Windows path ("C:\Program Files\...\claude.exe" --type=renderer).
|
||||
func argv0(cmdline string) string {
|
||||
s := strings.TrimSpace(cmdline)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' || s[0] == '\'' {
|
||||
if end := strings.IndexByte(s[1:], s[0]); end >= 0 {
|
||||
return s[1 : end+1]
|
||||
}
|
||||
return s[1:]
|
||||
}
|
||||
if i := strings.IndexByte(s, ' '); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── the kernel's executable path ────────────────────────────────────────────
|
||||
//
|
||||
// Every platform answers procExe() through sanitizeExePath, so ONE rule decides
|
||||
// what counts as an executable path and it is tested from any OS.
|
||||
//
|
||||
// ABSOLUTE OR EMPTY. A relative path ("claude", "./bin/claude") names a file
|
||||
// only against a working directory nobody recorded, so it is not an identity —
|
||||
// two processes started from different directories would share it. macOS is
|
||||
// where this actually bites: the kernel saves the path as it was PASSED to
|
||||
// execve (xnu bsd/kern/kern_exec.c exec_save_path, which copyinstr's the
|
||||
// caller's string before namei resolves it), so a relative exec produces a
|
||||
// relative saved path. Linux (readlink /proc/pid/exe) and Windows
|
||||
// (QueryFullProcessImageName) always answer absolute.
|
||||
//
|
||||
// Dropping it yields "" — an observation that cannot be marked as yours — which
|
||||
// is the safe direction: a mark that names nothing checkable is worse than no
|
||||
// mark (sanction.go, agentSanction).
|
||||
func sanitizeExePath(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
// Linux appends " (deleted)" once the file has been unlinked — measured on
|
||||
// this box: a running binary read "/tmp/x/sleepy" before `rm` and
|
||||
// "/tmp/x/sleepy (deleted)" after. STRIPPED, exactly as origin_linux.go:85
|
||||
// already does, because the alternative silently un-marks a running agent
|
||||
// the moment its package updates in place, and a mark the user has to
|
||||
// re-apply after every update is a control they stop using (sanction.go,
|
||||
// STORAGE). It concedes nothing an attacker did not already have: writing a
|
||||
// file called "claude (deleted)" into that directory needs the same write
|
||||
// access as overwriting `claude` itself.
|
||||
s = strings.TrimSuffix(s, " (deleted)")
|
||||
if !isAbsExePath(s) {
|
||||
return ""
|
||||
}
|
||||
// REFUSED, NEVER TRUNCATED, past a bound no real path reaches. Truncation is
|
||||
// not available to this function whatever the length: two different
|
||||
// executables under one long directory would cut down to the SAME string and
|
||||
// therefore to the same identity, which is a collision in the one field that
|
||||
// is supposed to tell programs apart. So an absurd path becomes "" —
|
||||
// unmarkable, the safe direction — and this field cannot be used to inflate
|
||||
// the heartbeat it travels on (agents.go, agentObs.Exe).
|
||||
//
|
||||
// 4096 is Linux's PATH_MAX, the largest of the three kernels' own limits
|
||||
// (macOS is 1024; Windows' extended form goes further but nothing a person
|
||||
// installs lives out there). It admits every real path and bounds the field.
|
||||
if len(s) > 4096 {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// isAbsExePath answers for BOTH path conventions, deliberately not
|
||||
// filepath.IsAbs: that function follows the separator of the machine it RUNS
|
||||
// on, so a Windows path would read as relative under the Linux test run that
|
||||
// exercises this file — the same trap binBase() (agents_parse.go:18-21) is
|
||||
// written to avoid.
|
||||
func isAbsExePath(s string) bool {
|
||||
switch {
|
||||
case s == "":
|
||||
return false
|
||||
case s[0] == '/':
|
||||
return true // POSIX, and the \Device\... form never reaches here
|
||||
case strings.HasPrefix(s, `\\`):
|
||||
return true // UNC: \\server\share\thing.exe
|
||||
case len(s) >= 3 && s[1] == ':' && (s[2] == '\\' || s[2] == '/') &&
|
||||
((s[0] >= 'A' && s[0] <= 'Z') || (s[0] >= 'a' && s[0] <= 'z')):
|
||||
return true // C:\thing.exe
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseProcargs2ExePath pulls the kernel's saved executable path out of a macOS
|
||||
// KERN_PROCARGS2 blob. Lives here, with a fixture, because it is a byte layout
|
||||
// and the Linux box that runs the tests must exercise it (this file's header).
|
||||
//
|
||||
// The layout, from Apple's own two sources rather than from a blog: the kernel
|
||||
// writes argc as the first word and the executable path as the first string
|
||||
// after it (xnu bsd/kern/kern_sysctl.c sysctl_procargsx, which stores the path
|
||||
// prefixed with "executable_path=" and strips that prefix back off on the way
|
||||
// out), and Apple's ps walks the same blob by SKIPPING that string to reach
|
||||
// argv[0] — "/* Skip the saved exec_path. */" in adv_cmds ps/print.c,
|
||||
// getproclline. Only that prefix is decoded here; argv and the environment
|
||||
// follow and are none of this function's business.
|
||||
//
|
||||
// WHY THE PATH AND NOT `ps -o comm=`: because comm on macOS is argv[0]. The
|
||||
// same getproclline is what backs the `comm` keyword (ps/keyword.c maps it to
|
||||
// just_command), so it reports the string the launcher chose, which is the
|
||||
// exact value this whole change exists to stop trusting. origin_darwin.go's
|
||||
// header and origin.go's procFacts.Exe both used to say the opposite;
|
||||
// both now say what it is.
|
||||
func parseProcargs2ExePath(buf []byte) string {
|
||||
if len(buf) < 5 {
|
||||
return ""
|
||||
}
|
||||
// argc rides in the first word, native endian — and both machines macOS
|
||||
// runs on (amd64, arm64) are little-endian.
|
||||
if argc := int32(binary.LittleEndian.Uint32(buf[:4])); argc < 0 {
|
||||
return "" // not a blob this function understands; never guess past it
|
||||
}
|
||||
rest := buf[4:]
|
||||
end := 0
|
||||
for end < len(rest) && rest[end] != 0 {
|
||||
end++
|
||||
}
|
||||
if end == len(rest) {
|
||||
return "" // no terminator inside the buffer: a truncated read, not a path
|
||||
}
|
||||
return sanitizeExePath(string(rest[:end]))
|
||||
}
|
||||
|
||||
// bundleRoot is the OUTERMOST macOS .app a path sits in, lowercased, or "".
|
||||
// "Google Chrome Helper.app" lives inside "Google Chrome.app/Contents/..."; the
|
||||
// outermost bundle is what makes a helper and its app the same program.
|
||||
func bundleRoot(path string) string {
|
||||
p := strings.ToLower(path)
|
||||
i := strings.Index(p, ".app/")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return p[:i+5]
|
||||
}
|
||||
|
||||
// isAppBundle: the EXECUTABLE (argv[0], never an argument) lives inside a
|
||||
// macOS application bundle.
|
||||
func isAppBundle(cmdline string) bool {
|
||||
return bundleRoot(argv0(cmdline)) != ""
|
||||
}
|
||||
|
||||
// hasTypeFlag: the raw Chromium marker. Necessary for a helper, never
|
||||
// sufficient — see isGUIHelper.
|
||||
func hasTypeFlag(cmdline string) bool {
|
||||
return strings.Contains(strings.ToLower(cmdline), " --type=")
|
||||
}
|
||||
|
||||
// sameProgram: child and parent run the same executable, or live in the same
|
||||
// .app bundle. This is what makes a `--type=` process a helper rather than an
|
||||
// agent that happens to mention the flag.
|
||||
func sameProgram(childCmd, parentCmd string) bool {
|
||||
c, p := argv0(childCmd), argv0(parentCmd)
|
||||
if c == "" || p == "" {
|
||||
return false
|
||||
}
|
||||
if binBase(c) == binBase(p) {
|
||||
return true
|
||||
}
|
||||
if r := bundleRoot(c); r != "" && r == bundleRoot(p) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isGUIHelper: a `--type=` process whose parent runs the same program.
|
||||
func isGUIHelper(cmdline, parentCmdline string) bool {
|
||||
return hasTypeFlag(cmdline) && sameProgram(cmdline, parentCmdline)
|
||||
}
|
||||
|
||||
// maxHelperHops bounds the walk from a helper up to its app. Chromium nests
|
||||
// at most a few levels (browser → zygote → renderer); a chain longer than
|
||||
// this is a corrupt parent map, and the walk stops rather than loops.
|
||||
const maxHelperHops = 8
|
||||
|
||||
// classifyObservations applies the kind rules to a process snapshot IN PLACE:
|
||||
// helpers are removed, every survivor gets a kind and a parent, and an app's
|
||||
// signature is renamed. Returns helper pid -> the app pid that owns it, so the
|
||||
// caller can re-attribute the helpers' connections.
|
||||
//
|
||||
// A helper's parent must itself be in the snapshot: helpers run the same
|
||||
// executable as their app, so if the app matched the tables, so did they, and
|
||||
// if the parent is NOT a candidate the child is not its helper (an agent under
|
||||
// a shell). A helper whose parent cannot be read is kept as an agent — reported
|
||||
// rather than silently dropped, and never pinned to pid 0.
|
||||
func classifyObservations(byPid map[int]agentObs, parent func(int) int) map[int]int {
|
||||
cmdOf := func(a agentObs) string {
|
||||
if a.raw != "" {
|
||||
return a.raw
|
||||
}
|
||||
return a.Cmdline
|
||||
}
|
||||
helper := map[int]bool{}
|
||||
for pid, a := range byPid {
|
||||
pp := parent(pid)
|
||||
if pp <= 1 {
|
||||
continue
|
||||
}
|
||||
pa, ok := byPid[pp]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isGUIHelper(cmdOf(a), cmdOf(pa)) {
|
||||
helper[pid] = true
|
||||
}
|
||||
}
|
||||
helperRoot := map[int]int{}
|
||||
for pid := range helper {
|
||||
root := parent(pid)
|
||||
for hops := 0; helper[root] && hops < maxHelperHops; hops++ {
|
||||
root = parent(root) // the zygote pattern: a helper spawned by a helper
|
||||
}
|
||||
if root <= 1 || helper[root] {
|
||||
continue // unresolvable: leave it reported as what it matched
|
||||
}
|
||||
if _, ok := byPid[root]; !ok {
|
||||
continue
|
||||
}
|
||||
helperRoot[pid] = root
|
||||
delete(byPid, pid)
|
||||
}
|
||||
ownsHelpers := map[int]bool{}
|
||||
for _, root := range helperRoot {
|
||||
ownsHelpers[root] = true
|
||||
}
|
||||
for pid, a := range byPid {
|
||||
a.PPID = parent(pid)
|
||||
if ownsHelpers[pid] || isAppBundle(cmdOf(a)) {
|
||||
a.Kind = kindApp
|
||||
a.Signature = appSignature(a.Signature)
|
||||
} else if a.automation {
|
||||
a.Kind = kindAutomation
|
||||
} else {
|
||||
a.Kind = kindAgent
|
||||
}
|
||||
byPid[pid] = a
|
||||
}
|
||||
return helperRoot
|
||||
}
|
||||
|
||||
// descendantsOf walks a pid -> parent map and returns every process below
|
||||
// root (children, grandchildren, ...), root excluded. Contain uses it so an
|
||||
// agent's shells and sub-processes go with it instead of being orphaned. A
|
||||
// cycle in a corrupt map terminates because each pid is visited once.
|
||||
func descendantsOf(parents map[int]int, root int) []int {
|
||||
children := map[int][]int{}
|
||||
for pid, pp := range parents {
|
||||
if pp > 0 && pid != pp {
|
||||
children[pp] = append(children[pp], pid)
|
||||
}
|
||||
}
|
||||
seen := map[int]bool{root: true}
|
||||
out := []int{}
|
||||
queue := []int{root}
|
||||
for len(queue) > 0 {
|
||||
cur := queue[0]
|
||||
queue = queue[1:]
|
||||
for _, c := range children[cur] {
|
||||
if seen[c] {
|
||||
continue
|
||||
}
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
queue = append(queue, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseProcStatPPID pulls the parent pid out of /proc/<pid>/stat. The comm
|
||||
// field is parenthesised and may itself contain spaces or parens, so the
|
||||
// fields are counted from the LAST ')' — state is next, then ppid.
|
||||
func parseProcStatPPID(stat string) int {
|
||||
i := strings.LastIndex(stat, ")")
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(f[1])
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseProcStatStartTicks pulls the process start time out of /proc/<pid>/stat.
|
||||
// Same technique as parseProcStatPPID and for the same reason — the comm field
|
||||
// is parenthesised and may itself contain spaces or parens, so the fields are
|
||||
// counted from the LAST ')'.
|
||||
//
|
||||
// starttime is field 22 of the whole line; the first field after ')' is field 3,
|
||||
// so it sits at index 19 of that slice. It counts clock ticks since boot, which
|
||||
// is what the delta's short-lived rule turns into an age (delta_linux.go).
|
||||
//
|
||||
// Here rather than in the Linux file, with no build tag, for the reason the top
|
||||
// of this file gives: one `go test` on any OS covers the byte layout, and a
|
||||
// parser only a Linux box can run is a parser only a Linux box checks.
|
||||
func parseProcStatStartTicks(stat string) (uint64, bool) {
|
||||
i := strings.LastIndex(stat, ")")
|
||||
if i < 0 {
|
||||
return 0, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// splitHostPortLoose parses the "address:port" forms the platform enumerators
|
||||
// hand back — `1.2.3.4:443`, `[fd00::1]:443`, `[::1]:11434` — into a bare IP
|
||||
// and a numeric port. Brackets are stripped so a v6 literal compares equal to
|
||||
// the same address from a DNS lookup.
|
||||
func splitHostPortLoose(s string) (string, int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
host, port, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
i := strings.LastIndex(s, ":")
|
||||
if i < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
host, port = s[:i], s[i+1:]
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n <= 0 || n > 65535 || host == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
return host, n, true
|
||||
}
|
||||
|
||||
// portOfAddr pulls the port out of a "host:port" env value (OLLAMA_ADDR,
|
||||
// PRIVATAE_LLM_ADDR). A bare host, a bare port, or junk yields 0 — never a guess.
|
||||
func portOfAddr(addr string) int {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return 0
|
||||
}
|
||||
addr = strings.TrimPrefix(strings.TrimPrefix(addr, "http://"), "https://")
|
||||
if i := strings.IndexAny(addr, "/?"); i >= 0 {
|
||||
addr = addr[:i]
|
||||
}
|
||||
_, p, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// isLocalScope reports whether an address is on the user's own side of the
|
||||
// internet: loopback, RFC1918 / ULA, link-local, or CGNAT (100.64/10, which the
|
||||
// mesh itself uses and net.IP.IsPrivate does not cover).
|
||||
func isLocalScope(addr string) bool {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyConn names the inference endpoint a connection is talking to, and
|
||||
// says whether that alone is enough to call an unknown process an agent.
|
||||
// Returns ("", false) when the connection is not inference traffic.
|
||||
//
|
||||
// The label carries two INDEPENDENT facts, so neither one overstates:
|
||||
//
|
||||
// prefix address class — "local:" only when the peer really is on the
|
||||
// user's own side (loopback/LAN/mesh). A model served from a public
|
||||
// address is still self-hosted inference, but calling it "local"
|
||||
// reads as a lie, so it is named by its address instead.
|
||||
// suffix port confidence — a trailing "?" marks an ambiguous port that only
|
||||
// corroborates and never promotes an unknown process.
|
||||
func classifyConn(c connObs, cloud map[string]string, strong, weak map[int]string) (string, bool) {
|
||||
if host, ok := cloud[c.RemoteIP]; ok {
|
||||
return host, true
|
||||
}
|
||||
where := c.RemoteIP
|
||||
if isLocalScope(c.RemoteIP) {
|
||||
where = "local"
|
||||
}
|
||||
if name, ok := strong[c.RemotePort]; ok {
|
||||
return where + ":" + strconv.Itoa(c.RemotePort) + " (" + name + ")", true
|
||||
}
|
||||
if name, ok := weak[c.RemotePort]; ok {
|
||||
return where + ":" + strconv.Itoa(c.RemotePort) + " (" + name + "?)", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// procDetail is what the per-pid lookups answer for a process the signature
|
||||
// scan never matched. A STRUCT rather than the tuple this used to be
|
||||
// (user, cmdline, token), because the field added to it is the one field that
|
||||
// must never be confused with the command line: a positional fourth string is
|
||||
// one transposition away from writing argv[0] into Exe, which is the forgery
|
||||
// sanction.go exists to stop. The compiler now refuses that mistake.
|
||||
type procDetail struct {
|
||||
User string
|
||||
Cmdline string
|
||||
Token string
|
||||
// Exe is the KERNEL's executable path for the pid, or "" — see agentObs.Exe.
|
||||
Exe string
|
||||
}
|
||||
|
||||
// mergeConnections folds the network signal into the process snapshot: an
|
||||
// endpoint annotates the agent (or app) that owns the connection, and a
|
||||
// STRONG endpoint on a process the signature scan missed becomes an
|
||||
// "llm-client" observation — the disguised case the network signal exists to
|
||||
// catch. A weak (ambiguous port) endpoint never creates an observation on its
|
||||
// own.
|
||||
//
|
||||
// GUI helpers: a socket held by a Chromium/Electron helper is the
|
||||
// APP's socket. A connection already re-attributed by the caller (ViaHelper)
|
||||
// annotates the app; an unknown `--type=` process found here is a helper only
|
||||
// if its parent runs the same program, and is then attributed up the chain to
|
||||
// the app. A process that merely mentions the flag is an ordinary client.
|
||||
func mergeConnections(byPid map[int]agentObs, conns []connObs, cloud map[string]string,
|
||||
strong, weak map[int]string, info func(int) procDetail, parent func(int) int) {
|
||||
annotate := func(pid int, label string, alone bool) bool {
|
||||
a, ok := byPid[pid]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !containsStr(a.LLMEndpoints, label) {
|
||||
a.LLMEndpoints = append(a.LLMEndpoints, label)
|
||||
}
|
||||
// An automation that talks to a model is an agent: the browser
|
||||
// driver was the hands, the model is the head. Only a strong
|
||||
// endpoint promotes — an ambiguous port annotates, as everywhere.
|
||||
if a.Kind == kindAutomation && alone {
|
||||
a.Kind = kindAgent
|
||||
}
|
||||
byPid[pid] = a
|
||||
return true
|
||||
}
|
||||
for _, c := range conns {
|
||||
label, alone := classifyConn(c, cloud, strong, weak)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if annotate(c.PID, label, alone) {
|
||||
continue
|
||||
}
|
||||
if !alone {
|
||||
continue
|
||||
}
|
||||
if c.ViaHelper {
|
||||
continue // the caller only rewrites to a root it kept, so this is unreachable; never invent one
|
||||
}
|
||||
d := info(c.PID)
|
||||
user, cmdline, token, exe := d.User, d.Cmdline, d.Token, d.Exe
|
||||
target, kind, sig := c.PID, kindClient, "llm-client"
|
||||
if hasTypeFlag(cmdline) {
|
||||
// Walk up while the parent runs the same program and is itself a
|
||||
// helper; the first same-program non-helper is the app.
|
||||
cur, curCmd := c.PID, cmdline
|
||||
resolved := false
|
||||
for hops := 0; hops < maxHelperHops; hops++ {
|
||||
root := parent(cur)
|
||||
if root <= 1 {
|
||||
break
|
||||
}
|
||||
rd := info(root)
|
||||
if !isGUIHelper(curCmd, rd.Cmdline) {
|
||||
break // not the same program: cur is what it is
|
||||
}
|
||||
if annotate(root, label, alone) {
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
if !hasTypeFlag(rd.Cmdline) {
|
||||
// The APP is now the observation, so its OWN exe travels
|
||||
// with it. Keeping the helper's would key a mark on a file
|
||||
// that is not the program the row names.
|
||||
target, user, cmdline, token, exe = root, rd.User, rd.Cmdline, rd.Token, rd.Exe
|
||||
kind, sig = kindApp, "gui-app"
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
cur, curCmd = root, rd.Cmdline
|
||||
}
|
||||
if resolved && target == c.PID {
|
||||
continue // annotated an existing observation up the chain
|
||||
}
|
||||
if !resolved && hasTypeFlag(cmdline) && target == c.PID {
|
||||
// A `--type=` process with no same-program parent (an agent
|
||||
// mentioning the flag, or an unreadable parent): a client.
|
||||
kind, sig = kindClient, "llm-client"
|
||||
}
|
||||
}
|
||||
byPid[target] = agentObs{
|
||||
PID: target, PPID: parent(target), Kind: kind, User: user, Signature: sig,
|
||||
Cmdline: cmdline, Exe: exe, LLMEndpoints: []string{label}, DelegationToken: token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Linux: `ss -tnpH state established` ─────────────────────────────────────
|
||||
|
||||
// pidFromSSLine extracts the pid from an ss process column: users:(("comm",pid=N,fd=M)).
|
||||
func pidFromSSLine(line string) int {
|
||||
i := strings.Index(line, "pid=")
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
rest := line[i+4:]
|
||||
j := 0
|
||||
for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
pid, _ := strconv.Atoi(rest[:j])
|
||||
return pid
|
||||
}
|
||||
|
||||
// parseSSOutput turns `ss -tnpH state established` into connections. Column 4
|
||||
// is the peer address; the pid rides in the users:(...) column.
|
||||
func parseSSOutput(out string, self int) []connObs {
|
||||
conns := []connObs{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
ip, port, ok := splitHostPortLoose(f[3])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pid := pidFromSSLine(line)
|
||||
if pid > 0 && pid != self {
|
||||
conns = append(conns, connObs{PID: pid, RemoteIP: ip, RemotePort: port})
|
||||
}
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
// ── macOS: `ps` and `lsof` ──────────────────────────────────────────────────
|
||||
|
||||
// parsePSOutput turns `ps -axo pid=,ppid=,user=,args=` into per-pid parent,
|
||||
// user and argv.
|
||||
func parsePSOutput(out string) (map[int]string, map[int][]string, map[int]int) {
|
||||
users := map[int]string{}
|
||||
argvs := map[int][]string{}
|
||||
parents := map[int]int{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(f[0])
|
||||
if err != nil || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
ppid, err := strconv.Atoi(f[1])
|
||||
if err != nil || ppid < 0 {
|
||||
continue
|
||||
}
|
||||
parents[pid] = ppid
|
||||
users[pid] = f[2]
|
||||
argvs[pid] = f[3:]
|
||||
}
|
||||
return users, argvs, parents
|
||||
}
|
||||
|
||||
// parseLsofOutput reads lsof's machine-readable field output (-Fpn): a `p<pid>`
|
||||
// line opens a process block, and each following `n<local>-><remote>` line is
|
||||
// one of its connections.
|
||||
func parseLsofOutput(out string, self int) []connObs {
|
||||
conns := []connObs{}
|
||||
pid := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
switch line[0] {
|
||||
case 'p':
|
||||
n, err := strconv.Atoi(strings.TrimSpace(line[1:]))
|
||||
if err != nil {
|
||||
pid = 0
|
||||
continue
|
||||
}
|
||||
pid = n
|
||||
case 'n':
|
||||
if pid <= 0 || pid == self {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(line, "->")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
ip, port, ok := splitHostPortLoose(line[i+2:])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
conns = append(conns, connObs{PID: pid, RemoteIP: ip, RemotePort: port})
|
||||
}
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
// parseEnvTokenSpaced pulls one variable out of `ps -E` output, where the
|
||||
// environment is space-separated rather than NUL-separated. The value ends at
|
||||
// the next whitespace: a token with spaces in it could not survive this, and
|
||||
// the tokens the host mints (del_ + base64url) never contain any.
|
||||
func parseEnvTokenSpaced(s, key string) string {
|
||||
pre := key + "="
|
||||
for _, field := range strings.Fields(s) {
|
||||
if strings.HasPrefix(field, pre) {
|
||||
return field[len(pre):]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Windows: the GetExtendedTcpTable buffers ────────────────────────────────
|
||||
//
|
||||
// Parsed here rather than beside the syscall so the byte layout is under test
|
||||
// on the machine that runs CI. Layouts are the documented MIB structures; the
|
||||
// two row shapes differ enough to be worth separate constants:
|
||||
//
|
||||
// MIB_TCPROW_OWNER_PID (24B): state, localAddr, localPort, remoteAddr, remotePort, pid
|
||||
// MIB_TCP6ROW_OWNER_PID (56B): localAddr[16], localScope, localPort,
|
||||
// remoteAddr[16], remoteScope, remotePort, state, pid
|
||||
//
|
||||
// Both tables are { DWORD dwNumEntries; ROW table[] } with the rows at offset 4.
|
||||
// Ports are DWORDs whose LOW 16 bits hold the port in NETWORK byte order, so
|
||||
// the first two bytes of the little-endian DWORD are the port, big-endian.
|
||||
const (
|
||||
winTCPRow4Size = 24
|
||||
winTCPRow6Size = 56
|
||||
winTCPStateEst = 5 // MIB_TCP_STATE_ESTAB
|
||||
)
|
||||
|
||||
func winPortAt(b []byte) int {
|
||||
if len(b) < 2 {
|
||||
return 0
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(b[:2]))
|
||||
}
|
||||
|
||||
// parseTCPTable decodes an IPv4 or IPv6 owner-pid TCP table into the
|
||||
// established outbound connections owned by other processes.
|
||||
func parseTCPTable(buf []byte, v6 bool, self int) []connObs {
|
||||
conns := []connObs{}
|
||||
if len(buf) < 4 {
|
||||
return conns
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(buf[:4]))
|
||||
rowSize := winTCPRow4Size
|
||||
if v6 {
|
||||
rowSize = winTCPRow6Size
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
off := 4 + i*rowSize
|
||||
if off+rowSize > len(buf) {
|
||||
break
|
||||
}
|
||||
row := buf[off : off+rowSize]
|
||||
var state, pid, port int
|
||||
var ip net.IP
|
||||
if v6 {
|
||||
port = winPortAt(row[44:48])
|
||||
state = int(binary.LittleEndian.Uint32(row[48:52]))
|
||||
pid = int(binary.LittleEndian.Uint32(row[52:56]))
|
||||
ip = net.IP(append([]byte(nil), row[24:40]...))
|
||||
} else {
|
||||
state = int(binary.LittleEndian.Uint32(row[0:4]))
|
||||
ip = net.IP(append([]byte(nil), row[12:16]...))
|
||||
port = winPortAt(row[16:20])
|
||||
pid = int(binary.LittleEndian.Uint32(row[20:24]))
|
||||
}
|
||||
if state != winTCPStateEst || pid <= 0 || pid == self || port == 0 {
|
||||
continue
|
||||
}
|
||||
conns = append(conns, connObs{PID: pid, RemoteIP: ip.String(), RemotePort: port})
|
||||
}
|
||||
return conns
|
||||
}
|
||||
|
||||
// parseWinEnvBlock finds one variable in a process environment block read out
|
||||
// of another process: UTF-16LE, NUL-separated entries, terminated by an empty
|
||||
// entry. Bounded by the caller's read; a truncated block simply stops early
|
||||
// rather than erroring, because a missing tag and an unreadable tag lead to
|
||||
// the same conservative outcome (the agent reads as unrequested).
|
||||
func parseWinEnvBlock(b []byte, key string) string {
|
||||
pre := strings.ToUpper(key) + "="
|
||||
u16 := make([]uint16, 0, len(b)/2)
|
||||
for i := 0; i+1 < len(b); i += 2 {
|
||||
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i <= len(u16); i++ {
|
||||
if i == len(u16) || u16[i] == 0 {
|
||||
if i == start {
|
||||
break // empty entry: end of block
|
||||
}
|
||||
entry := string(utf16Runes(u16[start:i]))
|
||||
if len(entry) >= len(pre) && strings.ToUpper(entry[:len(pre)]) == pre {
|
||||
return entry[len(pre):]
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// utf16Runes decodes a UTF-16 code-unit slice, including surrogate pairs.
|
||||
func utf16Runes(u []uint16) []rune {
|
||||
out := make([]rune, 0, len(u))
|
||||
for i := 0; i < len(u); i++ {
|
||||
c := u[i]
|
||||
if c >= 0xD800 && c < 0xDC00 && i+1 < len(u) && u[i+1] >= 0xDC00 && u[i+1] < 0xE000 {
|
||||
out = append(out, ((rune(c)-0xD800)<<10|(rune(u[i+1])-0xDC00))+0x10000)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out = append(out, rune(c))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user