mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: harden system-install review findings
Require root-owned, non-group/other-writable pmg for --system install; allow remove without that validation. Doctor checks npm resolution for PATH precedence, uses ImpliesInterception instead of message matching, and documents version-manager shadowing. Pass profile bin dir from the shim manager and note that system config ignores per-user files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -916,6 +916,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "SUCCESS: private binary rejected"
|
echo "SUCCESS: private binary rejected"
|
||||||
|
|
||||||
|
- name: Reject user-owned PMG binary for system install
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/pmg-user-writable"
|
||||||
|
cp bin/pmg "$HOME/pmg-user-writable/pmg"
|
||||||
|
chmod 755 "$HOME/pmg-user-writable/pmg"
|
||||||
|
if sudo "$HOME/pmg-user-writable/pmg" setup install --system; then
|
||||||
|
echo "ERROR: system install accepted a user-owned binary"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SUCCESS: user-owned binary rejected"
|
||||||
|
|
||||||
- name: Install PMG system-wide
|
- name: Install PMG system-wide
|
||||||
run: |
|
run: |
|
||||||
sudo install -m 755 bin/pmg /usr/local/bin/pmg
|
sudo install -m 755 bin/pmg /usr/local/bin/pmg
|
||||||
@@ -956,7 +967,7 @@ jobs:
|
|||||||
out=$(pmg setup doctor 2>&1 || true)
|
out=$(pmg setup doctor 2>&1 || true)
|
||||||
echo "$out"
|
echo "$out"
|
||||||
echo "$out" | grep -q 'No aliases (system install)'
|
echo "$out" | grep -q 'No aliases (system install)'
|
||||||
echo "$out" | grep -q 'System shim directory is in PATH'
|
echo "$out" | grep -Eq 'npm resolves to system shim|System shim directory is in PATH'
|
||||||
|
|
||||||
- name: Non-root user interception via system shims
|
- name: Non-root user interception via system shims
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+79
-30
@@ -3,7 +3,9 @@ package setup
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/internal/alias"
|
"github.com/safedep/pmg/internal/alias"
|
||||||
@@ -141,6 +143,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
|||||||
return doctor.CheckResult{
|
return doctor.CheckResult{
|
||||||
Status: doctor.StatusPass,
|
Status: doctor.StatusPass,
|
||||||
Message: aliasesInstalledMessage,
|
Message: aliasesInstalledMessage,
|
||||||
|
ImpliesInterception: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if shim.SystemShimsInstalled() {
|
if shim.SystemShimsInstalled() {
|
||||||
@@ -189,32 +192,7 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
|
|||||||
{
|
{
|
||||||
Name: checkShimInPath,
|
Name: checkShimInPath,
|
||||||
Category: "Shell Integration",
|
Category: "Shell Integration",
|
||||||
Run: func() doctor.CheckResult {
|
Run: checkShimInPathResult,
|
||||||
pathEntries := filepath.SplitList(os.Getenv("PATH"))
|
|
||||||
systemDir := shim.SystemBinDir()
|
|
||||||
if shim.SystemShimsInstalled() && pathContainsDir(pathEntries, systemDir) {
|
|
||||||
return doctor.CheckResult{
|
|
||||||
Status: doctor.StatusPass,
|
|
||||||
Message: "System shim directory is in PATH",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if userDir, err := shim.UserBinDir(); err == nil && pathContainsDir(pathEntries, userDir) {
|
|
||||||
return doctor.CheckResult{
|
|
||||||
Status: doctor.StatusPass,
|
|
||||||
Message: "Shim directory is in PATH",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if shim.SystemShimsInstalled() {
|
|
||||||
return doctor.CheckResult{
|
|
||||||
Status: doctor.StatusFail,
|
|
||||||
Message: "System shim directory not in PATH",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return doctor.CheckResult{
|
|
||||||
Status: doctor.StatusFail,
|
|
||||||
Message: "Shim directory not in PATH",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: checkProxyMode,
|
Name: checkProxyMode,
|
||||||
@@ -313,6 +291,80 @@ func pathContainsDir(pathEntries []string, dir string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathIsUnderDir(path, dir string) bool {
|
||||||
|
if path == "" || dir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
cleanDir := filepath.Clean(dir)
|
||||||
|
if cleanPath == cleanDir {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
prefix := cleanDir + string(os.PathSeparator)
|
||||||
|
return strings.HasPrefix(cleanPath, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkShimInPathResult() doctor.CheckResult {
|
||||||
|
pathEntries := filepath.SplitList(os.Getenv("PATH"))
|
||||||
|
systemDir := shim.SystemBinDir()
|
||||||
|
userDir, userDirErr := shim.UserBinDir()
|
||||||
|
resolved, lookErr := exec.LookPath("npm")
|
||||||
|
|
||||||
|
if lookErr == nil {
|
||||||
|
if shim.SystemShimsInstalled() && pathIsUnderDir(resolved, systemDir) {
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: "npm resolves to system shim",
|
||||||
|
ImpliesInterception: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userDirErr == nil && pathIsUnderDir(resolved, userDir) {
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: "npm resolves to PMG shim",
|
||||||
|
ImpliesInterception: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shim.SystemShimsInstalled() && pathContainsDir(pathEntries, systemDir) {
|
||||||
|
if lookErr == nil {
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusWarn,
|
||||||
|
Message: fmt.Sprintf("System shim directory is in PATH, but npm resolves to %s", resolved),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: "System shim directory is in PATH",
|
||||||
|
ImpliesInterception: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userDirErr == nil && pathContainsDir(pathEntries, userDir) {
|
||||||
|
if lookErr == nil {
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusWarn,
|
||||||
|
Message: fmt.Sprintf("Shim directory is in PATH, but npm resolves to %s", resolved),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: "Shim directory is in PATH",
|
||||||
|
ImpliesInterception: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shim.SystemShimsInstalled() {
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusFail,
|
||||||
|
Message: "System shim directory not in PATH",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doctor.CheckResult{
|
||||||
|
Status: doctor.StatusFail,
|
||||||
|
Message: "Shim directory not in PATH",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult {
|
func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult {
|
||||||
if !isInterceptionActive(coreResults) {
|
if !isInterceptionActive(coreResults) {
|
||||||
var results []doctor.CheckResult
|
var results []doctor.CheckResult
|
||||||
@@ -344,10 +396,7 @@ func runProtectionChecks(coreResults []doctor.CheckResult) []doctor.CheckResult
|
|||||||
|
|
||||||
func isInterceptionActive(coreResults []doctor.CheckResult) bool {
|
func isInterceptionActive(coreResults []doctor.CheckResult) bool {
|
||||||
for _, r := range coreResults {
|
for _, r := range coreResults {
|
||||||
if r.Name == checkShimInPath && r.Status == doctor.StatusPass {
|
if r.ImpliesInterception {
|
||||||
return true
|
|
||||||
}
|
|
||||||
if r.Name == checkShellAliases && r.Status == doctor.StatusPass && r.Message == aliasesInstalledMessage {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ func TestPathContainsDir(t *testing.T) {
|
|||||||
assert.False(t, pathContainsDir([]string{"/usr/bin"}, ""))
|
assert.False(t, pathContainsDir([]string{"/usr/bin"}, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPathIsUnderDir(t *testing.T) {
|
||||||
|
assert.True(t, pathIsUnderDir("/usr/local/lib/pmg/bin/npm", "/usr/local/lib/pmg/bin"))
|
||||||
|
assert.False(t, pathIsUnderDir("/usr/local/bin/npm", "/usr/local/lib/pmg/bin"))
|
||||||
|
assert.False(t, pathIsUnderDir("/usr/local/lib/pmg/bin-extra/npm", "/usr/local/lib/pmg/bin"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSystemInstallAliasesPassDoesNotActivateInterception(t *testing.T) {
|
func TestSystemInstallAliasesPassDoesNotActivateInterception(t *testing.T) {
|
||||||
results := []doctor.CheckResult{
|
results := []doctor.CheckResult{
|
||||||
{Name: checkShellAliases, Status: doctor.StatusPass, Message: "No aliases (system install)"},
|
{Name: checkShellAliases, Status: doctor.StatusPass, Message: "No aliases (system install)"},
|
||||||
@@ -24,9 +30,27 @@ func TestSystemInstallAliasesPassDoesNotActivateInterception(t *testing.T) {
|
|||||||
|
|
||||||
func TestAliasesInstalledActivatesInterception(t *testing.T) {
|
func TestAliasesInstalledActivatesInterception(t *testing.T) {
|
||||||
results := []doctor.CheckResult{
|
results := []doctor.CheckResult{
|
||||||
{Name: checkShellAliases, Status: doctor.StatusPass, Message: aliasesInstalledMessage},
|
{
|
||||||
|
Name: checkShellAliases,
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: aliasesInstalledMessage,
|
||||||
|
ImpliesInterception: true,
|
||||||
|
},
|
||||||
{Name: checkShimInPath, Status: doctor.StatusFail},
|
{Name: checkShimInPath, Status: doctor.StatusFail},
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.True(t, isInterceptionActive(results))
|
assert.True(t, isInterceptionActive(results))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShimInPathImpliesInterception(t *testing.T) {
|
||||||
|
results := []doctor.CheckResult{
|
||||||
|
{
|
||||||
|
Name: checkShimInPath,
|
||||||
|
Status: doctor.StatusPass,
|
||||||
|
Message: "npm resolves to system shim",
|
||||||
|
ImpliesInterception: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, isInterceptionActive(results))
|
||||||
|
}
|
||||||
|
|||||||
+4
-4
@@ -105,7 +105,7 @@ func install(system bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func installSystem() error {
|
func installSystem() error {
|
||||||
if err := errIfSystemInstallAllowed(); err != nil {
|
if err := requireSystemInstallSupported(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,11 +190,11 @@ func remove(system, removeConfig bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeSystem(removeConfig bool) error {
|
func removeSystem(removeConfig bool) error {
|
||||||
if err := errIfSystemInstallAllowed(); err != nil {
|
if err := requireSystemInstallSupported(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
shimMgr, err := shim.NewSystemShimManager()
|
shimMgr, err := shim.NewSystemShimManagerForRemove()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create system shim manager: %w", err)
|
return fmt.Errorf("failed to create system shim manager: %w", err)
|
||||||
}
|
}
|
||||||
@@ -212,7 +212,7 @@ func removeSystem(removeConfig bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func errIfSystemInstallAllowed() error {
|
func requireSystemInstallSupported() error {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
return usefulerror.NewUsefulError().
|
return usefulerror.NewUsefulError().
|
||||||
WithCode(errcodes.UnsupportedPlatform).
|
WithCode(errcodes.UnsupportedPlatform).
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestErrIfSystemInstallAllowed(t *testing.T) {
|
|||||||
t.Cleanup(func() { setupGeteuid = orig })
|
t.Cleanup(func() { setupGeteuid = orig })
|
||||||
|
|
||||||
setupGeteuid = func() int { return 0 }
|
setupGeteuid = func() int { return 0 }
|
||||||
err := errIfSystemInstallAllowed()
|
err := requireSystemInstallSupported()
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -26,7 +26,7 @@ func TestErrIfSystemInstallAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupGeteuid = func() int { return 1000 }
|
setupGeteuid = func() int { return 1000 }
|
||||||
err = errIfSystemInstallAllowed()
|
err = requireSystemInstallSupported()
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
usefulErr, ok := usefulerror.AsUsefulError(err)
|
usefulErr, ok := usefulerror.AsUsefulError(err)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ Optional lockdown (`global_lockdown: true`) is documented in [config.md](./confi
|
|||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Virtualenv.** After `source .venv/bin/activate`, bare `pip` uses the venv binary and skips PMG shims. Call `pmg pip …` explicitly.
|
- **Virtualenv.** After `source .venv/bin/activate`, bare `pip` uses the venv binary and skips PMG shims. Call `pmg pip …` explicitly.
|
||||||
|
- **Version managers.** Tools like nvm, pyenv, volta, and asdf often prepend their own bin directories from shell rc files that run after `/etc/profile.d`. That can put real `npm`/`pip` ahead of PMG shims even when the shim directory is on `PATH`. Prefer putting `/usr/local/lib/pmg/bin` first in a durable `ENV PATH` / login PATH, or call `pmg npm` / `pmg pip` explicitly. `pmg setup doctor` warns when `npm` resolves outside the shim directory.
|
||||||
- **No shell aliases.** System install only installs PATH shims. There is no `~/.pmg.rc` alias layer.
|
- **No shell aliases.** System install only installs PATH shims. There is no `~/.pmg.rc` alias layer.
|
||||||
- **Config changes.** `pmg config set` and `pmg config edit` are unavailable while the system config is active. Edit `/etc/safedep/pmg/config.yml` as root, or redeploy the file.
|
- **Config changes.** `pmg config set` and `pmg config edit` are unavailable while the system config is active. Edit `/etc/safedep/pmg/config.yml` as root, or redeploy the file.
|
||||||
- **Custom sandbox `policy_templates`.** Relative paths in the system config resolve under each user's config directory, not `/etc/safedep/pmg`. Prefer absolute paths.
|
- **Custom sandbox `policy_templates`.** Relative paths in the system config resolve under each user's config directory, not `/etc/safedep/pmg`. Prefer absolute paths.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type CheckResult struct {
|
|||||||
Category string
|
Category string
|
||||||
Status CheckStatus
|
Status CheckStatus
|
||||||
Message string
|
Message string
|
||||||
|
ImpliesInterception bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Check struct {
|
type Check struct {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package shim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fileOwnerUID(info os.FileInfo) (uint32, bool) {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint32(stat.Uid), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package shim
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func fileOwnerUID(info os.FileInfo) (uint32, bool) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func (m *ShimManager) Install() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.config.ManageProfile {
|
if m.config.ManageProfile {
|
||||||
if err := writeSystemProfile(); err != nil {
|
if err := writeSystemProfile(m.config.BinDir); err != nil {
|
||||||
return fmt.Errorf("failed to write system profile: %w", err)
|
return fmt.Errorf("failed to write system profile: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-4
@@ -20,6 +20,9 @@ const (
|
|||||||
var (
|
var (
|
||||||
systemBinDirOverride string
|
systemBinDirOverride string
|
||||||
systemProfilePathOverride string
|
systemProfilePathOverride string
|
||||||
|
// systemExecutableOwnershipCheck requires root ownership of the binary and
|
||||||
|
// its parent directories. Disabled in tests that cannot create root-owned files.
|
||||||
|
systemExecutableOwnershipCheck = true
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemBinDir returns the directory for system-wide PMG shims.
|
// SystemBinDir returns the directory for system-wide PMG shims.
|
||||||
@@ -40,16 +43,30 @@ func SystemProfilePath() string {
|
|||||||
|
|
||||||
// NewSystemShimManager creates a shim manager for system-wide install: shims
|
// NewSystemShimManager creates a shim manager for system-wide install: shims
|
||||||
// under SystemBinDir, no per-user rc edits, and /etc/profile.d management.
|
// under SystemBinDir, no per-user rc edits, and /etc/profile.d management.
|
||||||
|
// The current executable is validated for multi-user use.
|
||||||
func NewSystemShimManager() (*ShimManager, error) {
|
func NewSystemShimManager() (*ShimManager, error) {
|
||||||
|
return newSystemShimManager(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSystemShimManagerForRemove creates a system shim manager without
|
||||||
|
// validating the current executable. Uninstall must work even when the binary
|
||||||
|
// that originally installed the shims is no longer suitable for install.
|
||||||
|
func NewSystemShimManagerForRemove() (*ShimManager, error) {
|
||||||
|
return newSystemShimManager(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSystemShimManager(validateExecutable bool) (*ShimManager, error) {
|
||||||
aliasCfg := alias.DefaultConfig()
|
aliasCfg := alias.DefaultConfig()
|
||||||
pmgBin, err := currentExecutable()
|
pmgBin, err := currentExecutable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validateExecutable {
|
||||||
if err := validateSystemExecutable(pmgBin); err != nil {
|
if err := validateSystemExecutable(pmgBin); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &ShimManager{
|
return &ShimManager{
|
||||||
config: ShimConfig{
|
config: ShimConfig{
|
||||||
@@ -62,19 +79,70 @@ func NewSystemShimManager() (*ShimManager, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateSystemExecutable rejects binaries other users cannot execute. System shims hard-code this path.
|
// validateSystemExecutable rejects binaries unsafe for system-wide shims.
|
||||||
|
// System shims hard-code this path, so it must be world-executable, not
|
||||||
|
// group/other-writable, and (when ownership checks are enabled) root-owned
|
||||||
|
// under a root-owned, non-group/other-writable directory chain.
|
||||||
func validateSystemExecutable(path string) error {
|
func validateSystemExecutable(path string) error {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to inspect pmg executable %s: %w", path, err)
|
return fmt.Errorf("failed to inspect pmg executable %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.Mode().Perm()&0o001 == 0 {
|
perm := info.Mode().Perm()
|
||||||
|
if perm&0o001 == 0 {
|
||||||
return fmt.Errorf("pmg executable %s is not executable by all users", path)
|
return fmt.Errorf("pmg executable %s is not executable by all users", path)
|
||||||
}
|
}
|
||||||
|
if perm&0o022 != 0 {
|
||||||
|
return fmt.Errorf("pmg executable %s is writable by group or others", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if systemExecutableOwnershipCheck {
|
||||||
|
if err := requireRootOwnedPath(path, info); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := requireSafeAncestorDirs(filepath.Dir(path)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireRootOwnedPath(path string, info os.FileInfo) error {
|
||||||
|
uid, ok := fileOwnerUID(info)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot determine owner of %s", path)
|
||||||
|
}
|
||||||
|
if uid != 0 {
|
||||||
|
return fmt.Errorf("pmg executable %s must be owned by root", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSafeAncestorDirs(dir string) error {
|
||||||
|
for {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to inspect directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0o022 != 0 {
|
||||||
|
return fmt.Errorf("directory %s on pmg executable path is writable by group or others", dir)
|
||||||
|
}
|
||||||
|
uid, ok := fileOwnerUID(info)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot determine owner of directory %s", dir)
|
||||||
|
}
|
||||||
|
if uid != 0 {
|
||||||
|
return fmt.Errorf("directory %s on pmg executable path must be owned by root", dir)
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SystemShimsInstalled reports whether the system shim directory contains at
|
// SystemShimsInstalled reports whether the system shim directory contains at
|
||||||
// least one shim script.
|
// least one shim script.
|
||||||
func SystemShimsInstalled() bool {
|
func SystemShimsInstalled() bool {
|
||||||
@@ -108,8 +176,7 @@ func SystemProfileInstalled() bool {
|
|||||||
return strings.Contains(string(data), systemProfileMarker)
|
return strings.Contains(string(data), systemProfileMarker)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSystemProfile() error {
|
func writeSystemProfile(binDir string) error {
|
||||||
binDir := SystemBinDir()
|
|
||||||
path := SystemProfilePath()
|
path := SystemProfilePath()
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ func useSystemPaths(t *testing.T, dir string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
systemBinDirOverride = filepath.Join(dir, "bin")
|
systemBinDirOverride = filepath.Join(dir, "bin")
|
||||||
systemProfilePathOverride = filepath.Join(dir, "profile.d", "pmg.sh")
|
systemProfilePathOverride = filepath.Join(dir, "profile.d", "pmg.sh")
|
||||||
|
systemExecutableOwnershipCheck = false
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
systemBinDirOverride = ""
|
systemBinDirOverride = ""
|
||||||
systemProfilePathOverride = ""
|
systemProfilePathOverride = ""
|
||||||
|
systemExecutableOwnershipCheck = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +102,20 @@ func TestWriteSystemProfileRepairsStalePath(t *testing.T) {
|
|||||||
0o644,
|
0o644,
|
||||||
))
|
))
|
||||||
|
|
||||||
require.NoError(t, writeSystemProfile())
|
binDir := filepath.Join(root, "custom-bin")
|
||||||
|
require.NoError(t, writeSystemProfile(binDir))
|
||||||
|
|
||||||
content, err := os.ReadFile(SystemProfilePath())
|
content, err := os.ReadFile(SystemProfilePath())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(content), SystemBinDir())
|
assert.Contains(t, string(content), binDir)
|
||||||
assert.NotContains(t, string(content), "/stale/path")
|
assert.NotContains(t, string(content), "/stale/path")
|
||||||
|
assert.NotContains(t, string(content), SystemBinDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSystemExecutableRejectsPrivateBinary(t *testing.T) {
|
func TestValidateSystemExecutableRejectsPrivateBinary(t *testing.T) {
|
||||||
|
systemExecutableOwnershipCheck = false
|
||||||
|
t.Cleanup(func() { systemExecutableOwnershipCheck = true })
|
||||||
|
|
||||||
privateDir := t.TempDir()
|
privateDir := t.TempDir()
|
||||||
privateExecutable := filepath.Join(privateDir, "pmg")
|
privateExecutable := filepath.Join(privateDir, "pmg")
|
||||||
require.NoError(t, os.WriteFile(privateExecutable, []byte("binary"), 0o700))
|
require.NoError(t, os.WriteFile(privateExecutable, []byte("binary"), 0o700))
|
||||||
@@ -118,3 +125,40 @@ func TestValidateSystemExecutableRejectsPrivateBinary(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not executable by all users")
|
assert.Contains(t, err.Error(), "not executable by all users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateSystemExecutableRejectsGroupWritable(t *testing.T) {
|
||||||
|
systemExecutableOwnershipCheck = false
|
||||||
|
t.Cleanup(func() { systemExecutableOwnershipCheck = true })
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "pmg")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("binary"), 0o755))
|
||||||
|
require.NoError(t, os.Chmod(path, 0o775))
|
||||||
|
|
||||||
|
err := validateSystemExecutable(path)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "writable by group or others")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSystemExecutableRejectsNonRootOwner(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "pmg")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("binary"), 0o755))
|
||||||
|
|
||||||
|
err := validateSystemExecutable(path)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "must be owned by root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSystemShimManagerForRemoveSkipsValidation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
useSystemPaths(t, root)
|
||||||
|
systemExecutableOwnershipCheck = true
|
||||||
|
|
||||||
|
mgr, err := NewSystemShimManagerForRemove()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.Install())
|
||||||
|
require.NoError(t, mgr.Remove())
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func PrintSetupSystemInstallCmdInfo(shimBinDir, configDir, profilePath string) {
|
|||||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Shims: %s", shimBinDir)))
|
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Shims: %s", shimBinDir)))
|
||||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configDir)))
|
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Config: %s", configDir)))
|
||||||
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Profile: %s", profilePath)))
|
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Profile: %s", profilePath)))
|
||||||
|
fmt.Printf(" %s\n", Colors.Dim(fmt.Sprintf("Per-user config files are now ignored; edit %s/config.yml as root.", configDir)))
|
||||||
fmt.Printf("\n%s For Docker builds (RUN does not source profile.d), add:\n", Colors.Dim("ℹ"))
|
fmt.Printf("\n%s For Docker builds (RUN does not source profile.d), add:\n", Colors.Dim("ℹ"))
|
||||||
fmt.Printf(" %s\n", Colors.Bold(fmt.Sprintf(`ENV PATH="%s:$PATH"`, shimBinDir)))
|
fmt.Printf(" %s\n", Colors.Bold(fmt.Sprintf(`ENV PATH="%s:$PATH"`, shimBinDir)))
|
||||||
fmt.Printf("%s Login shells pick up PATH from profile.d. After venv activate, use `pmg pip`.\n", Colors.Dim("ℹ"))
|
fmt.Printf("%s Login shells pick up PATH from profile.d. After venv activate, use `pmg pip`.\n", Colors.Dim("ℹ"))
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if eventlogErr != nil {
|
if eventlogErr != nil {
|
||||||
log.Warnf("failed to initialize event logging: %v", eventlogErr)
|
ui.Fatalf("failed to initialize event logging: %v", eventlogErr)
|
||||||
// Soft-fail: unusable HOME must not block package-manager commands.
|
|
||||||
ui.Infof("%s [pmg] Event logging unavailable (%v)",
|
|
||||||
ui.Colors.Yellow("⚠"), eventlogErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := audit.Initialize(config.Get()); err != nil {
|
if err := audit.Initialize(config.Get()); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user