diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b174520..0114a1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 See [VERSIONING.md](VERSIONING.md) for why the version starts at 1.8.1. +## [Unreleased] + +### Fixed + +- **macOS hostname no longer flips with the network.** The agent reported `kern.hostname`, which macOS rewrites from DHCP or reverse DNS whenever `HostName` is unset, so a Mac on a VPN resolving through AWS showed up as `ip-…ec2.internal` on some scans and its real name on others. It now reports the configured `HostName` when set, else `LocalHostName`, read from the SystemConfiguration preferences plist with `scutil --get` as the fallback, and only then the kernel hostname. Existing Macs will show a new name once. + ## [1.17.0] - 2026-09-24 ### Added diff --git a/internal/device/device.go b/internal/device/device.go index 058afb87..659270bf 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -3,16 +3,18 @@ package device import ( "context" "strings" + "time" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" + "howett.net/plist" ) // Gather collects device information (hostname, serial, OS version, user identity). func Gather(ctx context.Context, exec executor.Executor) model.Device { - hostname, _ := exec.Hostname() - userIdentity := getDeveloperIdentity(exec) platform := exec.GOOS() + hostname := getHostname(ctx, exec, platform) + userIdentity := getDeveloperIdentity(exec) var serial, osVersion string switch platform { @@ -54,6 +56,52 @@ func SerialNumber(ctx context.Context, exec executor.Executor) string { } } +// darwinSCPrefsPath is where SystemConfiguration stores the names scutil +// reports. Reading it skips a subprocess; its layout is not a documented +// contract, so scutil remains the fallback. +const darwinSCPrefsPath = "/Library/Preferences/SystemConfiguration/preferences.plist" + +type darwinSCPrefs struct { + System struct { + System struct { + HostName string `plist:"HostName"` + } `plist:"System"` + Network struct { + HostNames struct { + LocalHostName string `plist:"LocalHostName"` + } `plist:"HostNames"` + } `plist:"Network"` + } `plist:"System"` +} + +// getHostname prefers the macOS configured names over os.Hostname: with no +// HostName set, macOS rewrites kern.hostname from DHCP or reverse DNS on every +// network change, so a VPN can turn "dev-mac" into "ip-10-0-1-5.ec2.internal". +func getHostname(ctx context.Context, exec executor.Executor, platform string) string { + if platform == model.PlatformDarwin { + if data, err := exec.ReadFile(darwinSCPrefsPath); err == nil { + var prefs darwinSCPrefs + if _, err := plist.Unmarshal(data, &prefs); err == nil { + for _, name := range []string{prefs.System.System.HostName, prefs.System.Network.HostNames.LocalHostName} { + if name = strings.TrimSpace(name); name != "" { + return name + } + } + } + } + for _, key := range []string{"HostName", "LocalHostName"} { + stdout, _, exitCode, err := exec.RunWithTimeout(ctx, 10*time.Second, "scutil", "--get", key) + if err == nil && exitCode == 0 { + if name := strings.TrimSpace(stdout); name != "" { + return name + } + } + } + } + hostname, _ := exec.Hostname() + return hostname +} + // getSerialNumberWindows and getOSVersionWindows are implemented in // device_windows.go (native API) and device_other.go (stub). diff --git a/internal/device/device_test.go b/internal/device/device_test.go index fad16586..650d7ce2 100644 --- a/internal/device/device_test.go +++ b/internal/device/device_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/step-security/dev-machine-guard/internal/executor" + "howett.net/plist" ) func TestGather_BasicFields(t *testing.T) { @@ -240,3 +241,78 @@ func TestGather_Windows(t *testing.T) { t.Errorf("user_identity: expected testuser, got %s", dev.UserIdentity) } } + +func TestGetHostname(t *testing.T) { + tests := []struct { + name string + goos string + hostName *string // nil = scutil reports "not set" + localName *string + want string + }{ + {"darwin prefers explicit HostName", "darwin", ptr("build-box"), ptr("dev-mac"), "build-box"}, + {"darwin falls back to LocalHostName", "darwin", nil, ptr("dev-mac"), "dev-mac"}, + {"darwin falls back to kernel hostname", "darwin", nil, nil, "ip-10-0-1-5.ec2.internal"}, + {"darwin ignores blank scutil output", "darwin", ptr(" \n"), ptr("dev-mac"), "dev-mac"}, + {"linux uses kernel hostname", "linux", ptr("build-box"), ptr("dev-mac"), "ip-10-0-1-5.ec2.internal"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS(tc.goos) + mock.SetHostname("ip-10-0-1-5.ec2.internal") + for key, val := range map[string]*string{"HostName": tc.hostName, "LocalHostName": tc.localName} { + if val == nil { + mock.SetCommand("", key+": not set\n", 1, "scutil", "--get", key) + } else { + mock.SetCommand(*val+"\n", "", 0, "scutil", "--get", key) + } + } + if got := getHostname(context.Background(), mock, tc.goos); got != tc.want { + t.Errorf("getHostname() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestGetHostname_SCPrefsPlist(t *testing.T) { + prefs := func(hostName, localName string, format int) []byte { + t.Helper() + v := map[string]any{"System": map[string]any{ + "System": map[string]any{"HostName": hostName, "ComputerName": "Dev's Mac"}, + "Network": map[string]any{"HostNames": map[string]any{"LocalHostName": localName}}, + }} + data, err := plist.Marshal(v, format) + if err != nil { + t.Fatalf("marshal plist: %v", err) + } + return data + } + tests := []struct { + name string + file []byte // nil = file absent + want string + }{ + {"xml HostName wins", prefs("build-box", "dev-mac", plist.XMLFormat), "build-box"}, + {"binary LocalHostName when HostName unset", prefs("", "dev-mac", plist.BinaryFormat), "dev-mac"}, + {"no names in plist falls back to scutil", prefs("", "", plist.XMLFormat), "scutil-name"}, + {"garbage plist falls back to scutil", []byte("not a plist"), "scutil-name"}, + {"missing plist falls back to scutil", nil, "scutil-name"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetHostname("ip-10-0-1-5.ec2.internal") + if tc.file != nil { + mock.SetFile(darwinSCPrefsPath, tc.file) + } + mock.SetCommand("", "HostName: not set\n", 1, "scutil", "--get", "HostName") + mock.SetCommand("scutil-name\n", "", 0, "scutil", "--get", "LocalHostName") + if got := getHostname(context.Background(), mock, "darwin"); got != tc.want { + t.Errorf("getHostname() = %q, want %q", got, tc.want) + } + }) + } +} + +func ptr(s string) *string { return &s }