bastion 0.54.0
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s

This commit is contained in:
Privatae
2026-10-21 22:29:10 +00:00
parent e4c9b69b46
commit d62da8adb4
58 changed files with 6944 additions and 124 deletions
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"sync"
"time"
)
// ACCOUNT NAMES FOR WINDOWS SIDS, ASKED ONCE PER SCAN AND NEVER FOR LONG.
//
// The Windows process scan names each agent's account (winTokenUser) and
// classifies it (ownerFactsReader), and both turn a SID into an account and a
// domain with LookupAccountSid. On a domain-joined machine that call can go
// to a domain controller, and when the controller cannot be reached it waits
// for the network to give up. Asked per process, with no cache, a scan of a
// few dozen processes could then hold the heartbeat for minutes.
//
// So a scan holds one sidNames: each SID is asked at most once, both callers
// share the answer, and the scan waits on lookups for at most a fixed budget
// in total. Once a lookup has used up the budget, every SID not already
// answered is unresolved for the rest of the scan: the account shows as its
// SID string and the owner's class as unknown, which is what the classifier
// already says when the domain could not be read.
//
// LookupAccountSid cannot be cancelled. A lookup the scan stopped waiting
// for finishes in its own goroutine and is then dropped. At most one lookup
// runs at a time across the process (the slots channel), so a controller
// that stays unreachable for many scans holds one blocked thread, not one per
// scan.
//
// The type has no build tag so the Linux test run checks it with a fake
// lookup; agents_windows.go supplies the real one.
// sidLookupBudget is the most one scan waits on account lookups in total. It
// is a bound on the harm, not a measured lookup time: a scan asks once per
// distinct account, and a domain controller that does not answer costs a
// scan this much of its 60-second beat and no more.
var sidLookupBudget = 2 * time.Second
// sidLookupSlots: one lookup in flight at a time, process-wide.
var sidLookupSlots = make(chan struct{}, 1)
// sidName is one SID's answer. OK is false when the lookup failed, timed
// out, or was never started because the budget was gone.
type sidName struct {
Account, Domain string
OK bool
}
type sidNames struct {
mu sync.Mutex
lookup func(sid string) (account, domain string, err error)
slots chan struct{}
left time.Duration
names map[string]sidName
}
func newSIDNames(lookup func(string) (string, string, error), budget time.Duration, slots chan struct{}) *sidNames {
return &sidNames{lookup: lookup, slots: slots, left: budget, names: map[string]sidName{}}
}
// resolve answers one SID, from the scan's cache when it has been asked.
func (c *sidNames) resolve(sid string) sidName {
c.mu.Lock()
defer c.mu.Unlock()
if n, ok := c.names[sid]; ok {
return n
}
n := c.ask(sid)
c.names[sid] = n
return n
}
// ask runs one lookup within what is left of the budget. Called with mu held.
func (c *sidNames) ask(sid string) sidName {
if c.left <= 0 {
return sidName{}
}
start := time.Now()
var n sidName
finished := boundedSIDCall(c.slots, c.left, func() {
a, d, err := c.lookup(sid)
n = sidName{Account: a, Domain: d, OK: err == nil}
})
if !finished {
c.left = 0
return sidName{}
}
c.left -= time.Since(start)
return n
}
// boundedSIDCall runs call on its own goroutine while holding one of slots,
// and waits for it at most budget in all, the wait for a free slot included.
// It reports whether call finished in time; only then may the caller read
// what call wrote. A call it stopped waiting for keeps its slot until it
// returns, so a lane whose lookup never returns has one blocked goroutine,
// and every later caller on that lane waits its budget and is told no.
func boundedSIDCall(slots chan struct{}, budget time.Duration, call func()) bool {
if budget <= 0 {
return false
}
timer := time.NewTimer(budget)
defer timer.Stop()
select {
case slots <- struct{}{}:
case <-timer.C:
return false // an earlier lookup on this lane still holds the slot
}
done := make(chan struct{})
go func() {
defer func() { <-slots }()
call()
close(done)
}()
select {
case <-done:
return true
case <-timer.C:
return false
}
}
// ACCOUNT LOOKUPS OUTSIDE A SCAN: the approvals pipe and the folder agent's drop.
//
// Both resolve one SID when something happens (a tray connects, a store's
// agent is started), not once per scan, so they cannot share a scan's budget.
// They share one process-wide cache instead (sidDirectory): an answer is kept
// for sidDirectoryTTL, so the tray's polling asks the directory once per
// account per ten minutes, and a lookup that answers after its caller stopped
// waiting still fills the cache for the next caller. Only an answer is
// cached; a failure or a timeout is asked again next time.
//
// Each caller has its own lane (slot and budget), so a lookup hung on one
// lane never blocks the other or the scan's: at most one goroutine per lane is
// ever held by a directory that does not answer.
//
// What a timeout means is the caller's decision, and both fail closed: the
// pipe refuses an approve (pipeApprove), the drop refuses to start the agent
// (faWinDropOwnerAccount).
// sidAccount is one SID's answer: the account, its domain and its SID type
// (SID_NAME_USE).
type sidAccount struct {
Account, Domain string
Type uint32
}
// sidOutcome is how a lookup outside a scan ended.
type sidOutcome int
const (
sidResolved sidOutcome = iota // the directory answered with an account
sidUnmapped // the directory answered: no such account
sidTimedOut // the directory did not answer within the lane's budget
)
// sidLane is one caller's single-flight slot and per-lookup budget.
type sidLane struct {
slots chan struct{}
budget time.Duration
}
const (
sidDirectoryTTL = 10 * time.Minute
sidDirectoryMax = 256 // accounts kept; a machine has far fewer people
)
type sidCached struct {
acct sidAccount
at time.Time
}
type sidDirectory struct {
mu sync.Mutex
lookup func(sid string) (sidAccount, error)
now func() time.Time
names map[string]sidCached
}
func newSIDDirectory(lookup func(string) (sidAccount, error)) *sidDirectory {
return &sidDirectory{lookup: lookup, now: time.Now, names: map[string]sidCached{}}
}
// resolve answers sid from the cache, or asks the directory on lane.
func (d *sidDirectory) resolve(sid string, lane sidLane) (sidAccount, sidOutcome) {
if a, ok := d.cached(sid); ok {
return a, sidResolved
}
var (
acct sidAccount
err error
)
finished := boundedSIDCall(lane.slots, lane.budget, func() {
// Asked again with the slot held: the lookup this caller queued
// behind may have just answered for the same SID.
if a, ok := d.cached(sid); ok {
acct = a
return
}
acct, err = d.lookup(sid)
if err == nil {
d.store(sid, acct) // also when the caller has stopped waiting
}
})
switch {
case !finished:
return sidAccount{}, sidTimedOut
case err != nil:
return sidAccount{}, sidUnmapped
}
return acct, sidResolved
}
func (d *sidDirectory) cached(sid string) (sidAccount, bool) {
d.mu.Lock()
defer d.mu.Unlock()
if c, ok := d.names[sid]; ok && d.now().Sub(c.at) < sidDirectoryTTL {
return c.acct, true
}
return sidAccount{}, false
}
func (d *sidDirectory) store(sid string, acct sidAccount) {
d.mu.Lock()
defer d.mu.Unlock()
if len(d.names) >= sidDirectoryMax {
for k, c := range d.names {
if d.now().Sub(c.at) >= sidDirectoryTTL {
delete(d.names, k)
}
}
if len(d.names) >= sidDirectoryMax {
d.names = map[string]sidCached{}
}
}
d.names[sid] = sidCached{acct: acct, at: d.now()}
}
// sidAccountLabel is DOMAIN\account, or the bare account with no domain.
func sidAccountLabel(a sidAccount) string {
if a.Domain != "" {
return a.Domain + `\` + a.Account
}
return a.Account
}
// winAccountLabel is how the scan names a token's account: DOMAIN\account,
// or the SID itself when it could not be resolved.
func winAccountLabel(sid string, names *sidNames) string {
n := names.resolve(sid)
switch {
case !n.OK:
return sid
case n.Domain != "":
return n.Domain + `\` + n.Account
}
return n.Account
}
// winAccountDomain is the account's domain for the owner classifier, "" when
// the SID could not be resolved (classifyWinOwner then says unknown).
func winAccountDomain(sid string, names *sidNames) string {
if n := names.resolve(sid); n.OK {
return n.Domain
}
return ""
}