mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Enforce native private state permissions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user