Files
privatae-bastion/client-go/agentowner.go
T
Privatae d62da8adb4
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s
bastion 0.54.0
2026-10-21 22:29:10 +00:00

434 lines
14 KiB
Go

package main
import (
"bytes"
"errors"
"os/exec"
"strconv"
"strings"
)
// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT.
//
// The process scan names the account an agent runs as (procUser on every
// platform, winTokenUser on Windows) and says nothing else about it. The
// question an assessment asks is about the account: is it an administrator, is
// it a person's own account or one that exists for a service, and is that
// person logged in right now. "An agent runs as a person, holds administrator
// rights, and nobody is at the keyboard" is a different finding from "an agent
// runs under a service account with no rights", and the account name alone
// cannot tell them apart.
//
// Three facts, each with an honest third value:
//
// owner_class domain_user, local_user, service, system, or unknown
// owner_admin yes, no, or unknown
// owner_interactive yes, no, or unknown
//
// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list
// has not learned that the account is unprivileged, and saying "no" there is
// the confident-wrong answer this plane refuses everywhere else
// (baseline_identity.go draws the same line for sudo rules it could not read).
//
// Every decision is in this file, with no build tag, so the Linux test run
// exercises the macOS and Windows rules too. The platform files
// (agents_<goos>.go) only read: /proc and /etc on Linux, `id`, `dscl` and
// `who` on macOS, the process token and the session list on Windows.
//
// These ride on agentObs, so they travel only where `agents` does: inside the
// heartbeat's `observing` gate (agents.go).
const (
ownerClassDomain = "domain_user"
ownerClassLocal = "local_user"
ownerClassService = "service"
ownerClassSystem = "system"
ownerUnknown = "unknown"
ownerYes = "yes"
ownerNo = "no"
)
// ownerFacts is the three answers for one account.
type ownerFacts struct {
Class string
Admin string
Interactive string
}
// unknownOwner is the answer when nothing could be read: a process whose
// owner this bastion may not inspect, or a build with no reader.
var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
func yesNo(b bool) string {
if b {
return ownerYes
}
return ownerNo
}
// ── Linux and macOS ─────────────────────────────────────────────────────────
// unixOwner is what a Linux or macOS reader collected about one account.
// Every field that can be unreadable carries its own "known" flag.
type unixOwner struct {
UID int // -1 when the uid could not be read
Name string
// LocalRecord: "yes" when the account is in this machine's own account
// database (/etc/passwd; the macOS local directory node), "no" when it is
// not, "unknown" when that could not be read.
LocalRecord string
// DirectorySource: this machine resolves accounts from a directory
// (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was
// copied from one). Only then does "not in the local database" mean a
// domain account rather than a uid with no account at all.
DirectorySource bool
// SystemUIDMax is the highest uid the platform hands to system accounts.
SystemUIDMax int
Groups []string // group names the account or process holds
GroupsKnown bool
AdminGroups map[string]bool
// SudoApplies: on this platform a sudo rule can grant administration to
// any user or group (Linux), so "in no admin group" is only a "no" once
// the rules were read as well. macOS leaves it false and decides on the
// admin group, which is what its stock sudoers grants.
// SudoNamed / SudoKnown: a sudo rule names the account directly or
// through a group (sudoPrincipals), and whether the rules were readable.
SudoApplies bool
SudoNamed bool
SudoKnown bool
// Sessions is the set of account names with a login session now
// (logind, utmp, `who`); SessionsKnown is false when none could be read.
Sessions map[string]bool
SessionsKnown bool
}
// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below
// UID_MIN (1000 by default) are system accounts. login.defs can move both;
// linuxUIDLimits reads it.
const linuxSystemUIDMax = 999
// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and
// keeps the ids below for its own daemons (_www, _spotlight and the rest).
const darwinSystemUIDMax = 500
func classifyUnixOwner(o unixOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.UID == 0:
f.Class = ownerClassSystem
case o.UID == -1:
// Unread uid: the class cannot be told from a name alone. Other
// negative ids are real: macOS's directory gives nobody -2.
case o.UID == 65534 || o.UID <= o.SystemUIDMax:
// nobody, and every uid in the system range, is an account that exists
// for a program rather than a person.
f.Class = ownerClassService
case strings.ContainsAny(o.Name, `@\`):
// user@realm (SSSD) and DOMAIN\user (winbind) are directory names.
f.Class = ownerClassDomain
case o.LocalRecord == ownerYes:
f.Class = ownerClassLocal
case o.LocalRecord == ownerNo && o.DirectorySource:
f.Class = ownerClassDomain
}
switch {
case o.UID == 0:
f.Admin = ownerYes
case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups):
f.Admin = ownerYes
case o.SudoKnown && o.SudoNamed:
f.Admin = ownerYes
case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies):
f.Admin = ownerNo
}
if o.SessionsKnown && o.Name != "" {
f.Interactive = yesNo(o.Sessions[o.Name])
}
return f
}
func hasAnyGroup(groups []string, want map[string]bool) bool {
for _, g := range groups {
if want[g] {
return true
}
}
return false
}
// linuxAdminGroups: the groups a stock distribution's sudoers grants
// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and
// Arch, admin on older Ubuntu).
var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true}
// darwinAdminGroups: macOS administrators are members of `admin`.
var darwinAdminGroups = map[string]bool{"admin": true}
// parseStatusIDs reads the real and effective uid and the supplementary gid
// list out of /proc/<pid>/status. ok is false when there is no Uid line.
func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) {
realUID, effUID = -1, -1
for _, line := range strings.Split(status, "\n") {
switch {
case strings.HasPrefix(line, "Uid:"):
f := strings.Fields(line)
if len(f) >= 3 {
r, err1 := strconv.Atoi(f[1])
e, err2 := strconv.Atoi(f[2])
if err1 == nil && err2 == nil {
realUID, effUID, ok = r, e, true
}
}
case strings.HasPrefix(line, "Groups:"):
for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) {
if n, err := strconv.Atoi(g); err == nil {
gids = append(gids, n)
}
}
}
}
return realUID, effUID, gids, ok
}
// parseGroupNames maps gid -> name from an /etc/group body.
func parseGroupNames(body string) map[int]string {
out := map[int]string{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = p[0]
}
}
return out
}
// parsePasswdUIDs is the set of uids /etc/passwd itself lists.
func parsePasswdUIDs(body string) map[int]bool {
out := map[int]bool{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = true
}
}
return out
}
// parseLoginDefsSysMax returns the highest system uid login.defs declares:
// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default.
func parseLoginDefsSysMax(body string) int {
sysMax, uidMin := -1, -1
for _, line := range strings.Split(body, "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 || strings.HasPrefix(f[0], "#") {
continue
}
n, err := strconv.Atoi(f[1])
if err != nil {
continue
}
switch f[0] {
case "SYS_UID_MAX":
sysMax = n
case "UID_MIN":
uidMin = n
}
}
switch {
case sysMax >= 0:
return sysMax
case uidMin > 0:
return uidMin - 1
}
return linuxSystemUIDMax
}
// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names
// a directory source, so an account missing from /etc/passwd is resolved from
// one rather than simply absent.
func nsswitchHasDirectory(body string) bool {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "passwd:") {
continue
}
for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) {
switch src {
case "sss", "ldap", "winbind", "nis", "nisplus":
return true
}
}
}
return false
}
// parseLogindUserState reads systemd-logind's per-user state file
// (/run/systemd/users/<uid>). active and online mean the user has a session
// now; lingering means services run for them with no session.
func parseLogindUserState(body string) (interactive bool, ok bool) {
for _, line := range strings.Split(body, "\n") {
if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found {
switch v {
case "active", "online":
return true, true
case "lingering", "closing", "offline":
return false, true
}
return false, false
}
}
return false, false
}
// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a
// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7)
// is a login session.
const (
utmpRecordSize = 384
utmpUserOffset = 44
utmpUserSize = 32
utmpUserProcess = 7
)
// parseUtmpUsers is the set of account names with a USER_PROCESS record.
func parseUtmpUsers(b []byte) map[string]bool {
out := map[string]bool{}
for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize {
rec := b[off : off+utmpRecordSize]
if int(rec[0])|int(rec[1])<<8 != utmpUserProcess {
continue
}
name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize]
if i := bytes.IndexByte(name, 0); i >= 0 {
name = name[:i]
}
if len(name) > 0 {
out[string(name)] = true
}
}
return out
}
// parseWhoUsers is the set of account names `who` lists (macOS): the first
// field of every line.
func parseWhoUsers(out string) map[string]bool {
users := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) > 0 {
users[f[0]] = true
}
}
return users
}
// parseIDGroups reads `id -Gn <user>`: group names separated by spaces.
func parseIDGroups(out string) []string { return strings.Fields(out) }
// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it
// says the local node has no such record. A dscl the timeout killed has an
// exit code of -1 and has said nothing about the account.
func dsclRecordMissing(err error) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() > 0
}
// parseDsclUser reads `dscl . -read /Users/<name> UniqueID OriginalNodeName`.
// A mobile account (cached from a directory) carries OriginalNodeName.
func parseDsclUser(out string) (uid int, mobile bool, ok bool) {
uid = -1
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if v, found := strings.CutPrefix(line, "UniqueID:"); found {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
uid, ok = n, true
}
}
if strings.HasPrefix(line, "OriginalNodeName:") {
mobile = true
}
}
return uid, mobile, ok
}
// ── Windows ─────────────────────────────────────────────────────────────────
// Well-known SIDs, from Microsoft's tables:
// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and
// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers
const (
sidLocalSystem = "S-1-5-18"
sidLocalService = "S-1-5-19"
sidNetworkService = "S-1-5-20"
sidAdministrators = "S-1-5-32-544"
sidInteractive = "S-1-5-4"
sidRemoteInteractive = "S-1-5-14"
sidLocalAccount = "S-1-5-113"
sidNTServicePrefix = "S-1-5-80-"
sidVirtualMachinePfx = "S-1-5-83-"
)
// winOwner is what the Windows reader collected from one process token.
type winOwner struct {
SID string
Domain string // the account's domain as LookupAccount names it
Computer string // this machine's NetBIOS name
// Groups are the token's group SIDs, deny-only ones included: an
// administrator running without elevation holds Administrators as a
// deny-only group, and is still an administrator account.
Groups map[string]bool
GroupsKnown bool
// SessionSIDs: accounts with a Remote Desktop Services session now
// (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem).
SessionSIDs map[string]bool
SessionsKnown bool
}
func classifyWinOwner(o winOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.SID == "":
case o.SID == sidLocalSystem:
f.Class = ownerClassSystem
case o.SID == sidLocalService || o.SID == sidNetworkService ||
strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx):
f.Class = ownerClassService
case o.GroupsKnown && o.Groups[sidLocalAccount]:
f.Class = ownerClassLocal
case o.Domain == "":
case strings.EqualFold(o.Domain, o.Computer):
f.Class = ownerClassLocal
case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"):
// A machine or domain account whose domain is not this computer:
// an Active Directory or Entra ID account.
f.Class = ownerClassDomain
}
switch {
case o.SID == sidLocalSystem:
// "System is a hidden member of Administrators" (the second page above).
f.Admin = ownerYes
case o.GroupsKnown:
f.Admin = yesNo(o.Groups[sidAdministrators])
}
switch {
case o.SessionsKnown && o.SID != "":
f.Interactive = yesNo(o.SessionSIDs[o.SID])
case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]):
// No session list (the bastion is not LocalSystem), but this very
// process was started by an interactive logon, so the account has
// one. The converse proves nothing: a scheduled task's token is not
// interactive while its owner may well be logged in.
f.Interactive = ownerYes
}
return f
}