diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index e322e827e..ce7d35afb 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -23,6 +23,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent" + internalSecurityutil "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rcourtman/pulse-go-rewrite/pkg/securityutil" "github.com/rs/zerolog" ) @@ -1902,7 +1903,7 @@ func testPendingUpdate(t *testing.T, stateDir string) *agentupdate.PendingPrivil func TestPendingPrivilegedUpdateCommitsOnlyAfterReadinessAndAcceptedReport(t *testing.T) { stateDir := t.TempDir() - if err := os.Chmod(stateDir, 0o700); err != nil { + if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil { t.Fatal(err) } pending := testPendingUpdate(t, stateDir) @@ -1952,7 +1953,7 @@ func TestPendingPrivilegedUpdateCommitsOnlyAfterReadinessAndAcceptedReport(t *te func TestPendingPrivilegedUpdateCancellationRollsBackAndClearsHandoff(t *testing.T) { stateDir := t.TempDir() - if err := os.Chmod(stateDir, 0o700); err != nil { + if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil { t.Fatal(err) } pending := testPendingUpdate(t, stateDir) @@ -1987,7 +1988,7 @@ func TestPendingPrivilegedUpdateCancellationRollsBackAndClearsHandoff(t *testing func TestPendingPrivilegedUpdateRollbackFailurePreservesHandoff(t *testing.T) { stateDir := t.TempDir() - if err := os.Chmod(stateDir, 0o700); err != nil { + if err := internalSecurityutil.HardenPrivatePath(stateDir, 0o700); err != nil { t.Fatal(err) } pending := testPendingUpdate(t, stateDir) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c4a049500..302396313 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -88,7 +88,11 @@ Runner readiness is exposed through a bounded, secret-free health marker that is replaced atomically only after its contents reach stable storage. POSIX targets must sync the containing directory after rename; Windows targets must use a write-through replacement rather than attempting to flush the read-only -directory handle returned by the standard library. +directory handle returned by the standard library. The marker and helper-update +handoff must also enforce platform-native privacy: owner-only modes on POSIX, +and a protected DACL limited to the owning identity, LocalSystem, and +Administrators on Windows. Unix-looking mode values are not Windows access +control. Fresh installs carry an explicit local command-authority profile. The closed values are `monitoring-only`, `command-capable`, and `legacy`. A `monitoring-only` service may accept remote configuration that keeps commands diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 653be4225..4d6902bc4 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -7185,6 +7185,8 @@ "internal/config/watcher.go", "internal/crypto/crypto.go", "internal/logging/logging.go", + "internal/securityutil/private_path_other.go", + "internal/securityutil/private_path_windows.go", "internal/securityutil/secure_storage_dir.go", "internal/telemetry/service_health.go", "internal/telemetry/telemetry.go", @@ -7366,6 +7368,21 @@ "pkg/tlsutil/tlsutil_test.go" ] }, + { + "id": "private-platform-paths", + "label": "platform-native private path hardening proof", + "match_prefixes": [], + "match_files": [ + "internal/securityutil/private_path_other.go", + "internal/securityutil/private_path_windows.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/securityutil/private_path_test.go", + "internal/securityutil/private_path_windows_test.go" + ] + }, { "id": "storage-directory-security", "label": "storage directory hardening proof", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4d602940b..e2c89fe77 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1589,6 +1589,12 @@ file-hardened at `0600`. The mount root itself must be validated as the real directory path rather than a symlink or other filesystem object, but its mode bits are not a fatal startup gate when Kubernetes or another runtime owns that mount point. +Agent lifecycle state that explicitly requires a private local path uses the +stricter shared `internal/securityutil/private_path_*` contract. It must reject +symlinks and special files before hardening. POSIX paths must grant no group or +other access; Windows paths must disable DACL inheritance and grant access only +to an approved owner identity, LocalSystem, and Administrators. `os.Chmod` +success and Unix-style mode bits are not evidence of Windows privacy. That same Security Overview surface must stay action-oriented once those low-risk states are demoted out of the global banner: `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` and diff --git a/internal/agentupdate/coverage_test.go b/internal/agentupdate/coverage_test.go index db817ad40..169ed7e60 100644 --- a/internal/agentupdate/coverage_test.go +++ b/internal/agentupdate/coverage_test.go @@ -41,7 +41,7 @@ func TestPrivilegedUpdateRollbackFailurePreservesPendingHandoff(t *testing.T) { sum := sha256.Sum256(binary) digest := hex.EncodeToString(sum[:]) helper := &fakePrivilegedUpdate{root: t.TempDir(), rollbackErr: errors.New("helper unavailable")} - if err := os.Chmod(helper.root, 0o700); err != nil { + if err := securityutil.HardenPrivatePath(helper.root, 0o700); err != nil { t.Fatal(err) } u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"}) diff --git a/internal/agentupdate/pending_update_persistence_other.go b/internal/agentupdate/pending_update_persistence_other.go new file mode 100644 index 000000000..eb8f8fe1d --- /dev/null +++ b/internal/agentupdate/pending_update_persistence_other.go @@ -0,0 +1,18 @@ +//go:build !windows + +package agentupdate + +import "os" + +func replacePendingUpdateFile(from, to string) error { + return os.Rename(from, to) +} + +func syncUpdateDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} diff --git a/internal/agentupdate/pending_update_persistence_windows.go b/internal/agentupdate/pending_update_persistence_windows.go new file mode 100644 index 000000000..b364f112c --- /dev/null +++ b/internal/agentupdate/pending_update_persistence_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package agentupdate + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +func replacePendingUpdateFile(from, to string) error { + fromPath, err := windows.UTF16PtrFromString(from) + if err != nil { + return fmt.Errorf("encode pending update source path: %w", err) + } + toPath, err := windows.UTF16PtrFromString(to) + if err != nil { + return fmt.Errorf("encode pending update destination path: %w", err) + } + if err := windows.MoveFileEx( + fromPath, + toPath, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, + ); err != nil { + return fmt.Errorf("replace pending update handoff with write-through: %w", err) + } + return nil +} + +func syncUpdateDirectory(string) error { + // MoveFileEx with WRITE_THROUGH is the Windows replacement durability + // barrier. Windows does not expose a portable directory fsync equivalent. + return nil +} diff --git a/internal/agentupdate/privileged_update.go b/internal/agentupdate/privileged_update.go index 09f7892e3..f1aee4f24 100644 --- a/internal/agentupdate/privileged_update.go +++ b/internal/agentupdate/privileged_update.go @@ -14,6 +14,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" ) const privilegedUpdateQuarantineDir = "/var/lib/pulse-agent/update-quarantine" @@ -164,20 +165,14 @@ func validPrivilegedArtifactID(value string) bool { return err == nil } -func syncUpdateDirectory(path string) error { - dir, err := os.Open(path) - if err != nil { - return err - } - defer dir.Close() - return dir.Sync() -} - func PersistPendingPrivilegedUpdate(stateDir, previousVersion string, activation agenthelper.UpdateResult) error { path, err := pendingPrivilegedUpdatePath(stateDir) if err != nil { return err } + if err := securityutil.HardenPrivatePath(stateDir, 0o700); err != nil { + return fmt.Errorf("harden pending update state directory: %w", err) + } if err := validatePendingUpdateStateDir(stateDir); err != nil { return err } @@ -198,7 +193,7 @@ func PersistPendingPrivilegedUpdate(stateDir, previousVersion string, activation } tempPath := temp.Name() defer os.Remove(tempPath) - if err := temp.Chmod(0o600); err != nil { + if err := securityutil.HardenPrivatePath(tempPath, 0o600); err != nil { _ = temp.Close() return err } @@ -213,7 +208,7 @@ func PersistPendingPrivilegedUpdate(stateDir, previousVersion string, activation if err := temp.Close(); err != nil { return err } - if err := os.Rename(tempPath, path); err != nil { + if err := replacePendingUpdateFile(tempPath, path); err != nil { return err } return syncUpdateDirectory(stateDir) @@ -234,9 +229,12 @@ func LoadPendingPrivilegedUpdate(stateDir string) (*PendingPrivilegedUpdate, err if err := validatePendingUpdateStateDir(stateDir); err != nil { return nil, err } - if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() || before.Mode().Perm()&0o077 != 0 { + if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() { return nil, errors.New("pending update handoff is not a private regular file") } + if err := securityutil.ValidatePrivatePath(path, before); err != nil { + return nil, fmt.Errorf("pending update handoff is not private: %w", err) + } file, err := os.Open(path) if err != nil { return nil, err @@ -288,9 +286,12 @@ func pendingPrivilegedUpdatePath(stateDir string) (string, error) { func validatePendingUpdateStateDir(stateDir string) error { info, err := os.Lstat(stateDir) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { + if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.New("pending update state directory must be a private real directory") } + if err := securityutil.ValidatePrivatePath(stateDir, info); err != nil { + return fmt.Errorf("pending update state directory must be private: %w", err) + } return nil } diff --git a/internal/agentupdate/privileged_update_test.go b/internal/agentupdate/privileged_update_test.go index e327d8fef..efd5731d1 100644 --- a/internal/agentupdate/privileged_update_test.go +++ b/internal/agentupdate/privileged_update_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" ) type fakePrivilegedUpdate struct { @@ -82,7 +83,7 @@ func TestPrivilegedUpdateStagesActivatesAndRollsBackRestartFailure(t *testing.T) sum := sha256.Sum256(binary) digest := hex.EncodeToString(sum[:]) helper := &fakePrivilegedUpdate{root: t.TempDir()} - if err := os.Chmod(helper.root, 0o700); err != nil { + if err := securityutil.HardenPrivatePath(helper.root, 0o700); err != nil { t.Fatal(err) } u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"}) @@ -111,7 +112,7 @@ func TestPrivilegedUpdateFailsClosedBeforeActivation(t *testing.T) { runtimeGOOS = goOSLinux binary := append([]byte{0x7f, 'E', 'L', 'F'}, []byte("update")...) helper := &fakePrivilegedUpdate{root: t.TempDir()} - if err := os.Chmod(helper.root, 0o700); err != nil { + if err := securityutil.HardenPrivatePath(helper.root, 0o700); err != nil { t.Fatal(err) } u := New(Config{PrivilegedUpdate: helper, Disabled: true, StateDir: helper.root, CurrentVersion: "1.0.0"}) @@ -128,7 +129,7 @@ func TestPrivilegedUpdateFailsClosedBeforeActivation(t *testing.T) { func TestPendingPrivilegedUpdateHandoffIsDurableAndStrict(t *testing.T) { stateDir := t.TempDir() - if err := os.Chmod(stateDir, 0o700); err != nil { + if err := securityutil.HardenPrivatePath(stateDir, 0o700); err != nil { t.Fatal(err) } activation := agenthelper.UpdateResult{ @@ -143,8 +144,10 @@ func TestPendingPrivilegedUpdateHandoffIsDurableAndStrict(t *testing.T) { t.Fatal(err) } path := filepath.Join(stateDir, pendingPrivilegedUpdateFile) - if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { - t.Fatalf("handoff mode=%v err=%v", info, err) + if info, err := os.Lstat(path); err != nil { + t.Fatalf("inspect handoff: %v", err) + } else if err := securityutil.ValidatePrivatePath(path, info); err != nil { + t.Fatalf("handoff is not private: %v", err) } loaded, err := LoadPendingPrivilegedUpdate(stateDir) if err != nil { @@ -163,7 +166,7 @@ func TestPendingPrivilegedUpdateHandoffIsDurableAndStrict(t *testing.T) { func TestPendingPrivilegedUpdateHandoffRejectsUnsafeState(t *testing.T) { stateDir := t.TempDir() - if err := os.Chmod(stateDir, 0o700); err != nil { + if err := securityutil.HardenPrivatePath(stateDir, 0o700); err != nil { t.Fatal(err) } path := filepath.Join(stateDir, pendingPrivilegedUpdateFile) diff --git a/internal/hostagent/action_runner_client.go b/internal/hostagent/action_runner_client.go index 450c3bc28..5a0fca777 100644 --- a/internal/hostagent/action_runner_client.go +++ b/internal/hostagent/action_runner_client.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog" ) @@ -106,15 +107,18 @@ func (c *CommandClient) writeActionRunnerHealth() error { if err := os.MkdirAll(dir, 0700); err != nil { return err } + if err := securityutil.HardenPrivatePath(dir, 0o700); err != nil { + return fmt.Errorf("harden action-runner health directory: %w", err) + } temp, err := os.CreateTemp(dir, ".health-*.tmp") if err != nil { return err } tempPath := temp.Name() defer os.Remove(tempPath) - if err := temp.Chmod(0600); err != nil { + if err := securityutil.HardenPrivatePath(tempPath, 0o600); err != nil { temp.Close() - return err + return fmt.Errorf("harden action-runner health marker: %w", err) } if _, err := temp.Write(encoded); err != nil { temp.Close() @@ -130,5 +134,15 @@ func (c *CommandClient) writeActionRunnerHealth() error { if err := replaceActionRunnerHealthFile(tempPath, c.healthPath); err != nil { return err } + info, err := os.Lstat(c.healthPath) + if err != nil { + return fmt.Errorf("inspect replaced action-runner health marker: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("action-runner health marker is not a real regular file") + } + if err := securityutil.ValidatePrivatePath(c.healthPath, info); err != nil { + return fmt.Errorf("validate private action-runner health marker: %w", err) + } return syncActionRunnerHealthDirectory(dir) } diff --git a/internal/hostagent/action_runner_client_test.go b/internal/hostagent/action_runner_client_test.go index 62c57ae63..c329140d4 100644 --- a/internal/hostagent/action_runner_client_test.go +++ b/internal/hostagent/action_runner_client_test.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rs/zerolog" ) @@ -120,9 +121,12 @@ func TestActionRunnerHealthIsAtomicBoundedAndSecretFree(t *testing.T) { if !health.Registered || health.RuntimeRole != agentexec.RuntimeRoleActionRunner || health.HostID != "agent-1" || health.Server != "https://pulse.example" || health.RegisteredAt.IsZero() { t.Fatalf("health = %+v", health) } - info, err := os.Stat(healthPath) - if err != nil || info.Mode().Perm() != 0600 { - t.Fatalf("health mode = %v, %v", info.Mode().Perm(), err) + info, err := os.Lstat(healthPath) + if err != nil { + t.Fatalf("inspect health marker: %v", err) + } + if err := securityutil.ValidatePrivatePath(healthPath, info); err != nil { + t.Fatalf("health marker is not private: %v", err) } matches, err := filepath.Glob(filepath.Join(dir, ".health-*.tmp")) if err != nil || len(matches) != 0 { diff --git a/internal/securityutil/private_path_other.go b/internal/securityutil/private_path_other.go new file mode 100644 index 000000000..3b290c003 --- /dev/null +++ b/internal/securityutil/private_path_other.go @@ -0,0 +1,36 @@ +//go:build !windows + +package securityutil + +import ( + "fmt" + "os" +) + +// HardenPrivatePath applies an owner-only mode to a file or directory. +func HardenPrivatePath(path string, mode os.FileMode) error { + before, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect private path before hardening: %w", err) + } + if before.Mode()&os.ModeSymlink != 0 || (!before.IsDir() && !before.Mode().IsRegular()) { + return fmt.Errorf("private path must be a real file or directory") + } + if err := os.Chmod(path, mode); err != nil { + return fmt.Errorf("harden private path permissions: %w", err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect hardened private path: %w", err) + } + return ValidatePrivatePath(path, info) +} + +// ValidatePrivatePath rejects paths accessible through group or other mode +// bits. Callers separately validate the expected filesystem object type. +func ValidatePrivatePath(_ string, info os.FileInfo) error { + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("private path grants group or other access") + } + return nil +} diff --git a/internal/securityutil/private_path_test.go b/internal/securityutil/private_path_test.go new file mode 100644 index 000000000..92723e01a --- /dev/null +++ b/internal/securityutil/private_path_test.go @@ -0,0 +1,36 @@ +package securityutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHardenPrivatePathProtectsDirectoryAndFile(t *testing.T) { + dir := t.TempDir() + if err := HardenPrivatePath(dir, 0o700); err != nil { + t.Fatal(err) + } + dirInfo, err := os.Lstat(dir) + if err != nil { + t.Fatal(err) + } + if err := ValidatePrivatePath(dir, dirInfo); err != nil { + t.Fatalf("private directory validation failed: %v", err) + } + + path := filepath.Join(dir, "state.json") + if err := os.WriteFile(path, []byte("state"), 0o666); err != nil { + t.Fatal(err) + } + if err := HardenPrivatePath(path, 0o600); err != nil { + t.Fatal(err) + } + fileInfo, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if err := ValidatePrivatePath(path, fileInfo); err != nil { + t.Fatalf("private file validation failed: %v", err) + } +} diff --git a/internal/securityutil/private_path_windows.go b/internal/securityutil/private_path_windows.go new file mode 100644 index 000000000..7e0121f5a --- /dev/null +++ b/internal/securityutil/private_path_windows.go @@ -0,0 +1,183 @@ +//go:build windows + +package securityutil + +import ( + "fmt" + "os" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + localSystemSID = "S-1-5-18" + administratorsSID = "S-1-5-32-544" +) + +// HardenPrivatePath protects a file or directory DACL from inheritance and +// grants access only to its owner identity, LocalSystem, and Administrators. +// Windows ignores Unix owner/group/other mode bits, so os.Chmod cannot provide +// this boundary. +func HardenPrivatePath(path string, _ os.FileMode) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect private Windows path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) { + return fmt.Errorf("private Windows path must be a real file or directory") + } + allowed, currentSID, err := privateWindowsSIDs() + if err != nil { + return err + } + if err := validatePrivateWindowsOwner(path, allowed); err != nil { + return err + } + + inheritance := "" + if info.IsDir() { + inheritance = "OICI" + } + entries := make([]string, 0, len(allowed)) + seen := make(map[string]struct{}, len(allowed)) + for _, sid := range append([]*windows.SID{currentSID}, allowed...) { + value := sid.String() + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + entries = append(entries, fmt.Sprintf("(A;%s;GA;;;%s)", inheritance, value)) + } + descriptor, err := windows.SecurityDescriptorFromString("D:P" + strings.Join(entries, "")) + if err != nil { + return fmt.Errorf("build private Windows DACL: %w", err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read private Windows DACL: %w", err) + } + if err := windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + dacl, + nil, + ); err != nil { + return fmt.Errorf("apply private Windows DACL: %w", err) + } + return ValidatePrivatePath(path, info) +} + +// ValidatePrivatePath verifies that inheritance is disabled and every access +// entry belongs to the current identity, LocalSystem, or Administrators. +func ValidatePrivatePath(path string, _ os.FileInfo) error { + allowed, currentSID, err := privateWindowsSIDs() + if err != nil { + return err + } + if err := validatePrivateWindowsOwner(path, allowed); err != nil { + return err + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("read private Windows security descriptor: %w", err) + } + if descriptor == nil { + return fmt.Errorf("private Windows path has no security descriptor") + } + control, _, err := descriptor.Control() + if err != nil { + return fmt.Errorf("read private Windows DACL control: %w", err) + } + if control&windows.SE_DACL_PROTECTED == 0 { + return fmt.Errorf("private Windows DACL still inherits access") + } + dacl, _, err := descriptor.DACL() + if err != nil { + return fmt.Errorf("read private Windows DACL: %w", err) + } + if dacl == nil || dacl.AceCount == 0 { + return fmt.Errorf("private Windows path has no access entries") + } + + currentAllowed := false + for index := uint32(0); index < uint32(dacl.AceCount); index++ { + var entry *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, index, &entry); err != nil { + return fmt.Errorf("read private Windows DACL entry: %w", err) + } + if entry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return fmt.Errorf("private Windows DACL contains a non-allow entry") + } + sid := (*windows.SID)(unsafe.Pointer(&entry.SidStart)) + if !sid.IsValid() || !windowsSIDAllowed(sid, allowed) { + return fmt.Errorf("private Windows DACL grants an unapproved identity") + } + if sid.Equals(currentSID) { + currentAllowed = true + } + } + if !currentAllowed { + return fmt.Errorf("private Windows DACL omits the current identity") + } + return nil +} + +func privateWindowsSIDs() ([]*windows.SID, *windows.SID, error) { + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, nil, fmt.Errorf("resolve current Windows identity: %w", err) + } + currentSID, err := user.User.Sid.Copy() + if err != nil { + return nil, nil, fmt.Errorf("copy current Windows identity: %w", err) + } + systemSID, err := windows.StringToSid(localSystemSID) + if err != nil { + return nil, nil, fmt.Errorf("resolve LocalSystem SID: %w", err) + } + adminSID, err := windows.StringToSid(administratorsSID) + if err != nil { + return nil, nil, fmt.Errorf("resolve Administrators SID: %w", err) + } + return []*windows.SID{currentSID, systemSID, adminSID}, currentSID, nil +} + +func validatePrivateWindowsOwner(path string, allowed []*windows.SID) error { + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("read private Windows path owner: %w", err) + } + if descriptor == nil { + return fmt.Errorf("private Windows path has no owner descriptor") + } + owner, _, err := descriptor.Owner() + if err != nil { + return fmt.Errorf("read private Windows path owner: %w", err) + } + if owner == nil || !windowsSIDAllowed(owner, allowed) { + return fmt.Errorf("private Windows path has an unapproved owner") + } + return nil +} + +func windowsSIDAllowed(candidate *windows.SID, allowed []*windows.SID) bool { + for _, sid := range allowed { + if candidate.Equals(sid) { + return true + } + } + return false +} diff --git a/internal/securityutil/private_path_windows_test.go b/internal/securityutil/private_path_windows_test.go new file mode 100644 index 000000000..02228cbd3 --- /dev/null +++ b/internal/securityutil/private_path_windows_test.go @@ -0,0 +1,42 @@ +//go:build windows + +package securityutil + +import ( + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestValidatePrivatePathRejectsInheritedOrBroadWindowsDACL(t *testing.T) { + path := filepath.Join(t.TempDir(), "broad.json") + if err := os.WriteFile(path, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + descriptor, err := windows.SecurityDescriptorFromString("D:(A;;GA;;;WD)") + if err != nil { + t.Fatal(err) + } + dacl, _, err := descriptor.DACL() + if err != nil { + t.Fatal(err) + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if err := ValidatePrivatePath(path, info); err == nil { + t.Fatal("broad Windows DACL was accepted") + } + if err := HardenPrivatePath(path, 0o600); err != nil { + t.Fatal(err) + } + if err := ValidatePrivatePath(path, info); err != nil { + t.Fatalf("hardened Windows DACL was rejected: %v", err) + } +}