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) } }