Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 50 additions & 2 deletions internal/device/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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).

Expand Down
76 changes: 76 additions & 0 deletions internal/device/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/step-security/dev-machine-guard/internal/executor"
"howett.net/plist"
)

func TestGather_BasicFields(t *testing.T) {
Expand Down Expand Up @@ -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 }
Loading