Files
pmg/config/config.go
T
Sahil BansalandGitHub 736c63a7b6 Sandbox fails for unsupported cmds (#129)
* introduce enforce_always sandbox config & hooks for flows

* fix sandbox failing for unsupported commands

* add hooks for pypi package managers

* add tests for sandbox hook

* introduce enforce_always flag for ease use & CI

* make comments descriptive

* remove hooks & update config to add API to configure sandbox

* add comments

* rm unused function
2026-01-19 22:24:59 +05:30

328 lines
11 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
_ "embed"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/safedep/dry/log"
)
const (
// Environment variable key for the insecure installation flag
PMG_INSECURE_INSTALLATION_ENV_KEY = "PMG_INSECURE_INSTALLATION"
// Allow overriding the config path from the environment
CONFIG_DIR_ENV_KEY = "PMG_CONFIG_DIR"
// Config path is computed as the user config directory + the default relative path
// when not overridden by the environment variable
CONFIG_DEFAULT_HOME_RELATIVE_PATH = "safedep/pmg"
// Default log directory is relative to the config directory.
CONFIG_DEFAULT_LOG_DIR = "logs"
// 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.
CONFIG_FILE_NAME = "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 {
Transitive bool `mapstructure:"transitive"`
TransitiveDepth int `mapstructure:"transitive_depth"`
IncludeDevDependencies bool `mapstructure:"include_dev_dependencies"`
// Paranoid mode enables active scanning of unknown packages for malware.
Paranoid bool `mapstructure:"paranoid"`
// TrustedPackages allows for trusting a suspicious package and ignoring the suspicious behaviour for the package in future installations
TrustedPackages []TrustedPackage `mapstructure:"trusted_packages"`
// 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"`
// ExperimentalProxyMode enables experimental proxy-based package interception.
// When enabled, PMG starts a proxy server and intercepts package manager requests in real-time.
ExperimentalProxyMode bool `mapstructure:"experimental_proxy_mode"`
// 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"`
}
// 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"`
}
// 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"`
// Pre-parsed PURL components (not serialized, computed at load time)
// These fields avoid repeated PURL parsing on every IsTrustedPackage() call
parsed bool
ecosystem packagev1.Ecosystem
name string
version string
}
// 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
// Internal config values computed at runtime and must be accessed via. API
configDir string
configFilePath string
eventLogDir string
}
// ConfigFilePath returns the path to the config file.
func (r *RuntimeConfig) ConfigFilePath() string {
return r.configFilePath
}
// 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
}
// 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 := false
if val := os.Getenv(PMG_INSECURE_INSTALLATION_ENV_KEY); val != "" {
if boolVal, err := strconv.ParseBool(val); err == nil {
insecureInstallation = boolVal
}
}
return RuntimeConfig{
Config: Config{
Transitive: true,
TransitiveDepth: 5,
IncludeDevDependencies: false,
Paranoid: false,
EventLogRetentionDays: 7,
SkipEventLogging: false,
ExperimentalProxyMode: false,
TrustedPackages: []TrustedPackage{},
Sandbox: SandboxConfig{
Enabled: false,
EnforceAlways: false,
},
},
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()
}
// 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))
}
configFilePath, err := configFilePath()
if err != nil {
panic(fmt.Errorf("failed to get config file path: %w", err))
}
eventLogDir, err := eventLogDir()
if err != nil {
panic(fmt.Errorf("failed to get event log directory: %w", err))
}
globalConfig.configDir = configDir
globalConfig.configFilePath = configFilePath
globalConfig.eventLogDir = eventLogDir
loadConfig()
if err := preprocessTrustedPackages(&globalConfig.Config); err != nil {
log.Warnf("Failed to preprocess trusted packages: %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. All loader
// functions should be safe with reasonable defaults and panic only in case of system errors.
func loadConfig() {
loadViperConfig()
}
// configDir computes the path to the config directory.
func configDir() (string, error) {
dir := os.Getenv(CONFIG_DIR_ENV_KEY)
if dir != "" {
return dir, nil
}
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to retrieve user config directory: %w", err)
}
return filepath.Join(userConfigDir, CONFIG_DEFAULT_HOME_RELATIVE_PATH), nil
}
// configFilePath computes the path to the config file.
func configFilePath() (string, error) {
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, CONFIG_FILE_NAME), nil
}
// 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, CONFIG_DEFAULT_HOME_RELATIVE_PATH, CONFIG_DEFAULT_LOG_DIR), nil
case "darwin", "linux":
configDir, err := configDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
}
return filepath.Join(configDir, CONFIG_DEFAULT_LOG_DIR), nil
default:
return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
}
// 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
}
func ConfigureSandbox(isInstallationCommand bool) {
if globalConfig.Config.Sandbox.Enabled {
// Apply sandbox to all commands if EnforceAlways=true, otherwise only to
// installation commands else disable the sandbox
globalConfig.Config.Sandbox.Enabled = globalConfig.Config.Sandbox.EnforceAlways || isInstallationCommand
}
}
// WriteTemplateConfig writes the template configuration file to disk if it doesn't already exist.
func WriteTemplateConfig() error {
configDir, err := configDir()
if err != nil {
return fmt.Errorf("failed to get config directory: %w", err)
}
if err := os.MkdirAll(configDir, 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
configFilePath, err := configFilePath()
if err != nil {
return fmt.Errorf("failed to get config file path: %w", err)
}
// Do not overwrite the config file if it already exists
if _, err := os.Stat(configFilePath); err == nil {
return nil
}
if err := os.WriteFile(configFilePath, []byte(templateConfig), 0o644); err != nil {
return fmt.Errorf("failed to write template config: %w", err)
}
return nil
}