mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: Opt-in lockdown for global config (#278)
* fix: Reject overriding managed flags * fix: Lockdown overrides when global config present * fix: Opt-in lock-down enforcement for global config * fix: Code review fixes * fix: Code review fixes * fix: Code review fixes
This commit is contained in:
+4
-1
@@ -45,7 +45,10 @@ func executeSetupInfo() error {
|
||||
configEntries["Config File"] = cfg.ConfigFilePath()
|
||||
configSource := "user"
|
||||
if cfg.IsManaged() {
|
||||
configSource = "global (managed)"
|
||||
configSource = "global"
|
||||
if cfg.IsLocked() {
|
||||
configSource = "global (locked)"
|
||||
}
|
||||
}
|
||||
configEntries["Config Source"] = configSource
|
||||
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
|
||||
|
||||
+121
-28
@@ -2,8 +2,10 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var skipDependencyCooldown bool
|
||||
@@ -11,37 +13,128 @@ var skipDependencyCooldown bool
|
||||
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
||||
var sandboxAllowRaw []string
|
||||
|
||||
// ApplyCobraFlags applies the cobra flags to the command.
|
||||
// These flags are local concern of the config package. This helper function is used
|
||||
// to bind them to the Cobra. The default values are taken from the global configuration,
|
||||
// allowing for overriding the configuration at runtime.
|
||||
// flagSpec declares a pmg flag once: how to bind it into cobra (bind) and the
|
||||
// metadata used to reason about it (managed). configFlagSpecs is the single
|
||||
// source of truth, so the cobra wiring and policy decisions cannot drift apart.
|
||||
type flagSpec struct {
|
||||
name string
|
||||
usage string
|
||||
|
||||
// true when the globally managed config governs this value
|
||||
managed bool
|
||||
|
||||
// bind registers the flag on fs. It owns the type, target field, and default
|
||||
// (read at bind time), keeping the flag tied to its config field with
|
||||
// compile-time safety rather than a stringly-typed key.
|
||||
bind func(fs *pflag.FlagSet, name, usage string)
|
||||
}
|
||||
|
||||
var configFlagSpecs = []flagSpec{
|
||||
{
|
||||
name: "transitive", usage: "Resolve transitive dependencies", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.Transitive, name, globalConfig.Config.Transitive, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transitive-depth", usage: "Maximum depth of transitive dependencies to resolve", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.IntVar(&globalConfig.Config.TransitiveDepth, name, globalConfig.Config.TransitiveDepth, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "include-dev-dependencies", usage: "Include dev dependencies in the dependency graph (slows down resolution)", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.IncludeDevDependencies, name, globalConfig.Config.IncludeDevDependencies, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry-run", usage: "Dry run skips execution of package manager", managed: false,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.DryRun, name, globalConfig.DryRun, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paranoid", usage: "Enable high-security defaults (treat suspicious as malicious)", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.Paranoid, name, globalConfig.Config.Paranoid, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip-event-log", usage: "Skip event logging", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.SkipEventLogging, name, globalConfig.Config.SkipEventLogging, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proxy-mode", usage: "Use proxy based interception", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.Proxy.Enabled, name, globalConfig.Config.Proxy.Enabled, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sandbox", usage: "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.Sandbox.Enabled, name, globalConfig.Config.Sandbox.Enabled, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sandbox-enforce", usage: "Apply sandbox to all commands, not just install commands (requires --sandbox)", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, name, globalConfig.Config.Sandbox.EnforceAlways, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sandbox-profile", usage: "Override sandbox policy profile (built-in name or path to custom YAML)", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.StringVar(&globalConfig.SandboxProfileOverride, name, globalConfig.SandboxProfileOverride, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sandbox-allow", usage: "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.StringArrayVar(&sandboxAllowRaw, name, nil, usage)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skip-dependency-cooldown", usage: "Skip dependency cooldown enforcement", managed: true,
|
||||
bind: func(fs *pflag.FlagSet, name, usage string) {
|
||||
fs.BoolVar(&skipDependencyCooldown, name, false, usage)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ApplyCobraFlags binds the config flags onto cmd as persistent flags. Defaults
|
||||
// are read from the current global configuration, allowing runtime overrides.
|
||||
func ApplyCobraFlags(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Transitive, "transitive",
|
||||
globalConfig.Config.Transitive, "Resolve transitive dependencies")
|
||||
cmd.PersistentFlags().IntVar(&globalConfig.Config.TransitiveDepth, "transitive-depth",
|
||||
globalConfig.Config.TransitiveDepth, "Maximum depth of transitive dependencies to resolve")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.IncludeDevDependencies, "include-dev-dependencies",
|
||||
globalConfig.Config.IncludeDevDependencies, "Include dev dependencies in the dependency graph (slows down resolution)")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.DryRun, "dry-run",
|
||||
globalConfig.DryRun, "Dry run skips execution of package manager")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Paranoid, "paranoid",
|
||||
globalConfig.Config.Paranoid, "Enable high-security defaults (treat suspicious as malicious)")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.SkipEventLogging, "skip-event-log",
|
||||
globalConfig.Config.SkipEventLogging, "Skip event logging")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Proxy.Enabled, "proxy-mode",
|
||||
globalConfig.Config.Proxy.Enabled, "Use proxy based interception")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.Enabled, "sandbox",
|
||||
globalConfig.Config.Sandbox.Enabled, "Enable sandbox mode to isolate package manager processes (EXPERIMENTAL)")
|
||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Sandbox.EnforceAlways, "sandbox-enforce",
|
||||
globalConfig.Config.Sandbox.EnforceAlways, "Apply sandbox to all commands, not just install commands (requires --sandbox)")
|
||||
cmd.PersistentFlags().StringVar(&globalConfig.SandboxProfileOverride, "sandbox-profile",
|
||||
globalConfig.SandboxProfileOverride, "Override sandbox policy profile (built-in name or path to custom YAML)")
|
||||
cmd.PersistentFlags().StringArrayVar(&sandboxAllowRaw, "sandbox-allow",
|
||||
nil, "Add runtime sandbox allow rule (type=value). Types: read, write, exec, net-connect, net-bind")
|
||||
fs := cmd.PersistentFlags()
|
||||
for _, f := range configFlagSpecs {
|
||||
f.bind(fs, f.name, f.usage)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown",
|
||||
false, "Skip dependency cooldown enforcement")
|
||||
// RejectManagedFlagOverrides fails when the active config is a locked global
|
||||
// config and the user explicitly set a flag whose value that config governs.
|
||||
// Operational flags (managed == false) are unaffected, and an unlocked managed
|
||||
// config allows flag overrides. Call it after flag parsing.
|
||||
func RejectManagedFlagOverrides(cmd *cobra.Command) error {
|
||||
if !Get().IsLocked() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var offending []string
|
||||
for _, f := range configFlagSpecs {
|
||||
if f.managed && cmd.Flags().Changed(f.name) {
|
||||
offending = append(offending, "--"+f.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(offending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return managedError(fmt.Sprintf("these flags cannot override the globally managed configuration (%s): %s",
|
||||
globalConfig.configFilePath, strings.Join(offending, ", ")))
|
||||
}
|
||||
|
||||
// FinalizeDependencyCooldownOverride disables dependency cooldown in the global
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withLockedState swaps globalConfig for a fresh managed config with the
|
||||
// requested lockdown state, and restores the original afterwards. Locked implies
|
||||
// managed, matching production (initConfig sets configLocked = IsManaged() && ...).
|
||||
func withLockedState(t *testing.T, locked bool) {
|
||||
t.Helper()
|
||||
|
||||
orig := globalConfig
|
||||
t.Cleanup(func() { globalConfig = orig })
|
||||
|
||||
cfg := DefaultConfig()
|
||||
globalConfig = &cfg
|
||||
globalConfig.configFilePath = "/global/config.yml" // managed: active path differs from user path
|
||||
globalConfig.userConfigFilePath = "/user/config.yml"
|
||||
globalConfig.configLocked = locked
|
||||
}
|
||||
|
||||
func TestRejectManagedFlagOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
locked bool
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{"locked blocks a managed flag", true, []string{"--paranoid"}, true},
|
||||
{"locked blocks sandbox-allow", true, []string{"--sandbox-allow", "read=/tmp"}, true},
|
||||
{"locked allows dry-run", true, []string{"--dry-run"}, false},
|
||||
{"locked allows no flags", true, nil, false},
|
||||
{"unlocked allows a managed flag", false, []string{"--paranoid"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withLockedState(t, tc.locked)
|
||||
|
||||
cmd := &cobra.Command{Use: "test", Run: func(*cobra.Command, []string) {}}
|
||||
ApplyCobraFlags(cmd)
|
||||
require.NoError(t, cmd.ParseFlags(tc.args))
|
||||
|
||||
err := RejectManagedFlagOverrides(cmd)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "globally managed")
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Proves the check sees managed flags set as inherited persistent flags on a
|
||||
// subcommand, which is how they reach the real root PersistentPreRun.
|
||||
func TestRejectManagedFlagOverridesDetectsInheritedFlag(t *testing.T) {
|
||||
withLockedState(t, true)
|
||||
|
||||
root := &cobra.Command{Use: "pmg"}
|
||||
ApplyCobraFlags(root)
|
||||
|
||||
var checked bool
|
||||
child := &cobra.Command{
|
||||
Use: "install",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
checked = true
|
||||
return RejectManagedFlagOverrides(cmd)
|
||||
},
|
||||
}
|
||||
root.AddCommand(child)
|
||||
root.SetArgs([]string{"install", "--sandbox=false"})
|
||||
|
||||
err := root.Execute()
|
||||
require.True(t, checked)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "globally managed")
|
||||
}
|
||||
|
||||
// Proves the SSOT table is internally consistent: every spec actually binds a
|
||||
// flag, every registered flag traces back to a spec (no out-of-band flags), and
|
||||
// the managed classification matches intent. Catches accidental managed flips
|
||||
// and config flags added without classification.
|
||||
func TestConfigFlagSpecsSSOT(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
ApplyCobraFlags(cmd)
|
||||
|
||||
specByName := make(map[string]flagSpec, len(configFlagSpecs))
|
||||
gotManaged := make(map[string]bool)
|
||||
for _, f := range configFlagSpecs {
|
||||
specByName[f.name] = f
|
||||
require.NotNil(t, cmd.PersistentFlags().Lookup(f.name), "spec %q is not registered by ApplyCobraFlags", f.name)
|
||||
if f.managed {
|
||||
gotManaged[f.name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// No flag is registered outside the SSOT table.
|
||||
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
|
||||
_, ok := specByName[f.Name]
|
||||
assert.True(t, ok, "flag --%s is registered but has no flagSpec", f.Name)
|
||||
})
|
||||
|
||||
wantManaged := map[string]bool{
|
||||
"transitive": true, "transitive-depth": true, "include-dev-dependencies": true,
|
||||
"paranoid": true, "skip-event-log": true, "proxy-mode": true,
|
||||
"sandbox": true, "sandbox-enforce": true, "sandbox-profile": true,
|
||||
"sandbox-allow": true, "skip-dependency-cooldown": true,
|
||||
}
|
||||
assert.Equal(t, wantManaged, gotManaged, "managed flag classification changed unexpectedly")
|
||||
}
|
||||
+30
-10
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
@@ -202,6 +201,7 @@ type RuntimeConfig struct {
|
||||
configDir string
|
||||
configFilePath string // active config: globally managed file if present, else per-user
|
||||
userConfigFilePath string // per-user config file, used for writes and removal
|
||||
configLocked bool // global file present and opted into lockdown (global_lockdown: true)
|
||||
eventLogDir string
|
||||
sandboxProfileDir string
|
||||
sandboxViolationCacheDir string
|
||||
@@ -245,6 +245,15 @@ func (r *RuntimeConfig) IsManaged() bool {
|
||||
return r.configFilePath != r.userConfigFilePath
|
||||
}
|
||||
|
||||
// IsLocked reports whether a globally managed config opted into lockdown via
|
||||
// global_lockdown: true. When locked, env and CLI overrides of config are
|
||||
// refused. An unlocked managed config is an overridable baseline: it stays the
|
||||
// authoritative file (the per-user file is still ignored), but env and CLI args
|
||||
// can override its values at runtime.
|
||||
func (r *RuntimeConfig) IsLocked() bool {
|
||||
return r.configLocked
|
||||
}
|
||||
|
||||
// EventLogDir returns the path to the event log directory.
|
||||
func (r *RuntimeConfig) EventLogDir() string {
|
||||
return r.eventLogDir
|
||||
@@ -296,12 +305,7 @@ type SandboxAllowOverride struct {
|
||||
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
||||
func DefaultConfig() RuntimeConfig {
|
||||
// Backward compatibility for the insecure installation flag before config was introduced.
|
||||
insecureInstallation := false
|
||||
if val := os.Getenv(pmgInsecureInstallationEnvKey); val != "" {
|
||||
if boolVal, err := strconv.ParseBool(val); err == nil {
|
||||
insecureInstallation = boolVal
|
||||
}
|
||||
}
|
||||
insecureInstallation := utils.EnvBool(pmgInsecureInstallationEnvKey, false)
|
||||
|
||||
return RuntimeConfig{
|
||||
Config: Config{
|
||||
@@ -401,6 +405,17 @@ func initConfig() {
|
||||
globalConfig.sandboxProfileDir = sandboxProfileDir
|
||||
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
||||
|
||||
// A globally managed config enforces lockdown only when it opts in via
|
||||
// global_lockdown, read straight from the file so it cannot be flipped by
|
||||
// env or CLI.
|
||||
globalConfig.configLocked = globalConfig.IsManaged() && globalConfigEnablesLockdown(globalConfigFilePath())
|
||||
|
||||
// When locked, env cannot bypass the config, including the
|
||||
// PMG_INSECURE_INSTALLATION malicious-package block bypass.
|
||||
if globalConfig.IsLocked() {
|
||||
globalConfig.InsecureInstallation = false
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
|
||||
if err := preprocessTrustedPackages(&globalConfig.Config); err != nil {
|
||||
@@ -655,10 +670,15 @@ func RemoveUserConfigFile() error {
|
||||
// globally managed configuration. It carries a useful error code and help text
|
||||
// so the CLI presents it as an expected, actionable failure rather than a bug.
|
||||
func NewManagedConfigError() error {
|
||||
msg := fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath)
|
||||
return managedError(fmt.Sprintf("configuration is globally managed (%s) and cannot be changed", globalConfig.configFilePath))
|
||||
}
|
||||
|
||||
// managedError builds the standard "globally managed" CLI error with a useful
|
||||
// code and actionable help.
|
||||
func managedError(message string) error {
|
||||
return usefulerror.Useful().
|
||||
WithCode(usefulerror.ErrCodePermissionDenied).
|
||||
WithHumanError(msg).
|
||||
WithHumanError(message).
|
||||
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
|
||||
Wrap(errors.New(msg))
|
||||
Wrap(errors.New(message))
|
||||
}
|
||||
|
||||
@@ -85,6 +85,93 @@ func TestSetConfigValueRefusedWhenManaged(t *testing.T) {
|
||||
assert.NoFileExists(t, filepath.Join(userDir, "config.yml"))
|
||||
}
|
||||
|
||||
func TestEnvDoesNotOverrideLockedConfig(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\nglobal_lockdown: true\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, globalDir)
|
||||
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("PMG_PARANOID", "false")
|
||||
initConfig()
|
||||
|
||||
require.True(t, Get().IsLocked())
|
||||
assert.True(t, Get().Config.Paranoid, "PMG_PARANOID must not override a locked config")
|
||||
}
|
||||
|
||||
func TestEnvOverridesManagedConfigWhenNotLocked(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, globalDir)
|
||||
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("PMG_PARANOID", "false")
|
||||
initConfig()
|
||||
|
||||
require.True(t, Get().IsManaged())
|
||||
require.False(t, Get().IsLocked())
|
||||
assert.False(t, Get().Config.Paranoid, "without lockdown, PMG_PARANOID overrides the managed baseline")
|
||||
}
|
||||
|
||||
func TestEnvOverridesUserConfigWhenNotManaged(t *testing.T) {
|
||||
userDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(userDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, t.TempDir()) // empty global dir -> not managed
|
||||
t.Setenv("PMG_CONFIG_DIR", userDir)
|
||||
t.Setenv("PMG_PARANOID", "false")
|
||||
initConfig()
|
||||
|
||||
require.False(t, Get().IsManaged())
|
||||
assert.False(t, Get().Config.Paranoid, "PMG_PARANOID should override the per-user config")
|
||||
}
|
||||
|
||||
func TestInsecureInstallationEnvIgnoredWhenLocked(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("global_lockdown: true\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, globalDir)
|
||||
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
|
||||
initConfig()
|
||||
|
||||
require.True(t, Get().IsLocked())
|
||||
assert.False(t, Get().InsecureInstallation, "PMG_INSECURE_INSTALLATION must not bypass a locked config")
|
||||
}
|
||||
|
||||
func TestInsecureInstallationHonoredWhenManagedNotLocked(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("paranoid: true\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, globalDir)
|
||||
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
|
||||
initConfig()
|
||||
|
||||
require.True(t, Get().IsManaged())
|
||||
require.False(t, Get().IsLocked())
|
||||
assert.True(t, Get().InsecureInstallation, "without lockdown, PMG_INSECURE_INSTALLATION is honored")
|
||||
}
|
||||
|
||||
func TestInsecureInstallationEnvHonoredWhenNotManaged(t *testing.T) {
|
||||
useManagedConfigDir(t, t.TempDir()) // empty global dir -> not managed
|
||||
t.Setenv("PMG_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("PMG_INSECURE_INSTALLATION", "true")
|
||||
initConfig()
|
||||
|
||||
require.False(t, Get().IsManaged())
|
||||
assert.True(t, Get().InsecureInstallation)
|
||||
}
|
||||
|
||||
func TestMalformedGlobalConfigFailsClosed(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
// Present but unparseable YAML ("mapping values not allowed in this context").
|
||||
require.NoError(t, os.WriteFile(filepath.Join(globalDir, "config.yml"), []byte("a: b: c\n"), 0o644))
|
||||
|
||||
useManagedConfigDir(t, globalDir)
|
||||
initConfig()
|
||||
|
||||
require.True(t, Get().IsManaged())
|
||||
assert.True(t, Get().IsLocked(), "a present but unparseable global config must fail closed (locked)")
|
||||
}
|
||||
|
||||
func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
|
||||
globalDir := t.TempDir()
|
||||
userDir := t.TempDir()
|
||||
|
||||
+58
-11
@@ -5,12 +5,15 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// loadViperConfig loads the configuration using Viper.
|
||||
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
|
||||
// When a globally managed config is active, env overrides are disabled and
|
||||
// managed flags are rejected, so the managed file is authoritative.
|
||||
// Cobra flags write directly to the config struct after this function runs.
|
||||
func loadViperConfig() error {
|
||||
// Use the active config path resolved by initConfig (globally managed file
|
||||
@@ -19,12 +22,18 @@ func loadViperConfig() error {
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigType("yaml")
|
||||
v.SetEnvPrefix("PMG")
|
||||
v.AutomaticEnv()
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
|
||||
// A locked global config must not be bypassable via PMG_* env vars, so
|
||||
// AutomaticEnv is enabled unless lockdown is in force. An unlocked managed
|
||||
// config stays an overridable baseline.
|
||||
if !globalConfig.IsLocked() {
|
||||
v.SetEnvPrefix("PMG")
|
||||
v.AutomaticEnv()
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
}
|
||||
|
||||
// Load the embedded template as the base so Viper knows all keys and their
|
||||
// defaults. This is required for AutomaticEnv to resolve PMG_* env vars for
|
||||
// defaults, and (when env overrides are enabled) can resolve PMG_* vars for
|
||||
// keys that are absent from or newer than the user's config file.
|
||||
if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil {
|
||||
return fmt.Errorf("failed to load default config: %w", err)
|
||||
@@ -56,17 +65,26 @@ func loadViperConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasProxySectionInFile checks whether the user's config file contains a
|
||||
// top-level "proxy" key. Returns false if the file doesn't exist or can't
|
||||
// be parsed.
|
||||
func hasProxySectionInFile(path string) bool {
|
||||
// readConfigFileKeys reads path and returns its top-level YAML mapping.
|
||||
func readConfigFileKeys(path string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// hasProxySectionInFile checks whether the config file at path contains a
|
||||
// top-level "proxy" key. A missing or unparseable file reports false.
|
||||
func hasProxySectionInFile(path string) bool {
|
||||
raw, err := readConfigFileKeys(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,18 +92,47 @@ func hasProxySectionInFile(path string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// globalConfigEnablesLockdown reports whether the global config file at path
|
||||
// enables lockdown. It is only called when a global config is present, so a read
|
||||
// or parse failure means a managed file we cannot interpret: fail closed
|
||||
// (locked) rather than silently dropping policy. global_lockdown is read directly
|
||||
// from the file, so it cannot be flipped via env or CLI.
|
||||
func globalConfigEnablesLockdown(path string) bool {
|
||||
raw, err := readConfigFileKeys(path)
|
||||
if err != nil {
|
||||
log.Warnf("could not read global config %q to determine lockdown (%v); defaulting to locked", path, err)
|
||||
return true
|
||||
}
|
||||
|
||||
value, ok := raw["global_lockdown"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
enabled, isBool := value.(bool)
|
||||
if !isBool {
|
||||
log.Warnf("config %q sets global_lockdown to a non-boolean value (%v); treating as disabled", path, value)
|
||||
return false
|
||||
}
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
// applyProxyLegacyFallback populates the new Proxy struct from deprecated
|
||||
// flat keys when the user's config file does not have a proxy: section.
|
||||
// New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence
|
||||
// over legacy config file keys to respect the documented precedence order.
|
||||
func applyProxyLegacyFallback(v *viper.Viper) {
|
||||
if os.Getenv("PMG_PROXY_ENABLED") == "" && v.IsSet("proxy_mode") {
|
||||
// A locked config ignores env, so env must not suppress the legacy migration.
|
||||
envIgnored := globalConfig.IsLocked()
|
||||
|
||||
if (envIgnored || os.Getenv("PMG_PROXY_ENABLED") == "") && v.IsSet("proxy_mode") {
|
||||
val := v.GetBool("proxy_mode")
|
||||
globalConfig.Config.Proxy.Enabled = val
|
||||
v.Set("proxy.enabled", val)
|
||||
}
|
||||
|
||||
if os.Getenv("PMG_PROXY_INSTALL_ONLY") == "" && v.IsSet("proxy_install_only") {
|
||||
if (envIgnored || os.Getenv("PMG_PROXY_INSTALL_ONLY") == "") && v.IsSet("proxy_install_only") {
|
||||
val := v.GetBool("proxy_install_only")
|
||||
globalConfig.Config.Proxy.InstallOnly = val
|
||||
v.Set("proxy.install_only", val)
|
||||
|
||||
@@ -72,6 +72,8 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express
|
||||
3. Config file (`config.yml`)
|
||||
4. Built-in defaults
|
||||
|
||||
Under a [globally managed config](#globally-managed-configuration) with `global_lockdown` enabled, PMG disables `PMG_*` and managed-flag overrides.
|
||||
|
||||
|
||||
**Limitation**
|
||||
|
||||
@@ -79,3 +81,62 @@ PMG_PROXY_INSTALL_ONLY=true pmg npm install express
|
||||
If a key is commented out (e.g. `# endpoint_id: "my-machine"`) or missing entirely, `set` will
|
||||
return a "key not found" error. To fix this, uncomment or add the key manually via `pmg config edit`,
|
||||
or run `pmg setup install` to merge missing template keys into your config.
|
||||
|
||||
## Globally Managed Configuration
|
||||
|
||||
For centrally managed or fleet deployments, PMG can read an OS-level **global config file**. When this file exists, it is authoritative: PMG uses it and ignores the per-user `config.yml` (the two are never merged). An administrator ships a machine-wide baseline this way, and can lock it (see [Lockdown](#lockdown)) to forbid user overrides.
|
||||
|
||||
**Paths** (used when the file is present):
|
||||
|
||||
| OS | Global config path |
|
||||
|---|---|
|
||||
| macOS | `/Library/Application Support/safedep/pmg/config.yml` |
|
||||
| Linux | `/etc/safedep/pmg/config.yml` |
|
||||
| Windows | `%PROGRAMDATA%\safedep\pmg\config.yml` |
|
||||
|
||||
Check whether a global config is active with `pmg setup info`:
|
||||
|
||||
```bash
|
||||
pmg setup info
|
||||
# Config Source: global <- global config, overrides allowed
|
||||
# Config Source: global (locked) <- global config with lockdown enabled
|
||||
# Config Source: user <- per-user config in effect
|
||||
```
|
||||
|
||||
### Behaviour
|
||||
|
||||
Whenever a global config file is present:
|
||||
|
||||
- **It is authoritative.** PMG ignores the per-user `config.yml`. The file may be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to a user's values.
|
||||
- **`config set` and `config edit` fail.** They return an error stating the config is globally managed. To change it, deploy an updated file at the OS path, which is root-owned and not writable by users.
|
||||
- **`pmg setup install` skips the per-user config.** It still creates shell aliases and shims per user.
|
||||
|
||||
By default a user can still override the global config's values at runtime through `PMG_*` environment variables and CLI flags. Enable lockdown to forbid that.
|
||||
|
||||
### Lockdown
|
||||
|
||||
Add `global_lockdown: true` to the global config to enforce it:
|
||||
|
||||
```yaml
|
||||
# Only meaningful in the global config file.
|
||||
global_lockdown: true
|
||||
```
|
||||
|
||||
When lockdown is on:
|
||||
|
||||
- **CLI flags that would change a managed value fail fast.** For example, `pmg --sandbox=false ...` or `pmg --paranoid ...` errors out instead of overriding policy. Governed flags: `--transitive`, `--transitive-depth`, `--include-dev-dependencies`, `--paranoid`, `--skip-event-log`, `--proxy-mode`, `--sandbox`, `--sandbox-enforce`, `--sandbox-profile`, `--sandbox-allow`, `--skip-dependency-cooldown`. Operational flags such as `--dry-run` keep working.
|
||||
- **`PMG_*` variables cannot change the config**, including `PMG_INSECURE_INSTALLATION` (which otherwise bypasses malicious-package blocking).
|
||||
|
||||
PMG reads `global_lockdown` straight from the global file, so a user cannot flip it through env or CLI. If the global file exists but cannot be read or parsed, PMG fails closed and treats it as locked. `PMG_CONFIG_DIR` and `PMG_CACHE_DIR` still relocate per-user state directories (logs, cache) in any mode, but leave the managed config alone.
|
||||
|
||||
### Precedence
|
||||
|
||||
| Mode | Effective order (highest to lowest) |
|
||||
|---|---|
|
||||
| No global config | CLI flags > `PMG_*` env > per-user `config.yml` > built-in defaults |
|
||||
| Global config, no lockdown | CLI flags > `PMG_*` env > global config > built-in defaults |
|
||||
| Global config, `global_lockdown: true` | global config > built-in defaults (env and managed-flag overrides refused) |
|
||||
|
||||
### Deploying via MDM (macOS)
|
||||
|
||||
Scripts to install or update PMG and deploy a global config across a macOS fleet (Jamf, Mosyle, Kandji, Intune) live in [`scripts/mdm`](../scripts/mdm). Bundle a `config.yml` next to the scripts. The installer places it at the global path, and the uninstaller removes it. See the [`scripts/mdm` README](../scripts/mdm/README.md) for details.
|
||||
|
||||
@@ -83,6 +83,13 @@ func main() {
|
||||
|
||||
log.InitZapLogger("pmg", "cli")
|
||||
|
||||
// Refuse flags that would override a globally managed config before any
|
||||
// config-dependent initialization (event logging, audit) runs, so a
|
||||
// managed flag like --skip-event-log cannot take effect first.
|
||||
if err := config.RejectManagedFlagOverrides(cmd); err != nil {
|
||||
ui.ErrorExit(err)
|
||||
}
|
||||
|
||||
// Initialize event logging (silently fail if it can't be initialized)
|
||||
var eventlogErr error
|
||||
if logFile != "" {
|
||||
|
||||
@@ -58,8 +58,9 @@ It then removes the machine-wide binary via `brew uninstall`, or by deleting `/u
|
||||
|
||||
## Globally managed config
|
||||
|
||||
Include a `config.yml` next to the scripts to centrally manage PMG configuration. When that file is present at `/Library/Application Support/safedep/pmg/config.yml`, PMG treats it as authoritative and **ignores every user's own config**. Users cannot override it: `pmg config set` and `pmg config edit` refuse, and the file is root-owned (`0644`), so it is not user-writable.
|
||||
Include a `config.yml` next to the scripts to centrally manage PMG configuration. When that file is present at `/Library/Application Support/safedep/pmg/config.yml`, PMG treats it as authoritative and **ignores every user's own config**. `pmg config set` and `pmg config edit` refuse, and the file is root-owned (`0644`), so it is not user-writable.
|
||||
|
||||
- By default the global config is an overridable baseline: users can still override its values at runtime with `PMG_*` env vars and CLI flags. Set `global_lockdown: true` in the bundled `config.yml` to forbid those overrides. See [Globally Managed Configuration](../../docs/config.md#globally-managed-configuration) for the full behaviour.
|
||||
- The file can be **partial**. Keys it does not set fall back to PMG's built-in defaults, not to user values.
|
||||
- To enable cloud sync, set `cloud.enabled: true` in the bundled `config.yml`. The install script skips the per-user `pmg config set` (a managed config refuses it) but still stores each logged-in user's credentials in the Keychain.
|
||||
- Install copies the bundled `config.yml` to the global path *before* configuring users, so each user's setup skips writing a per-user config.
|
||||
|
||||
Reference in New Issue
Block a user