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