WIP: acl interceptor and named pipe ui

This commit is contained in:
Theodor S. Midtlien
2026-07-23 14:14:42 +02:00
parent a12a3e4603
commit 27afbd7952
58 changed files with 3163 additions and 475 deletions
+29
View File
@@ -0,0 +1,29 @@
//go:build cgo && !osusergo && !windows
package shell
import "os/user"
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
// the NSS stack natively. If it fails, the user truly doesn't exist and
// getent would also fail.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
// libc's getgrouplist handles NSS groups natively.
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}
@@ -0,0 +1,92 @@
//go:build (!cgo || osusergo) && !windows
package shell
import (
"os"
"os/user"
"strconv"
log "github.com/sirupsen/logrus"
)
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
// getent goes through the host's NSS stack.
func LookupWithGetent(username string) (*user.User, error) {
u, err := user.Lookup(username)
if err == nil {
return u, nil
}
stdErr := err
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
u, _, getentErr := runGetentPasswd(username)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
return nil, stdErr
}
return u, nil
}
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
// falling back to getent if os/user fails (NSS groups under nocgo).
func LookupGroupWithGetent(name string) (*user.Group, error) {
g, err := user.LookupGroup(name)
if err == nil {
return g, nil
}
stdErr := err
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
g, getentErr := runGetentGroup(name)
if getentErr != nil {
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
return nil, stdErr
}
return g, nil
}
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
func CurrentUserWithGetent() (*user.User, error) {
u, err := user.Current()
if err == nil {
return u, nil
}
stdErr := err
uid := strconv.Itoa(os.Getuid())
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
u, _, getentErr := runGetentPasswd(uid)
if getentErr != nil {
return nil, stdErr
}
return u, nil
}
// GroupIdsWithFallback gets group IDs for a user via the id command first,
// falling back to user.GroupIds().
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
// only reads /etc/group and silently returns incomplete results for NSS users
// (no error, just missing groups). The id command goes through NSS and returns
// the full set.
func GroupIdsWithFallback(u *user.User) ([]string, error) {
ids, err := runIdGroups(u.Username)
if err == nil {
return ids, nil
}
log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err)
ids, stdErr := u.GroupIds()
if stdErr != nil {
return nil, stdErr
}
return ids, nil
}
+172
View File
@@ -0,0 +1,172 @@
package shell
import (
"os/user"
"runtime"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLookupWithGetent_CurrentUser(t *testing.T) {
// The current user should always be resolvable on any platform
current, err := user.Current()
require.NoError(t, err)
u, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, u.Username)
assert.Equal(t, current.Uid, u.Uid)
assert.Equal(t, current.Gid, u.Gid)
}
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
require.Error(t, err, "should fail for nonexistent user")
}
func TestCurrentUserWithGetent(t *testing.T) {
stdUser, err := user.Current()
require.NoError(t, err)
u, err := CurrentUserWithGetent()
require.NoError(t, err)
assert.Equal(t, stdUser.Uid, u.Uid)
assert.Equal(t, stdUser.Username, u.Username)
}
func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group")
if runtime.GOOS != "windows" {
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
}
}
}
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
if runtime.GOOS == "windows" {
// Windows stub always returns empty, which is correct
shell := GetShellFromGetent("1000")
assert.Empty(t, shell, "Windows stub should return empty")
return
}
current, err := user.Current()
require.NoError(t, err)
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
shell := GetShellFromGetent(current.Uid)
if shell == "" {
t.Log("GetShellFromGetent returned empty, getent may not be available")
return
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
func TestLookupWithGetent_RootUser(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no root user on Windows")
}
u, err := LookupWithGetent("root")
if err != nil {
t.Skip("root user not available on this system")
}
assert.Equal(t, "0", u.Uid, "root should have UID 0")
}
// TestIntegration_FullLookupChain exercises the complete user lookup chain
// against the real system, testing that all wrappers (LookupWithGetent,
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
// consistent and correct results when composed together.
func TestIntegration_FullLookupChain(t *testing.T) {
// Step 1: CurrentUserWithGetent must resolve the running user.
current, err := CurrentUserWithGetent()
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
require.NotEmpty(t, current.Uid)
require.NotEmpty(t, current.Username)
// Step 2: LookupWithGetent by the same username must return matching identity.
byName, err := LookupWithGetent(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
// Step 3: GroupIdsWithFallback must return at least the primary GID.
groups, err := GroupIdsWithFallback(current)
require.NoError(t, err)
require.NotEmpty(t, groups, "user must have at least one group")
foundPrimary := false
for _, gid := range groups {
if runtime.GOOS != "windows" {
_, err := strconv.ParseUint(gid, 10, 32)
require.NoError(t, err, "group ID %q must be a valid uint32", gid)
}
if gid == current.Gid {
foundPrimary = true
}
}
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
// Step 4: GetShellFromGetent should either return a valid shell path or empty
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
if runtime.GOOS != "windows" {
shell := GetShellFromGetent(current.Uid)
if shell != "" {
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
}
}
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
// testing the handoff between the two functions as used by the SSH server.
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
// Simulate the SSH server flow: lookup user, then get their groups.
resolved, err := LookupWithGetent(current.Username)
require.NoError(t, err)
groups, err := GroupIdsWithFallback(resolved)
require.NoError(t, err)
require.NotEmpty(t, groups, "resolved user must have groups")
// On Unix, all returned GIDs must be valid numeric values.
// On Windows, group IDs are SIDs (e.g., "S-1-5-32-544").
if runtime.GOOS != "windows" {
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be numeric", gid)
}
}
}
// TestIntegration_ShellLookupChain tests the full shell resolution chain
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
func TestIntegration_ShellLookupChain(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix shell lookup not applicable on Windows")
}
current, err := user.Current()
require.NoError(t, err)
// GetUserShell is the top-level function used by the SSH server.
shell := GetUserShell(current.Uid)
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
}
+165
View File
@@ -0,0 +1,165 @@
//go:build !windows
package shell
import (
"context"
"fmt"
"os/exec"
"os/user"
"runtime"
"strings"
"time"
)
const getentTimeout = 5 * time.Second
// GetShellFromGetent gets a user's login shell via getent by UID.
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
// directly and won't find NSS-provided users there.
func GetShellFromGetent(userID string) string {
_, shell, err := runGetentPasswd(userID)
if err != nil {
return ""
}
return shell
}
// GetUserFromGetent returns the resolved user from either a uid or username,
// going through the host's NSS stack.
func GetUserFromGetent(query string) (*user.User, error) {
u, _, err := runGetentPasswd(query)
return u, err
}
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
func runGetentPasswd(query string) (*user.User, string, error) {
if !validateGetentInput(query) {
return nil, "", fmt.Errorf("invalid getent input: %q", query)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "passwd", query).Output()
if err != nil {
return nil, "", fmt.Errorf("getent passwd %s: %w", query, err)
}
return parseGetentPasswd(string(out))
}
// runGetentGroup executes `getent group <query>` and returns the group.
func runGetentGroup(query string) (*user.Group, error) {
if !validateGetentInput(query) {
return nil, fmt.Errorf("invalid getent input: %q", query)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
if err != nil {
return nil, fmt.Errorf("getent group %s: %w", query, err)
}
return parseGetentGroup(string(out))
}
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
func parseGetentPasswd(output string) (*user.User, string, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 6 {
return nil, "", fmt.Errorf("unexpected getent output (need 6+ fields): %q", output)
}
if fields[0] == "" || fields[2] == "" || fields[3] == "" {
return nil, "", fmt.Errorf("missing required fields in getent output: %q", output)
}
var shell string
if len(fields) >= 7 {
shell = fields[6]
}
return &user.User{
Username: fields[0],
Uid: fields[2],
Gid: fields[3],
Name: fields[4],
HomeDir: fields[5],
}, shell, nil
}
// parseGetentGroup parses getent group output: "group:x:gid:members".
func parseGetentGroup(output string) (*user.Group, error) {
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
if len(fields) < 4 {
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
}
if fields[0] == "" || fields[2] == "" {
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
}
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
}
// validateGetentInput checks that the input is safe to pass to getent or id.
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
// rejected so the input can never be parsed as a command-line flag.
func validateGetentInput(input string) bool {
maxLen := 32
if runtime.GOOS == "linux" {
maxLen = 256
}
if len(input) == 0 || len(input) > maxLen {
return false
}
if input[0] == '-' {
return false
}
for _, r := range input {
if isAllowedGetentChar(r) {
continue
}
return false
}
return true
}
func isAllowedGetentChar(r rune) bool {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
return true
}
switch r {
case '.', '_', '-', '@', '+', '$':
return true
}
return false
}
// runIdGroups runs `id -G <username>` and returns the space-separated group IDs.
func runIdGroups(username string) ([]string, error) {
if !validateGetentInput(username) {
return nil, fmt.Errorf("invalid username for id command: %q", username)
}
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "id", "-G", username).Output()
if err != nil {
return nil, fmt.Errorf("id -G %s: %w", username, err)
}
trimmed := strings.TrimSpace(string(out))
if trimmed == "" {
return nil, fmt.Errorf("id -G %s: empty output", username)
}
return strings.Fields(trimmed), nil
}
+413
View File
@@ -0,0 +1,413 @@
//go:build !windows
package shell
import (
"os/exec"
"os/user"
"runtime"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseGetentPasswd(t *testing.T) {
tests := []struct {
name string
input string
wantUser *user.User
wantShell string
wantErr bool
errContains string
}{
{
name: "standard entry",
input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n",
wantUser: &user.User{
Username: "alice",
Uid: "1001",
Gid: "1001",
Name: "Alice Smith",
HomeDir: "/home/alice",
},
wantShell: "/bin/bash",
},
{
name: "root entry",
input: "root:x:0:0:root:/root:/bin/bash",
wantUser: &user.User{
Username: "root",
Uid: "0",
Gid: "0",
Name: "root",
HomeDir: "/root",
},
wantShell: "/bin/bash",
},
{
name: "empty gecos field",
input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin",
wantUser: &user.User{
Username: "svc",
Uid: "999",
Gid: "999",
Name: "",
HomeDir: "/var/lib/svc",
},
wantShell: "/usr/sbin/nologin",
},
{
name: "gecos with commas",
input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh",
wantUser: &user.User{
Username: "john",
Uid: "1002",
Gid: "1002",
Name: "John Doe,Room 101,555-1234,555-4321",
HomeDir: "/home/john",
},
wantShell: "/bin/zsh",
},
{
name: "remote user with large UID",
input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n",
wantUser: &user.User{
Username: "remoteuser",
Uid: "50001",
Gid: "50001",
Name: "Remote User",
HomeDir: "/home/remoteuser",
},
wantShell: "/bin/bash",
},
{
name: "no shell field (only 6 fields)",
input: "minimal:x:1000:1000::/home/minimal",
wantUser: &user.User{
Username: "minimal",
Uid: "1000",
Gid: "1000",
Name: "",
HomeDir: "/home/minimal",
},
wantShell: "",
},
{
name: "too few fields",
input: "bad:x:1000",
wantErr: true,
errContains: "need 6+ fields",
},
{
name: "empty username",
input: ":x:1000:1000::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty UID",
input: "test:x::1000::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty GID",
input: "test:x:1000:::/home/test:/bin/bash",
wantErr: true,
errContains: "missing required fields",
},
{
name: "empty input",
input: "",
wantErr: true,
errContains: "need 6+ fields",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, shell, err := parseGetentPasswd(tt.input)
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantUser.Username, u.Username, "username")
assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID")
assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID")
assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos")
assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory")
assert.Equal(t, tt.wantShell, shell, "shell")
})
}
}
func TestValidateGetentInput(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"normal username", "alice", true},
{"numeric UID", "1001", true},
{"dots and underscores", "alice.bob_test", true},
{"hyphen", "alice-bob", true},
{"leading hyphen rejected", "-i", false},
{"leading double hyphen rejected", "--no-idn", false},
{"lone hyphen rejected", "-", false},
{"kerberos principal", "user@REALM", true},
{"samba machine account", "MACHINE$", true},
{"NIS compat", "+user", true},
{"empty", "", false},
{"null byte", "alice\x00bob", false},
{"newline", "alice\nbob", false},
{"tab", "alice\tbob", false},
{"control char", "alice\x01bob", false},
{"DEL char", "alice\x7fbob", false},
{"space rejected", "alice bob", false},
{"semicolon rejected", "alice;bob", false},
{"backtick rejected", "alice`bob", false},
{"pipe rejected", "alice|bob", false},
{"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"},
{"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"},
{"257 chars exceeds all limits", makeLongString(257), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, validateGetentInput(tt.input))
})
}
}
func makeLongString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'a'
}
return string(b)
}
func TestRunGetent_RootUser(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
u, shell, err := runGetentPasswd("root")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
assert.Equal(t, "0", u.Gid)
assert.NotEmpty(t, shell, "root should have a shell")
}
func TestRunGetent_ByUID(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
u, _, err := runGetentPasswd("0")
require.NoError(t, err)
assert.Equal(t, "root", u.Username)
assert.Equal(t, "0", u.Uid)
}
func TestRunGetent_NonexistentUser(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
assert.Error(t, err)
}
func TestRunGetent_InvalidInput(t *testing.T) {
_, _, err := runGetentPasswd("")
assert.Error(t, err)
_, _, err = runGetentPasswd("user\x00name")
assert.Error(t, err)
}
func TestRunGetent_NotAvailable(t *testing.T) {
if _, err := exec.LookPath("getent"); err == nil {
t.Skip("getent is available, can't test missing case")
}
_, _, err := runGetentPasswd("root")
assert.Error(t, err, "should fail when getent is not installed")
}
func TestRunIdGroups_CurrentUser(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
groups, err := runIdGroups(current.Username)
require.NoError(t, err)
require.NotEmpty(t, groups, "current user should have at least one group")
for _, gid := range groups {
_, err := strconv.ParseUint(gid, 10, 32)
assert.NoError(t, err, "group ID %q should be a valid uint32", gid)
}
}
func TestRunIdGroups_NonexistentUser(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
_, err := runIdGroups("nonexistent_user_xyzzy_12345")
assert.Error(t, err)
}
func TestRunIdGroups_InvalidInput(t *testing.T) {
_, err := runIdGroups("")
assert.Error(t, err)
_, err = runIdGroups("user\x00name")
assert.Error(t, err)
}
func TestGetentResultsMatchStdlib(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetentPasswd(current.Username)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match")
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
assert.Equal(t, current.Gid, getentUser.Gid, "GID should match")
assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match")
}
func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
getentUser, _, err := runGetentPasswd(current.Uid)
require.NoError(t, err)
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
assert.Equal(t, current.Uid, getentUser.Uid, "UID should match")
}
func TestIdGroupsMatchStdlib(t *testing.T) {
if _, err := exec.LookPath("id"); err != nil {
t.Skip("id not available on this system")
}
current, err := user.Current()
require.NoError(t, err)
stdGroups, err := current.GroupIds()
if err != nil {
t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0")
}
idGroups, err := runIdGroups(current.Username)
require.NoError(t, err)
// Deduplicate both lists: id -G can return duplicates (e.g., root in Docker)
// and ElementsMatch treats duplicates as distinct.
assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user")
}
func uniqueStrings(ss []string) []string {
seen := make(map[string]struct{}, len(ss))
out := make([]string, 0, len(ss))
for _, s := range ss {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly
// reads the current user's shell from /etc/passwd by comparing it against what
// getent reports (which goes through NSS).
func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
shell := getShellFromPasswd(current.Uid)
if shell == "" {
t.Skip("current user not found in /etc/passwd (may be an NSS-only user)")
}
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
if _, err := exec.LookPath("getent"); err == nil {
_, getentShell, getentErr := runGetentPasswd(current.Uid)
if getentErr == nil && getentShell != "" {
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
}
}
}
// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read
// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on
// any standard Unix system.
func TestGetShellFromPasswd_RootUser(t *testing.T) {
shell := getShellFromPasswd("0")
require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd")
assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell)
}
// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd
// returns empty for a UID that doesn't exist in /etc/passwd.
func TestGetShellFromPasswd_NonexistentUID(t *testing.T) {
shell := getShellFromPasswd("4294967294")
assert.Empty(t, shell, "nonexistent UID should return empty shell")
}
// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly
// and cross-validates every entry against getent to ensure parseGetentPasswd
// and getShellFromPasswd agree on shell values.
func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
if _, err := exec.LookPath("getent"); err != nil {
t.Skip("getent not available")
}
// Pick a few well-known system UIDs that are virtually always in /etc/passwd.
uids := []string{"0"} // root
current, err := user.Current()
require.NoError(t, err)
uids = append(uids, current.Uid)
for _, uid := range uids {
passwdShell := getShellFromPasswd(uid)
if passwdShell == "" {
continue
}
_, getentShell, err := runGetentPasswd(uid)
if err != nil {
continue
}
assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid)
}
}
+31
View File
@@ -0,0 +1,31 @@
//go:build windows
package shell
import "os/user"
// LookupWithGetent on Windows just delegates to os/user.Lookup.
// Windows does not use NSS/getent; its user lookup works without CGO.
func LookupWithGetent(username string) (*user.User, error) {
return user.Lookup(username)
}
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
func CurrentUserWithGetent() (*user.User, error) {
return user.Current()
}
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
func LookupGroupWithGetent(name string) (*user.Group, error) {
return user.LookupGroup(name)
}
// GetShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
func GetShellFromGetent(_ string) string {
return ""
}
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
func GroupIdsWithFallback(u *user.User) ([]string, error) {
return u.GroupIds()
}
+155
View File
@@ -0,0 +1,155 @@
package shell
import (
"bufio"
"fmt"
"os"
"os/exec"
"os/user"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
)
const (
defaultUnixShell = "/bin/sh"
pwshExe = "pwsh.exe" // #nosec G101 - This is not a credential, just executable name
powershellExe = "powershell.exe"
)
// GetUserShell returns the appropriate shell for the given user ID.
// Handles all platform-specific logic and fallbacks consistently.
func GetUserShell(userID string) string {
switch runtime.GOOS {
case "windows":
return getWindowsUserShell()
default:
return getUnixUserShell(userID)
}
}
// getWindowsUserShell returns the best shell for Windows users.
// We intentionally do not support cmd.exe or COMSPEC fallbacks to avoid command injection
// vulnerabilities that arise from cmd.exe's complex command line parsing and special characters.
// PowerShell provides safer argument handling and is available on all modern Windows systems.
// Order: pwsh.exe -> powershell.exe
func getWindowsUserShell() string {
if path, err := exec.LookPath(pwshExe); err == nil {
return path
}
if path, err := exec.LookPath(powershellExe); err == nil {
return path
}
return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
}
// getUnixUserShell returns the shell for Unix-like systems.
// Tries /etc/passwd first (fast, no subprocess), falls back to getent for NSS users.
func getUnixUserShell(userID string) string {
if shell := getShellFromPasswd(userID); shell != "" {
return shell
}
if shell := GetShellFromGetent(userID); shell != "" {
return shell
}
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
return defaultUnixShell
}
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
func getShellFromPasswd(userID string) string {
file, err := os.Open("/etc/passwd")
if err != nil {
return ""
}
defer func() {
if err := file.Close(); err != nil {
log.Warnf("close /etc/passwd file: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Split(line, ":")
if len(fields) < 7 {
continue
}
// field 2 is UID
if fields[2] == userID {
shell := strings.TrimSpace(fields[6])
return shell
}
}
if err := scanner.Err(); err != nil {
log.Warnf("error reading /etc/passwd: %v", err)
}
return ""
}
// PrepareUserEnv prepares environment variables for user execution.
func PrepareUserEnv(user *user.User, shell string) []string {
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
if runtime.GOOS == "windows" {
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
}
return []string{
fmt.Sprint("SHELL=" + shell),
fmt.Sprint("USER=" + user.Username),
fmt.Sprint("LOGNAME=" + user.Username),
fmt.Sprint("HOME=" + user.HomeDir),
"PATH=" + pathValue,
}
}
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
// This is a whitelist of variables that SSH clients can send to the server.
func AcceptEnv(envVar string) bool {
varName := envVar
if idx := strings.Index(envVar, "="); idx != -1 {
varName = envVar[:idx]
}
exactMatches := []string{
"LANG",
"LANGUAGE",
"TERM",
"COLORTERM",
"EDITOR",
"VISUAL",
"PAGER",
"LESS",
"LESSCHARSET",
"TZ",
}
prefixMatches := []string{
"LC_",
}
for _, exact := range exactMatches {
if varName == exact {
return true
}
}
for _, prefix := range prefixMatches {
if strings.HasPrefix(varName, prefix) {
return true
}
}
return false
}