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()
|
configEntries["Config File"] = cfg.ConfigFilePath()
|
||||||
configSource := "user"
|
configSource := "user"
|
||||||
if cfg.IsManaged() {
|
if cfg.IsManaged() {
|
||||||
configSource = "global (managed)"
|
configSource = "global"
|
||||||
|
if cfg.IsLocked() {
|
||||||
|
configSource = "global (locked)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
configEntries["Config Source"] = configSource
|
configEntries["Config Source"] = configSource
|
||||||
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
|
configEntries["Proxy Mode"] = strconv.FormatBool(cfg.IsProxyModeEnabled())
|
||||||
|
|||||||
+121
-28
@@ -2,8 +2,10 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
var skipDependencyCooldown bool
|
var skipDependencyCooldown bool
|
||||||
@@ -11,37 +13,128 @@ var skipDependencyCooldown bool
|
|||||||
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
// sandboxAllowRaw holds the raw --sandbox-allow flag values before parsing.
|
||||||
var sandboxAllowRaw []string
|
var sandboxAllowRaw []string
|
||||||
|
|
||||||
// ApplyCobraFlags applies the cobra flags to the command.
|
// flagSpec declares a pmg flag once: how to bind it into cobra (bind) and the
|
||||||
// These flags are local concern of the config package. This helper function is used
|
// metadata used to reason about it (managed). configFlagSpecs is the single
|
||||||
// to bind them to the Cobra. The default values are taken from the global configuration,
|
// source of truth, so the cobra wiring and policy decisions cannot drift apart.
|
||||||
// allowing for overriding the configuration at runtime.
|
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) {
|
func ApplyCobraFlags(cmd *cobra.Command) {
|
||||||
cmd.PersistentFlags().BoolVar(&globalConfig.Config.Transitive, "transitive",
|
fs := cmd.PersistentFlags()
|
||||||
globalConfig.Config.Transitive, "Resolve transitive dependencies")
|
for _, f := range configFlagSpecs {
|
||||||
cmd.PersistentFlags().IntVar(&globalConfig.Config.TransitiveDepth, "transitive-depth",
|
f.bind(fs, f.name, f.usage)
|
||||||
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")
|
|
||||||
|
|
||||||
cmd.PersistentFlags().BoolVar(&skipDependencyCooldown, "skip-dependency-cooldown",
|
// RejectManagedFlagOverrides fails when the active config is a locked global
|
||||||
false, "Skip dependency cooldown enforcement")
|
// 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
|
// 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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
@@ -202,6 +201,7 @@ type RuntimeConfig struct {
|
|||||||
configDir string
|
configDir string
|
||||||
configFilePath string // active config: globally managed file if present, else per-user
|
configFilePath string // active config: globally managed file if present, else per-user
|
||||||
userConfigFilePath string // per-user config file, used for writes and removal
|
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
|
eventLogDir string
|
||||||
sandboxProfileDir string
|
sandboxProfileDir string
|
||||||
sandboxViolationCacheDir string
|
sandboxViolationCacheDir string
|
||||||
@@ -245,6 +245,15 @@ func (r *RuntimeConfig) IsManaged() bool {
|
|||||||
return r.configFilePath != r.userConfigFilePath
|
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.
|
// EventLogDir returns the path to the event log directory.
|
||||||
func (r *RuntimeConfig) EventLogDir() string {
|
func (r *RuntimeConfig) EventLogDir() string {
|
||||||
return r.eventLogDir
|
return r.eventLogDir
|
||||||
@@ -296,12 +305,7 @@ type SandboxAllowOverride struct {
|
|||||||
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
// The config package return an appropriate RuntimeConfig based on the environment and the configuration.
|
||||||
func DefaultConfig() RuntimeConfig {
|
func DefaultConfig() RuntimeConfig {
|
||||||
// Backward compatibility for the insecure installation flag before config was introduced.
|
// Backward compatibility for the insecure installation flag before config was introduced.
|
||||||
insecureInstallation := false
|
insecureInstallation := utils.EnvBool(pmgInsecureInstallationEnvKey, false)
|
||||||
if val := os.Getenv(pmgInsecureInstallationEnvKey); val != "" {
|
|
||||||
if boolVal, err := strconv.ParseBool(val); err == nil {
|
|
||||||
insecureInstallation = boolVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RuntimeConfig{
|
return RuntimeConfig{
|
||||||
Config: Config{
|
Config: Config{
|
||||||
@@ -401,6 +405,17 @@ func initConfig() {
|
|||||||
globalConfig.sandboxProfileDir = sandboxProfileDir
|
globalConfig.sandboxProfileDir = sandboxProfileDir
|
||||||
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
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()
|
loadConfig()
|
||||||
|
|
||||||
if err := preprocessTrustedPackages(&globalConfig.Config); err != nil {
|
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
|
// 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.
|
// so the CLI presents it as an expected, actionable failure rather than a bug.
|
||||||
func NewManagedConfigError() error {
|
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().
|
return usefulerror.Useful().
|
||||||
WithCode(usefulerror.ErrCodePermissionDenied).
|
WithCode(usefulerror.ErrCodePermissionDenied).
|
||||||
WithHumanError(msg).
|
WithHumanError(message).
|
||||||
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
|
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"))
|
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) {
|
func TestRemoveUserConfigFileNeverTouchesGlobal(t *testing.T) {
|
||||||
globalDir := t.TempDir()
|
globalDir := t.TempDir()
|
||||||
userDir := t.TempDir()
|
userDir := t.TempDir()
|
||||||
|
|||||||
+58
-11
@@ -5,12 +5,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadViperConfig loads the configuration using Viper.
|
// loadViperConfig loads the configuration using Viper.
|
||||||
// Precedence (highest to lowest): cobra flags > env vars > config file > defaults.
|
// 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.
|
// Cobra flags write directly to the config struct after this function runs.
|
||||||
func loadViperConfig() error {
|
func loadViperConfig() error {
|
||||||
// Use the active config path resolved by initConfig (globally managed file
|
// Use the active config path resolved by initConfig (globally managed file
|
||||||
@@ -19,12 +22,18 @@ func loadViperConfig() error {
|
|||||||
|
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
v.SetEnvPrefix("PMG")
|
|
||||||
v.AutomaticEnv()
|
// A locked global config must not be bypassable via PMG_* env vars, so
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
// 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
|
// 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.
|
// keys that are absent from or newer than the user's config file.
|
||||||
if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil {
|
if err := v.ReadConfig(strings.NewReader(templateConfig)); err != nil {
|
||||||
return fmt.Errorf("failed to load default config: %w", err)
|
return fmt.Errorf("failed to load default config: %w", err)
|
||||||
@@ -56,17 +65,26 @@ func loadViperConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasProxySectionInFile checks whether the user's config file contains a
|
// readConfigFileKeys reads path and returns its top-level YAML mapping.
|
||||||
// top-level "proxy" key. Returns false if the file doesn't exist or can't
|
func readConfigFileKeys(path string) (map[string]any, error) {
|
||||||
// be parsed.
|
|
||||||
func hasProxySectionInFile(path string) bool {
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var raw map[string]any
|
var raw map[string]any
|
||||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,18 +92,47 @@ func hasProxySectionInFile(path string) bool {
|
|||||||
return ok
|
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
|
// applyProxyLegacyFallback populates the new Proxy struct from deprecated
|
||||||
// flat keys when the user's config file does not have a proxy: section.
|
// 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
|
// New env vars (PMG_PROXY_ENABLED, PMG_PROXY_INSTALL_ONLY) take precedence
|
||||||
// over legacy config file keys to respect the documented precedence order.
|
// over legacy config file keys to respect the documented precedence order.
|
||||||
func applyProxyLegacyFallback(v *viper.Viper) {
|
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")
|
val := v.GetBool("proxy_mode")
|
||||||
globalConfig.Config.Proxy.Enabled = val
|
globalConfig.Config.Proxy.Enabled = val
|
||||||
v.Set("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")
|
val := v.GetBool("proxy_install_only")
|
||||||
globalConfig.Config.Proxy.InstallOnly = val
|
globalConfig.Config.Proxy.InstallOnly = val
|
||||||
v.Set("proxy.install_only", 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`)
|
3. Config file (`config.yml`)
|
||||||
4. Built-in defaults
|
4. Built-in defaults
|
||||||
|
|
||||||
|
Under a [globally managed config](#globally-managed-configuration) with `global_lockdown` enabled, PMG disables `PMG_*` and managed-flag overrides.
|
||||||
|
|
||||||
|
|
||||||
**Limitation**
|
**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
|
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`,
|
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.
|
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")
|
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)
|
// Initialize event logging (silently fail if it can't be initialized)
|
||||||
var eventlogErr error
|
var eventlogErr error
|
||||||
if logFile != "" {
|
if logFile != "" {
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ It then removes the machine-wide binary via `brew uninstall`, or by deleting `/u
|
|||||||
|
|
||||||
## Globally managed config
|
## 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.
|
- 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.
|
- 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.
|
- 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