Files
pmg/config/config.go
T
Sahilb315 82ddbc70af feat(proxy): accept transparent redirected connections
The proxy only accepted explicit clients, which announce their
destination with a CONNECT request. A client whose connection is
redirected at the kernel level speaks TLS immediately instead, so
http.Server tries to parse a TLS record as an HTTP request line and
drops the connection.

Demultiplex on the first byte of an accepted connection. A TLS
handshake record (0x16) cannot begin an HTTP method, so it separates
the two cleanly. Redirected connections have their destination
recovered from the ClientHello SNI and are served by synthesising the
CONNECT the client never sent, which keeps the MITM decision, cert
generation and interceptor chain on the existing code path.

Redirected connections deliberately bypass http.Server. It issues a
background read while a handler runs, which consumes the first byte of
the replayed ClientHello and corrupts the handshake. The CONNECT
response is also suppressed, since a client mid handshake expects a
ServerHello and would read those bytes as a malformed TLS record.

Off by default. Enabled with `pmg proxy start --transparent` or
proxy.server.transparent, and only useful alongside a redirect
mechanism such as an eBPF connect rewrite.
2026-07-27 12:31:24 +05:30

1108 lines
39 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"runtime"
"time"
_ "embed"
"github.com/safedep/pmg/internal/fsutil"
"github.com/safedep/dry/log"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/dry/utils"
"github.com/safedep/pmg/errcodes"
"github.com/spf13/viper"
)
const (
// Verbosity level constants for the config file
VerbositySilent = "silent"
VerbosityNormal = "normal"
VerbosityVerbose = "verbose"
// Environment variable key for the insecure installation flag
pmgInsecureInstallationEnvKey = "PMG_INSECURE_INSTALLATION"
// Allow overriding the config path from the environment
pmgConfigDirEnvKey = "PMG_CONFIG_DIR"
// Allow overriding the cache path from the environment
pmgCacheDirEnvKey = "PMG_CACHE_DIR"
// Config path is computed as the user config directory + the default relative path
// when not overridden by the environment variable
pmgDefaultHomeRelativePath = "safedep/pmg"
// Default log directory is relative to the config directory.
pmgDefaultLogDir = "logs"
// Default sandbox profile directory is relative to the config directory.
pmgDefaultSandboxProfileDir = "sandbox/profiles"
// Default sandbox overlay directory is relative to the config directory.
// Per-repo overlays persisted by `pmg sandbox allow` live here.
pmgDefaultSandboxOverlayDir = "sandbox/overlays"
// Default sandbox preset directory is relative to the config directory.
// User/community preset YAML files live here.
pmgDefaultSandboxPresetDir = "sandbox/presets"
// Default sandbox violation cache directory is relative to the cache root.
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
// Default localdb directory is relative to the cache root.
pmgDefaultLocalDBDir = "localdb"
// Default localdb file name for PMG's shared SQLite database.
pmgDefaultLocalDBFileName = "pmg.db"
// Config file name.
// Important: The config file path and the schema should be backward compatible. In case of breaking config
// changes, we must introduce a new file name and a migration path.
pmgConfigFileName = "config.yml"
)
//go:embed config.template.yml
var templateConfig string
// Config is the global configuration for PMG that can be persisted or loaded from a given source.
// Here we only define the configuration that can be persisted or loaded from a given source and
// not those that we believe should not be persisted (eg. insecure installation, etc.)
type Config struct {
// Paranoid enables high-security defaults (e.g., treating suspicious behavior as malicious).
Paranoid bool `mapstructure:"paranoid"`
// DisableTelemetry allows turning off telemetry collection.
DisableTelemetry bool `mapstructure:"disable_telemetry"`
// TrustedPackages allows for trusting a suspicious package and ignoring the suspicious behaviour for the package in future installations
TrustedPackages []TrustedPackage `mapstructure:"trusted_packages"`
// AdvisoryMessage is an optional org-specific message appended to every
// block output, regardless of which control blocked the installation.
AdvisoryMessage string `mapstructure:"advisory_message"`
// SkipEventLogging allows for skipping event logging.
SkipEventLogging bool `mapstructure:"skip_event_logging"`
// EventLogRetentionDays is the number of days to retain event logs.
EventLogRetentionDays int `mapstructure:"event_log_retention_days"`
// Deprecated: Use Proxy.InstallOnly instead. Kept for backward compatibility with old config files.
ProxyInstallOnly bool `mapstructure:"proxy_install_only"`
// Verbosity controls the UI verbosity level. Valid values: "silent", "normal", "verbose".
Verbosity string `mapstructure:"verbosity"`
// Sandbox enables sandboxing of package manager processes with controlled filesystem,
// network, and process execution access. Provides defense-in-depth against supply chain attacks.
Sandbox SandboxConfig `mapstructure:"sandbox"`
DependencyCooldown DependencyCooldownConfig `mapstructure:"dependency_cooldown"`
// AnalysisCache configures the optional cross-run cache of malware-analysis
// verdicts, so repeat installs of an already-screened dependency graph skip
// the per-package analysis round-trip.
AnalysisCache AnalysisCacheConfig `mapstructure:"analysis_cache"`
Cloud CloudConfig `mapstructure:"cloud"`
Proxy ProxyConfig `mapstructure:"proxy"`
}
// AnalysisCacheConfig is the umbrella for per-analyzer cross-run caches. Caching
// is analyzer-specific — each analyzer decides what is safe to cache — so config
// is nested per analyzer rather than shared. Today only the Malysis (malware)
// analyzer has a cache; future analyzers can add their own sub-config here.
type AnalysisCacheConfig struct {
// Malysis configures the cross-run cache for the Malysis malware analyzer.
Malysis MalysisCacheConfig `mapstructure:"malysis"`
}
// MalysisCacheConfig configures a persistent, cross-run cache of package
// malware-analysis verdicts produced by the Malysis analyzer.
//
// By default PMG keeps an in-memory analysis cache that lives only for the
// duration of a single invocation, so every install re-screens the whole
// resolved graph against the analysis backend. When Enabled, clean (ALLOW)
// verdicts are additionally persisted on disk and reused across runs, which
// makes repeat installs of an unchanged graph fast.
//
// Security trade-off: a version that was clean when first screened but is later
// flagged as malicious is served from cache (and thus allowed) until its entry
// expires; TTL bounds that exposure window. Only ALLOW verdicts are cached —
// suspicious, malicious, and tenant-excluded verdicts are always re-evaluated.
// Disabled by default.
type MalysisCacheConfig struct {
Enabled bool `mapstructure:"enabled"`
// TTL is how long a cached verdict remains valid. A non-positive TTL
// disables persistence (entries are always treated as a miss).
TTL time.Duration `mapstructure:"ttl"`
}
// CloudConfig configures audit event sync to SafeDep Cloud.
type CloudConfig struct {
Enabled bool `mapstructure:"enabled"`
EndpointID string `mapstructure:"endpoint_id"`
AutoSync CloudAutoSyncConfig `mapstructure:"auto_sync"`
}
// CloudAutoSyncConfig controls opportunistic background sync of the cloud
// audit WAL. When Enabled, PMG spawns a detached `pmg cloud sync-background`
// child at the end of each invocation, gated by a per-host cooldown so the
// sync does not fire on every command.
type CloudAutoSyncConfig struct {
Enabled bool `mapstructure:"enabled"`
MinInterval time.Duration `mapstructure:"min_interval"`
Timeout time.Duration `mapstructure:"timeout"`
}
type ProxyConfig struct {
InstallOnly bool `mapstructure:"install_only"`
SkipCommands map[string][]string `mapstructure:"skip_commands"`
Server ProxyServerConfig `mapstructure:"server"`
}
// ProxyServerConfig configures the persistent proxy server (`pmg proxy start`).
type ProxyServerConfig struct {
// ListenHost is the host the persistent proxy binds to. Defaults to
// 127.0.0.1 (loopback). Set to 0.0.0.0 or a specific interface only for a
// deliberately hosted deployment: a non-loopback bind exposes the MITM
// proxy to the network. The --host flag overrides this.
ListenHost string `mapstructure:"listen_host"`
// ListenPort is the port the persistent proxy binds to. 0 (default) means a
// random free port. The --port flag overrides this.
ListenPort int `mapstructure:"listen_port"`
// Transparent additionally accepts connections that were redirected to the
// proxy rather than addressed to it. A redirected client believes it reached
// the real registry, so it speaks TLS immediately instead of sending CONNECT,
// and its destination is recovered from the TLS SNI. Off by default: it is
// only useful alongside a redirect mechanism such as the Linux eBPF
// enforcement layer. The --transparent flag overrides this.
Transparent bool `mapstructure:"transparent"`
}
// SandboxConfig configures the sandbox system for isolating package manager processes.
type SandboxConfig struct {
// Enabled enables sandbox mode (opt-in by default for backward compatibility).
Enabled bool `mapstructure:"enabled"`
// EnforceAlways controls scope of sandbox enforcement:
// - When true: sandbox applies to all package manager commands
// - When false: sandbox only applies to install commands, others run unrestricted (default)
EnforceAlways bool `mapstructure:"enforce_always"`
// Policies maps package manager names to their sandbox policy references.
// Key is package manager name (e.g., "npm", "pip"), value is policy reference.
Policies map[string]SandboxPolicyRef `mapstructure:"policies"`
// PolicyTemplates maps template names to their paths.
PolicyTemplates map[string]SandboxPolicyTemplate `mapstructure:"policy_templates"`
}
// DependencyCooldownConfig blocks installation of package versions published within a
// configurable time window, reducing exposure to supply chain attacks.
type DependencyCooldownConfig struct {
Enabled bool `mapstructure:"enabled"`
Days int `mapstructure:"days"`
// Skip is a per-control skip list of packages exempt from the cooldown
// window. Unlike the top-level trusted_packages (which waives every PMG
// control — malware analysis, cooldown, and any future controls — and is
// already honored here), an entry on this list waives ONLY the cooldown
// wait, never malware analysis, so a fast-tracked package is still
// scanned. Intended for first-party / internal packages that must be
// installed immediately on release.
//
// Matching: a PURL without a version skips cooldown for ALL versions of the
// package (package-level); a PURL with a version skips cooldown for that
// version only (version-level).
Skip []TrustedPackage `mapstructure:"skip"`
}
// legacyProfileAliases maps old default profile names, keyed by package
// manager, to their per-PM leaf profiles. When npm-restrictive and
// pypi-restrictive became pure bases with no environment allows (and
// pnpm-restrictive was renamed to pnpm), existing config files kept the old
// mappings (config merge preserves user values), so the old defaults are
// re-mapped at read time.
var legacyProfileAliases = map[string]map[string]string{
"npm-restrictive": {
"npm": "npm",
"yarn": "yarn",
"bun": "bun",
},
"pnpm-restrictive": {
"pnpm": "pnpm",
},
"pypi-restrictive": {
"pip": "pip",
"pip3": "pip",
"pipx": "pipx",
"poetry": "poetry",
"uv": "uv",
"uvx": "uvx",
},
}
// PolicyFor returns the sandbox policy reference for a package manager,
// re-mapping legacy default profiles to their per-PM leaf profiles. The
// re-mapping is skipped when a policy template overrides the legacy name,
// since the user's custom template must keep winning as it did before the
// profile split.
func (s *SandboxConfig) PolicyFor(pmName string) (SandboxPolicyRef, bool) {
ref, exists := s.Policies[pmName]
if !exists {
return SandboxPolicyRef{}, false
}
if leaves, legacy := legacyProfileAliases[ref.Profile]; legacy {
if _, overridden := s.PolicyTemplates[ref.Profile]; !overridden {
if leaf, ok := leaves[pmName]; ok {
ref.Profile = leaf
}
}
}
return ref, true
}
// SandboxPolicyTemplate defines a template for a sandbox policy, used to map
// a profile name to a path.
type SandboxPolicyTemplate struct {
// Path is the path to the template file.
// Relative path can be used to reference a template file in the config directory (example: ./npm-restrictive.yml)
Path string `mapstructure:"path"`
}
// SandboxPolicyRef references a sandbox policy for a specific package manager.
type SandboxPolicyRef struct {
// Enabled enables sandboxing for this specific package manager.
Enabled bool `mapstructure:"enabled"`
// Profile is the name of a built-in profile (e.g., "npm-restrictive")
// or an absolute path to a custom YAML policy file.
Profile string `mapstructure:"profile"`
}
// TrustedPackage is a package that is trusted by the user and will be ignored by the security guardrails.
type TrustedPackage struct {
Purl string `mapstructure:"purl"`
Reason string `mapstructure:"reason"`
purlRef
}
// RuntimeConfig is the configuration that is used at runtime. It contains static configuration
// that can be loaded from a source and, if allowed, overridden by the user at runtime.
type RuntimeConfig struct {
Config Config
// DryRun enables dry-run mode for the package manager, where actual execution of commands is skipped.
DryRun bool
// InsecureInstallation allows bypassing install blocking on malicious packages
InsecureInstallation bool
// SandboxProfileOverride is a runtime override for the sandbox policy profile.
// When set, this profile path is used instead of the configured policy for all package managers.
// This is a CLI-only flag (--sandbox-profile) and is not persisted to config.yml.
SandboxProfileOverride string
// SandboxAllowOverrides holds runtime sandbox allow rules from --sandbox-allow flags.
// These are additive rules applied on top of the resolved sandbox policy.
// Not persisted to config.yml.
SandboxAllowOverrides []SandboxAllowOverride
// Internal config values computed at runtime and must be accessed via. API
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
sandboxOverlayDir string
sandboxPresetDir string
sandboxViolationCacheDir string
localDBDir string
cacheDir string
viper *viper.Viper
}
// CloudSyncDBPath returns the path to the cloud sync WAL database.
func (r *RuntimeConfig) CloudSyncDBPath() string {
return filepath.Join(r.configDir, "cloud-sync.db")
}
// CloudSyncLockPath returns the path to the cross-process lock file that
// serializes manual `pmg cloud sync` and the auto-sync background child.
func (r *RuntimeConfig) CloudSyncLockPath() string {
return filepath.Join(r.configDir, "cloud-sync.lock")
}
// CloudSyncLastRunPath returns the path to the timestamp file recording the
// last sync attempt (success or failure) in Unix epoch seconds.
func (r *RuntimeConfig) CloudSyncLastRunPath() string {
return filepath.Join(r.configDir, "cloud-sync.lastrun")
}
// ConfigFilePath returns the path to the active config file (the globally
// managed file when present, otherwise the per-user file).
func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath
}
// UserConfigFilePath returns the per-user config file path, regardless of
// whether a globally managed config is active.
func (r *RuntimeConfig) UserConfigFilePath() string {
return r.userConfigFilePath
}
// IsManaged reports whether the active config is the globally managed file.
// When true, the per-user file is ignored and config writes are refused. It is
// derived: the active path differs from the per-user path only when the global
// file was chosen.
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
}
// ConfigDir returns the path to the config directory.
func (r *RuntimeConfig) ConfigDir() string {
return r.configDir
}
// SandboxProfileDir returns the path to the user sandbox profile directory.
func (r *RuntimeConfig) SandboxProfileDir() string {
return r.sandboxProfileDir
}
// SandboxOverlayDir returns the path to the per-repo sandbox overlay directory.
func (r *RuntimeConfig) SandboxOverlayDir() string {
return r.sandboxOverlayDir
}
// SandboxPresetDir returns the path to the user sandbox preset directory.
func (r *RuntimeConfig) SandboxPresetDir() string {
return r.sandboxPresetDir
}
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
func (r *RuntimeConfig) SandboxViolationCacheDir() string {
return r.sandboxViolationCacheDir
}
// CacheDir returns the path to the PMG cache root directory. This follows the
// platform cache convention (XDG cache dir on Linux, ~/Library/Caches on macOS,
// %LOCALAPPDATA% on Windows) and is overridable via PMG_CACHE_DIR. Caching
// layers (e.g. the analysis cache) should store regenerable data here rather
// than under the config directory.
func (r *RuntimeConfig) CacheDir() string {
return r.cacheDir
}
// LocalDBDir returns the directory holding PMG's shared localdb SQLite file.
// localdb writes sibling -wal/-shm files here, so the Dir and FileName are
// exposed separately to match localdb.Config rather than as a joined path.
func (r *RuntimeConfig) LocalDBDir() string {
return r.localDBDir
}
// LocalDBFileName returns the file name of PMG's shared localdb SQLite file.
func (r *RuntimeConfig) LocalDBFileName() string {
return pmgDefaultLocalDBFileName
}
// SandboxAllowType represents the type of a sandbox allow override.
type SandboxAllowType string
const (
SandboxAllowRead SandboxAllowType = "read"
SandboxAllowWrite SandboxAllowType = "write"
SandboxAllowExec SandboxAllowType = "exec"
SandboxAllowNetConnect SandboxAllowType = "net-connect"
SandboxAllowNetBind SandboxAllowType = "net-bind"
SandboxAllowEnv SandboxAllowType = "env"
SandboxAllowPreset SandboxAllowType = "preset"
)
// SandboxAllowOverride represents a single --sandbox-allow flag value.
type SandboxAllowOverride struct {
// Type is the resource type (read, write, exec, net-connect, net-bind,
// env, preset).
Type SandboxAllowType
// Value is the resolved value (absolute path, host:port, etc.).
Value string
// Raw is the original CLI value before resolution (for logging/warnings).
Raw string
}
// DefaultConfig is a fail safe contract for the runtime configuration.
// 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 := utils.EnvBool(pmgInsecureInstallationEnvKey, false)
return RuntimeConfig{
Config: Config{
Paranoid: false,
DisableTelemetry: false,
EventLogRetentionDays: 7,
SkipEventLogging: false,
TrustedPackages: []TrustedPackage{},
AdvisoryMessage: "",
Verbosity: VerbosityNormal,
Sandbox: SandboxConfig{
Enabled: false,
EnforceAlways: false,
},
DependencyCooldown: DependencyCooldownConfig{
Enabled: true,
Days: 5,
},
AnalysisCache: AnalysisCacheConfig{
Malysis: MalysisCacheConfig{
Enabled: false,
TTL: 24 * time.Hour,
},
},
Cloud: CloudConfig{
Enabled: false,
AutoSync: CloudAutoSyncConfig{
Enabled: true,
MinInterval: 15 * time.Minute,
Timeout: 5 * time.Minute,
},
},
Proxy: ProxyConfig{
InstallOnly: false,
SkipCommands: map[string][]string{},
Server: ProxyServerConfig{
ListenHost: "127.0.0.1",
},
},
},
DryRun: false,
InsecureInstallation: insecureInstallation,
}
}
// globalConfig is the global configuration for PMG.
// It is initialized in the init function and can be overridden by a repository.
var globalConfig *RuntimeConfig
func init() {
initConfig()
}
// Reload re-runs the initialization that runs at package init. Tests that
// mutate PMG_CONFIG_DIR via t.Setenv must call this so the resolved config
// directory reflects the new env, instead of the value computed when the
// package was first loaded.
func Reload() {
initConfig()
}
// initConfig should be idempotent and can be called multiple times.
// This is required for testing purposes.
func initConfig() {
defaultConfig := DefaultConfig()
globalConfig = &defaultConfig
configDir, err := configDir()
if err != nil {
panic(fmt.Errorf("failed to get config directory: %w", err))
}
activeConfigPath, err := resolveConfigFile()
if err != nil {
panic(fmt.Errorf("failed to resolve config file path: %w", err))
}
userConfigPath, err := userConfigFilePath()
if err != nil {
panic(fmt.Errorf("failed to get user config file path: %w", err))
}
eventLogDir, err := eventLogDir()
if err != nil {
panic(fmt.Errorf("failed to get event log directory: %w", err))
}
sandboxProfileDir, err := sandboxProfileDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox profile directory: %w", err))
}
sandboxViolationCacheDir, err := sandboxViolationCacheDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err))
}
sandboxOverlayDir, err := sandboxOverlayDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox overlay directory: %w", err))
}
sandboxPresetDir, err := sandboxPresetDir()
if err != nil {
panic(fmt.Errorf("failed to get sandbox preset directory: %w", err))
}
cacheRootDir, err := cacheDir()
if err != nil {
panic(fmt.Errorf("failed to get cache directory: %w", err))
}
localDBDir, err := localDBDir()
if err != nil {
panic(fmt.Errorf("failed to get localdb directory: %w", err))
}
globalConfig.configDir = configDir
globalConfig.configFilePath = activeConfigPath
globalConfig.userConfigFilePath = userConfigPath
globalConfig.eventLogDir = eventLogDir
globalConfig.sandboxProfileDir = sandboxProfileDir
globalConfig.sandboxOverlayDir = sandboxOverlayDir
globalConfig.sandboxPresetDir = sandboxPresetDir
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
globalConfig.localDBDir = localDBDir
globalConfig.cacheDir = cacheRootDir
// 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 := preprocessPackageRefs(&globalConfig.Config); err != nil {
log.Warnf("Failed to preprocess package refs: %v", err)
}
}
// loadConfig loads the configuration from the config file.
// This is where we determine the source of config and use the appropriate loader.
// Right now we only support loading from a config file using Viper. If loading
// fails, the default configuration is used and a warning is logged.
func loadConfig() {
if err := loadViperConfig(); err != nil {
log.Warnf("Failed to load config, using defaults: %v", err)
}
}
// configGeteuid is overridable in tests to exercise root path resolution
// without running as root.
var configGeteuid = os.Geteuid
// rootHomeDir returns root's home from the passwd database. Path resolution
// for root must not consult HOME or XDG_*: sudo and su can preserve the
// invoking user's environment (GitHub runners, sudo -E, su without -), which
// would make root create root-owned state inside that user's home and
// fail-close every later non-root pmg run for them.
func rootHomeDir() (string, error) {
u, err := user.LookupId("0")
if err != nil {
return "", fmt.Errorf("failed to resolve root home directory: %w", err)
}
if u.HomeDir == "" {
return "", fmt.Errorf("root user has no home directory")
}
return u.HomeDir, nil
}
// rootConfigDir mirrors os.UserConfigDir platform conventions for root's
// passwd home.
func rootConfigDir() (string, error) {
home, err := rootHomeDir()
if err != nil {
return "", err
}
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Application Support"), nil
}
return filepath.Join(home, ".config"), nil
}
// rootCacheDir mirrors os.UserCacheDir platform conventions for root's
// passwd home.
func rootCacheDir() (string, error) {
home, err := rootHomeDir()
if err != nil {
return "", err
}
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "Caches"), nil
}
return filepath.Join(home, ".cache"), nil
}
// Overridable in tests to exercise the passwd-unavailable fallback.
var (
rootConfigDirResolver = rootConfigDir
rootCacheDirResolver = rootCacheDir
)
// currentUserHomeDir returns the current user's home from the passwd
// database, ignoring HOME and XDG_* env vars that may be leaked from another
// account. Overridable in tests.
var currentUserHomeDir = func() (string, error) {
u, err := user.Current()
if err != nil {
return "", err
}
if u.HomeDir == "" {
return "", fmt.Errorf("user %s has no home directory in the passwd database", u.Username)
}
return u.HomeDir, nil
}
// unwritableDirCause is why the current user cannot write a per-user
// directory. The remedy must match the cause: chown-ing a directory that
// belongs to another account steals it and breaks that account, so chown is
// only safe for a directory inside the current user's passwd home, which a
// leaked environment cannot influence.
type unwritableDirCause int
const (
// causeExplicitConfigDir: PMG_CONFIG_DIR selected the directory.
causeExplicitConfigDir unwritableDirCause = iota
// causeLeakedHomeEnv: the directory is outside the current user's passwd
// home, so HOME or XDG_CONFIG_HOME leaked from another account (e.g.
// sudo -u on hosts that preserve the environment).
causeLeakedHomeEnv
// causeRootCreatedDir: the directory is inside the user's own home; a
// root or sudo run most likely created it root-owned.
causeRootCreatedDir
)
func classifyUnwritableDir(dir string) unwritableDirCause {
if os.Getenv(pmgConfigDirEnvKey) != "" {
return causeExplicitConfigDir
}
home, err := currentUserHomeDir()
if err == nil && !fsutil.PathWithinDir(dir, home) {
return causeLeakedHomeEnv
}
return causeRootCreatedDir
}
// UnwritableConfigDirRemedy renders the remedy for a per-user config or
// event-log directory the current user cannot write: help is the full
// explanation for fatal CLI errors, fix the terse variant for the doctor
// table. Cause diagnosis lives in classifyUnwritableDir.
func UnwritableConfigDirRemedy(dir string) (help, fix string) {
switch classifyUnwritableDir(dir) {
case causeExplicitConfigDir:
return fmt.Sprintf("PMG_CONFIG_DIR points at %s; make it writable by your user", dir),
"Make PMG_CONFIG_DIR writable"
case causeLeakedHomeEnv:
return fmt.Sprintf(
"pmg resolved its config directory to %s, outside your home: HOME or XDG_CONFIG_HOME leaked from another account (e.g. sudo -u). Fix the environment, e.g. export XDG_CONFIG_HOME=\"$HOME/.config\"",
dir),
`Fix leaked env: export XDG_CONFIG_HOME="$HOME/.config"`
default:
chown := fmt.Sprintf("sudo chown -R $(id -un) %s", dir)
return fmt.Sprintf("If a root or sudo run created it, restore ownership: %s", chown), chown
}
}
// isSudoElevation reports whether pmg is running as root via sudo, i.e. a
// non-root user elevated and sudo may have preserved that user's HOME/XDG_*.
// Only then do per-user paths divert to root's own home, so root does not
// create state inside the invoking user's home. Running genuinely as root
// (no sudo) keeps honoring HOME/XDG_*, which is legitimate and intended (e.g.
// golden Docker images that set HOME/XDG_CONFIG_HOME on purpose). This mirrors
// the SUDO_USER guard used elsewhere (cmd/setup/cert.go). su without sudo does
// not set SUDO_USER and is not covered; the unwritable-dir remedy still guides
// the user if such a run poisons a directory.
func isSudoElevation() bool {
return configGeteuid() == 0 && os.Getenv("SUDO_USER") != ""
}
// configDir computes the path to the config directory.
func configDir() (string, error) {
dir := os.Getenv(pmgConfigDirEnvKey)
if dir != "" {
return dir, nil
}
if isSudoElevation() {
if base, err := rootConfigDirResolver(); err == nil {
return filepath.Join(base, pmgDefaultHomeRelativePath), nil
} else {
// No resolvable root passwd entry (e.g. scratch containers,
// minimal chroots). Fall back to env-based resolution: without a
// passwd database there is no user switching, so the cross-user
// poisoning this branch prevents cannot occur.
log.Warnf("failed to resolve root home for config dir, using environment: %v", err)
}
}
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to retrieve user config directory: %w", err)
}
return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath), nil
}
// userConfigFilePath computes the path to the per-user config file.
func userConfigFilePath() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgConfigFileName), nil
}
// globalConfigDirOverride replaces the OS-level managed config directory. It
// exists only for tests within this package. There is intentionally no env var
// or flag for it, so a user cannot point the "managed" config at their own file
// and bypass the globally managed config.
var globalConfigDirOverride string
// globalConfigDir returns the OS-level directory for a globally managed config
// file, or "" when the platform has no such location.
func globalConfigDir() string {
if globalConfigDirOverride != "" {
return globalConfigDirOverride
}
switch runtime.GOOS {
case "darwin":
return "/Library/Application Support/safedep/pmg"
case "linux":
return "/etc/safedep/pmg"
case "windows":
programData := os.Getenv("PROGRAMDATA")
if programData == "" {
programData = `C:\ProgramData`
}
return filepath.Join(programData, "safedep", "pmg")
}
return ""
}
// globalConfigFilePath returns the path to the globally managed config file, or
// "" when the platform has no global config location.
func globalConfigFilePath() string {
dir := globalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, pmgConfigFileName)
}
// resolveConfigFile picks the active config file. The globally managed file,
// when present, is authoritative and the per-user file is ignored entirely.
func resolveConfigFile() (string, error) {
if global := globalConfigFilePath(); global != "" && isRegularFile(global) {
return global, nil
}
return userConfigFilePath()
}
func isRegularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}
// eventLogDir computes the path to the event log directory.
func eventLogDir() (string, error) {
// For rationale on why different directory for Windows, see:
// https://github.com/safedep/pmg/pull/82#discussion_r2636746036
switch runtime.GOOS {
case "windows":
// Windows: %LOCALAPPDATA%\safedep\pmg\logs or %USERPROFILE%\safedep\pmg\logs
baseDir := os.Getenv("LOCALAPPDATA")
if baseDir == "" {
baseDir = os.Getenv("USERPROFILE")
if baseDir == "" {
return "", fmt.Errorf("could not determine Windows user directory for event log storage")
}
}
return filepath.Join(baseDir, pmgDefaultHomeRelativePath, pmgDefaultLogDir), nil
case "darwin", "linux":
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultLogDir), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// cacheDir computes the path to the cache root directory.
func cacheDir() (string, error) {
dir := os.Getenv(pmgCacheDirEnvKey)
if dir != "" {
return dir, nil
}
switch runtime.GOOS {
case "windows":
// Windows: %LOCALAPPDATA%\safedep\pmg or %USERPROFILE%\safedep\pmg
baseDir := os.Getenv("LOCALAPPDATA")
if baseDir == "" {
baseDir = os.Getenv("USERPROFILE")
if baseDir == "" {
return "", fmt.Errorf("could not determine Windows user directory for cache storage")
}
}
return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil
case "darwin", "linux":
if isSudoElevation() {
if base, err := rootCacheDirResolver(); err == nil {
return filepath.Join(base, pmgDefaultHomeRelativePath), nil
} else {
// Same fallback rationale as configDir.
log.Warnf("failed to resolve root home for cache dir, using environment: %v", err)
}
}
userCacheDir, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("failed to retrieve user cache directory: %w", err)
}
return filepath.Join(userCacheDir, pmgDefaultHomeRelativePath), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// sandboxProfileDir computes the path to the sandbox profile directory.
func sandboxProfileDir() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil
}
// sandboxOverlayDir computes the path to the per-repo sandbox overlay directory.
func sandboxOverlayDir() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultSandboxOverlayDir), nil
}
// sandboxPresetDir computes the path to the user sandbox preset directory.
func sandboxPresetDir() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, pmgDefaultSandboxPresetDir), nil
}
// sandboxViolationCacheDir computes the path to the sandbox violation cache directory.
func sandboxViolationCacheDir() (string, error) {
cacheDir, err := cacheDir()
if err != nil {
return "", fmt.Errorf("failed to get cache directory: %w", err)
}
return filepath.Join(cacheDir, pmgDefaultSandboxViolationCacheDir), nil
}
// localDBDir computes the directory holding PMG's shared localdb SQLite file
// and its WAL/shm siblings.
func localDBDir() (string, error) {
cacheDir, err := cacheDir()
if err != nil {
return "", fmt.Errorf("failed to get cache directory: %w", err)
}
return filepath.Join(cacheDir, pmgDefaultLocalDBDir), nil
}
// Get returns the global configuration.
// This is the public API for the configuration package. This package should guarantee
// that this function will never return nil.
func Get() *RuntimeConfig {
return globalConfig
}
// AdvisoryMessage returns the org-configured advisory message appended to
// policy block output. Empty when not configured.
func AdvisoryMessage() string {
return globalConfig.Config.AdvisoryMessage
}
func ConfigureSandbox(mayDownloadPackages bool) {
if globalConfig.Config.Sandbox.Enabled {
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
// commands that may download packages (install, update, etc.)
globalConfig.Config.Sandbox.Enabled = globalConfig.Config.Sandbox.EnforceAlways || mayDownloadPackages
}
}
// WriteTemplateConfig writes the template configuration file to disk.
// If the config file does not exist, the full template is written.
// If it already exists, missing keys from the template are merged
// into the existing config while preserving all user values and comments.
//
// When a globally managed config is active, this is a no-op: the per-user
// file is ignored at load time, so creating it would only mislead.
func WriteTemplateConfig() error {
if globalConfig.IsManaged() {
return nil
}
configFilePath, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
return writeTemplateConfigFile(configFilePath)
}
// RemoveUserConfigFile deletes the per-user config file. It never touches the
// globally managed file. A missing file is not an error.
func RemoveUserConfigFile() error {
path, err := userConfigFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
return removeFileIfExists(path)
}
// WriteSystemTemplateConfig writes the template configuration to the OS-level
// managed config path (e.g. /etc/safedep/pmg/config.yml on Linux). Used by
// `pmg setup install --system`.
func WriteSystemTemplateConfig() error {
path := globalConfigFilePath()
if path == "" {
return fmt.Errorf("system config is not supported on %s", runtime.GOOS)
}
// MkdirAll and WriteFile honor the process umask, so a hardened root
// umask (e.g. 077) would otherwise leave the managed config unreadable
// by non-root users — silently disabling the system-wide policy for
// them. Only directories created here and the file we write are touched;
// pre-existing directories keep their permissions.
if err := fsutil.MkdirAllRootOwned(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := writeTemplateConfigFile(path); err != nil {
return err
}
return fsutil.ForceRootOwned(path, 0o644)
}
// RemoveSystemConfigFile deletes the globally managed config file. A missing
// file is not an error. Returns an error when the platform has no system path.
func RemoveSystemConfigFile() error {
path := globalConfigFilePath()
if path == "" {
return fmt.Errorf("system config is not supported on %s", runtime.GOOS)
}
return removeFileIfExists(path)
}
// SystemConfigDir returns the OS-level managed config directory, or "" when
// unsupported.
func SystemConfigDir() string {
return globalConfigDir()
}
func writeTemplateConfigFile(configFilePath string) error {
if err := os.MkdirAll(filepath.Dir(configFilePath), 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
existingConfig, err := os.ReadFile(configFilePath)
if os.IsNotExist(err) {
return os.WriteFile(configFilePath, []byte(templateConfig), 0o644)
}
if err != nil {
return fmt.Errorf("failed to read existing config: %w", err)
}
merged, err := utils.MergeYAML(existingConfig, []byte(templateConfig))
if err != nil {
return fmt.Errorf("failed to merge config: %w", err)
}
if err := os.WriteFile(configFilePath, merged, 0o644); err != nil {
return fmt.Errorf("failed to write merged config: %w", err)
}
return nil
}
func removeFileIfExists(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove config file %q: %w", path, err)
}
return nil
}
// NewManagedConfigError returns the error shown when a user tries to change a
// 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 {
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.NewUsefulError().
WithCode(errcodes.PermissionDenied).
WithHumanError(message).
WithHelp("This machine's PMG configuration is centrally managed. Contact your administrator to change it.").
Wrap(errors.New(message))
}