bastion 0.54.0
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Linux reader end to end over an invented machine: a /proc with two
|
||||
// processes and an /etc and /run that say who they belong to. Nothing here is
|
||||
// read from the box the test runs on.
|
||||
func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(rel, body string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+
|
||||
"kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n")
|
||||
write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n")
|
||||
write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n")
|
||||
write("etc/nsswitch.conf", "passwd: files\n")
|
||||
write("run/systemd/users/1001", "UID=1001\nSTATE=active\n")
|
||||
write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n")
|
||||
write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n")
|
||||
write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n")
|
||||
write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n")
|
||||
|
||||
oldID, oldProc := identityRoot, procRoot
|
||||
identityRoot, procRoot = root, filepath.Join(root, "proc")
|
||||
t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc })
|
||||
|
||||
read := ownerFactsReader()
|
||||
for _, c := range []struct {
|
||||
pid int
|
||||
user string
|
||||
want ownerFacts
|
||||
}{
|
||||
{400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||
{401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
|
||||
{402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}},
|
||||
// Effective root under kim's account: administrator, class unchanged.
|
||||
{403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}},
|
||||
// A pid with no status file: everything unknown, nothing guessed.
|
||||
{404, "ghost", unknownOwner},
|
||||
} {
|
||||
if got := read(c.pid, c.user); got != c.want {
|
||||
t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every observation scanAgents returns carries the owner facts: the wiring,
|
||||
// not only the classifier. The one process is this test itself, so the facts
|
||||
// come from the real /proc and are only checked for being filled in.
|
||||
func TestScanAgentsCarriesOwnerFacts(t *testing.T) {
|
||||
prev := scanProcessesFn
|
||||
t.Cleanup(func() { scanProcessesFn = prev })
|
||||
self := os.Getpid()
|
||||
scanProcessesFn = func() (map[int]agentObs, error) {
|
||||
return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code",
|
||||
Cmdline: "claude", raw: "claude"}}, nil
|
||||
}
|
||||
got, err := scanAgents()
|
||||
if err != nil || len(got) == 0 {
|
||||
t.Fatalf("scan: %d observations, %v", len(got), err)
|
||||
}
|
||||
// The network signal may add this box's own model clients; every
|
||||
// observation, however it was caught, must carry the facts.
|
||||
for _, a := range got {
|
||||
if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" {
|
||||
t.Fatalf("owner facts missing from pid %d", a.PID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every value below is invented. No account, group or SID here belongs to a
|
||||
// real machine.
|
||||
|
||||
func TestClassifyUnixOwner(t *testing.T) {
|
||||
linux := func(o unixOwner) unixOwner {
|
||||
o.SystemUIDMax = linuxSystemUIDMax
|
||||
o.AdminGroups = linuxAdminGroups
|
||||
o.SudoApplies = true
|
||||
return o
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
in unixOwner
|
||||
want ownerFacts
|
||||
}{
|
||||
{"root is the system and an administrator",
|
||||
linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
|
||||
Sessions: map[string]bool{}, SessionsKnown: true}),
|
||||
ownerFacts{ownerClassSystem, ownerYes, ownerNo}},
|
||||
{"a daemon account in the system range",
|
||||
linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
|
||||
Sessions: map[string]bool{}, SessionsKnown: true}),
|
||||
ownerFacts{ownerClassService, ownerNo, ownerNo}},
|
||||
{"nobody is a service account",
|
||||
linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}),
|
||||
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||
{"a person in the sudo group, logged in",
|
||||
linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"},
|
||||
GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
|
||||
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||
{"a person a sudo rule names directly",
|
||||
linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"},
|
||||
GroupsKnown: true, SudoNamed: true, SudoKnown: true}),
|
||||
ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}},
|
||||
{"no admin group and the sudo rules read: no",
|
||||
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"},
|
||||
GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
|
||||
ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
|
||||
{"no admin group but the sudo rules unreadable: unknown, never no",
|
||||
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}),
|
||||
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
|
||||
{"groups unreadable: unknown, never no",
|
||||
linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}),
|
||||
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
|
||||
{"not in passwd on a machine that resolves from a directory",
|
||||
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}),
|
||||
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||
{"not in passwd and no directory: a uid with no account, not a domain user",
|
||||
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}),
|
||||
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||
{"a realm-qualified name is a directory account",
|
||||
linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}),
|
||||
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||
{"uid unread",
|
||||
linux(unixOwner{UID: -1, Name: ""}),
|
||||
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||
{"macOS admin group member",
|
||||
unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"},
|
||||
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
|
||||
Sessions: map[string]bool{"sam": true}, SessionsKnown: true},
|
||||
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||
{"macOS standard user: the admin group decides",
|
||||
unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"},
|
||||
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
|
||||
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||
{"macOS daemon",
|
||||
unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax,
|
||||
AdminGroups: darwinAdminGroups},
|
||||
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||
{"macOS mobile account",
|
||||
unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true,
|
||||
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
|
||||
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := classifyUnixOwner(c.in); got != c.want {
|
||||
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWinOwner(t *testing.T) {
|
||||
const box = "DESK-DEMO"
|
||||
user := "S-1-5-21-1111111111-2222222222-3333333333-1001"
|
||||
cases := []struct {
|
||||
name string
|
||||
in winOwner
|
||||
want ownerFacts
|
||||
}{
|
||||
{"LocalSystem",
|
||||
winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box},
|
||||
ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}},
|
||||
{"NetworkService",
|
||||
winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box,
|
||||
Groups: map[string]bool{}, GroupsKnown: true},
|
||||
ownerFacts{ownerClassService, ownerNo, ownerUnknown}},
|
||||
{"a virtual service account",
|
||||
winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box},
|
||||
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
|
||||
{"local administrator, unelevated, at the desktop",
|
||||
winOwner{SID: user, Domain: box, Computer: box,
|
||||
Groups: map[string]bool{sidAdministrators: true, sidInteractive: true},
|
||||
GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true},
|
||||
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
|
||||
{"domain user, not an administrator, no session now",
|
||||
winOwner{SID: user, Domain: "CORPDEMO", Computer: box,
|
||||
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true,
|
||||
SessionSIDs: map[string]bool{}, SessionsKnown: true},
|
||||
ownerFacts{ownerClassDomain, ownerNo, ownerNo}},
|
||||
{"the local-account group decides even with no domain name",
|
||||
winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true},
|
||||
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||
{"no session list, but this process is an interactive logon",
|
||||
winOwner{SID: user, Domain: box, Computer: box,
|
||||
Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true},
|
||||
ownerFacts{ownerClassLocal, ownerNo, ownerYes}},
|
||||
{"no session list and a batch token: unknown, never no",
|
||||
winOwner{SID: user, Domain: box, Computer: box,
|
||||
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true},
|
||||
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
|
||||
{"token unreadable",
|
||||
winOwner{},
|
||||
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := classifyWinOwner(c.in); got != c.want {
|
||||
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerFileParsers(t *testing.T) {
|
||||
status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n"
|
||||
r, e, gids, ok := parseStatusIDs(status)
|
||||
if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 {
|
||||
t.Fatalf("status: %d %d %v %v", r, e, gids, ok)
|
||||
}
|
||||
if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok {
|
||||
t.Fatal("a status with no Uid line must not read as uid 0")
|
||||
}
|
||||
names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n")
|
||||
if names[27] != "sudo" || names[0] != "root" || len(names) != 2 {
|
||||
t.Fatalf("groups: %v", names)
|
||||
}
|
||||
uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n")
|
||||
if !uids[1001] || !uids[0] || uids[5] {
|
||||
t.Fatalf("passwd: %v", uids)
|
||||
}
|
||||
for body, want := range map[string]int{
|
||||
"": linuxSystemUIDMax,
|
||||
"UID_MIN 500\n": 499,
|
||||
"UID_MIN 1000\nSYS_UID_MAX 899\n": 899,
|
||||
"# SYS_UID_MAX 10\n": linuxSystemUIDMax,
|
||||
} {
|
||||
if got := parseLoginDefsSysMax(body); got != want {
|
||||
t.Errorf("login.defs %q: %d, want %d", body, got, want)
|
||||
}
|
||||
}
|
||||
if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") ||
|
||||
nsswitchHasDirectory("passwd: files systemd\n") {
|
||||
t.Fatal("nsswitch directory detection")
|
||||
}
|
||||
for body, want := range map[string][2]bool{
|
||||
"UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true},
|
||||
"STATE=lingering\n": {false, true},
|
||||
"STATE=something-new\n": {false, false},
|
||||
"UID=1001\n": {false, false},
|
||||
} {
|
||||
got, ok := parseLogindUserState(body)
|
||||
if got != want[0] || ok != want[1] {
|
||||
t.Errorf("logind %q: %v %v", body, got, ok)
|
||||
}
|
||||
}
|
||||
if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 {
|
||||
t.Fatalf("who: %v", u)
|
||||
}
|
||||
uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n")
|
||||
if !ok || uid != 1204567 || !mobile {
|
||||
t.Fatalf("dscl: %d %v %v", uid, mobile, ok)
|
||||
}
|
||||
if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" {
|
||||
t.Fatalf("id: %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) {
|
||||
rec := func(typ uint16, user string) []byte {
|
||||
b := make([]byte, utmpRecordSize)
|
||||
binary.LittleEndian.PutUint16(b, typ)
|
||||
copy(b[utmpUserOffset:], user)
|
||||
return b
|
||||
}
|
||||
var blob []byte
|
||||
blob = append(blob, rec(utmpUserProcess, "pat")...)
|
||||
blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended
|
||||
blob = append(blob, rec(utmpUserProcess, "lee")...)
|
||||
blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread
|
||||
got := parseUtmpUsers(blob)
|
||||
if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 {
|
||||
t.Fatalf("utmp: %v", got)
|
||||
}
|
||||
}
|
||||
+161
-3
@@ -213,9 +213,17 @@ type agentObs struct {
|
||||
// what makes a service RESTART the same entry rather than a new one
|
||||
// (baseline.go). Empty where the platform does not supply one: darwin and
|
||||
// windows never populate procFacts.Unit, so those fall back to the origin.
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Evidence []evidence `json:"evidence,omitempty"`
|
||||
raw string // the UNTRUNCATED command line, for classification only; never reported
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// The account behind User (agentowner.go): domain_user, local_user,
|
||||
// service, system or unknown; whether it administers this machine; and
|
||||
// whether it has a login session now. yes, no or unknown each — unknown
|
||||
// is what an unreadable token or group list says, never no. Empty only on
|
||||
// an observation made outside scanAgents.
|
||||
OwnerClass string `json:"owner_class,omitempty"`
|
||||
OwnerAdmin string `json:"owner_admin,omitempty"`
|
||||
OwnerInteractive string `json:"owner_interactive,omitempty"`
|
||||
Evidence []evidence `json:"evidence,omitempty"`
|
||||
raw string // the UNTRUNCATED command line, for classification only; never reported
|
||||
// automation: the signature came from the automation tables, so the
|
||||
// classifier gives this kind `automation` unless model traffic promotes it.
|
||||
automation bool
|
||||
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
|
||||
}
|
||||
extra := originExtras()
|
||||
facts := procFactsReader()
|
||||
owners := ownerFactsReader()
|
||||
out := make([]agentObs, 0, len(byPid))
|
||||
for pid, a := range byPid {
|
||||
if faIsOwnChild(pid) {
|
||||
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
|
||||
if f, ok := facts(pid); ok {
|
||||
a.Unit = f.Unit
|
||||
}
|
||||
// Who the owning account is, beside who it is called. Read here
|
||||
// rather than in each platform's walk so a process the network
|
||||
// signal alone caught (mergeConnections) gets the same answer.
|
||||
o := owners(pid, a.User)
|
||||
a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive
|
||||
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
|
||||
if a.DelegationToken != "" {
|
||||
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
|
||||
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanMCPProcessesFn is the seam a test uses to hand the correlation a
|
||||
// process list; production never reassigns it.
|
||||
var scanMCPProcessesFn = scanMCPProcesses
|
||||
|
||||
// mcpServersReport reads the declared tool servers and correlates them with
|
||||
// the process list. It lives here, not in mcpconfig.go, because reading the
|
||||
// process list runs `ps` on macOS: the config reader itself starts nothing,
|
||||
// and the claims test holds it to that.
|
||||
func mcpServersReport() ([]mcpServerObs, mcpConfigScan) {
|
||||
declared, scan, read := cachedMCPConfigs()
|
||||
if !scan.Supported {
|
||||
scan.ProcessScan = "unsupported"
|
||||
return []mcpServerObs{}, scan
|
||||
}
|
||||
procs, err := scanMCPProcessesFn(mcpWantedBases(declared))
|
||||
switch {
|
||||
case err == nil:
|
||||
scan.ProcessScan = "ok"
|
||||
case agentScanSupported:
|
||||
scan.ProcessScan = "failed"
|
||||
default:
|
||||
scan.ProcessScan = "unsupported"
|
||||
}
|
||||
if !read {
|
||||
// No read of the files has finished yet (timed_out says so). With
|
||||
// nothing known to be declared, every tool-server-looking process
|
||||
// would read as running_not_declared, which nobody has checked.
|
||||
return []mcpServerObs{}, scan
|
||||
}
|
||||
out := correlateMCP(declared, procs, err == nil)
|
||||
if out == nil {
|
||||
out = []mcpServerObs{}
|
||||
}
|
||||
if len(out) >= mcpMaxServers {
|
||||
scan.Truncated = true
|
||||
}
|
||||
return out, scan
|
||||
}
|
||||
|
||||
// mcpCache holds the last complete read of the config files. The heartbeat
|
||||
// is every 60 s and the read is up to a thousand files across every account;
|
||||
// like the scheduler inventory (schedCacheFor) it is redone every ten
|
||||
// minutes, while the process correlation above runs on every beat. One read
|
||||
// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL,
|
||||
// counted from when it started: a home on a network mount that stopped
|
||||
// answering holds that one goroutine, delays one beat by at most that much,
|
||||
// never the beats after it, and never starts a second read.
|
||||
var mcpCache struct {
|
||||
sync.Mutex
|
||||
at time.Time
|
||||
have bool
|
||||
declared []mcpServerObs
|
||||
scan mcpConfigScan
|
||||
inflight chan struct{}
|
||||
started time.Time // when the read in flight began
|
||||
gen int
|
||||
}
|
||||
|
||||
const mcpCacheFor = 10 * time.Minute
|
||||
|
||||
// mcpReadTimeout is how long one beat waits for a read in progress.
|
||||
var mcpReadTimeout = 20 * time.Second
|
||||
|
||||
// cachedMCPConfigs is the last complete read. read is false when no read has
|
||||
// finished yet, so the list says nothing about what is declared.
|
||||
func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) {
|
||||
mcpCache.Lock()
|
||||
if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor {
|
||||
d, s := mcpCache.declared, mcpCache.scan
|
||||
mcpCache.Unlock()
|
||||
return d, s, true
|
||||
}
|
||||
done := mcpCache.inflight
|
||||
if done == nil {
|
||||
done = make(chan struct{})
|
||||
mcpCache.inflight, mcpCache.started = done, time.Now()
|
||||
gen := mcpCache.gen
|
||||
go func() {
|
||||
d, s := readMCPConfigs()
|
||||
mcpCache.Lock()
|
||||
if mcpCache.gen == gen {
|
||||
mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true
|
||||
mcpCache.inflight = nil
|
||||
}
|
||||
mcpCache.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
wait := mcpReadTimeout - time.Since(mcpCache.started)
|
||||
mcpCache.Unlock()
|
||||
timedOut := false
|
||||
if wait <= 0 {
|
||||
// An earlier beat already waited the whole time for this read.
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
timedOut = true
|
||||
}
|
||||
} else {
|
||||
t := time.NewTimer(wait)
|
||||
select {
|
||||
case <-done:
|
||||
case <-t.C:
|
||||
timedOut = true
|
||||
}
|
||||
t.Stop()
|
||||
}
|
||||
mcpCache.Lock()
|
||||
defer mcpCache.Unlock()
|
||||
if !mcpCache.have {
|
||||
// Nothing read yet: say so, rather than "no servers".
|
||||
return nil, mcpConfigScan{Supported: true, TimedOut: true}, false
|
||||
}
|
||||
s := mcpCache.scan
|
||||
s.TimedOut = timedOut
|
||||
return mcpCache.declared, s, true
|
||||
}
|
||||
|
||||
// resetMCPConfigCache forgets the last read; a read still running is
|
||||
// ignored when it finishes. Tests use it to start clean.
|
||||
func resetMCPConfigCache() {
|
||||
mcpCache.Lock()
|
||||
defer mcpCache.Unlock()
|
||||
mcpCache.gen++
|
||||
mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
llmIPCache map[string]string
|
||||
llmIPCacheAt time.Time
|
||||
@@ -554,11 +695,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
|
||||
// done for nothing, on the user's own hardware.
|
||||
var schedEntries []schedEntry
|
||||
var schedScan schedScanReport
|
||||
// Declared tool servers (mcpconfig.go): the agent clients' own config
|
||||
// files, correlated with the process list. Same gate and same
|
||||
// quiet-vs-blind rule as the schedulers: read only for a pairing entitled
|
||||
// to observe, and the capability travels only from a build that can.
|
||||
var mcpServers []mcpServerObs
|
||||
var mcpScan mcpConfigScan
|
||||
if observing {
|
||||
schedEntries, schedScan = scanSchedulers()
|
||||
if schedScan.Supported {
|
||||
caps = append(caps, "scheduler-observe")
|
||||
}
|
||||
mcpServers, mcpScan = mcpServersReport()
|
||||
if mcpScan.Supported {
|
||||
caps = append(caps, "tool-server-observe")
|
||||
}
|
||||
}
|
||||
// Context preparation (contextprep.go): the tools on this box that
|
||||
// turn a user's bytes into their content — a PDF's text, an image's OCR.
|
||||
@@ -674,6 +825,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
|
||||
// that read 0 crontabs is visibly different from one that read 40.
|
||||
body["schedulers"] = schedEntries
|
||||
body["scheduler_scan"] = schedScan
|
||||
// The tool servers each agent client declares, and which are running
|
||||
// (or running with no declaration). Paths, server names and command
|
||||
// lines, so inside this gate with the agent list; env values and
|
||||
// header values never leave mcpconfig.go, only their names and
|
||||
// whether one looked like a secret.
|
||||
body["tool_servers"] = mcpServers
|
||||
body["tool_server_scan"] = mcpScan
|
||||
// Where this machine keeps its own record — the findings journal and
|
||||
// the signed accounting (machinestore.go, baselinesign.go). The host
|
||||
// drafts a machine store at this root for the user to approve, once;
|
||||
|
||||
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ownerFactsReader answers the three owner facts agentowner.go decides on.
|
||||
// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the
|
||||
// system's own tools, each a fixed argv under runTimed's timeout: `dscl .`
|
||||
// (the LOCAL node only) for the uid and whether the account is a mobile copy
|
||||
// of a directory account, `id -Gn` for its groups (nested groups resolved by
|
||||
// the system), and `who` once per scan for who is logged in. Answers are kept
|
||||
// per account for the scan, so ten agents of one person cost three commands.
|
||||
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||
var sessions map[string]bool
|
||||
sessionsOK, sessionsRead := false, false
|
||||
cache := map[string]ownerFacts{}
|
||||
return func(pid int, user string) ownerFacts {
|
||||
if user == "" || strings.ContainsAny(user, "/ \t\n") {
|
||||
return unknownOwner
|
||||
}
|
||||
if f, ok := cache[user]; ok {
|
||||
return f
|
||||
}
|
||||
if !sessionsRead {
|
||||
sessionsRead = true
|
||||
if out, err := runTimed("who"); err == nil {
|
||||
sessions, sessionsOK = parseWhoUsers(string(out)), true
|
||||
}
|
||||
}
|
||||
o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown,
|
||||
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
|
||||
Sessions: sessions, SessionsKnown: sessionsOK}
|
||||
out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName")
|
||||
if err == nil {
|
||||
if uid, mobile, ok := parseDsclUser(string(out)); ok {
|
||||
o.UID = uid
|
||||
o.LocalRecord = ownerYes
|
||||
if mobile {
|
||||
o.LocalRecord, o.DirectorySource = ownerNo, true
|
||||
}
|
||||
}
|
||||
} else if dsclRecordMissing(err) {
|
||||
// dscl ran and has no such record in the local node: the account
|
||||
// comes from a directory. The uid then has to come from id.
|
||||
o.LocalRecord, o.DirectorySource = ownerNo, true
|
||||
if out, err := runTimed("id", "-u", "--", user); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||
o.UID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := runTimed("id", "-Gn", "--", user); err == nil {
|
||||
o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true
|
||||
}
|
||||
f := classifyUnixOwner(o)
|
||||
cache[user] = f
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||
// servers against: one `ps` sweep, every process with its parent and
|
||||
// arguments. An error is a sweep that did not happen.
|
||||
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||
_, argvs, parents, err := psSweep()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
self := os.Getpid()
|
||||
out := make([]mcpProc, 0, len(argvs))
|
||||
for pid, argv := range argvs {
|
||||
if pid != self {
|
||||
out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// procParent answers from the sweep scanProcesses just took.
|
||||
func procParent(pid int) int {
|
||||
psCache.Lock()
|
||||
|
||||
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||
// servers against: every process, with its parent and command line, from one
|
||||
// /proc walk. The want set only matters on Windows, where reading a command
|
||||
// line costs a handle per process. An error is a walk that did not happen,
|
||||
// and then no running state is guessed.
|
||||
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", procRoot, err)
|
||||
}
|
||||
self := os.Getpid()
|
||||
var out []mcpProc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil || pid == self || !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := mcpProc{PID: pid}
|
||||
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil {
|
||||
p.PPID = parseProcStatPPID(string(raw))
|
||||
}
|
||||
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil {
|
||||
p.Argv = splitNul(raw)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// procParent reads one process's parent pid from /proc/<pid>/stat.
|
||||
func procParent(pid int) int {
|
||||
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
@@ -126,6 +155,109 @@ func procUser(pid int) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ownerFactsReader answers, per process, the three facts about its owner that
|
||||
// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group,
|
||||
// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once
|
||||
// per scan, on first use, and every process looks itself up in them.
|
||||
//
|
||||
// The uid and groups come from /proc/<pid>/status, so they are what the
|
||||
// PROCESS holds, the same source procUser names it from.
|
||||
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||
var (
|
||||
loaded bool
|
||||
groupNames map[int]string
|
||||
groupsOK bool
|
||||
passwd map[int]bool
|
||||
passwdOK bool
|
||||
sysMax int
|
||||
directory bool
|
||||
sudoers map[string]bool
|
||||
sudoKnown bool
|
||||
sessions map[string]bool
|
||||
sessionsOK bool
|
||||
logindUsers = identityPath("/run/systemd/users")
|
||||
)
|
||||
load := func() {
|
||||
loaded = true
|
||||
if b, err := os.ReadFile(identityPath("/etc/group")); err == nil {
|
||||
groupNames, groupsOK = parseGroupNames(string(b)), true
|
||||
}
|
||||
if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil {
|
||||
passwd, passwdOK = parsePasswdUIDs(string(b)), true
|
||||
}
|
||||
sysMax = linuxSystemUIDMax
|
||||
if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil {
|
||||
sysMax = parseLoginDefsSysMax(string(b))
|
||||
}
|
||||
if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil {
|
||||
directory = nsswitchHasDirectory(string(b))
|
||||
}
|
||||
var partial string
|
||||
sudoers, partial = sudoPrincipals()
|
||||
sudoKnown = partial == ""
|
||||
// Who has a session: logind's own record when it runs, else utmp.
|
||||
// Neither readable leaves the answer unknown rather than "nobody".
|
||||
if _, err := os.Stat(logindUsers); err != nil {
|
||||
if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil {
|
||||
sessions, sessionsOK = parseUtmpUsers(b), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return func(pid int, user string) ownerFacts {
|
||||
if !loaded {
|
||||
load()
|
||||
}
|
||||
raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status")
|
||||
if err != nil {
|
||||
return unknownOwner
|
||||
}
|
||||
realUID, effUID, gids, ok := parseStatusIDs(string(raw))
|
||||
if !ok {
|
||||
return unknownOwner
|
||||
}
|
||||
o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory,
|
||||
SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true,
|
||||
SudoNamed: sudoers[user], SudoKnown: sudoKnown}
|
||||
if passwdOK {
|
||||
o.LocalRecord = yesNo(passwd[realUID])
|
||||
}
|
||||
if groupsOK {
|
||||
o.GroupsKnown = true
|
||||
for _, g := range gids {
|
||||
if n, ok := groupNames[g]; ok {
|
||||
o.Groups = append(o.Groups, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case sessionsOK:
|
||||
o.Sessions, o.SessionsKnown = sessions, true
|
||||
default:
|
||||
b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID))
|
||||
switch {
|
||||
case err == nil:
|
||||
if live, ok := parseLogindUserState(string(b)); ok {
|
||||
o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
// logind keeps a file for every user it tracks; none means
|
||||
// this account has no session and no lingering services.
|
||||
if _, derr := os.Stat(logindUsers); derr == nil {
|
||||
o.Sessions, o.SessionsKnown = map[string]bool{}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
f := classifyUnixOwner(o)
|
||||
if effUID == 0 {
|
||||
// A process running with effective uid 0 holds root whatever
|
||||
// account started it: that is the administrator answer that
|
||||
// matters, while the class stays the account's own.
|
||||
f.Admin = ownerYes
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func procCmdline(pid int) string {
|
||||
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
|
||||
if err != nil {
|
||||
|
||||
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
|
||||
func procExe(pid int) string { return "" }
|
||||
func procParent(pid int) int { return 0 }
|
||||
func processParents() map[int]int { return map[int]int{} }
|
||||
|
||||
// ownerFactsReader: no reader here, so every owner fact is unknown.
|
||||
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||
return func(int, string) ownerFacts { return unknownOwner }
|
||||
}
|
||||
|
||||
// scanMCPProcesses: no enumerator, so running state is never measured here.
|
||||
func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator }
|
||||
|
||||
+169
-5
@@ -306,6 +306,7 @@ func processParents() map[int]int {
|
||||
// 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) {
|
||||
winBeginSIDScan()
|
||||
out := map[int]agentObs{}
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
|
||||
// servers against. Every process comes from one Toolhelp snapshot with its
|
||||
// parent; the command line, which costs a handle and a memory read, is read
|
||||
// only for a program in want (interpreters, package runners, and every
|
||||
// program a declared server names) or whose own name carries the word mcp.
|
||||
// The rest keep their parent link with no command line, which is all the
|
||||
// correlation needs from them. An error is a snapshot that did not happen.
|
||||
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
|
||||
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()
|
||||
var out []mcpProc
|
||||
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
|
||||
pid := int(e.ProcessID)
|
||||
if pid <= 0 || pid == self {
|
||||
continue
|
||||
}
|
||||
p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)}
|
||||
exe := windows.UTF16ToString(e.ExeFile[:])
|
||||
if base := binBase(exe); want[base] || mcpWord(base) {
|
||||
cmdline := ""
|
||||
if h, herr := winOpenForRead(pid); herr == nil {
|
||||
cmdline = winCmdline(h)
|
||||
windows.CloseHandle(h)
|
||||
}
|
||||
if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 {
|
||||
p.Argv = argv
|
||||
} else {
|
||||
p.Argv = []string{exe}
|
||||
}
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
||||
return nil, fmt.Errorf("process walk: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
|
||||
|
||||
var (
|
||||
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
|
||||
if err != nil || tu.User.Sid == nil {
|
||||
return ""
|
||||
}
|
||||
account, domain, _, err := tu.User.Sid.LookupAccount("")
|
||||
return winAccountLabel(tu.User.Sid.String(), winScanSIDNames())
|
||||
}
|
||||
|
||||
// winSIDScan is the current scan's account-name cache (sidnames.go), shared
|
||||
// by winTokenUser and ownerFactsReader so a SID is looked up once per scan
|
||||
// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a
|
||||
// new one; the readers that run after it in scanAgents use that one.
|
||||
var winSIDScan struct {
|
||||
sync.Mutex
|
||||
names *sidNames
|
||||
}
|
||||
|
||||
func winBeginSIDScan() *sidNames {
|
||||
n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots)
|
||||
winSIDScan.Lock()
|
||||
winSIDScan.names = n
|
||||
winSIDScan.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
func winScanSIDNames() *sidNames {
|
||||
winSIDScan.Lock()
|
||||
n := winSIDScan.names
|
||||
winSIDScan.Unlock()
|
||||
if n == nil {
|
||||
return winBeginSIDScan()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// winLookupSID is LookupAccountSid on this machine for one SID string.
|
||||
func winLookupSID(s string) (string, string, error) {
|
||||
a, err := winLookupSIDAccount(s)
|
||||
return a.Account, a.Domain, err
|
||||
}
|
||||
|
||||
// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded:
|
||||
// callers go through sidNames or winAccounts.
|
||||
func winLookupSIDAccount(s string) (sidAccount, error) {
|
||||
sid, err := windows.StringToSid(s)
|
||||
if err != nil {
|
||||
return tu.User.Sid.String()
|
||||
return sidAccount{}, err
|
||||
}
|
||||
if domain != "" {
|
||||
return domain + `\` + account
|
||||
account, domain, typ, err := sid.LookupAccount("")
|
||||
return sidAccount{Account: account, Domain: domain, Type: typ}, err
|
||||
}
|
||||
|
||||
// winAccounts is the process-wide account cache for lookups made outside a
|
||||
// scan: the approvals pipe and the folder agent's drop (sidnames.go).
|
||||
var winAccounts = newSIDDirectory(winLookupSIDAccount)
|
||||
|
||||
// ownerFactsReader answers the three owner facts agentowner.go decides on,
|
||||
// from each process's own token: its user SID and that SID's domain, and its
|
||||
// group SIDs (deny-only ones included, which is how an administrator running
|
||||
// without elevation still reads as an administrator account). The session
|
||||
// list is read once per scan and needs LocalSystem, which the system service
|
||||
// is; elsewhere it is left unknown and the token's own logon type answers
|
||||
// what it can (classifyWinOwner). The domain comes from the scan's shared
|
||||
// account-name cache (winScanSIDNames), so a SID winTokenUser already looked
|
||||
// up is not asked again, and an unreachable domain controller leaves the
|
||||
// class unknown instead of holding the scan.
|
||||
func ownerFactsReader() func(pid int, user string) ownerFacts {
|
||||
computer, _ := windows.ComputerName()
|
||||
names := winScanSIDNames()
|
||||
var sessions map[string]bool
|
||||
sessionsOK, sessionsRead := false, false
|
||||
return func(pid int, _ string) ownerFacts {
|
||||
if !sessionsRead {
|
||||
sessionsRead = true
|
||||
sessions, sessionsOK = winSessionSIDs()
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return unknownOwner
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var tok windows.Token
|
||||
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
|
||||
return unknownOwner
|
||||
}
|
||||
defer tok.Close()
|
||||
o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK}
|
||||
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
|
||||
o.SID = tu.User.Sid.String()
|
||||
o.Domain = winAccountDomain(o.SID, names)
|
||||
}
|
||||
if tg, err := tok.GetTokenGroups(); err == nil {
|
||||
o.Groups, o.GroupsKnown = map[string]bool{}, true
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Sid != nil {
|
||||
o.Groups[g.Sid.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return classifyWinOwner(o)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
// winSessionSIDs is the set of user SIDs with an active or disconnected
|
||||
// Remote Desktop Services session (the console is one). ok is false when the
|
||||
// list could not be read: WTSQueryUserToken needs LocalSystem's privilege.
|
||||
func winSessionSIDs() (map[string]bool, bool) {
|
||||
var info *windows.WTS_SESSION_INFO
|
||||
var count uint32
|
||||
if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
|
||||
list := unsafe.Slice(info, count)
|
||||
out := map[string]bool{}
|
||||
for _, s := range list {
|
||||
if s.State != windows.WTSActive && s.State != windows.WTSDisconnected {
|
||||
continue
|
||||
}
|
||||
var tok windows.Token
|
||||
if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil {
|
||||
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
return nil, false
|
||||
}
|
||||
continue // a session with no user (the services session, a login screen)
|
||||
}
|
||||
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
|
||||
out[tu.User.Sid.String()] = true
|
||||
}
|
||||
tok.Close()
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func procUser(pid int) string {
|
||||
|
||||
@@ -978,6 +978,20 @@ func cmdRepinAnchor() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorAlreadyHolds: the anchor names an active pairing here, and host
|
||||
// (claim-anchor --host, "" for none) names that same pairing.
|
||||
func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
|
||||
c := pairingFor(cfgs, prev.AnchorHost)
|
||||
if c == nil || !c.active() {
|
||||
return false
|
||||
}
|
||||
if host == "" {
|
||||
return true
|
||||
}
|
||||
named, err := pickPairing(cfgs, host)
|
||||
return err == nil && named.HostName == c.HostName
|
||||
}
|
||||
|
||||
// pickPairing resolves --host against this box's pairings. It accepts the full
|
||||
// host_name the box reports, or just the handle in front of it, because the
|
||||
// name the user is looking at depends on which surface they read it from —
|
||||
@@ -1021,6 +1035,17 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
|
||||
return err
|
||||
}
|
||||
prev := currentAnchor()
|
||||
// Already anchored to the host this asks for (no token, no other host
|
||||
// named), and that host's pairing is active: done, not an error. `pair`
|
||||
// anchors a networked box to its first host by itself, and an installer
|
||||
// that runs claim-anchor after pairing must find that a success. An anchor
|
||||
// whose pairing is pending or revoked is not done, and is refused below as
|
||||
// before. --host takes the short handle as well as the full name
|
||||
// (pickPairing), so the same host named either way is the same request.
|
||||
if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) {
|
||||
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
|
||||
return nil
|
||||
}
|
||||
if prev != nil && !rotate {
|
||||
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
|
||||
}
|
||||
|
||||
@@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already anchored — still refused without --rotate", func(t *testing.T) {
|
||||
t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
|
||||
// `pair` anchors a networked box by itself, and an installer that runs
|
||||
// claim-anchor afterwards must find that done, not an error.
|
||||
freshBox(t, true)
|
||||
writePairing(t, tMember)
|
||||
if err := writeAnchor("Acme", tMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") {
|
||||
t.Fatalf("dropping the token must not let a second claim through: %v", err)
|
||||
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||
t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
|
||||
t.Fatalf("naming the anchor host itself must succeed: %v", err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
|
||||
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
|
||||
t.Fatalf("the anchor must be untouched: %+v", a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
|
||||
// A success here would tell an installer the anchor holds while its
|
||||
// host has no active lanes on this box.
|
||||
for _, st := range []string{pairingPending, pairingRevoked} {
|
||||
freshBox(t, true)
|
||||
writePairingState(t, tMember, st)
|
||||
if err := writeAnchor("Acme", tMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", ""); err == nil {
|
||||
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
|
||||
}
|
||||
if got := loadState(t, tMember); got != st {
|
||||
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
|
||||
freshBox(t, true)
|
||||
writePairing(t, tMember)
|
||||
writePairing(t, tAnchor)
|
||||
if err := writeAnchor("Acme", tMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
|
||||
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||
t.Fatalf("the anchor must be untouched: %+v", a)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -138,6 +138,30 @@ type pipeClient struct {
|
||||
// "" when nobody is.
|
||||
SessionUserSID string
|
||||
SessionActive bool // WTSActive: the session someone is in front of
|
||||
// NameTimedOut: the account directory did not answer for UserSID within
|
||||
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
|
||||
// account approved. pipeApprove refuses; list and deny go on, by SID.
|
||||
NameTimedOut bool
|
||||
}
|
||||
|
||||
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
|
||||
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
|
||||
// request is read, so the lookup gets well under that: a directory that does
|
||||
// not answer costs one connection 2 s, and holds at most one goroutine for
|
||||
// the pipe however many connections come.
|
||||
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
|
||||
|
||||
// pipeClientName fills c.UserName from the account directory, or sets
|
||||
// c.NameTimedOut when the directory did not answer in time. A SID with no
|
||||
// account leaves UserName empty: the log shows the SID, as before.
|
||||
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
|
||||
acct, outcome := dir.resolve(c.UserSID, lane)
|
||||
switch outcome {
|
||||
case sidResolved:
|
||||
c.UserName = sidAccountLabel(acct)
|
||||
case sidTimedOut:
|
||||
c.NameTimedOut = true
|
||||
}
|
||||
}
|
||||
|
||||
// purposeDigest is the sha256 of a purpose as the tray carries it back.
|
||||
@@ -330,6 +354,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
|
||||
var pipeApproveMu sync.Mutex
|
||||
|
||||
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
|
||||
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
|
||||
// approval's audit line names the account that gave it, and when the
|
||||
// directory could not say whose account this is, nothing is approved.
|
||||
// Before the throttle and the code, so this counts as no wrong code.
|
||||
if c.NameTimedOut {
|
||||
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
|
||||
" in time; try again in a minute, or approve from an Administrator terminal"}
|
||||
}
|
||||
pipeApproveMu.Lock()
|
||||
defer pipeApproveMu.Unlock()
|
||||
code := strings.TrimSpace(req.Code)
|
||||
|
||||
@@ -311,12 +311,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
|
||||
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
|
||||
}
|
||||
c.UserSID = u.User.Sid.String()
|
||||
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil {
|
||||
c.UserName = name
|
||||
if dom != "" {
|
||||
c.UserName = dom + `\` + name
|
||||
}
|
||||
}
|
||||
// Bounded and cached (sidnames.go): a directory that does not answer
|
||||
// leaves NameTimedOut set, and pipeApprove refuses.
|
||||
pipeClientName(&c, winAccounts, pipeSIDLane)
|
||||
var sess, n uint32
|
||||
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
|
||||
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
// Audit rows to the system log, as well as to their files.
|
||||
//
|
||||
// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a
|
||||
// pairing's audit.jsonl for terminals, root commands, store ops and op runs)
|
||||
// lives here, on this machine. The host can fetch them over the tunnel but
|
||||
// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host
|
||||
// as the only central view of a fleet, and the host is the agent the audit is
|
||||
// about. So each row is also written to the machine's own system log, where
|
||||
// whatever already collects that log (journald or syslog forwarding, a log
|
||||
// shipper, a SIEM) takes it to a store the host cannot write:
|
||||
//
|
||||
// Linux, macOS syslog, facility auth, tag privatae-bastion-audit
|
||||
// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit)
|
||||
// Windows the Application event log, source PrivataeBastionAudit, event id 1
|
||||
//
|
||||
// The system-log line is the file's row with two fields added in front of it:
|
||||
// `audit`, which file the row went to, and `pairing`, the pairing it belongs to
|
||||
// ("" for a box-level file). The file stays the record: a system log that is
|
||||
// missing or refuses a write costs that copy and nothing else, and is never a
|
||||
// reason to refuse or slow the op being audited.
|
||||
//
|
||||
// A root command's text is the one thing the file has that the system-log line
|
||||
// does not: it is whatever the host typed, a password on a command line
|
||||
// included, and the system log is readable by more accounts than the 0600 file
|
||||
// and forwarded off the machine by design. The line keeps command_sha256 and
|
||||
// command_bytes, so a collector can still match it to the file and the
|
||||
// transcript and see if either was changed.
|
||||
//
|
||||
// The write itself never runs on the audited op's goroutine. The syslog socket
|
||||
// is a blocking write: a journald or syslog daemon that is stuck or slow would
|
||||
// otherwise hold every audited op, and every other audit writer behind the
|
||||
// lock, for as long as it stays stuck. Lines go into a bounded queue that one
|
||||
// goroutine drains; a full queue drops the line and counts it, and the next
|
||||
// line queued after the gap carries the count (auditDroppedField).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
auditSyslogTag = "privatae-bastion-audit"
|
||||
auditEventSource = "PrivataeBastionAudit"
|
||||
auditEventID = 1
|
||||
auditFilePairing = "pairing_audit.jsonl"
|
||||
auditFilePairingD = "audit.jsonl"
|
||||
)
|
||||
|
||||
// auditSink takes one line for the system log. A variable so a test can watch
|
||||
// the lines; the default queues it for the platform's writer and never blocks.
|
||||
var auditSink = auditEnqueue
|
||||
|
||||
const (
|
||||
// auditQueueMax lines wait for the system log; beyond that they are
|
||||
// dropped and counted. A burst of audited ops far larger than any lane
|
||||
// produces fits; a system log stuck for good holds no more than this.
|
||||
auditQueueMax = 4096
|
||||
// auditDroppedField is added to the first line queued after a drop: how
|
||||
// many lines the system log lost just before it, so the gap shows where.
|
||||
auditDroppedField = "system_log_dropped"
|
||||
// auditFlushOnExit bounds how long a finished command waits for its rows
|
||||
// to reach a slow system log.
|
||||
auditFlushOnExit = 2 * time.Second
|
||||
)
|
||||
|
||||
// auditQueue is the bounded queue in front of a system-log writer, drained by
|
||||
// one goroutine so lines keep their order.
|
||||
type auditQueue struct {
|
||||
ch chan []byte
|
||||
write func([]byte)
|
||||
once sync.Once
|
||||
dropped atomic.Int64
|
||||
pending atomic.Int64 // queued or being written
|
||||
}
|
||||
|
||||
func newAuditQueue(n int, write func([]byte)) *auditQueue {
|
||||
return &auditQueue{ch: make(chan []byte, n), write: write}
|
||||
}
|
||||
|
||||
// The box's one queue, in front of systemAuditWrite (auditlog_*.go).
|
||||
var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) })
|
||||
|
||||
func auditEnqueue(line []byte) { auditQ.enqueue(line) }
|
||||
|
||||
// auditFlush waits, at most d, for queued lines to reach the system log. A
|
||||
// command that audits and then exits (pair, claim-anchor, set-mode) calls it
|
||||
// on the way out (main.go), so its rows are not lost with the process; a
|
||||
// stuck system log costs that wait and nothing more.
|
||||
func auditFlush(d time.Duration) { auditQ.flush(d) }
|
||||
|
||||
// enqueue hands one line to the drain goroutine, or drops it and counts it
|
||||
// when the queue is full. It never waits on the writer.
|
||||
func (q *auditQueue) enqueue(line []byte) {
|
||||
q.once.Do(func() { go q.drain() })
|
||||
n := q.dropped.Swap(0)
|
||||
if n > 0 {
|
||||
line = auditWithDropped(line, n)
|
||||
}
|
||||
q.pending.Add(1)
|
||||
select {
|
||||
case q.ch <- line:
|
||||
default:
|
||||
q.pending.Add(-1)
|
||||
q.dropped.Add(n + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *auditQueue) drain() {
|
||||
for line := range q.ch {
|
||||
q.write(line)
|
||||
q.pending.Add(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *auditQueue) flush(d time.Duration) {
|
||||
deadline := time.Now().Add(d)
|
||||
for q.pending.Load() > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// auditWithDropped adds the drop count to a line (every line here is the
|
||||
// JSON object auditToSystemLog made).
|
||||
func auditWithDropped(line []byte, n int64) []byte {
|
||||
fields := map[string]any{}
|
||||
dec := json.NewDecoder(bytes.NewReader(line))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&fields); err != nil {
|
||||
return line
|
||||
}
|
||||
fields[auditDroppedField] = n
|
||||
out, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return line
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditToSystemLog sends one audit row, as it was written to its file, to the
|
||||
// system log, with the file's name and the pairing in front.
|
||||
func auditToSystemLog(file, pairing string, rec []byte) {
|
||||
fields := map[string]any{}
|
||||
dec := json.NewDecoder(bytes.NewReader(rec))
|
||||
dec.UseNumber() // numbers go out exactly as the file has them
|
||||
if err := dec.Decode(&fields); err != nil {
|
||||
return
|
||||
}
|
||||
if cmd, ok := fields["command"].(string); ok {
|
||||
// The text stays in the file; see the top of this file.
|
||||
delete(fields, "command")
|
||||
if _, ok := fields["command_bytes"]; !ok {
|
||||
fields["command_bytes"] = len(cmd)
|
||||
}
|
||||
}
|
||||
fields["audit"] = file
|
||||
fields["pairing"] = pairing
|
||||
line, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
auditSink(line)
|
||||
}
|
||||
|
||||
// auditPairingOfDir names the pairing of a per-pairing audit file from its
|
||||
// directory: the last path element, which is the pairing's host name
|
||||
// (Config.dcnDir: dcn/<host_name>), except for a legacy pairing that kept the
|
||||
// old shared directory, where it is that directory's name.
|
||||
func auditPairingOfDir(dir string) string {
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(filepath.Clean(dir))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/syslog"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
auditSyslogMu sync.Mutex
|
||||
auditSyslog *syslog.Writer
|
||||
)
|
||||
|
||||
// systemAuditWrite sends one line to syslog (journald on a systemd machine),
|
||||
// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on
|
||||
// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped
|
||||
// after a failed write, so the next row tries again; a machine with no syslog
|
||||
// socket costs the copy, never the row in the file.
|
||||
func systemAuditWrite(line []byte) {
|
||||
auditSyslogMu.Lock()
|
||||
defer auditSyslogMu.Unlock()
|
||||
if auditSyslog == nil {
|
||||
w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
auditSyslog = w
|
||||
}
|
||||
if err := auditSyslog.Info(string(line)); err != nil {
|
||||
_ = auditSyslog.Close()
|
||||
auditSyslog = nil
|
||||
}
|
||||
}
|
||||
|
||||
// registerAuditEventSource has nothing to register outside Windows: syslog
|
||||
// takes any tag.
|
||||
func registerAuditEventSource() error { return nil }
|
||||
|
||||
func removeAuditEventSource() {}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The sink the binary ships with, read before auditlog_test.go's init swaps
|
||||
// it out (package variables are set before any init runs).
|
||||
var auditSinkShipped = auditSink
|
||||
|
||||
// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the
|
||||
// platform's writer called on the audited op's own goroutine.
|
||||
func TestAuditSinkIsTheQueue(t *testing.T) {
|
||||
if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() {
|
||||
t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns,
|
||||
// far more rows than the queue holds are all taken at once; the ones past the
|
||||
// bound are dropped and counted, and the count rides on the next line that
|
||||
// gets into the queue.
|
||||
func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
q := newAuditQueue(8, func(l []byte) {
|
||||
<-release
|
||||
mu.Lock()
|
||||
got = append(got, string(l))
|
||||
mu.Unlock()
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 1000; i++ {
|
||||
q.enqueue([]byte(`{"n":1}`))
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("audit rows waited on a stuck system log")
|
||||
}
|
||||
close(release)
|
||||
q.flush(5 * time.Second)
|
||||
// Nothing lost without a count: every one of the 1001 rows is either a
|
||||
// line written or counted on a line written after the gap.
|
||||
q.enqueue([]byte(`{"n":2}`))
|
||||
q.flush(5 * time.Second)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var counted float64
|
||||
for _, l := range got {
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(l), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, ok := m[auditDroppedField].(float64); ok {
|
||||
counted += n
|
||||
}
|
||||
}
|
||||
if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 {
|
||||
t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in
|
||||
// the 0600 file; the system-log line carries its digest and length.
|
||||
func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) {
|
||||
var line string
|
||||
prev := auditSink
|
||||
auditSink = func(l []byte) { line = string(l) }
|
||||
t.Cleanup(func() { auditSink = prev })
|
||||
auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`))
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(line, "hunter2") || m["command"] != nil {
|
||||
t.Fatalf("the command text reached the system log: %s", line)
|
||||
}
|
||||
if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) {
|
||||
t.Fatalf("the line must keep the digest and the length: %s", line)
|
||||
}
|
||||
auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`))
|
||||
if !strings.Contains(line, `"command_bytes":70000`) {
|
||||
t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/syslog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a
|
||||
// datagram socket nobody reads blocks once the socket's queue is full, as
|
||||
// /dev/log does while journald is stuck. Audit rows behind the queue all
|
||||
// return at once; the same writer called inline (as 0.54.0 first did) does not.
|
||||
func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "al")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
sock := filepath.Join(dir, "s")
|
||||
l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Closing the reading end is what lets the stuck writes below return
|
||||
// (refused) so their goroutines end; the writer itself is never closed,
|
||||
// since its Close would wait behind a write that is stuck.
|
||||
t.Cleanup(func() { l.Close() })
|
||||
w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(line []byte) { _ = w.Info(string(line)) }
|
||||
|
||||
// The inline writer does block: without this the test proves nothing.
|
||||
inline := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 5000; i++ {
|
||||
write([]byte(`{"op":"x"}`))
|
||||
}
|
||||
close(inline)
|
||||
}()
|
||||
select {
|
||||
case <-inline:
|
||||
t.Skip("this kernel never blocked a datagram writer; nothing to bound")
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
q := newAuditQueue(64, write)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 5000; i++ {
|
||||
q.enqueue([]byte(`{"op":"y"}`))
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("audit rows waited on a stuck syslog socket")
|
||||
}
|
||||
if q.dropped.Load() == 0 {
|
||||
t.Fatal("rows past the bound must be counted as dropped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The test binary never writes to the real system log: the suite's audit
|
||||
// rows would otherwise land in the journal of whatever machine runs it.
|
||||
func init() { auditSink = func([]byte) {} }
|
||||
|
||||
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
|
||||
func auditSinkCapture(t *testing.T) func() []map[string]any {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var lines [][]byte
|
||||
prev := auditSink
|
||||
auditSink = func(line []byte) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lines = append(lines, append([]byte(nil), line...))
|
||||
}
|
||||
t.Cleanup(func() { auditSink = prev })
|
||||
return func() []map[string]any {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var out []map[string]any
|
||||
for _, l := range lines {
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal(l, &m); err != nil {
|
||||
t.Fatalf("system-log line is not JSON: %s", l)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func auditFileRows(t *testing.T, path string) []map[string]any {
|
||||
t.Helper()
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("audit file %s: %v", path, err)
|
||||
}
|
||||
defer fh.Close()
|
||||
var out []map[string]any
|
||||
sc := bufio.NewScanner(fh)
|
||||
for sc.Scan() {
|
||||
m := map[string]any{}
|
||||
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
|
||||
t.Fatalf("audit row is not JSON: %s", sc.Text())
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditSameRow: the system-log line is the file's row, every field equal,
|
||||
// with `audit` and `pairing` added.
|
||||
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
|
||||
t.Helper()
|
||||
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
|
||||
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
|
||||
}
|
||||
for k, v := range file {
|
||||
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
|
||||
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
|
||||
}
|
||||
}
|
||||
if len(logged) != len(file)+2 {
|
||||
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
|
||||
}
|
||||
}
|
||||
|
||||
func jsonEq(a, b any) bool {
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
|
||||
// it writes to its file to the system log as well, unchanged, naming the file
|
||||
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
|
||||
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
|
||||
// terminals and root commands.
|
||||
func TestAuditRowsReachTheSystemLog(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
got := auditSinkCapture(t)
|
||||
cfgDir, err := configDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
|
||||
if err := os.MkdirAll(pairDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
|
||||
pairingAudit("pairing_add", "other.host.example", "ok")
|
||||
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
|
||||
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
|
||||
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
|
||||
|
||||
lines := got()
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
|
||||
}
|
||||
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
|
||||
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
|
||||
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
|
||||
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
|
||||
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
|
||||
}
|
||||
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
|
||||
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
|
||||
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
|
||||
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
|
||||
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
|
||||
}
|
||||
|
||||
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
|
||||
// costs the copy only; the row is still written to its file.
|
||||
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
|
||||
prev := auditSink
|
||||
auditSink = func([]byte) {}
|
||||
t.Cleanup(func() { auditSink = prev })
|
||||
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
|
||||
if err := os.MkdirAll(pairDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
|
||||
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
|
||||
t.Fatalf("file rows = %d, want 1", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
|
||||
// the digits the file has, not as a float.
|
||||
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
|
||||
var line string
|
||||
prev := auditSink
|
||||
auditSink = func(l []byte) { line = string(l) }
|
||||
t.Cleanup(func() { auditSink = prev })
|
||||
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
|
||||
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
|
||||
t.Fatalf("system-log line %s does not carry the id exactly", line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
)
|
||||
|
||||
var (
|
||||
auditEventMu sync.Mutex
|
||||
auditEventLog *eventlog.Log
|
||||
)
|
||||
|
||||
// systemAuditWrite sends one line to the Application event log under the
|
||||
// source PrivataeBastionAudit, event id 1, at information level. The source
|
||||
// is registered when the system service is installed
|
||||
// (registerAuditEventSource); before that, or if the log refuses, the copy is
|
||||
// lost and the row in the file is not. Called only from auditQ's drain
|
||||
// (auditlog.go): the event log service can be slow to answer.
|
||||
func systemAuditWrite(line []byte) {
|
||||
auditEventMu.Lock()
|
||||
defer auditEventMu.Unlock()
|
||||
if auditEventLog == nil {
|
||||
l, err := eventlog.Open(auditEventSource)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
auditEventLog = l
|
||||
}
|
||||
if err := auditEventLog.Info(auditEventID, string(line)); err != nil {
|
||||
_ = auditEventLog.Close()
|
||||
auditEventLog = nil
|
||||
}
|
||||
}
|
||||
|
||||
// registerAuditEventSource registers PrivataeBastionAudit as an Application
|
||||
// event log source, so the rows show with their own source name. Already
|
||||
// registered is success.
|
||||
func registerAuditEventSource() error {
|
||||
err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error)
|
||||
if err != nil && strings.Contains(err.Error(), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// removeAuditEventSource unregisters the source when the service is removed.
|
||||
// Events already written stay in the log.
|
||||
func removeAuditEventSource() {
|
||||
_ = eventlog.Remove(auditEventSource)
|
||||
}
|
||||
+418
-33
@@ -11,6 +11,7 @@ package main
|
||||
// runs) is driven in claims_unix_test.go and beside each lane's own tests.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -49,23 +50,7 @@ func parseSources(t *testing.T) *srcPkg {
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", n, err)
|
||||
}
|
||||
p.files[n] = f
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := funcKey(fd)
|
||||
// The same name in two platform files (processIsRoot, …) is kept
|
||||
// once per file so neither body is lost to the other.
|
||||
if _, dup := p.funcs[key]; dup {
|
||||
key = key + "@" + n
|
||||
}
|
||||
p.funcs[key] = fd
|
||||
if fd.Recv != nil {
|
||||
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
|
||||
}
|
||||
}
|
||||
p.addFile(n, f)
|
||||
}
|
||||
if len(p.files) < 50 {
|
||||
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
|
||||
@@ -73,6 +58,46 @@ func parseSources(t *testing.T) *srcPkg {
|
||||
return p
|
||||
}
|
||||
|
||||
// addFile registers one parsed file and its functions. A test that proves a
|
||||
// check can fail adds a synthetic file through here, so it is indexed exactly
|
||||
// as a real one would be.
|
||||
func (p *srcPkg) addFile(n string, f *ast.File) {
|
||||
p.files[n] = f
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := funcKey(fd)
|
||||
// The same name in two platform files (processIsRoot, …) is kept
|
||||
// once per file so neither body is lost to the other.
|
||||
if _, dup := p.funcs[key]; dup {
|
||||
key = key + "@" + n
|
||||
}
|
||||
p.funcs[key] = fd
|
||||
if fd.Recv != nil {
|
||||
p.methods[fd.Name.Name] = append(p.methods[fd.Name.Name], key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// funcKeysIn is the p.funcs key of every function declared in file n.
|
||||
func (p *srcPkg) funcKeysIn(n string) []string {
|
||||
var out []string
|
||||
for _, d := range p.files[n].Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key := funcKey(fd)
|
||||
if p.funcs[key] != fd {
|
||||
key = key + "@" + n
|
||||
}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// funcKey is "name" for a function and "Type.name" for a method.
|
||||
func funcKey(fd *ast.FuncDecl) string {
|
||||
if fd.Recv == nil || len(fd.Recv.List) == 0 {
|
||||
@@ -488,10 +513,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// No MCP endpoint. Every HTTP route the binary registers, on any listener,
|
||||
// is in this table; none of them, and no op name on the management lane,
|
||||
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC,
|
||||
// the protocol MCP is carried on; and no dependency is an MCP library.
|
||||
// No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
|
||||
// the binary registers, on any listener, is in this table; none of them, and
|
||||
// no op name on the management lane, names MCP; no string in the code is a
|
||||
// URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
|
||||
// carried on; and no dependency is an MCP library.
|
||||
//
|
||||
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
|
||||
// string containing "/mcp" at all. The connection-config reader
|
||||
// (mcpconfig.go) has to name the files agent clients declare their tool
|
||||
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
|
||||
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
|
||||
// protocol instead of the word (mcpReaderViolations):
|
||||
//
|
||||
// - no literal anywhere is a URL or route with an mcp path segment;
|
||||
// - the config basenames and keys may appear in mcpconfig.go and nowhere
|
||||
// else, so a second file that starts reading (or writing) them is a
|
||||
// change a reviewer sees here;
|
||||
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
|
||||
// can reach through this package's call graph, net.Dial or net.Listen,
|
||||
// net/http, os/exec, TLS dialling or the websocket client. The reader is
|
||||
// handed its process list by agents.go; it starts and dials nothing.
|
||||
//
|
||||
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
|
||||
// fail.
|
||||
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||
p := parseSources(t)
|
||||
// A route served by more than one surface names each file, "a.go|b.go":
|
||||
@@ -547,14 +592,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range mcpReaderViolations(p) {
|
||||
t.Error(v)
|
||||
}
|
||||
for name, f := range p.files {
|
||||
for _, s := range stringLits(f) {
|
||||
l := strings.ToLower(s)
|
||||
if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") ||
|
||||
strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") {
|
||||
t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)
|
||||
}
|
||||
}
|
||||
for _, imp := range f.Imports {
|
||||
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
|
||||
t.Errorf("%s imports %s", name, imp.Path.Value)
|
||||
@@ -575,6 +616,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mcpConfigNames are the file basenames and keys agent clients declare tool
|
||||
// servers under. Only mcpconfig.go may name them. settings.json and
|
||||
// config.toml are left out on purpose: many programs use those names (the
|
||||
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
|
||||
// about tool servers; the keys beside them do.
|
||||
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
|
||||
"mcpservers", "context_servers", "mcp_servers",
|
||||
// The clients' own directories, which name a settings.json or
|
||||
// config.toml as surely as the basename would.
|
||||
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
|
||||
|
||||
// mcpURLOrRoute reports whether a string literal is a URL or route that
|
||||
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
|
||||
// /mcp-proxy) except a client's config FILE name: /mcp.json and
|
||||
// /mcp_config.json.
|
||||
func mcpURLOrRoute(l string) bool {
|
||||
l = strings.ToLower(l)
|
||||
if i := strings.Index(l, "://"); i >= 0 {
|
||||
rest := l[i+3:]
|
||||
if j := strings.IndexByte(rest, '/'); j >= 0 {
|
||||
return strings.Contains(rest[j:], "/mcp")
|
||||
}
|
||||
return false
|
||||
}
|
||||
for i := 0; ; {
|
||||
j := strings.Index(l[i:], "/mcp")
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
rest := l[i+j+len("/mcp"):]
|
||||
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
|
||||
return true
|
||||
}
|
||||
i += j + len("/mcp")
|
||||
}
|
||||
}
|
||||
|
||||
// mcpReaderSink: a package-level function or type that opens a connection,
|
||||
// resolves a name, or starts a process — named as a call or as a value.
|
||||
func mcpReaderSink(recv, sel string) bool {
|
||||
switch {
|
||||
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
|
||||
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
|
||||
return true
|
||||
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
|
||||
return true
|
||||
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
|
||||
return true
|
||||
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
|
||||
return true
|
||||
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
|
||||
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
|
||||
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
|
||||
return true
|
||||
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
|
||||
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
|
||||
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
|
||||
return true
|
||||
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
|
||||
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
|
||||
// A DLL loaded by name reaches any function in it, past every check here.
|
||||
return true
|
||||
case recv == "registry" && sel == "OpenRemoteKey":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
|
||||
// whatever the receiver is called — d.Dial on a net.Dialer variable,
|
||||
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
|
||||
// purpose: it only applies to code the reader can reach.
|
||||
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
|
||||
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
|
||||
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
|
||||
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
|
||||
"CombinedOutput": true, "Connect": true}
|
||||
|
||||
// mcpReaderAllowedImports is every package a config-reader file may import:
|
||||
// parsing, strings, and opening files. Anything else is a change a reviewer
|
||||
// has to see here first. x/sys/windows and its registry package are the
|
||||
// Windows open (CreateFile and the handle's own type, attributes and owner)
|
||||
// and the profile list it checks owners against; each can also start a
|
||||
// process, open a socket, load a DLL or open a remote registry, and those
|
||||
// calls are sinks in mcpReaderSink like their unix equivalents.
|
||||
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
|
||||
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
|
||||
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
|
||||
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
|
||||
"golang.org/x/sys/windows/registry": true}
|
||||
|
||||
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
|
||||
// The http, exec and websocket packages also export constants and types
|
||||
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
|
||||
// their functions count here.
|
||||
func mcpValueSink(recv, sel string) bool {
|
||||
switch recv {
|
||||
case "http", "exec", "websocket", "rpc", "jsonrpc":
|
||||
switch sel {
|
||||
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
|
||||
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return mcpReaderSink(recv, sel)
|
||||
}
|
||||
|
||||
// mcpSinksIn lists the network and process calls inside one declaration.
|
||||
func mcpSinksIn(n ast.Node) []string {
|
||||
var out []string
|
||||
called := map[*ast.SelectorExpr]bool{}
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if c, ok := x.(*ast.CallExpr); ok {
|
||||
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
|
||||
called[sel] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
switch v := x.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
recv := exprText(v.X)
|
||||
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
|
||||
out = append(out, recv+"."+v.Sel.Name)
|
||||
}
|
||||
case *ast.CallExpr:
|
||||
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
|
||||
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// mcpReaderViolations is every way the package breaks the guarantee above.
|
||||
func mcpReaderViolations(p *srcPkg) []string {
|
||||
var out []string
|
||||
names := make([]string, 0, len(p.files))
|
||||
for n := range p.files {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var readerFiles []string
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, "mcpconfig") {
|
||||
readerFiles = append(readerFiles, name)
|
||||
}
|
||||
for _, s := range stringLits(p.files[name]) {
|
||||
l := strings.ToLower(s)
|
||||
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
|
||||
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
|
||||
}
|
||||
// The protocol's namespace. The reader may name the npm scope
|
||||
// its reference servers are published under, to recognise one
|
||||
// running; nothing else may name it at all.
|
||||
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
|
||||
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
|
||||
}
|
||||
if name == "mcpconfig.go" {
|
||||
continue
|
||||
}
|
||||
for _, b := range mcpConfigNames {
|
||||
if strings.Contains(l, b) {
|
||||
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(readerFiles) == 0 {
|
||||
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
|
||||
}
|
||||
var roots []string
|
||||
for _, name := range readerFiles {
|
||||
for _, imp := range p.files[name].Imports {
|
||||
path, _ := strconv.Unquote(imp.Path.Value)
|
||||
if !mcpReaderAllowedImports[path] {
|
||||
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
|
||||
}
|
||||
}
|
||||
roots = append(roots, p.funcKeysIn(name)...)
|
||||
}
|
||||
// Package-level variables are nodes too ("var:name"): a function held in
|
||||
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
|
||||
// reads the variable, and a reader file's own variables are roots, since
|
||||
// their initialisers run with the package.
|
||||
g := p.callGraph()
|
||||
vars := map[string][]ast.Expr{}
|
||||
for _, name := range names {
|
||||
for _, d := range p.files[name].Decls {
|
||||
gd, ok := d.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
for _, sp := range gd.Specs {
|
||||
vs := sp.(*ast.ValueSpec)
|
||||
for _, id := range vs.Names {
|
||||
key := "var:" + id.Name
|
||||
vars[key] = append(vars[key], vs.Values...)
|
||||
if strings.HasPrefix(name, "mcpconfig") {
|
||||
roots = append(roots, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refs := func(n ast.Node) []string {
|
||||
var out []string
|
||||
ast.Inspect(n, func(x ast.Node) bool {
|
||||
if id, ok := x.(*ast.Ident); ok {
|
||||
if _, isVar := vars["var:"+id.Name]; isVar {
|
||||
out = append(out, "var:"+id.Name)
|
||||
}
|
||||
if _, isFn := p.funcs[id.Name]; isFn {
|
||||
out = append(out, id.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
sinks := map[string][]string{}
|
||||
for key, fd := range p.funcs {
|
||||
g[key] = append(g[key], refs(fd)...)
|
||||
if s := mcpSinksIn(fd); len(s) > 0 {
|
||||
sinks[key] = s
|
||||
}
|
||||
}
|
||||
for key, vals := range vars {
|
||||
for _, v := range vals {
|
||||
g[key] = append(g[key], refs(v)...)
|
||||
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
|
||||
}
|
||||
}
|
||||
seen := reach(g, roots, nil)
|
||||
for fn, path := range seen {
|
||||
if what := sinks[fn]; len(what) > 0 {
|
||||
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
|
||||
fn, strings.Join(what, ", "), path))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
|
||||
// network package, reaches a dialler through another function, or a second
|
||||
// file that names a client's config file or an /mcp URL: each one is added
|
||||
// as a synthetic source and must be reported. The real tree must report
|
||||
// nothing, and the scan must have found sinks and roots to compare, so a
|
||||
// broken extraction cannot pass by finding nothing.
|
||||
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
|
||||
clean := parseSources(t)
|
||||
if v := mcpReaderViolations(clean); len(v) != 0 {
|
||||
t.Fatalf("the real tree must be clean: %v", v)
|
||||
}
|
||||
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
|
||||
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
|
||||
}
|
||||
add := func(p *srcPkg, name, src string) {
|
||||
t.Helper()
|
||||
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.addFile(name, f)
|
||||
}
|
||||
cases := []struct {
|
||||
name, want string
|
||||
files map[string]string
|
||||
}{
|
||||
{"the reader execs", "can reach mcpSneakRun", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
|
||||
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
|
||||
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
|
||||
{"the reader imports net/http", "imports net/http", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
|
||||
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
|
||||
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
|
||||
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
|
||||
{"a route", "addresses an MCP endpoint", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
|
||||
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
|
||||
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
|
||||
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
|
||||
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
|
||||
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
|
||||
{"the reader resolves a name", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
|
||||
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
|
||||
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
|
||||
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
|
||||
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
|
||||
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
|
||||
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
|
||||
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
|
||||
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
|
||||
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
|
||||
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p := parseSources(t)
|
||||
for n, src := range c.files {
|
||||
add(p, n, src)
|
||||
}
|
||||
found := false
|
||||
for _, v := range mcpReaderViolations(p) {
|
||||
if strings.Contains(v, c.want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
|
||||
}
|
||||
}
|
||||
// A config file name is not a URL path, and a file in the reader may
|
||||
// name one.
|
||||
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
|
||||
if mcpURLOrRoute(ok) {
|
||||
t.Errorf("%q read as an MCP URL or route", ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// callGraph maps each function to what it calls or refers to, by name.
|
||||
// A method call through a value is resolved to every method of that name in
|
||||
// the package, so the graph over-approximates: a path it cannot rule out is
|
||||
@@ -582,6 +963,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
|
||||
// followed; none of the handlers below does that.
|
||||
func (p *srcPkg) callGraph() map[string][]string {
|
||||
g := map[string][]string{}
|
||||
// The per-file duplicates ("name@file"), by name, built once: looking
|
||||
// them up by prefix for every identifier made this graph cost seconds.
|
||||
dups := map[string][]string{}
|
||||
for k := range p.funcs {
|
||||
if i := strings.IndexByte(k, '@'); i >= 0 {
|
||||
dups[k[:i]] = append(dups[k[:i]], k)
|
||||
}
|
||||
}
|
||||
for key, fd := range p.funcs {
|
||||
if fd.Body == nil {
|
||||
continue
|
||||
@@ -593,11 +982,7 @@ func (p *srcPkg) callGraph() map[string][]string {
|
||||
if _, ok := p.funcs[v.Name]; ok {
|
||||
out = append(out, v.Name)
|
||||
}
|
||||
for k := range p.funcs {
|
||||
if strings.HasPrefix(k, v.Name+"@") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
out = append(out, dups[v.Name]...)
|
||||
case *ast.CallExpr:
|
||||
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
|
||||
out = append(out, p.methods[sel.Sel.Name]...)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
// The release builds linux, darwin and windows (deploy/build.sh). Any other
|
||||
// unix (freebsd, openbsd) still builds, with the local context socket unable
|
||||
// to tell who is on the other end: contextPeerUser then fails, and
|
||||
// contextSockServe answers every connection with that reason and forwards
|
||||
// nothing. An ask that cannot be attributed is refused, never let through.
|
||||
func contextPeerUID(uintptr) (uint32, error) { return 0, contextSockUnsupported }
|
||||
@@ -189,6 +189,7 @@ func dcnAudit(dir, op string, storeID int64, path, decision string) {
|
||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
||||
"store_id": storeID, "path": path, "decision": decision,
|
||||
})
|
||||
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
|
||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
|
||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
|
||||
@@ -98,15 +98,13 @@ func faResolveDropOwner(root, stateDir string) (*faOwner, error) {
|
||||
if err != nil || sid == nil {
|
||||
return nil, fmt.Errorf("cannot read the folder's owner: %v", err)
|
||||
}
|
||||
name, domain, typ, err := sid.LookupAccount("")
|
||||
// Bounded and cached (sidnames.go); no answer in time refuses the start.
|
||||
acct, err := faWinDropOwnerAccount(sid.String(), winAccounts, faDropSIDLane)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid.String())
|
||||
return nil, err
|
||||
}
|
||||
full := name
|
||||
if domain != "" {
|
||||
full = domain + `\` + name
|
||||
}
|
||||
if why := faWinOwnerRefusal(sid.String(), typ, full); why != "" {
|
||||
name, full := acct.Account, sidAccountLabel(acct)
|
||||
if why := faWinOwnerRefusal(sid.String(), acct.Type, full); why != "" {
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
home := faWinProfileDir(sid.String())
|
||||
|
||||
@@ -5,12 +5,38 @@ package main
|
||||
// that gather their inputs are in the tagged file.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// faDropSIDLane bounds the lookup of the granted folder's owner (sidnames.go).
|
||||
// The start runs on the store's own goroutine and nothing waits on it, so the
|
||||
// budget is generous enough for a slow directory that does answer. It is a
|
||||
// bound all the same: the store is marked as ensuring for as long as the start
|
||||
// runs, and every reconcile skips it while it is, so a lookup that never
|
||||
// returned would hold the store as "installing" for good. Timed out, the store
|
||||
// is failed with the reason and the reconcile tries again
|
||||
// (faReconcileEvery), by when a late answer is in the cache.
|
||||
var faDropSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 10 * time.Second}
|
||||
|
||||
// faWinDropOwnerAccount is the folder owner's account from the directory. It
|
||||
// fails closed: no account, or no answer in time, and no agent is started.
|
||||
func faWinDropOwnerAccount(sid string, dir *sidDirectory, lane sidLane) (sidAccount, error) {
|
||||
acct, outcome := dir.resolve(sid, lane)
|
||||
switch outcome {
|
||||
case sidTimedOut:
|
||||
return sidAccount{}, fmt.Errorf("refusing to run the agent for now: this machine's account directory did not answer "+
|
||||
"for the granted folder's owner (%s) within %v; it is tried again at the next check", sid, lane.budget)
|
||||
case sidUnmapped:
|
||||
return sidAccount{}, fmt.Errorf("refusing to run the agent: the granted folder's owner (%s) has no account on this machine", sid)
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// winSidTypeUser is SidTypeUser: the SID names a person's account rather than
|
||||
// a group, an alias or a well-known identity.
|
||||
const winSidTypeUser = 1
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/build"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// EVERY PLATFORM FUNCTION EXISTS ON EVERY TARGET THAT BUILDS THIS PACKAGE.
|
||||
//
|
||||
// A function with one file per platform (contextPeerUID in contextsock_linux.go
|
||||
// and contextsock_darwin.go) and a caller in a file every unix builds
|
||||
// (contextsock_unix.go) leaves any other unix with an undefined name, and
|
||||
// nothing in CI cross-builds one: `GOOS=freebsd go build` failed that way.
|
||||
// The release targets are linux, darwin and windows (deploy/build.sh);
|
||||
// freebsd and openbsd are not released, but they build, with what has no
|
||||
// implementation there refusing (contextsock_other.go and the other
|
||||
// *_other.go files).
|
||||
//
|
||||
// This asks the toolchain which files each target compiles (go/build's
|
||||
// MatchFile) and checks that every package function called from those files
|
||||
// is declared in them. It is not a type check, and it does not compile; it
|
||||
// catches the missing-platform-file mistake, which is the one that happened,
|
||||
// on the platform the tests already run on.
|
||||
func TestEveryCalledPlatformFunctionIsDeclaredOnEveryTarget(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type parsed struct {
|
||||
name string
|
||||
file *ast.File
|
||||
}
|
||||
var files []parsed
|
||||
declaredAnywhere := map[string]bool{}
|
||||
fset := token.NewFileSet()
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, n, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", n, err)
|
||||
}
|
||||
files = append(files, parsed{n, f})
|
||||
for _, d := range f.Decls {
|
||||
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
|
||||
declaredAnywhere[fd.Name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(files) < 50 {
|
||||
t.Fatalf("parsed only %d files; is the test running in the package directory?", len(files))
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin", "windows", "freebsd", "openbsd"} {
|
||||
ctx := build.Default
|
||||
ctx.GOOS = goos
|
||||
ctx.CgoEnabled = false
|
||||
declared := map[string]bool{}
|
||||
var matched []parsed
|
||||
for _, p := range files {
|
||||
ok, err := ctx.MatchFile(".", p.name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p.name, err)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, p)
|
||||
for _, d := range p.file.Decls {
|
||||
if fd, ok := d.(*ast.FuncDecl); ok && fd.Recv == nil {
|
||||
declared[fd.Name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
missing := map[string]string{}
|
||||
for _, p := range matched {
|
||||
ast.Inspect(p.file, func(x ast.Node) bool {
|
||||
c, ok := x.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && declaredAnywhere[id.Name] && !declared[id.Name] {
|
||||
missing[id.Name] = p.name
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
var out []string
|
||||
for fn, where := range missing {
|
||||
out = append(out, fn+" (called in "+where+")")
|
||||
}
|
||||
sort.Strings(out)
|
||||
if len(out) > 0 {
|
||||
t.Errorf("GOOS=%s: called but not declared for this target: %s", goos, strings.Join(out, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// installShFunc returns one shell function's text from deploy/install.sh.
|
||||
func installShFunc(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(name) + `\(\) \{\n.*?^\}\n`).Find(raw)
|
||||
if m == nil {
|
||||
t.Fatalf("install.sh defines no %s()", name)
|
||||
}
|
||||
return string(m)
|
||||
}
|
||||
|
||||
// TestInstallShKeepsAPairedLocalMachineLocal: a root reinstall defaults to
|
||||
// networked, except on a machine already paired in local mode, which keeps
|
||||
// its mode (paired_local). Switched, its pairing would sit on an unanchored
|
||||
// networked machine, which refuses every op until claim-anchor.
|
||||
func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) {
|
||||
fn := installShFunc(t, "paired_local")
|
||||
run := func(dirs ...string) bool {
|
||||
t.Helper()
|
||||
args := append([]string{"-c", fn + `paired_local "$@"`, "sh"}, dirs...)
|
||||
err := exec.Command("sh", args...).Run()
|
||||
if _, ok := err.(*exec.ExitError); err != nil && !ok {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
mk := func(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
d := t.TempDir()
|
||||
for name, body := range files {
|
||||
p := filepath.Join(d, name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
missing := filepath.Join(t.TempDir(), "none")
|
||||
|
||||
if !run(mk(t, map[string]string{"pairings/a.host.example.json": "{}"}), missing) {
|
||||
t.Fatal("a paired machine with no mode file is local and must stay local")
|
||||
}
|
||||
if !run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": `{"mode":"local"}`})) {
|
||||
t.Fatal("a paired machine in local mode must stay local")
|
||||
}
|
||||
if !run(mk(t, map[string]string{"config.json": "{}"})) {
|
||||
t.Fatal("a pre-0.10 single pairing (config.json) is a pairing")
|
||||
}
|
||||
if !run(missing, mk(t, map[string]string{"pairings/a.json": "{}"})) {
|
||||
t.Fatal("a pairing in the second folder counts")
|
||||
}
|
||||
if run(mk(t, map[string]string{"pairings/a.json": "{}", "mode.json": "{\n \"mode\": \"networked\"\n}"})) {
|
||||
t.Fatal("a networked machine is not kept local")
|
||||
}
|
||||
if run(mk(t, map[string]string{"pairings/.keep": ""}), missing) {
|
||||
t.Fatal("a machine with no pairing gets the networked default")
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ func main() {
|
||||
// Started by the Windows Service Control Manager: the service runs `run`
|
||||
// itself and never reaches the argument parsing below.
|
||||
if maybeRunAsWindowsService() {
|
||||
auditFlush(auditFlushOnExit)
|
||||
return
|
||||
}
|
||||
if len(os.Args) < 2 {
|
||||
@@ -618,6 +619,9 @@ func main() {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
// The command's audit rows reach the system log before the process ends
|
||||
// (auditlog.go queues them off the op's own path).
|
||||
auditFlush(auditFlushOnExit)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every value below is invented. Each case is a way a real config carries a
|
||||
// credential on the command line; the record that leaves the machine must
|
||||
// carry none of them, in any field.
|
||||
func TestMCPRecordNeverCarriesASecretValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
e mcpEntry
|
||||
secrets []string
|
||||
}{
|
||||
{"a database URL with a password", mcpEntry{command: "npx", args: []string{"-y", "@modelcontextprotocol/server-postgres", "postgresql://demo:PwDemo7731@localhost/demo"}}, []string{"PwDemo7731"}},
|
||||
{"a key in a URL query, and an Authorization header argument", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://mcp.example.com/sse?api_key=QkeyDemo1234", "--header", "Authorization: Bearer HdrDemo45678"}}, []string{"QkeyDemo1234", "HdrDemo45678"}},
|
||||
{"a header given as --header=Name:value", mcpEntry{command: "npx", args: []string{"mcp-remote", "--header=X-Api-Key:HkeyDemo9999"}}, []string{"HkeyDemo9999"}},
|
||||
{"a short password after a secret flag", mcpEntry{command: "demo-srv", args: []string{"--password", "shrt1"}}, []string{"shrt1"}},
|
||||
{"a password that starts with a dollar sign", mcpEntry{command: "demo-srv", args: []string{"--password", "$DollarPw99x"}}, []string{"DollarPw99x"}},
|
||||
{"a short value in docker -e NAME=value", mcpEntry{command: "docker", args: []string{"run", "-e", "API_KEY=ab12", "demo/img"}}, []string{"ab12"}},
|
||||
{"NAME=value inside --env=", mcpEntry{command: "docker", args: []string{"run", "--env=API_TOKEN=TokDemo5678", "demo/img"}}, []string{"TokDemo5678"}},
|
||||
{"the whole command line in command", mcpEntry{command: "demo-srv --token CmdTokDemo42"}, []string{"CmdTokDemo42"}},
|
||||
{"a git URL with a password", mcpEntry{command: "uvx", args: []string{"--from", "git+https://demo:GitPwDemo31@git.example.com/o/r", "demo"}}, []string{"GitPwDemo31"}},
|
||||
{"a git URL with a token as the user", mcpEntry{command: "npx", args: []string{"-y", "git+https://GitUserTok55@git.example.com/o/r"}}, []string{"GitUserTok55"}},
|
||||
{"a token in a URL path", mcpEntry{command: "npx", args: []string{"mcp-remote", "https://hooks.example.com/api/s/PathTokDemo8812/sse"}}, []string{"PathTokDemo8812"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, _ := json.Marshal(mcpAnalyze(c.e))
|
||||
for _, s := range c.secrets {
|
||||
if strings.Contains(string(b), s) {
|
||||
t.Errorf("%s: the record carries %q: %s", c.name, s, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A running server no file declares goes through the same redaction.
|
||||
out := correlateMCP(nil, []mcpProc{{PID: 7, PPID: 1, Argv: []string{"node", "/opt/demo/mcp-server.js",
|
||||
"postgresql://demo:ShadowPw77x@db.example.com/x"}}}, true)
|
||||
b, _ := json.Marshal(out)
|
||||
if strings.Contains(string(b), "ShadowPw77x") {
|
||||
t.Errorf("an undeclared server's record carries its password: %s", b)
|
||||
}
|
||||
// What is still kept: a reference to where the secret comes from, and
|
||||
// the URL's host.
|
||||
o := mcpAnalyze(mcpEntry{command: "demo-srv", args: []string{"--token", "${DEMO_TOKEN}", "https://mcp.example.com/sse"}})
|
||||
if !strings.Contains(o.Command, "${DEMO_TOKEN}") || !strings.Contains(o.Command, "https://mcp.example.com") {
|
||||
t.Errorf("redaction took more than the secret: %q", o.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// One record, and the list, stay bounded however large the file's names are
|
||||
// and however many processes look like servers.
|
||||
func TestMCPRecordAndListStayBounded(t *testing.T) {
|
||||
env := map[string]string{}
|
||||
for i := 0; i < 5000; i++ {
|
||||
env[fmt.Sprintf("DEMO_VAR_%04d_%s", i, strings.Repeat("x", 200))] = ""
|
||||
}
|
||||
o := mcpAnalyze(mcpEntry{name: strings.Repeat("n", 100000), command: "npx",
|
||||
args: []string{"-y", strings.Repeat("p", 100000)}, env: env})
|
||||
if b, _ := json.Marshal(o); len(b) > 8192 {
|
||||
t.Errorf("one stdio record is %d bytes", len(b))
|
||||
}
|
||||
o = mcpAnalyze(mcpEntry{name: "r", url: "https://" + strings.Repeat("a", 100000) + ".example.com/x"})
|
||||
if b, _ := json.Marshal(o); len(b) > 8192 {
|
||||
t.Errorf("one remote record is %d bytes", len(b))
|
||||
}
|
||||
var procs []mcpProc
|
||||
for i := 0; i < 3000; i++ {
|
||||
procs = append(procs, mcpProc{PID: 10 + i, PPID: 1, Argv: []string{"demo-mcp-server"}})
|
||||
}
|
||||
if out := correlateMCP(nil, procs, true); len(out) > mcpMaxServers {
|
||||
t.Errorf("%d records from undeclared servers; the list is capped at %d", len(out), mcpMaxServers)
|
||||
}
|
||||
}
|
||||
|
||||
// A crafted config.toml nested thousands deep is refused, not walked on the
|
||||
// stack; a realistic depth still parses.
|
||||
func TestParseTOMLSubsetRefusesDeepNesting(t *testing.T) {
|
||||
for name, src := range map[string]string{
|
||||
"arrays": "a = " + strings.Repeat("[", 5000) + strings.Repeat("]", 5000) + "\n",
|
||||
"inline tables": "a = " + strings.Repeat("{b = ", 5000) + "1" + strings.Repeat("}", 5000) + "\n",
|
||||
} {
|
||||
if _, err := parseTOMLSubset(src); err == nil {
|
||||
t.Errorf("%s nested 5000 deep parsed", name)
|
||||
}
|
||||
}
|
||||
if _, err := parseTOMLSubset("[mcp_servers.x]\nenv = { A = { B = [[1, 2], [3]] } }\n"); err != nil {
|
||||
t.Errorf("a realistic depth: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// macOS's nobody is uid -2 in its directory; it is a service account, not
|
||||
// an unread uid.
|
||||
func TestClassifyUnixOwnerNegativeDirectoryUID(t *testing.T) {
|
||||
f := classifyUnixOwner(unixOwner{UID: -2, Name: "nobody", SystemUIDMax: darwinSystemUIDMax, LocalRecord: ownerYes})
|
||||
if f.Class != ownerClassService {
|
||||
t.Errorf("uid -2: %+v", f)
|
||||
}
|
||||
if f := classifyUnixOwner(unixOwner{UID: -1, Name: "demo"}); f.Class != ownerUnknown {
|
||||
t.Errorf("an unread uid: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// dscl that was killed by the timeout has not said the record is missing:
|
||||
// only a real non-zero exit does.
|
||||
func TestDsclRecordMissingOnlyOnARealExit(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("needs sh")
|
||||
}
|
||||
_, killed := exec.Command("sh", "-c", "kill -9 $$").Output()
|
||||
_, missing := exec.Command("sh", "-c", "exit 56").Output()
|
||||
if dsclRecordMissing(killed) {
|
||||
t.Error("a killed dscl read as no local record")
|
||||
}
|
||||
if !dsclRecordMissing(missing) {
|
||||
t.Error("dscl exiting 56 did not read as no local record")
|
||||
}
|
||||
if dsclRecordMissing(nil) {
|
||||
t.Error("success read as no local record")
|
||||
}
|
||||
}
|
||||
|
||||
// A read that stops answering (a home on a network mount) holds neither the
|
||||
// beat nor a second reader, and a finished read is reused for the cache
|
||||
// window instead of redone on every 60-second beat.
|
||||
func TestMCPServersReportNeverWaitsOnAStuckRead(t *testing.T) {
|
||||
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
|
||||
block := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(block)
|
||||
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
|
||||
resetMCPConfigCache()
|
||||
})
|
||||
resetMCPConfigCache()
|
||||
var calls int32
|
||||
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
<-block
|
||||
return nil, true
|
||||
}
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, nil }
|
||||
mcpReadTimeout = 100 * time.Millisecond
|
||||
|
||||
for beat := 0; beat < 3; beat++ {
|
||||
done := make(chan mcpConfigScan, 1)
|
||||
go func() { _, s := mcpServersReport(); done <- s }()
|
||||
select {
|
||||
case s := <-done:
|
||||
if !s.TimedOut || !s.Supported {
|
||||
t.Fatalf("beat %d: a read that has not finished must say so: %+v", beat, s)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("beat %d waited on a stuck read", beat)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("%d reads started while one was stuck", n)
|
||||
}
|
||||
|
||||
resetMCPConfigCache()
|
||||
atomic.StoreInt32(&calls, 0)
|
||||
home := t.TempDir()
|
||||
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []mcpHome{{Dir: home, ConfigDir: home}}, true
|
||||
}
|
||||
for beat := 0; beat < 3; beat++ {
|
||||
if _, s := mcpServersReport(); s.TimedOut {
|
||||
t.Fatalf("beat %d timed out: %+v", beat, s)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("the files were read %d times in one cache window", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A read stuck on a home that stopped answering delays ONE beat by at most
|
||||
// mcpReadTimeout; the beats after it do not wait on the same read again. And
|
||||
// until a read has finished, nothing is known to be declared, so no running
|
||||
// process may be reported as running_not_declared.
|
||||
func TestMCPStuckReadDelaysOneBeatAndClaimsNoShadows(t *testing.T) {
|
||||
oldH, oldS, oldP, oldT := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout
|
||||
block := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
close(block)
|
||||
mcpHomesFn, mcpSystemFn, scanMCPProcessesFn, mcpReadTimeout = oldH, oldS, oldP, oldT
|
||||
resetMCPConfigCache()
|
||||
})
|
||||
resetMCPConfigCache()
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { <-block; return nil, true }
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) {
|
||||
return []mcpProc{{PID: 4242, PPID: 1, Argv: []string{"demo-mcp-server", "--stdio"}}}, nil
|
||||
}
|
||||
mcpReadTimeout = 400 * time.Millisecond
|
||||
|
||||
start := time.Now()
|
||||
out, s := mcpServersReport()
|
||||
if took := time.Since(start); took < mcpReadTimeout/2 {
|
||||
t.Fatalf("the first beat did not wait for the read at all (%v)", took)
|
||||
}
|
||||
if !s.TimedOut || len(out) != 0 {
|
||||
t.Fatalf("before any read finished: timed_out %v, servers %+v", s.TimedOut, out)
|
||||
}
|
||||
for beat := 1; beat < 4; beat++ {
|
||||
start := time.Now()
|
||||
out, s := mcpServersReport()
|
||||
if took := time.Since(start); took > mcpReadTimeout/2 {
|
||||
t.Fatalf("beat %d waited %v on a read an earlier beat already waited out", beat, took)
|
||||
}
|
||||
if !s.TimedOut || len(out) != 0 {
|
||||
t.Fatalf("beat %d: timed_out %v, servers %+v", beat, s.TimedOut, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The accounts whose agent clients mcpconfig.go reads on macOS: the service's
|
||||
// own home and every directory under /Users except Shared. The per-user
|
||||
// configuration directory is ~/Library/Application Support.
|
||||
func mcpHomes() ([]mcpHome, bool) {
|
||||
var homes []string
|
||||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||
homes = append(homes, h)
|
||||
}
|
||||
if entries, err := os.ReadDir("/Users"); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && e.Name() != "Shared" && e.Name()[0] != '.' {
|
||||
homes = append(homes, filepath.Join("/Users", e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []mcpHome
|
||||
for _, h := range dedupeStrings(homes) {
|
||||
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "Library", "Application Support")})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func mcpSystemFiles() []mcpSource { return mcpSystemSources("darwin", "") }
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The accounts whose agent clients mcpconfig.go reads on Linux: the same
|
||||
// homes the app inventory reads (userHomes: the service's own home and every
|
||||
// directory under /home). A home this process may not enter yields files it
|
||||
// counts as unreadable, never as empty. The per-user configuration directory
|
||||
// is the XDG default, ~/.config; another account's XDG_CONFIG_HOME lives in
|
||||
// its own environment, which a system service does not see.
|
||||
func mcpHomes() ([]mcpHome, bool) {
|
||||
var out []mcpHome
|
||||
for _, h := range userHomes() {
|
||||
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, ".config")})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func mcpSystemFiles() []mcpSource { return mcpSystemSources("linux", "") }
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A FIFO left where a client keeps its config must not hold the read, and
|
||||
// with it the heartbeat: it is counted unreadable and the scan moves on.
|
||||
func TestReadMCPConfigsDoesNotBlockOnAFIFO(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(home, ".cursor", "mcp.json"), 0o600); err != nil {
|
||||
t.Skipf("cannot make a FIFO here: %v", err)
|
||||
}
|
||||
oldH, oldS := mcpHomesFn, mcpSystemFn
|
||||
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldH, oldS })
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
|
||||
done := make(chan mcpConfigScan, 1)
|
||||
go func() { _, scan := readMCPConfigs(); done <- scan }()
|
||||
select {
|
||||
case scan := <-done:
|
||||
if scan.Unreadable != 1 {
|
||||
t.Fatalf("the FIFO was not counted: %+v", scan)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("reading the config files blocked on a FIFO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// mcpOpenRegular opens path for reading only if what it names, after any
|
||||
// symlinks, is a regular file, without ever opening anything else. macOS has
|
||||
// no O_PATH, so the links are resolved first, the resolved path is required
|
||||
// to be a regular file, and it is then opened with O_NOFOLLOW_ANY: a link
|
||||
// put in its place since the look makes the open fail rather than lead
|
||||
// somewhere new, and a device can only sit in devfs, which the look already
|
||||
// refused. The opened file must be the one looked at.
|
||||
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
lst, err := os.Lstat(resolved)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !lst.Mode().IsRegular() {
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
fd, err := unix.Open(resolved, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK|unix.O_NOFOLLOW_ANY, 0)
|
||||
if err != nil {
|
||||
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
fi, err := f.Stat()
|
||||
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, lst) {
|
||||
f.Close()
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
return f, mcpFileOwner(fi), nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// mcpOpenRegular opens path for reading only if what it names, after any
|
||||
// symlinks, is a regular file, without ever opening anything else. The first
|
||||
// open is O_PATH: it resolves the path and pins the inode but does not open
|
||||
// the file (no driver runs, a FIFO does not wait). Only once fstat says
|
||||
// regular is that same inode reopened for reading through /proc/self/fd, so
|
||||
// swapping the path between the look and the read reaches nothing new.
|
||||
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||
pfd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, "", &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
defer unix.Close(pfd)
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstat(pfd, &st); err != nil {
|
||||
return nil, "", &os.PathError{Op: "fstat", Path: path, Err: err}
|
||||
}
|
||||
if st.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOCTTY|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, "", &os.PathError{Op: "reopen", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
fi, err := f.Stat()
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
f.Close()
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
return f, mcpFileOwner(fi), nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// A config path that is, or links to, something other than a regular file
|
||||
// is never OPENED for reading. Opening is not harmless: a device's driver
|
||||
// runs on open (a watchdog arms, a serial line toggles DTR), so a symlink at
|
||||
// ~/.cursor/mcp.json pointing at one would let any account make this root
|
||||
// service act on hardware. inotify reports every real open of the target;
|
||||
// the reader's look at what the path is must not be one.
|
||||
func TestMCPReadFileNeverOpensANonRegularTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fifo := filepath.Join(dir, "target")
|
||||
if err := unix.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Skipf("cannot make a FIFO here: %v", err)
|
||||
}
|
||||
link := filepath.Join(dir, "mcp.json")
|
||||
if err := os.Symlink(fifo, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ino, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
|
||||
if err != nil {
|
||||
t.Skipf("no inotify: %v", err)
|
||||
}
|
||||
defer unix.Close(ino)
|
||||
if _, err := unix.InotifyAddWatch(ino, fifo, unix.IN_OPEN); err != nil {
|
||||
t.Skipf("cannot watch: %v", err)
|
||||
}
|
||||
var scan mcpConfigScan
|
||||
for _, p := range []string{link, fifo} {
|
||||
if b, ok := mcpReadFile(p, &scan); ok || b != nil {
|
||||
t.Fatalf("%s read as a config file", p)
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
if n, _ := unix.Read(ino, buf); n > 0 {
|
||||
t.Fatalf("the FIFO was opened %d time(s) by the reader", n/unix.SizeofInotifyEvent)
|
||||
}
|
||||
if scan.Unreadable != 2 {
|
||||
t.Fatalf("scan: %+v", scan)
|
||||
}
|
||||
// A regular file through a symlink is still read, as the client would.
|
||||
real := filepath.Join(dir, "real.json")
|
||||
if err := os.WriteFile(real, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link2 := filepath.Join(dir, "link2.json")
|
||||
if err := os.Symlink(real, link2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mcpReadFile(link2, &scan); !ok {
|
||||
t.Fatal("a symlink to a regular file was not read")
|
||||
}
|
||||
}
|
||||
|
||||
// A file in one account's home, or in a project that account listed, is read
|
||||
// only when that account or root owns it. Otherwise one account could point
|
||||
// its own config path, or its list of projects, at another account's files
|
||||
// and have this service read them on its behalf.
|
||||
func TestReadMCPConfigsSkipsAFileAnotherAccountOwns(t *testing.T) {
|
||||
if os.Geteuid() != 0 {
|
||||
t.Skip("needs root to make files owned by other uids")
|
||||
}
|
||||
const owner, other = 42101, 42102
|
||||
root := t.TempDir()
|
||||
if err := os.Chmod(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(root, "home", "demo")
|
||||
victim := filepath.Join(root, "victim")
|
||||
write := func(p, body string, uid int) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chown(p, uid, uid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(victim, "secret.json"), `{"mcpServers": {"victim-file": {"command": "demo-v"}}}`, other)
|
||||
write(filepath.Join(victim, ".mcp.json"), `{"mcpServers": {"victim-project": {"command": "demo-w"}}}`, other)
|
||||
write(filepath.Join(root, "rootowned.json"), `{"mcpServers": {"root-file": {"command": "demo-r"}}}`, 0)
|
||||
projJSON, _ := json.Marshal(victim)
|
||||
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"own": {"command": "demo-o"}}, "projects": {`+string(projJSON)+`: {}}}`, owner)
|
||||
if err := os.MkdirAll(filepath.Join(home, ".cursor"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(victim, "secret.json"), filepath.Join(home, ".cursor", "mcp.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A dotfile manager's link to a root-owned file is the account's own.
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "rootowned.json"), filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chown(home, owner, owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
|
||||
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
|
||||
mcpGOOS = "linux"
|
||||
mcpHomesFn = func() ([]mcpHome, bool) {
|
||||
return []mcpHome{{Dir: home, ConfigDir: filepath.Join(home, ".config")}}, true
|
||||
}
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
|
||||
got, scan := readMCPConfigs()
|
||||
names := map[string]bool{}
|
||||
for _, o := range got {
|
||||
names[o.Name] = true
|
||||
}
|
||||
if names["victim-file"] || names["victim-project"] {
|
||||
t.Errorf("read another account's file on this account's behalf: %v", names)
|
||||
}
|
||||
if !names["own"] || !names["root-file"] {
|
||||
t.Errorf("the account's own and root-owned files must still be read: %v", names)
|
||||
}
|
||||
if scan.ForeignOwner != 2 {
|
||||
t.Errorf("scan: %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// No account list on this platform (mcpconfig_other.go), so the reader never
|
||||
// gets here; this keeps the package building. A path is still opened only
|
||||
// when it was a regular file, and the opened file must be the one looked at.
|
||||
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !st.Mode().IsRegular() {
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil || !fi.Mode().IsRegular() || !os.SameFile(fi, st) {
|
||||
f.Close()
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
return f, mcpOwnerUnknown, nil
|
||||
}
|
||||
|
||||
var mcpAdminOwners = map[string]bool{}
|
||||
|
||||
// mcpPathOwner: no owner model here, so no owner check.
|
||||
func mcpPathOwner(string) string { return mcpNoOwnerCheck }
|
||||
@@ -0,0 +1,122 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// The decisions are in mcpconfig_winopen.go; this file makes the calls.
|
||||
|
||||
// The values mcpconfig_winopen.go spells out must be Windows' own: each line
|
||||
// fails to compile if one differs.
|
||||
var (
|
||||
_ = [1]struct{}{}[mcpWinFileTypeDisk^windows.FILE_TYPE_DISK]
|
||||
_ = [1]struct{}{}[mcpWinAttrDirectory^windows.FILE_ATTRIBUTE_DIRECTORY]
|
||||
_ = [1]struct{}{}[mcpWinAttrDevice^windows.FILE_ATTRIBUTE_DEVICE]
|
||||
_ = [1]struct{}{}[mcpWinAttrReparse^windows.FILE_ATTRIBUTE_REPARSE_POINT]
|
||||
)
|
||||
|
||||
var mcpAdminOwners = mcpWinAdminOwners
|
||||
|
||||
// mcpOpenRegular opens path with CreateFile and keeps it only when the
|
||||
// handle is a regular file on disk (mcpWinOpenWith). It shares read, write
|
||||
// and delete with everyone else: the agent client that owns the file must be
|
||||
// able to save it, including by replacing it, while this reads it.
|
||||
func mcpOpenRegular(path string) (*os.File, string, error) {
|
||||
return mcpWinOpenWith(mcpWinSystemOps, path)
|
||||
}
|
||||
|
||||
var mcpWinSystemOps = mcpWinOps{
|
||||
openPath: mcpWinCreateFile,
|
||||
handleInfo: mcpWinHandleFacts,
|
||||
closeHandle: func(h uintptr) { windows.CloseHandle(windows.Handle(h)) },
|
||||
asFile: func(h uintptr, path string) *os.File { return os.NewFile(h, path) },
|
||||
}
|
||||
|
||||
func mcpWinCreateFile(path string) (uintptr, error) {
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
h, err := windows.CreateFile(p, windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil,
|
||||
windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
return 0, &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
return uintptr(h), nil
|
||||
}
|
||||
|
||||
// mcpWinHandleFacts reads the type, the attributes and the owner from the
|
||||
// handle. GENERIC_READ carries READ_CONTROL, which GetSecurityInfo needs for
|
||||
// the owner; an owner that cannot be read is left "" and the file is then
|
||||
// read only where no owner check applies.
|
||||
func mcpWinHandleFacts(h uintptr) (mcpWinHandleInfo, error) {
|
||||
wh := windows.Handle(h)
|
||||
var i mcpWinHandleInfo
|
||||
t, err := windows.GetFileType(wh)
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
i.FileType = t
|
||||
if t != windows.FILE_TYPE_DISK {
|
||||
return i, nil // refused by mcpWinRegular; nothing more to ask it
|
||||
}
|
||||
var bi windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(wh, &bi); err != nil {
|
||||
return i, err
|
||||
}
|
||||
i.Attributes = bi.FileAttributes
|
||||
if sd, err := windows.GetSecurityInfo(wh, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION); err == nil {
|
||||
if o, _, err := sd.Owner(); err == nil && o != nil {
|
||||
i.Owner = o.String()
|
||||
}
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// mcpPathOwner is the SID whose profile dir is, from the ProfileList the
|
||||
// User Profile Service keeps
|
||||
// (HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>,
|
||||
// value ProfileImagePath). A directory it does not name, or names twice,
|
||||
// gives mcpOwnerUnknown, so only SYSTEM's and the Administrators group's
|
||||
// files are read there.
|
||||
func mcpPathOwner(dir string) string {
|
||||
return mcpWinProfileSID(dir, mcpWinProfiles())
|
||||
}
|
||||
|
||||
func mcpWinProfiles() map[string]string {
|
||||
out := map[string]string{}
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer k.Close()
|
||||
sids, err := k.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, sid := range sids {
|
||||
sk, err := registry.OpenKey(k, sid, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, typ, err := sk.GetStringValue("ProfileImagePath")
|
||||
sk.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if typ == registry.EXPAND_SZ {
|
||||
if x, err := registry.ExpandString(p); err == nil {
|
||||
p = x
|
||||
}
|
||||
}
|
||||
out[sid] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
// No account list on this platform: the scan says unsupported rather than
|
||||
// reporting a machine with no tool servers.
|
||||
func mcpHomes() ([]mcpHome, bool) { return nil, false }
|
||||
|
||||
func mcpSystemFiles() []mcpSource { return nil }
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// mcpAdminOwners: root owns files in any account's home without that being
|
||||
// another account's file.
|
||||
var mcpAdminOwners = map[string]bool{"0": true}
|
||||
|
||||
// mcpFileOwner is the uid that owns an opened file, as mcpOwnerAllowed
|
||||
// compares it.
|
||||
func mcpFileOwner(fi os.FileInfo) string {
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||
return strconv.FormatUint(uint64(st.Uid), 10)
|
||||
}
|
||||
return mcpOwnerUnknown // matches no account and not root: the file is skipped
|
||||
}
|
||||
|
||||
// mcpPathOwner is the uid that owns an account's home: the account whose
|
||||
// files are read there. A home that cannot be looked at gives root, so only
|
||||
// root-owned files would be read in it.
|
||||
func mcpPathOwner(dir string) string {
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return "0"
|
||||
}
|
||||
if s, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
return strconv.FormatUint(uint64(s.Uid), 10)
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every fixture here is invented: server names, packages, hosts, paths and
|
||||
// the secret-shaped values, which are built from repeated letters so no real
|
||||
// credential can be in this file.
|
||||
|
||||
var (
|
||||
fakeAnthropicKey = "sk-ant-api03-" + strings.Repeat("Q", 40)
|
||||
fakeGitHubToken = "ghp_" + strings.Repeat("Z", 36)
|
||||
fakeLiteral = "plain-" + strings.Repeat("k", 12)
|
||||
)
|
||||
|
||||
func entriesByName(t *testing.T, format, body string) map[string]mcpEntry {
|
||||
t.Helper()
|
||||
es, _, err := mcpParse(format, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", format, err)
|
||||
}
|
||||
out := map[string]mcpEntry{}
|
||||
for _, e := range es {
|
||||
out[e.name] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestMCPParseClaudeDesktopShape(t *testing.T) {
|
||||
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {
|
||||
"files": {"command": "npx", "args": ["-y", "@demo-scope/server-files@1.4.2", "/srv/demo"]},
|
||||
"search": {"command": "uvx", "args": ["demo-search"], "env": {"DEMO_API_KEY": "`+fakeLiteral+`"}}
|
||||
}, "otherSetting": true}`)
|
||||
if len(es) != 2 || es["files"].command != "npx" || es["search"].env["DEMO_API_KEY"] == "" {
|
||||
t.Fatalf("entries: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPParseClaudeCodeUserAndProjects(t *testing.T) {
|
||||
body := `{"numStartups": 3, "mcpServers": {"u1": {"type": "stdio", "command": "demo-tool"}},
|
||||
"projects": {
|
||||
"/home/demo/proj-a": {"mcpServers": {"p1": {"type": "http", "url": "https://tools.example.com/x"}}},
|
||||
"/home/demo/proj-b": {"allowedTools": []}
|
||||
}}`
|
||||
es, projects, err := mcpParse(mcpFormatClaude, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(es) != 2 || es[0].name != "u1" || es[0].projectScoped || es[1].name != "p1" || !es[1].projectScoped {
|
||||
t.Fatalf("entries: %+v", es)
|
||||
}
|
||||
want := []string{filepath.FromSlash("/home/demo/proj-a"), filepath.FromSlash("/home/demo/proj-b")}
|
||||
if !reflect.DeepEqual(projects, want) {
|
||||
t.Fatalf("projects: %v", projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPParseVSCodeJSONC(t *testing.T) {
|
||||
es := entriesByName(t, mcpFormatVSCode, `{
|
||||
// a comment the file is allowed to carry
|
||||
"inputs": [{"id": "tok", "type": "promptString", "password": true}],
|
||||
"servers": {
|
||||
"remote": {"type": "sse", "url": "https://tools.example.com/sse", "headers": {"Authorization": "Bearer ${input:tok}"},},
|
||||
/* a block comment, "with a quote" */
|
||||
"local": {"command": "node", "args": ["/opt/demo/server.js"],},
|
||||
},
|
||||
}`)
|
||||
if es["remote"].typ != "sse" || es["local"].command != "node" || len(es) != 2 {
|
||||
t.Fatalf("entries: %+v", es)
|
||||
}
|
||||
// A // inside a string is data, not a comment.
|
||||
es = entriesByName(t, mcpFormatVSCode, `{"servers": {"u": {"url": "https://h.example.com//x"}}}`)
|
||||
if es["u"].url != "https://h.example.com//x" {
|
||||
t.Fatalf("a URL lost its slashes: %q", es["u"].url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPParseZedBothCommandForms(t *testing.T) {
|
||||
es := entriesByName(t, mcpFormatZed, `{
|
||||
"theme": "One Dark", // Zed's settings carry everything else too
|
||||
"context_servers": {
|
||||
"flat": {"source": "custom", "command": "demo-mcp-server", "args": ["--stdio"], "env": {"DEMO_TOKEN": "`+fakeLiteral+`"}},
|
||||
"nested": {"command": {"path": "/usr/local/bin/demo", "args": ["serve"], "env": {"LOG_LEVEL": "info"}}},
|
||||
"remote": {"url": "https://zed-tools.example.com/mcp", "headers": {"X-Api-Key": "`+fakeLiteral+`"}}
|
||||
}
|
||||
}`)
|
||||
if es["flat"].command != "demo-mcp-server" || es["nested"].command != "/usr/local/bin/demo" ||
|
||||
!reflect.DeepEqual(es["nested"].args, []string{"serve"}) || es["nested"].env["LOG_LEVEL"] != "info" ||
|
||||
es["remote"].headers["X-Api-Key"] == "" {
|
||||
t.Fatalf("entries: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPParseWindsurfServerURL(t *testing.T) {
|
||||
es := entriesByName(t, mcpFormatServers, `{"mcpServers": {"w": {"serverUrl": "https://ws-tools.example.com/mcp"}}}`)
|
||||
if es["w"].url != "https://ws-tools.example.com/mcp" {
|
||||
t.Fatalf("serverUrl not read: %+v", es)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPParseCodexTOML(t *testing.T) {
|
||||
body := `# invented Codex config
|
||||
model = "demo-model"
|
||||
|
||||
[mcp_servers.docs]
|
||||
command = "npx"
|
||||
args = [
|
||||
"-y", # a comment inside an array
|
||||
"demo-docs-server@2.0.1",
|
||||
]
|
||||
env = { "DEMO_KEY" = "` + fakeLiteral + `", PLAIN = 'x' }
|
||||
env_vars = ["HOME_HINT", { name = "FORWARDED", source = "local" }]
|
||||
startup_timeout_sec = 20
|
||||
|
||||
[mcp_servers."remote tools"]
|
||||
url = "https://codex-tools.example.com/mcp"
|
||||
bearer_token_env_var = "DEMO_BEARER"
|
||||
enabled = false
|
||||
|
||||
[mcp_servers.docs.tools.search]
|
||||
approval_mode = "approve"
|
||||
|
||||
[[profiles_list]]
|
||||
command = "not a server"
|
||||
`
|
||||
es := entriesByName(t, mcpFormatCodex, body)
|
||||
d := es["docs"]
|
||||
if d.command != "npx" || !reflect.DeepEqual(d.args, []string{"-y", "demo-docs-server@2.0.1"}) ||
|
||||
d.env["DEMO_KEY"] == "" || d.env["PLAIN"] != "x" ||
|
||||
!reflect.DeepEqual(d.envNames, []string{"HOME_HINT", "FORWARDED"}) {
|
||||
t.Fatalf("docs: %+v", d)
|
||||
}
|
||||
r := es["remote tools"]
|
||||
if r.url == "" || !r.authHint || !r.disabled || !reflect.DeepEqual(r.envNames, []string{"DEMO_BEARER"}) {
|
||||
t.Fatalf("remote: %+v", r)
|
||||
}
|
||||
if len(es) != 2 {
|
||||
t.Fatalf("an [[array of tables]] leaked in, or a server was lost: %v", es)
|
||||
}
|
||||
if _, err := parseTOMLSubset("[mcp_servers.x]\ncommand = \"unterminated\n"); err == nil {
|
||||
t.Fatal("a broken line must fail the file, not half-read it")
|
||||
}
|
||||
if v, _ := parseTOMLSubset(`k = "a\tb\u00e9\"q"`); v["k"] != "a\tbé\"q" {
|
||||
t.Fatalf("escapes: %q", v["k"])
|
||||
}
|
||||
}
|
||||
|
||||
// THE VALUE NEVER LEAVES. A secret in env, a header, an argument or the URL
|
||||
// sets one bool; the marshalled record carries the variable's NAME and
|
||||
// nothing of the value.
|
||||
func TestMCPAnalyzeKeepsNamesAndDropsValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
e mcpEntry
|
||||
}{
|
||||
{"env, known prefix", mcpEntry{name: "a", command: "npx", args: []string{"demo"},
|
||||
env: map[string]string{"ANTHROPIC_API_KEY": fakeAnthropicKey}}},
|
||||
{"env, known prefix under a plain name", mcpEntry{name: "a2", command: "demo",
|
||||
env: map[string]string{"DEMO_SETTING": fakeAnthropicKey}}},
|
||||
{"env, literal under a secret name", mcpEntry{name: "b", command: "demo",
|
||||
env: map[string]string{"SERVICE_TOKEN": fakeLiteral}}},
|
||||
{"header", mcpEntry{name: "c", url: "https://h.example.com/mcp",
|
||||
headers: map[string]string{"Authorization": "Bearer " + fakeLiteral}}},
|
||||
{"argument after a flag", mcpEntry{name: "d", command: "demo", args: []string{"--api-key", fakeLiteral}}},
|
||||
{"argument with =", mcpEntry{name: "e", command: "demo", args: []string{"--token=" + fakeLiteral}}},
|
||||
{"bare token argument", mcpEntry{name: "f", command: "demo", args: []string{fakeGitHubToken}}},
|
||||
{"docker -e NAME=value", mcpEntry{name: "g", command: "docker",
|
||||
args: []string{"run", "-i", "--rm", "-e", "GITHUB_TOKEN=" + fakeLiteral, "ghcr.io/demo/server"}}},
|
||||
{"URL query", mcpEntry{name: "h", url: "https://h.example.com/mcp?api_key=" + fakeLiteral}},
|
||||
{"URL password", mcpEntry{name: "i", url: "https://u:" + fakeLiteral + "@h.example.com/mcp"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o := mcpAnalyze(c.e)
|
||||
if !o.SecretInPlaintext {
|
||||
t.Errorf("%s: the secret was not seen", c.name)
|
||||
}
|
||||
b, _ := json.Marshal(o)
|
||||
for _, v := range []string{fakeAnthropicKey, fakeLiteral, fakeGitHubToken} {
|
||||
if strings.Contains(string(b), v) {
|
||||
t.Errorf("%s: a value left in the record: %s", c.name, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
o := mcpAnalyze(mcpEntry{name: "a", command: "npx", args: []string{"demo"},
|
||||
env: map[string]string{"B_KEY": "${env:B_KEY}", "A_URL": "https://h.example.com", "LOG": "debug",
|
||||
"KEY_FILE": "/etc/demo/key.pem"}, envNames: []string{"FORWARDED", "LOG"}})
|
||||
if o.SecretInPlaintext {
|
||||
t.Fatal("a reference, a URL, a path and a short value are not secrets in plain text")
|
||||
}
|
||||
if !reflect.DeepEqual(o.EnvNames, []string{"A_URL", "B_KEY", "FORWARDED", "KEY_FILE", "LOG"}) {
|
||||
t.Fatalf("env names: %v", o.EnvNames)
|
||||
}
|
||||
if b, _ := json.Marshal(o); strings.Contains(string(b), "debug") || strings.Contains(string(b), "key.pem") {
|
||||
t.Fatalf("an env value travelled: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPPackageAndPinned(t *testing.T) {
|
||||
cases := []struct {
|
||||
cmd string
|
||||
args []string
|
||||
source, name, pinned string
|
||||
}{
|
||||
{"npx", []string{"-y", "@demo-scope/server-files@1.4.2", "/srv"}, "npx", "@demo-scope/server-files", "yes"},
|
||||
{"npx", []string{"-y", "@demo-scope/server-files"}, "npx", "@demo-scope/server-files", "no"},
|
||||
{"npx", []string{"demo-server@latest"}, "npx", "demo-server", "no"},
|
||||
{"npx", []string{"demo-server@^1.2.0"}, "npx", "demo-server", "no"},
|
||||
{"npx", []string{"--package", "demo-pkg@3.0.0", "demo-bin"}, "npx", "demo-pkg", "yes"},
|
||||
{"C:\\Program Files\\nodejs\\npx.cmd", []string{"-y", "demo-win@0.1.0"}, "npx", "demo-win", "yes"},
|
||||
{"pnpm", []string{"dlx", "demo-dlx@1.0.0"}, "npx", "demo-dlx", "yes"},
|
||||
{"npx", []string{"github:demo/repo"}, "npx", "github:demo/repo", "unknown"},
|
||||
{"uvx", []string{"demo-py==0.3.1"}, "uvx", "demo-py", "yes"},
|
||||
{"uvx", []string{"demo-py@0.3.1"}, "uvx", "demo-py", "yes"},
|
||||
{"uvx", []string{"--from", "demo-py>=0.3", "demo-cli"}, "uvx", "demo-py", "no"},
|
||||
{"uvx", []string{"demo-py"}, "uvx", "demo-py", "no"},
|
||||
{"uv", []string{"tool", "run", "demo-py==1.0"}, "uvx", "demo-py", "yes"},
|
||||
{"pipx", []string{"run", "demo-pipx==2.1"}, "pip", "demo-pipx", "yes"},
|
||||
{"docker", []string{"run", "-i", "--rm", "-e", "X", "ghcr.io/demo/srv:1.2"}, "docker", "ghcr.io/demo/srv", "no"},
|
||||
{"docker", []string{"run", "--rm", "localhost:5000/demo/srv@sha256:" + strings.Repeat("a", 64)}, "docker", "localhost:5000/demo/srv", "yes"},
|
||||
{"docker", []string{"run", "-i", "demo/srv"}, "docker", "demo/srv", "no"},
|
||||
{"node", []string{"/opt/demo/server.js"}, "unknown", "", "unknown"},
|
||||
{"/usr/local/bin/demo-mcp-server", []string{"stdio"}, "binary", "", "unknown"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, n, p := mcpPackage(c.cmd, c.args)
|
||||
if s != c.source || n != c.name || p != c.pinned {
|
||||
t.Errorf("%s %v: got %s %q %s, want %s %q %s", c.cmd, c.args, s, n, p, c.source, c.name, c.pinned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPTransportLocalityAuth(t *testing.T) {
|
||||
cases := []struct {
|
||||
e mcpEntry
|
||||
transport, locality, auth, urlHst string
|
||||
}{
|
||||
{mcpEntry{command: "demo"}, "stdio", "local_process", "not_applicable", ""},
|
||||
{mcpEntry{typ: "streamable-http", url: "https://tools.example.com/mcp"}, "http", "remote", "unknown", "tools.example.com"},
|
||||
{mcpEntry{url: "https://tools.example.com/sse"}, "sse", "remote", "unknown", "tools.example.com"},
|
||||
{mcpEntry{url: "wss://tools.example.com/x"}, "websocket", "remote", "unknown", "tools.example.com"},
|
||||
{mcpEntry{typ: "ws", url: "ws://127.0.0.1:9000"}, "websocket", "loopback", "unknown", "127.0.0.1"},
|
||||
{mcpEntry{url: "http://localhost:3333/mcp"}, "http", "loopback", "unknown", "localhost"},
|
||||
{mcpEntry{url: "http://10.20.30.40:80/mcp"}, "http", "lan", "unknown", "10.20.30.40"},
|
||||
{mcpEntry{url: "http://toolbox.internal/mcp"}, "http", "lan", "unknown", "toolbox.internal"},
|
||||
{mcpEntry{url: "http://toolbox/mcp"}, "http", "lan", "unknown", "toolbox"},
|
||||
{mcpEntry{url: "https://t.example.com/mcp", headers: map[string]string{"Authorization": "Bearer ${env:T}"}}, "http", "remote", "yes", "t.example.com"},
|
||||
{mcpEntry{url: "https://t.example.com/mcp", authHint: true}, "http", "remote", "yes", "t.example.com"},
|
||||
{mcpEntry{typ: "carrier-pigeon", url: "https://t.example.com"}, "unknown", "remote", "unknown", "t.example.com"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
o := mcpAnalyze(c.e)
|
||||
if o.Transport != c.transport || o.Locality != c.locality || o.AuthConfigured != c.auth || o.URLHost != c.urlHst {
|
||||
t.Errorf("%+v: got %s %s %s %q", c.e, o.Transport, o.Locality, o.AuthConfigured, o.URLHost)
|
||||
}
|
||||
if c.transport == "stdio" && o.BindClass != "not_applicable" || c.transport != "stdio" && o.BindClass != "unknown" {
|
||||
t.Errorf("%+v: bind %s", c.e, o.BindClass)
|
||||
}
|
||||
}
|
||||
// The command is the cmdline handling's: joined, and cut at 200.
|
||||
long := mcpAnalyze(mcpEntry{command: "demo", args: []string{strings.Repeat("a", 300)}})
|
||||
if len(long.Command) != 200 {
|
||||
t.Fatalf("command not truncated like a cmdline: %d", len(long.Command))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrelateMCPStates(t *testing.T) {
|
||||
declared := []mcpServerObs{
|
||||
mcpAnalyze(mcpEntry{name: "files", command: "npx", args: []string{"-y", "@demo-scope/server-files@1.4.2"}}),
|
||||
mcpAnalyze(mcpEntry{name: "idle", command: "uvx", args: []string{"demo-idle"}}),
|
||||
mcpAnalyze(mcpEntry{name: "bin", command: "/opt/demo/bin/demo-tool", args: []string{"serve"}}),
|
||||
mcpAnalyze(mcpEntry{name: "saas", url: "https://tools.example.com/mcp"}),
|
||||
mcpAnalyze(mcpEntry{name: "local-url", url: "http://127.0.0.1:7000/mcp"}),
|
||||
}
|
||||
procs := []mcpProc{
|
||||
{PID: 10, PPID: 1, Argv: []string{"demo-client"}},
|
||||
{PID: 20, PPID: 10, Argv: []string{"npm", "exec", "-y", "@demo-scope/server-files@1.4.2"}},
|
||||
// npx's child: no package name in its own line, inherits the match.
|
||||
{PID: 21, PPID: 20, Argv: []string{"node", "/home/demo/.npm/_npx/abc/node_modules/.bin/mcp-server-files"}},
|
||||
{PID: 30, PPID: 10, Argv: []string{"/opt/demo/bin/demo-tool", "serve"}},
|
||||
// Shadow: a server nobody declared, and its own child (collapsed).
|
||||
{PID: 40, PPID: 1, Argv: []string{"node", "/opt/x/node_modules/@modelcontextprotocol/server-memory/dist/index.js"}},
|
||||
{PID: 41, PPID: 40, Argv: []string{"node", "/opt/x/mcp-worker.js"}},
|
||||
{PID: 50, PPID: 1, Argv: []string{"/usr/bin/github-mcp-server", "stdio", "--token=" + fakeGitHubToken}},
|
||||
// Not servers: an agent run with an MCP flag, and a grep.
|
||||
{PID: 60, PPID: 1, Argv: []string{"claude", "--mcp-config", "/tmp/mcp.json"}},
|
||||
{PID: 61, PPID: 1, Argv: []string{"grep", "-r", "mcp-server", "/srv"}},
|
||||
{PID: 70, PPID: 1}, // a process whose command line was not read
|
||||
}
|
||||
got := correlateMCP(declared, procs, true)
|
||||
state := map[string]mcpServerObs{}
|
||||
var shadows []mcpServerObs
|
||||
for _, o := range got {
|
||||
if o.RunningState == mcpStateShadow {
|
||||
shadows = append(shadows, o)
|
||||
continue
|
||||
}
|
||||
state[o.Name] = o
|
||||
}
|
||||
if s := state["files"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{20, 21}) {
|
||||
t.Errorf("files: %s %v", s.RunningState, s.PIDs)
|
||||
}
|
||||
if s := state["idle"]; s.RunningState != mcpStateIdle {
|
||||
t.Errorf("idle: %s", s.RunningState)
|
||||
}
|
||||
if s := state["bin"]; s.RunningState != mcpStateRunning || !reflect.DeepEqual(s.PIDs, []int{30}) {
|
||||
t.Errorf("bin: %s %v", s.RunningState, s.PIDs)
|
||||
}
|
||||
if s := state["saas"]; s.RunningState != mcpStateNotLocal {
|
||||
t.Errorf("saas: %s", s.RunningState)
|
||||
}
|
||||
if s := state["local-url"]; s.RunningState != mcpStateUnknown {
|
||||
t.Errorf("local-url: %s", s.RunningState)
|
||||
}
|
||||
if len(shadows) != 2 || !reflect.DeepEqual(shadows[0].PIDs, []int{40}) || !reflect.DeepEqual(shadows[1].PIDs, []int{50}) {
|
||||
t.Fatalf("shadow servers: %+v", shadows)
|
||||
}
|
||||
if !shadows[1].SecretInPlaintext || strings.Contains(shadows[1].Command, fakeGitHubToken) {
|
||||
t.Fatalf("a running server's secret argument travelled: %+v", shadows[1])
|
||||
}
|
||||
|
||||
// No process list: nothing is guessed, and no shadow is invented.
|
||||
for _, o := range correlateMCP(declared, nil, false) {
|
||||
if o.RunningState == mcpStateShadow || (o.Transport == "stdio" && o.RunningState != mcpStateUnknown) {
|
||||
t.Errorf("with no process list %s read %s", o.Name, o.RunningState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reader over invented homes: the per-OS path table, the projects
|
||||
// Claude Code lists, the system file, and an unreadable file that must be
|
||||
// counted rather than read as empty.
|
||||
func TestReadMCPConfigsOverFixtureHomes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, "home", "demo")
|
||||
proj := filepath.Join(root, "work", "proj")
|
||||
write := func(p, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := mcpHome{Dir: home, ConfigDir: filepath.Join(home, ".config")}
|
||||
projJSON, _ := json.Marshal(proj)
|
||||
write(filepath.Join(home, ".claude.json"), `{"mcpServers": {"u": {"command": "demo-u"}},
|
||||
"projects": {`+string(projJSON)+`: {}}}`)
|
||||
write(filepath.Join(home, ".cursor", "mcp.json"), `{"mcpServers": {"c": {"url": "https://c.example.com/mcp"}}}`)
|
||||
write(filepath.Join(home, ".codex", "config.toml"), "[mcp_servers.x]\ncommand = \"demo-x\"\n")
|
||||
write(filepath.Join(proj, ".mcp.json"), `{"mcpServers": {"p": {"command": "demo-p"}}}`)
|
||||
write(filepath.Join(proj, ".vscode", "mcp.json"), `{"servers": {"v": {"command": "demo-v"},},}`)
|
||||
write(filepath.Join(home, ".config", "zed", "settings.json"), `{"context_servers": {"z": {"command": "demo-z"}`) // broken
|
||||
// A directory where a file should be: present, and unreadable as a file.
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys := filepath.Join(root, "etc", "managed-mcp.json")
|
||||
write(sys, `{"mcpServers": {"s": {"command": "demo-s"}}}`)
|
||||
|
||||
oldH, oldS, oldOS := mcpHomesFn, mcpSystemFn, mcpGOOS
|
||||
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, mcpGOOS = oldH, oldS, oldOS })
|
||||
mcpGOOS = "linux"
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{h}, true }
|
||||
mcpSystemFn = func() []mcpSource { return []mcpSource{{mcpClientCode, mcpScopeSystem, sys, mcpFormatServers}} }
|
||||
|
||||
got, scan := readMCPConfigs()
|
||||
where := map[string]string{}
|
||||
for _, o := range got {
|
||||
where[o.Name] = o.Client + "/" + o.Scope
|
||||
}
|
||||
want := map[string]string{"s": "claude-code/system", "u": "claude-code/user", "c": "cursor/user",
|
||||
"x": "codex/user", "p": "claude-code/project", "v": "vscode/project"}
|
||||
if !reflect.DeepEqual(where, want) {
|
||||
t.Fatalf("servers: %v", where)
|
||||
}
|
||||
if scan.Accounts != 1 || scan.Projects != 1 || scan.Unparsed != 1 || scan.Unreadable != 1 || scan.FilesRead != 7 {
|
||||
t.Fatalf("scan: %+v", scan)
|
||||
}
|
||||
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { return nil, false }
|
||||
if got, scan := readMCPConfigs(); got != nil || scan.Supported {
|
||||
t.Fatal("an unsupported platform must say so, not report an empty machine")
|
||||
}
|
||||
}
|
||||
|
||||
// The path table names each client's file on each platform.
|
||||
func TestMCPConfigSourcesPerPlatform(t *testing.T) {
|
||||
find := func(srcs []mcpSource, client, suffix string) bool {
|
||||
for _, s := range srcs {
|
||||
if s.Client == client && strings.HasSuffix(filepath.ToSlash(s.Path), suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
mac := mcpConfigSources("darwin", mcpHome{Dir: "/Users/demo", ConfigDir: "/Users/demo/Library/Application Support"})
|
||||
win := mcpConfigSources("windows", mcpHome{Dir: `C:\Users\demo`, ConfigDir: `C:\Users\demo\AppData\Roaming`})
|
||||
lin := mcpConfigSources("linux", mcpHome{Dir: "/home/demo", ConfigDir: "/home/demo/.config"})
|
||||
for _, c := range []struct {
|
||||
srcs []mcpSource
|
||||
client, suffix string
|
||||
}{
|
||||
{mac, mcpClientDesktop, "Library/Application Support/Claude/claude_desktop_config.json"},
|
||||
{win, mcpClientDesktop, "Roaming/Claude/claude_desktop_config.json"},
|
||||
{lin, mcpClientCode, "/home/demo/.claude.json"},
|
||||
{lin, mcpClientCursor, ".cursor/mcp.json"},
|
||||
{mac, mcpClientVSCode, "Application Support/Code/User/mcp.json"},
|
||||
{lin, mcpClientWindsurf, ".config/devin/mcp_config.json"},
|
||||
{lin, mcpClientWindsurf, ".codeium/windsurf/mcp_config.json"},
|
||||
{lin, mcpClientCodex, ".codex/config.toml"},
|
||||
{mac, mcpClientZed, "/Users/demo/.config/zed/settings.json"},
|
||||
{win, mcpClientZed, "Roaming/Zed/settings.json"},
|
||||
} {
|
||||
if !find(c.srcs, c.client, filepath.ToSlash(c.suffix)) {
|
||||
t.Errorf("%s: no source ending %s", c.client, c.suffix)
|
||||
}
|
||||
}
|
||||
if s := mcpSystemSources("windows", `C:\Program Files`); len(s) != 1 || !strings.HasSuffix(filepath.ToSlash(s[0].Path), "ClaudeCode/managed-mcp.json") {
|
||||
t.Errorf("windows system sources: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The heartbeat's report: a process walk that failed says so, and every
|
||||
// local server then reads unknown rather than "not running".
|
||||
func TestMCPServersReportWhenTheProcessWalkFails(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(home, ".claude.json"),
|
||||
[]byte(`{"mcpServers": {"u": {"command": "demo-u"}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldH, oldS, oldP := mcpHomesFn, mcpSystemFn, scanMCPProcessesFn
|
||||
t.Cleanup(func() { mcpHomesFn, mcpSystemFn, scanMCPProcessesFn = oldH, oldS, oldP })
|
||||
resetMCPConfigCache()
|
||||
t.Cleanup(resetMCPConfigCache)
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: home, ConfigDir: home}}, true }
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
|
||||
scanMCPProcessesFn = func(map[string]bool) ([]mcpProc, error) { return nil, os.ErrPermission }
|
||||
got, scan := mcpServersReport()
|
||||
if len(got) != 1 || got[0].RunningState != mcpStateUnknown || scan.ProcessScan == "ok" {
|
||||
t.Fatalf("failed walk: %+v %+v", got, scan)
|
||||
}
|
||||
|
||||
scanMCPProcessesFn = func(want map[string]bool) ([]mcpProc, error) {
|
||||
if !want["demo-u"] {
|
||||
t.Error("the declared program was not in the set whose command lines are read")
|
||||
}
|
||||
return []mcpProc{{PID: 9, PPID: 1, Argv: []string{"/usr/bin/demo-u"}}}, nil
|
||||
}
|
||||
got, scan = mcpServersReport()
|
||||
if len(got) != 1 || got[0].RunningState != mcpStateRunning || scan.ProcessScan != "ok" {
|
||||
t.Fatalf("walk: %+v %+v", got, scan)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The accounts whose agent clients mcpconfig.go reads on Windows: every
|
||||
// profile directory beside the Public one (normally C:\Users\*), except the
|
||||
// templates and shared profiles Windows keeps there. %USERPROFILE% is not
|
||||
// used to find them: under LocalSystem it is the system profile, not a
|
||||
// person's. The per-user configuration directory is AppData\Roaming, which
|
||||
// is what %APPDATA% names for that account.
|
||||
func mcpHomes() ([]mcpHome, bool) {
|
||||
root := ""
|
||||
if pub := os.Getenv("PUBLIC"); pub != "" {
|
||||
root = filepath.Dir(pub)
|
||||
} else if drive := os.Getenv("SystemDrive"); drive != "" {
|
||||
root = drive + `\Users`
|
||||
}
|
||||
var homes []string
|
||||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||||
homes = append(homes, h)
|
||||
}
|
||||
if root != "" {
|
||||
if entries, err := os.ReadDir(root); err == nil {
|
||||
for _, e := range entries {
|
||||
switch strings.ToLower(e.Name()) {
|
||||
case "public", "default", "default user", "all users", "defaultapppool":
|
||||
continue
|
||||
}
|
||||
if e.IsDir() {
|
||||
homes = append(homes, filepath.Join(root, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []mcpHome
|
||||
for _, h := range dedupeStrings(homes) {
|
||||
out = append(out, mcpHome{Dir: h, ConfigDir: filepath.Join(h, "AppData", "Roaming")})
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func mcpSystemFiles() []mcpSource { return mcpSystemSources("windows", os.Getenv("ProgramFiles")) }
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// THE WINDOWS OPEN, DECIDED WHERE A LINUX TEST CAN SEE IT.
|
||||
//
|
||||
// mcpconfig_open_windows.go opens a config path with CreateFile and asks the
|
||||
// HANDLE what it holds; every decision about the answer is here, with no
|
||||
// build tag, so the Linux test run checks the Windows rules with fake handles
|
||||
// (mcpconfig_winopen_test.go). Nothing here calls Windows.
|
||||
//
|
||||
// The open does not follow a reparse point at the last component
|
||||
// (FILE_FLAG_OPEN_REPARSE_POINT): a symbolic link, junction or other
|
||||
// reparse point at a config path is opened as itself, seen as a reparse
|
||||
// point, and refused. What is checked is the handle that will be read, so
|
||||
// there is no gap between a look and an open for a swap to fall into. A
|
||||
// directory, a device, a pipe or anything else that is not a file on disk is
|
||||
// refused the same way. A reparse point in a directory ABOVE the file is
|
||||
// followed, as for any program opening the path; the file it leads to must
|
||||
// then be owned by the account whose profile it is read for, or by SYSTEM or
|
||||
// the Administrators group (mcpOwnerAllowed), so a link into another
|
||||
// account's profile reads nothing of it.
|
||||
|
||||
// The Windows values the decision reads, from the SDK headers (winbase.h,
|
||||
// winnt.h) as golang.org/x/sys/windows carries them: FILE_TYPE_DISK,
|
||||
// FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_DEVICE, FILE_ATTRIBUTE_REPARSE_POINT.
|
||||
const (
|
||||
mcpWinFileTypeDisk = 0x0001
|
||||
mcpWinAttrDirectory = 0x0010
|
||||
mcpWinAttrDevice = 0x0040
|
||||
mcpWinAttrReparse = 0x0400
|
||||
)
|
||||
|
||||
// mcpWinAdminOwners: the owners a file in any account's profile may have
|
||||
// besides that account. SYSTEM (S-1-5-18), and the Administrators group
|
||||
// (S-1-5-32-544), which is the default owner of what an elevated
|
||||
// administrator creates.
|
||||
var mcpWinAdminOwners = map[string]bool{"S-1-5-18": true, "S-1-5-32-544": true}
|
||||
|
||||
// mcpWinHandleInfo is what the open read from the handle it holds.
|
||||
type mcpWinHandleInfo struct {
|
||||
FileType uint32 // GetFileType
|
||||
Attributes uint32 // GetFileInformationByHandle's FileAttributes
|
||||
Owner string // the owner SID from GetSecurityInfo; "" when unread
|
||||
}
|
||||
|
||||
// mcpWinRegular: the handle is a file on disk that is not a directory, a
|
||||
// device or a reparse point.
|
||||
func mcpWinRegular(i mcpWinHandleInfo) bool {
|
||||
return i.FileType == mcpWinFileTypeDisk &&
|
||||
i.Attributes&(mcpWinAttrDirectory|mcpWinAttrDevice|mcpWinAttrReparse) == 0
|
||||
}
|
||||
|
||||
// mcpWinOps are the Windows calls the open makes, as values so a test can
|
||||
// stand fakes in for them. The field names are not method names anywhere in
|
||||
// the package: the claims test resolves a call through a value by name.
|
||||
type mcpWinOps struct {
|
||||
openPath func(path string) (uintptr, error)
|
||||
handleInfo func(h uintptr) (mcpWinHandleInfo, error)
|
||||
closeHandle func(h uintptr)
|
||||
asFile func(h uintptr, path string) *os.File
|
||||
}
|
||||
|
||||
// mcpWinOpenWith opens path, checks the handle, and hands back the file and
|
||||
// its owner SID. A handle that is refused is closed before return and never
|
||||
// becomes a file.
|
||||
func mcpWinOpenWith(ops mcpWinOps, path string) (*os.File, string, error) {
|
||||
h, err := ops.openPath(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
info, err := ops.handleInfo(h)
|
||||
if err != nil {
|
||||
ops.closeHandle(h)
|
||||
return nil, "", &os.PathError{Op: "query", Path: path, Err: err}
|
||||
}
|
||||
if !mcpWinRegular(info) {
|
||||
ops.closeHandle(h)
|
||||
return nil, "", errMCPNotRegular
|
||||
}
|
||||
owner := info.Owner
|
||||
if owner == "" {
|
||||
owner = mcpOwnerUnknown
|
||||
}
|
||||
return ops.asFile(h, path), owner, nil
|
||||
}
|
||||
|
||||
// mcpWinProfileSID is the SID whose profile directory is dir, from the
|
||||
// ProfileList (SID to ProfileImagePath, already expanded). mcpOwnerUnknown
|
||||
// when no SID or more than one names it; only an administrator's or
|
||||
// SYSTEM's files are then read there.
|
||||
func mcpWinProfileSID(dir string, profiles map[string]string) string {
|
||||
norm := func(p string) string {
|
||||
return strings.ToLower(strings.TrimRight(strings.ReplaceAll(p, "/", `\`), `\`))
|
||||
}
|
||||
want := norm(dir)
|
||||
var hits []string
|
||||
for sid, p := range profiles {
|
||||
if strings.HasPrefix(sid, "S-1-") && !strings.Contains(sid, ".") && norm(p) == want {
|
||||
hits = append(hits, sid)
|
||||
}
|
||||
}
|
||||
sort.Strings(hits)
|
||||
if len(hits) != 1 {
|
||||
return mcpOwnerUnknown
|
||||
}
|
||||
return hits[0]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Windows open's decisions, with fake handles: Windows itself never runs
|
||||
// here, and nothing in CI runs on Windows, so this is what checks the rules.
|
||||
// The SIDs are invented.
|
||||
|
||||
const (
|
||||
testWinSIDOwner = "S-1-5-21-1000000001-1000000002-1000000003-1201"
|
||||
testWinSIDOther = "S-1-5-21-1000000001-1000000002-1000000003-1202"
|
||||
)
|
||||
|
||||
type fakeWinHandles struct {
|
||||
info mcpWinHandleInfo
|
||||
infoErr error
|
||||
openErr error
|
||||
openCount int
|
||||
closed []uintptr
|
||||
files []uintptr
|
||||
backing string // a real file the "handle" reads from
|
||||
}
|
||||
|
||||
func (f *fakeWinHandles) ops(t *testing.T) mcpWinOps {
|
||||
return mcpWinOps{
|
||||
openPath: func(string) (uintptr, error) {
|
||||
if f.openErr != nil {
|
||||
return 0, f.openErr
|
||||
}
|
||||
f.openCount++
|
||||
return uintptr(100 + f.openCount), nil
|
||||
},
|
||||
handleInfo: func(uintptr) (mcpWinHandleInfo, error) { return f.info, f.infoErr },
|
||||
closeHandle: func(h uintptr) { f.closed = append(f.closed, h) },
|
||||
asFile: func(h uintptr, path string) *os.File {
|
||||
f.files = append(f.files, h)
|
||||
r, err := os.Open(f.backing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPWinOpenRefusesWhatIsNotARegularFileOnDisk(t *testing.T) {
|
||||
backing := filepath.Join(t.TempDir(), "mcp.json")
|
||||
if err := os.WriteFile(backing, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refused := map[string]mcpWinHandleInfo{
|
||||
"symbolic link or junction (reparse point)": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrReparse | 0x20, Owner: testWinSIDOwner},
|
||||
"directory": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDirectory, Owner: testWinSIDOwner},
|
||||
"device attribute": {FileType: mcpWinFileTypeDisk, Attributes: mcpWinAttrDevice, Owner: testWinSIDOwner},
|
||||
"character device": {FileType: 0x0002, Owner: testWinSIDOwner},
|
||||
"pipe": {FileType: 0x0003, Owner: testWinSIDOwner},
|
||||
"unknown type": {FileType: 0x0000, Owner: testWinSIDOwner},
|
||||
}
|
||||
for name, info := range refused {
|
||||
fk := &fakeWinHandles{info: info, backing: backing}
|
||||
f, _, err := mcpWinOpenWith(fk.ops(t), `C:\Users\demo\.cursor\mcp.json`)
|
||||
if f != nil || !errors.Is(err, errMCPNotRegular) {
|
||||
t.Errorf("%s: opened (err %v)", name, err)
|
||||
}
|
||||
if len(fk.closed) != 1 || len(fk.files) != 0 {
|
||||
t.Errorf("%s: handle not closed, or made into a file: closed %v files %v", name, fk.closed, fk.files)
|
||||
}
|
||||
}
|
||||
|
||||
fk := &fakeWinHandles{infoErr: errors.New("query failed"), backing: backing}
|
||||
if f, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); f != nil || err == nil || len(fk.closed) != 1 {
|
||||
t.Errorf("a handle that cannot be queried must be closed and refused: %v %v", err, fk.closed)
|
||||
}
|
||||
|
||||
fk = &fakeWinHandles{openErr: &os.PathError{Op: "open", Path: `C:\x`, Err: os.ErrNotExist}, backing: backing}
|
||||
if _, _, err := mcpWinOpenWith(fk.ops(t), `C:\x`); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("a missing file must stay a missing file (not counted unreadable): %v", err)
|
||||
}
|
||||
|
||||
// A plain file (FILE_ATTRIBUTE_ARCHIVE) is opened, and its owner comes back.
|
||||
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: testWinSIDOwner}, backing: backing}
|
||||
f, owner, err := mcpWinOpenWith(fk.ops(t), `C:\x`)
|
||||
if err != nil || f == nil || owner != testWinSIDOwner || len(fk.closed) != 0 {
|
||||
t.Fatalf("a regular file was refused: %v %q %v", err, owner, fk.closed)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// An owner that could not be read is never an owner that matches.
|
||||
fk = &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x80}, backing: backing}
|
||||
f, owner, err = mcpWinOpenWith(fk.ops(t), `C:\x`)
|
||||
if err != nil || owner != mcpOwnerUnknown {
|
||||
t.Fatalf("unread owner: %v %q", err, owner)
|
||||
}
|
||||
f.Close()
|
||||
if mcpOwnerAllowed(owner, mcpOwnerUnknown, mcpWinAdminOwners) {
|
||||
t.Fatal("an unread owner matched an unknown profile owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPWinOwnerRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file, want string
|
||||
ok bool
|
||||
}{
|
||||
{testWinSIDOwner, testWinSIDOwner, true},
|
||||
{"S-1-5-18", testWinSIDOwner, true}, // SYSTEM
|
||||
{"S-1-5-32-544", testWinSIDOwner, true}, // Administrators
|
||||
{testWinSIDOther, testWinSIDOwner, false},
|
||||
{"S-1-1-0", testWinSIDOwner, false}, // Everyone
|
||||
{"", testWinSIDOwner, false},
|
||||
{mcpOwnerUnknown, testWinSIDOwner, false},
|
||||
{testWinSIDOther, mcpOwnerUnknown, false},
|
||||
{"S-1-5-18", mcpOwnerUnknown, true},
|
||||
{testWinSIDOther, mcpNoOwnerCheck, true},
|
||||
} {
|
||||
if got := mcpOwnerAllowed(c.file, c.want, mcpWinAdminOwners); got != c.ok {
|
||||
t.Errorf("file owner %q read for %q: allowed %v, want %v", c.file, c.want, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPWinProfileSID(t *testing.T) {
|
||||
profiles := map[string]string{
|
||||
"S-1-5-18": `C:\Windows\system32\config\systemprofile`,
|
||||
testWinSIDOwner: `C:\Users\demo`,
|
||||
testWinSIDOwner + ".bak": `C:\Users\demo`,
|
||||
testWinSIDOther: `C:\Users\other`,
|
||||
"S-1-5-21-1000000001-1000000002-9-1": `C:\Users\twice`,
|
||||
"S-1-5-21-1000000001-1000000002-9-2": `C:\Users\twice`,
|
||||
}
|
||||
for dir, want := range map[string]string{
|
||||
`C:\Users\demo`: testWinSIDOwner,
|
||||
`c:\users\DEMO\`: testWinSIDOwner,
|
||||
`C:/Users/demo`: testWinSIDOwner,
|
||||
`C:\Users\other`: testWinSIDOther,
|
||||
`C:\Users\twice`: mcpOwnerUnknown,
|
||||
`C:\Users\nobody`: mcpOwnerUnknown,
|
||||
} {
|
||||
if got := mcpWinProfileSID(dir, profiles); got != want {
|
||||
t.Errorf("%s: %q, want %q", dir, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through mcpReadFileAs with the Windows open's rules: a file in a
|
||||
// profile that another account owns is not read and is counted, one SYSTEM
|
||||
// owns is read.
|
||||
func TestMCPReadFileAsWindowsOwnerRules(t *testing.T) {
|
||||
backing := filepath.Join(t.TempDir(), "mcp.json")
|
||||
if err := os.WriteFile(backing, []byte(`{"mcpServers":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldOpen, oldAdmins := mcpOpenRegularFn, mcpAdminOwners
|
||||
t.Cleanup(func() { mcpOpenRegularFn, mcpAdminOwners = oldOpen, oldAdmins })
|
||||
mcpAdminOwners = mcpWinAdminOwners
|
||||
for _, c := range []struct {
|
||||
owner string
|
||||
read bool
|
||||
foreign int
|
||||
}{
|
||||
{testWinSIDOwner, true, 0},
|
||||
{"S-1-5-18", true, 0},
|
||||
{testWinSIDOther, false, 1},
|
||||
{"", false, 1},
|
||||
} {
|
||||
fk := &fakeWinHandles{info: mcpWinHandleInfo{FileType: mcpWinFileTypeDisk, Attributes: 0x20, Owner: c.owner}, backing: backing}
|
||||
mcpOpenRegularFn = func(p string) (*os.File, string, error) { return mcpWinOpenWith(fk.ops(t), p) }
|
||||
var scan mcpConfigScan
|
||||
_, ok := mcpReadFileAs(`C:\Users\demo\.cursor\mcp.json`, testWinSIDOwner, &scan)
|
||||
if ok != c.read || scan.ForeignOwner != c.foreign {
|
||||
t.Errorf("owner %q: read %v foreign %d, want %v %d", c.owner, ok, scan.ForeignOwner, c.read, c.foreign)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ func mgmtAudit(self, op, family, verdict, why string, exit int, args []string, a
|
||||
line["approval"] = approval
|
||||
}
|
||||
rec, _ := json.Marshal(line)
|
||||
auditToSystemLog(mgmtAuditFile, self, rec)
|
||||
mgmtAuditMu.Lock()
|
||||
defer mgmtAuditMu.Unlock()
|
||||
|
||||
|
||||
@@ -818,6 +818,12 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
||||
priv := setupBox(t)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
|
||||
// The tool-server reader looks in an empty home, not this machine's.
|
||||
oldHomes, oldSys := mcpHomesFn, mcpSystemFn
|
||||
t.Cleanup(func() { mcpHomesFn, mcpSystemFn = oldHomes, oldSys })
|
||||
empty := t.TempDir()
|
||||
mcpHomesFn = func() ([]mcpHome, bool) { return []mcpHome{{Dir: empty, ConfigDir: empty}}, true }
|
||||
mcpSystemFn = func() []mcpSource { return nil }
|
||||
|
||||
beat := func(self string) map[string]any {
|
||||
t.Helper()
|
||||
@@ -846,13 +852,16 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
||||
// The store root names the operator's OS account; it is path material
|
||||
// like the rest and travels only with `observe`. The holes map travels
|
||||
// with it: absent must mean "not entitled", and only that.
|
||||
"machine_record_root", "machine_record_dropped"} {
|
||||
"machine_record_root", "machine_record_dropped",
|
||||
// The tool servers agent clients declare name files, servers and
|
||||
// command lines: observe material like the agent list.
|
||||
"tool_servers", "tool_server_scan"} {
|
||||
if _, present := m[k]; present {
|
||||
t.Fatalf("%q travelled to a pairing that cannot observe — that is the machine's "+
|
||||
"process list going to a host approved for one model", k)
|
||||
}
|
||||
}
|
||||
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") {
|
||||
if hasCap(m, "agent-observe") || hasCap(m, "scheduler-observe") || hasCap(m, "tool-server-observe") {
|
||||
t.Fatalf("the observe capabilities were advertised to a pairing outside the family: %v",
|
||||
m["capabilities"])
|
||||
}
|
||||
@@ -869,6 +878,9 @@ func TestTheObservePlanesPayloadIsGatedOnObserve(t *testing.T) {
|
||||
if _, present := m["schedulers"]; !present {
|
||||
t.Fatal("a pairing granted observe must still get the scheduler inventory")
|
||||
}
|
||||
if _, present := m["tool_servers"]; !present || !hasCap(m, "tool-server-observe") {
|
||||
t.Fatal("a pairing granted observe must get the declared tool servers and their capability")
|
||||
}
|
||||
|
||||
// The anchor holds every family, and an unanchored box gates nothing — the
|
||||
// two cases that must not change, which between them cover nearly every install.
|
||||
|
||||
@@ -210,6 +210,31 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A networked machine is a server. Left unanchored it refuses every op to
|
||||
// every host (capsAllowIn: unanchoredNetworkedRefusal) until someone runs
|
||||
// claim-anchor, a step people missed. So the first pairing on a networked
|
||||
// box with no anchor anchors it, as `claim-anchor` with no token would, and
|
||||
// from then on every terminal and write waits for the person's approval and
|
||||
// every later pairing for this host's. Only with machine authority, which
|
||||
// claim-anchor needs anyway.
|
||||
// The pairing itself succeeded, so a failed anchor is reported, not
|
||||
// returned: `pair` failing here would read as "could not pair" to a caller
|
||||
// (npx cseries exits on it) when the pairing exists. claim-anchor, which
|
||||
// such a caller runs next, reports the anchor's own failure.
|
||||
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
}
|
||||
if anchored {
|
||||
if fresh, lerr := loadPairings(); lerr == nil {
|
||||
if c := pairingFor(fresh, cfg.HostName); c != nil {
|
||||
cfg, all = c, fresh
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
|
||||
fmt.Println("waits for your approval, and any later host waits for this one to approve it.")
|
||||
fmt.Printf("Enrol your second factor now, so you can approve here:\n %s\n\n", stepupEnrollHint())
|
||||
}
|
||||
if cfg.tcpTransport() && cfg.active() {
|
||||
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName)
|
||||
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
|
||||
@@ -242,6 +267,19 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorFirstNetworkedPairing anchors a networked machine that has no anchor
|
||||
// to the pairing just made, when it is the machine's only one and the caller
|
||||
// holds machine authority (see cmdPair). Reports whether it anchored.
|
||||
func anchorFirstNetworkedPairing(cfg *Config, all []*Config) (bool, error) {
|
||||
if boxMode() != modeNetworked || currentAnchor() != nil || len(all) != 1 || !machineAuthorityHeld() {
|
||||
return false, nil
|
||||
}
|
||||
if err := cmdClaimAnchor("", false, "", cfg.HostName); err != nil {
|
||||
return false, fmt.Errorf("paired with %s, but anchoring this networked machine to it failed: %w\n anchor it with: %s", cfg.HostName, err, anchorCommandHint())
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// redeemURLBase is the platform's enrollment ingress. A variable so a test can
|
||||
// serve a redeem response on loopback; nothing outside tests assigns it.
|
||||
var redeemURLBase = "https://api.privatae.ai"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPairAnchorsANetworkedMachineToItsFirstHost: on a networked machine with
|
||||
// no anchor, the first pairing anchors it (as claim-anchor with no token
|
||||
// would), so a server is not left refusing everything until claim-anchor is
|
||||
// run by hand. A local machine,
|
||||
// an already anchored one, a second pairing, or a caller without machine
|
||||
// authority are left as they were.
|
||||
func TestPairAnchorsANetworkedMachineToItsFirstHost(t *testing.T) {
|
||||
held := true
|
||||
old := machineAuthorityHeld
|
||||
machineAuthorityHeld = func() bool { return held }
|
||||
t.Cleanup(func() { machineAuthorityHeld = old })
|
||||
|
||||
box := func(t *testing.T, networked bool) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
||||
if networked {
|
||||
if err := setBoxMode(modeNetworked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
only := func(t *testing.T, host string) (*Config, []*Config) {
|
||||
t.Helper()
|
||||
all, err := loadPairings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pairingFor(all, host), all
|
||||
}
|
||||
|
||||
t.Run("networked, no anchor, first pairing: anchored and active", func(t *testing.T) {
|
||||
held = true
|
||||
box(t, true)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
anchored, err := anchorFirstNetworkedPairing(cfg, all)
|
||||
if err != nil || !anchored {
|
||||
t.Fatalf("the first pairing on a networked box must anchor it: anchored=%v err=%v", anchored, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||
t.Fatalf("anchor = %+v, want %s", a, tMember)
|
||||
}
|
||||
if got := loadState(t, tMember); got != pairingActive {
|
||||
t.Fatalf("the anchor never waits on itself: state %q", got)
|
||||
}
|
||||
// The installer's claim-anchor after it finds the work done.
|
||||
if err := cmdClaimAnchor("", false, "", ""); err != nil {
|
||||
t.Fatalf("claim-anchor after pair must succeed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local machine: not anchored", func(t *testing.T) {
|
||||
held = true
|
||||
box(t, false)
|
||||
writePairing(t, tMember)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("a local machine must not be anchored: %v %v", anchored, err)
|
||||
}
|
||||
if currentAnchor() != nil {
|
||||
t.Fatal("a local machine has no anchor")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already anchored: a new pairing stays pending", func(t *testing.T) {
|
||||
held = true
|
||||
box(t, true)
|
||||
writePairing(t, tAnchor)
|
||||
if err := writeAnchor("Acme", tAnchor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("an anchored machine must not be re-anchored: %v %v", anchored, err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
|
||||
t.Fatalf("anchor moved: %+v", a)
|
||||
}
|
||||
if got := loadState(t, tMember); got != pairingPending {
|
||||
t.Fatalf("a later host waits for the anchor's approval: state %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a second pairing on an unanchored machine: left for claim-anchor --host", func(t *testing.T) {
|
||||
held = true
|
||||
box(t, true)
|
||||
writePairing(t, tAnchor)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("with two pairings the person picks the anchor: %v %v", anchored, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no machine authority: not anchored", func(t *testing.T) {
|
||||
held = true
|
||||
box(t, true) // set-mode networked itself needs machine authority
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
held = false
|
||||
cfg, all := only(t, tMember)
|
||||
if anchored, err := anchorFirstNetworkedPairing(cfg, all); anchored || err != nil {
|
||||
t.Fatalf("without machine authority nothing is anchored: %v %v", anchored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func pairingAudit(op, subject, decision string) {
|
||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op,
|
||||
"subject": subject, "decision": decision,
|
||||
})
|
||||
auditToSystemLog(auditFilePairing, "", rec)
|
||||
fh, err := os.OpenFile(filepath.Join(d, "pairing_audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -138,3 +138,14 @@ func restartHintFor(goos string) string {
|
||||
}
|
||||
return "Restart the service so the change takes: systemctl restart privatae-bastion"
|
||||
}
|
||||
|
||||
// stepupEnrollHint is the stepup-enroll line to print after a networked
|
||||
// machine is anchored: approvals on it need a second factor or a device.
|
||||
func stepupEnrollHint() string { return stepupEnrollHintFor(runtime.GOOS) }
|
||||
|
||||
func stepupEnrollHintFor(goos string) string {
|
||||
if goos == "windows" {
|
||||
return "privatae-bastion stepup-enroll (from this Administrator terminal)"
|
||||
}
|
||||
return "sudo -H privatae-bastion stepup-enroll"
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ func rootExecAudit(dir string, rec map[string]any) {
|
||||
rec["ts"] = time.Now().UTC().Format(time.RFC3339)
|
||||
rec["op"] = sessionOpRootExec
|
||||
raw, _ := json.Marshal(rec)
|
||||
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), raw)
|
||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -254,6 +254,7 @@ func sessionAudit(dir, op, sessionID, user, event string, exit int, detail strin
|
||||
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID,
|
||||
"user": user, "event": event, "exit": exit, "decision": detail,
|
||||
})
|
||||
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
|
||||
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
// Account lookups made outside a scan (sidnames.go): the approvals pipe and the
|
||||
// folder agent's drop. Fake lookups stand in for LookupAccountSid, so these run
|
||||
// on every platform. Every SID and name here is invented.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSIDDana = "S-1-5-21-1000000001-1000000002-1000000003-1201"
|
||||
|
||||
// hangingLookup blocks until release is closed, then answers with acct.
|
||||
func hangingLookup(calls *int32, release <-chan struct{}, acct sidAccount) func(string) (sidAccount, error) {
|
||||
return func(string) (sidAccount, error) {
|
||||
atomic.AddInt32(calls, 1)
|
||||
<-release
|
||||
return acct, nil
|
||||
}
|
||||
}
|
||||
|
||||
// A directory that does not answer: every caller is told "timed out" within
|
||||
// its budget, only one lookup is ever started on the lane, and the answer that
|
||||
// comes late fills the cache for the next caller.
|
||||
func TestSIDDirectoryUnreachableTimesOutWithinBudget(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
var calls int32
|
||||
dir := newSIDDirectory(hangingLookup(&calls, release,
|
||||
sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}))
|
||||
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
start := time.Now()
|
||||
if _, out := dir.resolve(testSIDDana, lane); out != sidTimedOut {
|
||||
t.Fatalf("call %d: outcome %v while the directory does not answer", i, out)
|
||||
}
|
||||
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||
t.Fatalf("call %d waited %v, budget %v", i, took, lane.budget)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("%d lookups started while the first was still blocked", n)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
a, out := dir.resolve(testSIDDana, lane)
|
||||
if out == sidResolved {
|
||||
if sidAccountLabel(a) != `EXAMPLECORP\dana` {
|
||||
t.Fatalf("late answer = %+v", a)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the late answer never reached the cache")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("the late answer was asked again: %d lookups", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Answers are kept for sidDirectoryTTL; a failure is asked again every time.
|
||||
func TestSIDDirectoryCachesAnswersNotFailures(t *testing.T) {
|
||||
var calls int32
|
||||
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if sid == testSIDDana {
|
||||
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
|
||||
}
|
||||
return sidAccount{}, errors.New("none mapped")
|
||||
})
|
||||
now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
|
||||
dir.now = func() time.Time { return now }
|
||||
lane := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, out := dir.resolve(testSIDDana, lane); out != sidResolved {
|
||||
t.Fatalf("outcome %v", out)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("%d lookups for one account within the TTL", n)
|
||||
}
|
||||
now = now.Add(sidDirectoryTTL)
|
||||
dir.resolve(testSIDDana, lane)
|
||||
if n := atomic.LoadInt32(&calls); n != 2 {
|
||||
t.Fatalf("an expired answer was not asked again: %d lookups", n)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, out := dir.resolve("S-1-5-21-9-9-9-9", lane); out != sidUnmapped {
|
||||
t.Fatalf("unmapped SID outcome %v", out)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 4 {
|
||||
t.Fatalf("a failure was cached: %d lookups", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A lookup hung on one lane does not hold another lane.
|
||||
func TestSIDDirectoryLanesAreIndependent(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
dir := newSIDDirectory(func(sid string) (sidAccount, error) {
|
||||
if sid == testSIDDana {
|
||||
<-release
|
||||
}
|
||||
return sidAccount{Account: "erin", Type: winSidTypeUser}, nil
|
||||
})
|
||||
stuck := sidLane{slots: make(chan struct{}, 1), budget: 50 * time.Millisecond}
|
||||
other := sidLane{slots: make(chan struct{}, 1), budget: time.Second}
|
||||
if _, out := dir.resolve(testSIDDana, stuck); out != sidTimedOut {
|
||||
t.Fatalf("stuck lane outcome %v", out)
|
||||
}
|
||||
if _, out := dir.resolve("S-1-5-21-1000000001-1000000002-1000000003-1202", other); out != sidResolved {
|
||||
t.Fatalf("the other lane was held by the stuck one: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// THE APPROVALS PIPE FAILS CLOSED. A client whose account the directory could
|
||||
// not name in time is never approved, even with a right code; it is not
|
||||
// counted as a wrong code; list still answers. Once the directory answers,
|
||||
// the same code approves.
|
||||
func TestPipeApproveRefusedWhenTheAccountLookupTimedOut(t *testing.T) {
|
||||
dir, idA, purposeA, _, _ := pipeBox(t)
|
||||
now := time.Now()
|
||||
|
||||
release := make(chan struct{})
|
||||
var calls int32
|
||||
accounts := newSIDDirectory(hangingLookup(&calls, release,
|
||||
sidAccount{Account: "pat", Domain: "EXAMPLE", Type: winSidTypeUser}))
|
||||
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||
|
||||
c := thePerson
|
||||
c.UserName = ""
|
||||
start := time.Now()
|
||||
pipeClientName(&c, accounts, lane)
|
||||
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||
t.Fatalf("the client's name lookup took %v, budget %v", took, lane.budget)
|
||||
}
|
||||
if !c.NameTimedOut || c.UserName != "" {
|
||||
t.Fatalf("a lookup that did not answer must be marked: %+v", c)
|
||||
}
|
||||
|
||||
code := pipeCodeNow()
|
||||
approve := pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}
|
||||
if resp := pipeServe(approve, c, now); resp.OK || !strings.Contains(resp.Error, "did not answer") {
|
||||
t.Fatalf("an approve on a timed-out lookup must be refused: %+v", resp)
|
||||
}
|
||||
if r := stateOf(t, dir, idA); r.State != "pending" {
|
||||
t.Fatalf("a timed-out lookup changed the request: %s", r.State)
|
||||
}
|
||||
if f := pipeThrottleLoad(dir); f.Failures != 0 {
|
||||
t.Fatalf("the refusal was counted as a wrong code: %d", f.Failures)
|
||||
}
|
||||
if resp := pipeServe(pipeRequest{Op: "list"}, c, now); !resp.OK {
|
||||
t.Fatalf("list must still answer by SID: %+v", resp)
|
||||
}
|
||||
|
||||
close(release)
|
||||
c2 := thePerson
|
||||
c2.UserName = ""
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
c2.NameTimedOut = false
|
||||
pipeClientName(&c2, accounts, lane)
|
||||
if !c2.NameTimedOut {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the directory's late answer never reached the pipe")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if c2.UserName != `EXAMPLE\pat` {
|
||||
t.Fatalf("name = %q", c2.UserName)
|
||||
}
|
||||
if resp := pipeServe(approve, c2, now); !resp.OK {
|
||||
t.Fatalf("with the account named, the code approves: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// An account the directory says does not exist is still served by SID, as
|
||||
// before: that is an answer, not a timeout.
|
||||
func TestPipeClientNameUnmappedIsNotATimeout(t *testing.T) {
|
||||
accounts := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
|
||||
c := thePerson
|
||||
c.UserName = ""
|
||||
pipeClientName(&c, accounts, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
|
||||
if c.NameTimedOut || c.UserName != "" {
|
||||
t.Fatalf("unmapped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// THE DROP FAILS CLOSED. The folder owner's lookup is bounded, and a
|
||||
// directory that does not answer starts no agent; no account starts none.
|
||||
func TestFaWinDropOwnerAccountFailsClosed(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls int32
|
||||
slow := newSIDDirectory(hangingLookup(&calls, release, sidAccount{Account: "dana", Type: winSidTypeUser}))
|
||||
lane := sidLane{slots: make(chan struct{}, 1), budget: 100 * time.Millisecond}
|
||||
start := time.Now()
|
||||
if _, err := faWinDropOwnerAccount(testSIDDana, slow, lane); err == nil || !strings.Contains(err.Error(), "did not answer") {
|
||||
t.Fatalf("a directory that does not answer must refuse the start: %v", err)
|
||||
}
|
||||
if took := time.Since(start); took > lane.budget+500*time.Millisecond {
|
||||
t.Fatalf("the drop waited %v, budget %v", took, lane.budget)
|
||||
}
|
||||
|
||||
none := newSIDDirectory(func(string) (sidAccount, error) { return sidAccount{}, errors.New("none mapped") })
|
||||
if _, err := faWinDropOwnerAccount(testSIDDana, none, sidLane{slots: make(chan struct{}, 1), budget: time.Second}); err == nil ||
|
||||
!strings.Contains(err.Error(), "has no account") {
|
||||
t.Fatalf("no account must refuse the start: %v", err)
|
||||
}
|
||||
|
||||
ok := newSIDDirectory(func(string) (sidAccount, error) {
|
||||
return sidAccount{Account: "dana", Domain: "EXAMPLECORP", Type: winSidTypeUser}, nil
|
||||
})
|
||||
a, err := faWinDropOwnerAccount(testSIDDana, ok, sidLane{slots: make(chan struct{}, 1), budget: time.Second})
|
||||
if err != nil || a.Type != winSidTypeUser || sidAccountLabel(a) != `EXAMPLECORP\dana` {
|
||||
t.Fatalf("answered: %+v %v", a, err)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invented SIDs: the domain part is made up and names no real domain.
|
||||
const (
|
||||
testSIDAlice = "S-1-5-21-1000000001-1000000002-1000000003-1101"
|
||||
testSIDBob = "S-1-5-21-1000000001-1000000002-1000000003-1102"
|
||||
)
|
||||
|
||||
// One scan asks each SID once, and the account label (winTokenUser) and the
|
||||
// owner's domain (ownerFactsReader) share the answer.
|
||||
func TestSIDNamesAsksEachSIDOncePerScan(t *testing.T) {
|
||||
var calls int32
|
||||
lookup := func(sid string) (string, string, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
switch sid {
|
||||
case testSIDAlice:
|
||||
return "alice", "EXAMPLECORP", nil
|
||||
case testSIDBob:
|
||||
return "bob", "EXAMPLECORP", nil
|
||||
}
|
||||
return "", "", errors.New("none mapped")
|
||||
}
|
||||
names := newSIDNames(lookup, time.Second, make(chan struct{}, 1))
|
||||
for i := 0; i < 40; i++ {
|
||||
if got := winAccountLabel(testSIDAlice, names); got != `EXAMPLECORP\alice` {
|
||||
t.Fatalf("label = %q", got)
|
||||
}
|
||||
if got := winAccountDomain(testSIDAlice, names); got != "EXAMPLECORP" {
|
||||
t.Fatalf("domain = %q", got)
|
||||
}
|
||||
winAccountLabel(testSIDBob, names)
|
||||
}
|
||||
if got := winAccountLabel("S-1-5-21-9-9-9-9", names); got != "S-1-5-21-9-9-9-9" {
|
||||
t.Fatalf("an unmapped SID must be shown as itself, got %q", got)
|
||||
}
|
||||
winAccountDomain("S-1-5-21-9-9-9-9", names)
|
||||
if n := atomic.LoadInt32(&calls); n != 3 {
|
||||
t.Fatalf("%d lookups for 3 distinct SIDs in one scan", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain controller that does not answer: the first lookup hangs. The scan
|
||||
// must stop waiting at its budget, every later SID must come back unresolved
|
||||
// at once (no second lookup started), the owner's class must read unknown,
|
||||
// and the next scan must not stack a second blocked lookup behind the first.
|
||||
func TestSIDNamesUnreachableDirectoryDegradesWithinBudget(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls int32
|
||||
lookup := func(sid string) (string, string, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
<-release
|
||||
return "", "", errors.New("released")
|
||||
}
|
||||
slots := make(chan struct{}, 1)
|
||||
const budget = 150 * time.Millisecond
|
||||
|
||||
for scan := 0; scan < 3; scan++ {
|
||||
names := newSIDNames(lookup, budget, slots)
|
||||
start := time.Now()
|
||||
for i := 0; i < 30; i++ {
|
||||
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 2000+i)
|
||||
if got := winAccountLabel(sid, names); got != sid {
|
||||
t.Fatalf("scan %d: unresolved SID shown as %q", scan, got)
|
||||
}
|
||||
o := classifyWinOwner(winOwner{SID: sid, Domain: winAccountDomain(sid, names),
|
||||
Computer: "DESKTOP-TEST", Groups: map[string]bool{}, GroupsKnown: true})
|
||||
if o.Class != ownerUnknown {
|
||||
t.Fatalf("scan %d: class %q for an account whose domain could not be read", scan, o.Class)
|
||||
}
|
||||
}
|
||||
if took := time.Since(start); took > budget+time.Second {
|
||||
t.Fatalf("scan %d spent %v on lookups, budget %v", scan, took, budget)
|
||||
}
|
||||
}
|
||||
if n := atomic.LoadInt32(&calls); n != 1 {
|
||||
t.Fatalf("%d lookups started while the first was still blocked", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow but answering lookup is used, and only what it took counts against
|
||||
// the budget.
|
||||
func TestSIDNamesSlowLookupWithinBudgetIsUsed(t *testing.T) {
|
||||
lookup := func(sid string) (string, string, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return "carol", "EXAMPLECORP", nil
|
||||
}
|
||||
names := newSIDNames(lookup, 2*time.Second, make(chan struct{}, 1))
|
||||
for i := 0; i < 5; i++ {
|
||||
sid := fmt.Sprintf("S-1-5-21-1000000001-1000000002-1000000003-%d", 3000+i)
|
||||
if d := winAccountDomain(sid, names); d != "EXAMPLECORP" {
|
||||
t.Fatalf("lookup %d within budget came back %q", i, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,13 @@ func installSystemService() error {
|
||||
return fmt.Errorf("the %s service is registered but did not start: %w", winServiceName, err)
|
||||
}
|
||||
fmt.Printf("System service installed: %s, running %s as LocalSystem.\n", winServiceName, self)
|
||||
// Audit rows go to the Application event log under their own source as
|
||||
// well as to their files (auditlog.go). A failure costs that copy only.
|
||||
if err := registerAuditEventSource(); err != nil {
|
||||
fmt.Printf(" Audit rows will not reach the event log (%v); they are still written to their files.\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Every audit row is also written to the Application event log, source %s.\n", auditEventSource)
|
||||
}
|
||||
fmt.Println(" It starts at boot, before anyone signs in, and restarts five seconds after any failure.")
|
||||
// The person's tray, at every sign-in (tray.go trayFor). A failure here
|
||||
// leaves the service serving; approvals still work from this terminal.
|
||||
@@ -294,6 +301,7 @@ func uninstallSystemService() error {
|
||||
return fmt.Errorf("cannot remove the %s service: %w", winServiceName, err)
|
||||
}
|
||||
removeTrayLogonEntry()
|
||||
removeAuditEventSource()
|
||||
fmt.Printf("System service removed: %s is no longer registered.\n", winServiceName)
|
||||
fmt.Printf(" The bastion's state under %s is left in place.\n", windowsSystemAppData(os.Getenv("SystemRoot")))
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user