Files
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

863 lines
30 KiB
Go

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
}