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

531 lines
18 KiB
Go

//go:build windows
package main
import (
"encoding/binary"
"errors"
"fmt"
"os"
"strings"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// Windows enumeration for the observe plane.
//
// This is the gap that mattered most: the bastion already SHIPS a Windows
// binary and enforces its grants there, but both enumerators were /proc and
// `ss`, so a Windows box observed nothing at all while reporting healthy. On
// the fleets where "we have machines we have no visibility of" is the actual
// problem, those machines are overwhelmingly Windows.
//
// Nothing here is a new dependency: golang.org/x/sys was already in the module
// graph, and every struct layout below is taken from ITS definitions via
// unsafe.Offsetof rather than hardcoded — so a layout change upstream moves
// our offsets with it instead of silently corrupting a read.
//
// Two enumerators:
//
// processes CreateToolhelp32Snapshot, then the PEB for the command line.
// Toolhelp gives the image name only, and on Windows the agent
// worth catching (claude-code, an npm package) runs as node.exe —
// so without the command line the common case is invisible.
// connections GetExtendedTcpTable (iphlpapi), IPv4 and IPv6. Deliberately
// NOT `netstat -ano`: netstat's STATE column is localized, so
// parsing it would silently find nothing on a non-English
// Windows. The API returns a numeric state instead.
//
// Every per-process read is best-effort. Reading another process's memory
// needs rights the bastion may not hold (a process of another user, or an
// elevated one, when the bastion is not itself elevated). A refusal yields an
// empty command line or delegation tag, which is the SAME conservative outcome
// as Linux when /proc/PID/environ is unreadable: the agent is still observed,
// and an unreadable tag reads as unrequested, which surfaces it rather than
// hiding it.
const (
agentScanSupported = true
agentScanKind = "toolhelp+iphlpapi"
)
// Offsets are derived from the x/sys/windows struct definitions, never written
// down as literals: PEB.ProcessParameters, RTL_USER_PROCESS_PARAMETERS's
// CommandLine and Environment, and NTUnicodeString.Buffer.
var (
_peb windows.PEB
_rupp windows.RTL_USER_PROCESS_PARAMETERS
_ntus windows.NTUnicodeString
offPEBProcessParameters = unsafe.Offsetof(_peb.ProcessParameters)
offRUPPCommandLine = unsafe.Offsetof(_rupp.CommandLine)
offRUPPEnvironment = unsafe.Offsetof(_rupp.Environment)
offNTUSBuffer = unsafe.Offsetof(_ntus.Buffer)
ptrSize = int(unsafe.Sizeof(uintptr(0)))
)
// winEnvReadSizes: the environment block has no length we can read portably
// (the size field's offset moved across Windows releases), so we read a bounded
// window and stop at the block's own double-NUL terminator. A read that runs
// off the end of the mapped region fails wholesale, so we step down rather than
// give up — a 4 KiB environment is still worth having.
var winEnvReadSizes = []int{64 * 1024, 16 * 1024, 4 * 1024}
const winCmdlineMax = 32 * 1024
func readRemote(h windows.Handle, addr uintptr, n int) ([]byte, error) {
if addr == 0 || n <= 0 {
return nil, errors.New("remote read: bad address or size")
}
buf := make([]byte, n)
var got uintptr
if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(n), &got); err != nil {
return nil, err
}
if int(got) < n {
return buf[:got], nil
}
return buf, nil
}
func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) {
b, err := readRemote(h, addr, ptrSize)
if err != nil || len(b) < ptrSize {
if err == nil {
err = errors.New("remote read: short pointer")
}
return 0, err
}
if ptrSize == 8 {
return uintptr(binary.LittleEndian.Uint64(b)), nil
}
return uintptr(binary.LittleEndian.Uint32(b)), nil
}
// winOpenForRead opens a process for the two rights the reads below need.
// PROCESS_QUERY_LIMITED_INFORMATION (not the full QUERY_INFORMATION) is the
// least privilege that still answers NtQueryInformationProcess.
func winOpenForRead(pid int) (windows.Handle, error) {
return windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ,
false, uint32(pid))
}
// winImagePath is the KERNEL's record of which file a process runs, from an
// already-open handle: QueryFullProcessImageName, which reads the image path
// the loader recorded rather than anything in the process's own memory.
//
// NOT Toolhelp32's PROCESSENTRY32.ExeFile — the `exe` local in scanProcesses
// below, and origin_windows.go:50. That field is a BASENAME with no directory
// at all, so "claude.exe" under C:\Users\user\AppData\... and "claude.exe"
// dropped in %TEMP% are one string. A basename cannot be an identity.
//
// Flags 0, never PROCESS_NAME_NATIVE: the Win32 form (C:\...) is what a person
// reading the page recognises and what another tool on the box would print.
// The native form names the same file through a device path
// (\Device\HarddiskVolume3\...) whose volume numbering is not stable across
// boots.
//
// Two buffer sizes, the shape winEnvReadSizes above already uses. MAX_PATH is
// not the limit on a machine with long paths enabled, so one try is not enough;
// past 4096 there is no point trying, because sanitizeExePath refuses a path
// that long anyway (agents_parse.go) and the extra pass would only produce a
// value this function throws away. A refusal — a process of another user, or an
// elevated one when the bastion is not — yields "".
func winImagePath(h windows.Handle) string {
for _, n := range []uint32{windows.MAX_PATH, 4096} {
buf := make([]uint16, n)
size := n
err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size)
if err == nil {
return sanitizeExePath(windows.UTF16ToString(buf[:size]))
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return "" // a refusal, not a small buffer: a bigger one changes nothing
}
}
return ""
}
// winProcessParams walks PBI -> PEB -> ProcessParameters and returns the
// remote address of the parameter block.
func winProcessParams(h windows.Handle) (uintptr, error) {
var pbi windows.PROCESS_BASIC_INFORMATION
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil); err != nil {
return 0, err
}
peb := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
if peb == 0 {
return 0, errors.New("process params: no PEB")
}
return readRemotePtr(h, peb+offPEBProcessParameters)
}
// winCmdline reads a process's full command line out of its PEB.
func winCmdline(h windows.Handle) string {
params, err := winProcessParams(h)
if err != nil || params == 0 {
return ""
}
us, err := readRemote(h, params+offRUPPCommandLine, int(unsafe.Sizeof(_ntus)))
if err != nil || len(us) < int(offNTUSBuffer)+ptrSize {
return ""
}
length := int(binary.LittleEndian.Uint16(us[0:2])) // bytes, not code units
if length <= 0 {
return ""
}
if length > winCmdlineMax {
length = winCmdlineMax
}
bufAddr, err := readRemotePtr(h, params+offRUPPCommandLine+offNTUSBuffer)
if err != nil || bufAddr == 0 {
return ""
}
raw, err := readRemote(h, bufAddr, length)
if err != nil || len(raw) < 2 {
return ""
}
u16 := make([]uint16, len(raw)/2)
for i := range u16 {
u16[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
}
return windows.UTF16ToString(u16)
}
// winEnvValue reads one variable out of a process's environment block.
func winEnvValue(h windows.Handle, key string) string {
params, err := winProcessParams(h)
if err != nil || params == 0 {
return ""
}
envAddr, err := readRemotePtr(h, params+offRUPPEnvironment)
if err != nil || envAddr == 0 {
return ""
}
for _, n := range winEnvReadSizes {
raw, err := readRemote(h, envAddr, n)
if err != nil || len(raw) == 0 {
continue
}
if v := parseWinEnvBlock(raw, key); v != "" {
return v
}
// A successful read that simply lacks the key is an answer, not a
// reason to retry smaller.
return ""
}
return ""
}
// winParents caches the pid -> parent map from the last Toolhelp walk, so the
// shared classifier's per-pid parent lookups cost nothing extra.
var winParents = struct {
sync.Mutex
m map[int]int
}{m: map[int]int{}}
func procParent(pid int) int {
winParents.Lock()
defer winParents.Unlock()
return winParents.m[pid]
}
// winCreationTime is when a process started, in 100ns FILETIME units.
func winCreationTime(pid int) (int64, bool) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return 0, false
}
defer windows.CloseHandle(h)
var created, exited, kernel, user windows.Filetime
if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil {
return 0, false
}
return created.Nanoseconds(), true
}
// processParents walks a fresh snapshot for the CONTAIN path, and keeps only
// parent links that can be true.
//
// Toolhelp's ParentProcessID is the pid of the process that CREATED this one,
// recorded once and never updated. When that creator exits, Windows recycles
// its pid; a later process (say, the agent being contained) can receive it,
// and every orphan the old creator left behind then reads as the agent's
// child. On a kill path that is an unrelated process terminated. A real
// parent is always older than its child, so a link is kept only when both
// creation times are readable and the child started after the parent; an
// unreadable process contributes no link at all (fail closed: no kill). Linux
// and macOS reparent orphans to a live process and need none of this.
func processParents() map[int]int {
out := map[int]int{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return out
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
raw := map[int]int{}
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
if pid, pp := int(e.ProcessID), int(e.ParentProcessID); pid > 0 && pp > 0 {
raw[pid] = pp
}
}
created := map[int]int64{}
timeOf := func(pid int) (int64, bool) {
if t, ok := created[pid]; ok {
return t, t > 0
}
t, ok := winCreationTime(pid)
if !ok {
created[pid] = 0
return 0, false
}
created[pid] = t
return t, true
}
for pid, pp := range raw {
ct, okc := timeOf(pid)
pt, okp := timeOf(pp)
if okc && okp && ct >= pt {
out[pid] = pp
}
}
winParents.Lock()
winParents.m = raw // classification (not killing) may use the raw links
winParents.Unlock()
return out
}
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) {
out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, fmt.Errorf("process snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
self := os.Getpid()
parents := map[int]int{}
defer func() {
winParents.Lock()
winParents.m = parents
winParents.Unlock()
}()
// The loop ends on ERROR_NO_MORE_FILES; any other error — Process32First
// refusing a snapshot it just handed out — is a walk that did not happen.
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
pid := int(e.ProcessID)
if pp := int(e.ParentProcessID); pid > 0 && pp > 0 {
parents[pid] = pp
}
if pid <= 0 || pid == self {
continue
}
exe := windows.UTF16ToString(e.ExeFile[:])
base := binBase(exe)
_, direct := agentBinaries[base]
_, autoDirect := automationBinaries[base]
if !direct && !autoDirect && !interpreters[base] {
continue // not a candidate: never pay for a memory read
}
// argv[0] comes from Toolhelp (always right); the raw command line is
// appended as one element purely so the substring rules can see it.
// No tokenizing: Windows command lines are a single quoted string, and
// splitting one on spaces mangles "C:\Program Files\..." into a
// basename that matches nothing.
// ONE handle for all three reads. Opening the process per field cost
// three OpenProcess/CloseHandle round trips for every candidate on
// every beat, and could even disagree with itself if the pid was
// recycled between them.
argv := []string{exe}
cmdline, user, token, image := "", "", "", ""
if h, herr := winOpenForRead(pid); herr == nil {
cmdline = winCmdline(h)
user = winTokenUser(h)
token = winAgentToken(h)
image = winImagePath(h)
windows.CloseHandle(h)
} else if h, herr := windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)); herr == nil {
// The image path needs strictly LESS than the reads above:
// QueryFullProcessImageName takes QUERY_LIMITED_INFORMATION alone,
// while the PEB walk also needs VM_READ. So a process whose memory
// this bastion may not read can still say which FILE it is running —
// and that is the difference between an observation the user can
// mark as theirs and one they cannot (sanction.go refuses to mark an
// observation with no path). Without this branch, every process that
// refused the combined open would be permanently unsanctionable.
image = winImagePath(h)
windows.CloseHandle(h)
}
if cmdline != "" {
argv = append(argv, cmdline)
}
sig, auto, ok := matchSignature(argv)
if !ok {
continue
}
shown := cmdline
if shown == "" {
shown = exe
}
out[pid] = agentObs{PID: pid, User: user, Signature: sig,
Cmdline: truncate(shown, 200), Exe: image,
DelegationToken: token, raw: shown, automation: auto}
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, fmt.Errorf("process walk: %w", err)
}
return out, nil
}
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
var (
modIphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
procGetExtendedTcpTable = modIphlpapi.NewProc("GetExtendedTcpTable")
)
const (
winAFInet = 2 // AF_INET
winAFInet6 = 23 // AF_INET6
// TCP_TABLE_OWNER_PID_CONNECTIONS — connected sockets with their owning pid.
winTCPTableOwnerPIDConnections = 4
winErrInsufficientBuffer = 122
)
// extendedTCPTable fetches the owner-pid TCP table for one address family.
// Two-pass sizing, with a couple of retries because the table can grow between
// the sizing call and the read.
func extendedTCPTable(af uint32) ([]byte, error) {
var size uint32
r, _, _ := procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0,
uintptr(af), winTCPTableOwnerPIDConnections, 0)
if r != 0 && r != winErrInsufficientBuffer {
return nil, syscall.Errno(r)
}
for attempt := 0; attempt < 3; attempt++ {
if size == 0 {
return nil, nil
}
buf := make([]byte, size)
r, _, _ = procGetExtendedTcpTable.Call(uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)), 0, uintptr(af), winTCPTableOwnerPIDConnections, 0)
if r == 0 {
return buf, nil
}
if r != winErrInsufficientBuffer {
return nil, syscall.Errno(r)
}
// size now holds the larger requirement; loop and try again.
}
return nil, errors.New("GetExtendedTcpTable: table kept growing")
}
func scanConnections() []connObs {
self := os.Getpid()
conns := []connObs{}
for _, fam := range []struct {
af uint32
v6 bool
}{{winAFInet, false}, {winAFInet6, true}} {
buf, err := extendedTCPTable(fam.af)
if err != nil || len(buf) == 0 {
continue // one family failing must not lose the other
}
conns = append(conns, parseTCPTable(buf, fam.v6, self)...)
}
return conns
}
// ── per-process detail ──────────────────────────────────────────────────────
// winTokenUser resolves the owner of an ALREADY-OPEN process handle, so a
// caller holding one handle can read every field from it.
func winTokenUser(h windows.Handle) string {
var tok windows.Token
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
return ""
}
defer tok.Close()
tu, err := tok.GetTokenUser()
if err != nil || tu.User.Sid == nil {
return ""
}
account, domain, _, err := tu.User.Sid.LookupAccount("")
if err != nil {
return tu.User.Sid.String()
}
if domain != "" {
return domain + `\` + account
}
return account
}
func procUser(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winTokenUser(h)
}
func procCmdline(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return truncate(strings.TrimSpace(winCmdline(h)), 200)
}
// procExe is the KERNEL's executable path for one pid. It opens with
// QUERY_LIMITED_INFORMATION alone — the least right that answers
// QueryFullProcessImageName, and less than winOpenForRead asks for — so a
// process whose memory is closed to this bastion still reports which file it
// runs. "" when even that handle is refused.
func procExe(pid int) string {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winImagePath(h)
}
// winAgentToken reads the delegation tag out of an already-open process
// handle, under either accepted name. One handle, both names: the environment
// block is read per call, and re-opening the process for the second name could
// land on a recycled pid.
func winAgentToken(h windows.Handle) string {
for _, name := range agentTokenEnvNames {
if tok := winEnvValue(h, name); tok != "" {
return tok
}
}
return ""
}
// procEnvToken reads the host-issued delegation tag (PRIVATAE_AGENT_TOKEN, or
// its older name; see agentTokenEnvNames) from the agent's environment block.
func procEnvToken(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winAgentToken(h)
}