/* Package config provides a way to find and load SOPS configuration files */ package config //import "github.com/getsops/sops/v3/config" import ( "fmt" "os" "path" "path/filepath" "regexp" "strings" "github.com/getsops/sops/v3" "github.com/getsops/sops/v3/age" "github.com/getsops/sops/v3/azkv" "github.com/getsops/sops/v3/gcpkms" "github.com/getsops/sops/v3/hckms" "github.com/getsops/sops/v3/hcvault" "github.com/getsops/sops/v3/kms" "github.com/getsops/sops/v3/pgp" "github.com/getsops/sops/v3/publish" "go.yaml.in/yaml/v3" ) type fileSystem interface { Stat(name string) (os.FileInfo, error) } type osFS struct { stat func(string) (os.FileInfo, error) } func (fs osFS) Stat(name string) (os.FileInfo, error) { return fs.stat(name) } var fs fileSystem = osFS{stat: os.Stat} const ( maxDepth = 100 configFileName = ".sops.yaml" alternateConfigName = ".sops.yml" ) // ConfigFileResult contains the path to a config file and any warnings type ConfigFileResult struct { Path string Warning string } // LookupConfigFile looks for a sops config file in the current working directory // and on parent directories, up to the maxDepth limit. // It returns a result containing the file path and any warnings. func LookupConfigFile(start string) (ConfigFileResult, error) { filepath := path.Dir(start) var foundAlternatePath string for i := 0; i < maxDepth; i++ { configPath := path.Join(filepath, configFileName) _, err := fs.Stat(configPath) if err == nil { result := ConfigFileResult{Path: configPath} if foundAlternatePath != "" { result.Warning = fmt.Sprintf( "ignoring %q when searching for config file; the config file must be called %q; using %q instead", foundAlternatePath, configFileName, configPath) } return result, nil } // Check for alternate filename if we haven't found one yet if foundAlternatePath == "" { alternatePath := path.Join(filepath, alternateConfigName) _, altErr := fs.Stat(alternatePath) if altErr == nil { foundAlternatePath = alternatePath } } filepath = path.Join(filepath, "..") } // No config file found result := ConfigFileResult{} if foundAlternatePath != "" { result.Warning = fmt.Sprintf( "ignoring %q when searching for config file; the config file must be called %q", foundAlternatePath, configFileName) } return result, fmt.Errorf("config file not found") } // FindConfigFile looks for a sops config file in the current working directory and on parent directories, up to the limit defined by the maxDepth constant. func FindConfigFile(start string) (string, error) { result, err := LookupConfigFile(start) return result.Path, err } type DotenvStoreConfig struct{} type INIStoreConfig struct{} type JSONStoreConfig struct { Indent int `yaml:"indent"` } type JSONBinaryStoreConfig struct { Indent int `yaml:"indent"` } type YAMLStoreConfig struct { Indent int `yaml:"indent"` } type StoresConfig struct { Dotenv DotenvStoreConfig `yaml:"dotenv"` INI INIStoreConfig `yaml:"ini"` JSONBinary JSONBinaryStoreConfig `yaml:"json_binary"` JSON JSONStoreConfig `yaml:"json"` YAML YAMLStoreConfig `yaml:"yaml"` } type configFile struct { CreationRules []creationRule `yaml:"creation_rules"` DestinationRules []destinationRule `yaml:"destination_rules"` Stores StoresConfig `yaml:"stores"` } type keyGroup struct { Merge []keyGroup `yaml:"merge"` KMS []kmsKey `yaml:"kms"` GCPKMS []gcpKmsKey `yaml:"gcp_kms"` HCKms []hckmsKey `yaml:"hckms"` AzureKV []azureKVKey `yaml:"azure_keyvault"` Vault []string `yaml:"hc_vault"` Age []string `yaml:"age"` PGP []string `yaml:"pgp"` } type gcpKmsKey struct { ResourceID string `yaml:"resource_id"` } type kmsKey struct { Arn string `yaml:"arn"` Role string `yaml:"role,omitempty"` Context map[string]*string `yaml:"context"` AwsProfile string `yaml:"aws_profile"` } type azureKVKey struct { VaultURL string `yaml:"vaultUrl"` Key string `yaml:"key"` Version string `yaml:"version"` } type hckmsKey struct { KeyID string `yaml:"key_id"` } type destinationRule struct { PathRegex string `yaml:"path_regex"` S3Bucket string `yaml:"s3_bucket"` S3Prefix string `yaml:"s3_prefix"` GCSBucket string `yaml:"gcs_bucket"` GCSPrefix string `yaml:"gcs_prefix"` VaultPath string `yaml:"vault_path"` VaultAddress string `yaml:"vault_address"` VaultKVMountName string `yaml:"vault_kv_mount_name"` VaultKVVersion int `yaml:"vault_kv_version"` RecreationRule creationRule `yaml:"recreation_rule,omitempty"` OmitExtensions bool `yaml:"omit_extensions"` } type creationRule struct { PathRegex string `yaml:"path_regex"` KMS interface{} `yaml:"kms"` // string or []string AwsProfile string `yaml:"aws_profile"` Age interface{} `yaml:"age"` // string or []string PGP interface{} `yaml:"pgp"` // string or []string GCPKMS interface{} `yaml:"gcp_kms"` // string or []string HCKms []string `yaml:"hckms"` AzureKeyVault interface{} `yaml:"azure_keyvault"` // string or []string VaultURI interface{} `yaml:"hc_vault_transit_uri"` // string or []string KeyGroups []keyGroup `yaml:"key_groups"` ShamirThreshold int `yaml:"shamir_threshold"` UnencryptedSuffix string `yaml:"unencrypted_suffix"` EncryptedSuffix string `yaml:"encrypted_suffix"` UnencryptedRegex string `yaml:"unencrypted_regex"` EncryptedRegex string `yaml:"encrypted_regex"` UnencryptedCommentRegex string `yaml:"unencrypted_comment_regex"` EncryptedCommentRegex string `yaml:"encrypted_comment_regex"` MACOnlyEncrypted bool `yaml:"mac_only_encrypted"` } // Helper methods to safely extract keys as []string func (c *creationRule) GetKMSKeys() ([]string, error) { return parseKeyField(c.KMS, "kms") } func (c *creationRule) GetAgeKeys() ([]string, error) { return parseKeyField(c.Age, "age") } func (c *creationRule) GetPGPKeys() ([]string, error) { return parseKeyField(c.PGP, "pgp") } func (c *creationRule) GetGCPKMSKeys() ([]string, error) { return parseKeyField(c.GCPKMS, "gcp_kms") } func (c *creationRule) GetAzureKeyVaultKeys() ([]string, error) { return parseKeyField(c.AzureKeyVault, "azure_keyvault") } func (c *creationRule) GetVaultURIs() ([]string, error) { return parseKeyField(c.VaultURI, "hc_vault_transit_uri") } // Utility function to handle both string and []string func parseKeyField(field interface{}, fieldName string) ([]string, error) { if field == nil { return []string{}, nil } switch v := field.(type) { case string: if v == "" { return []string{}, nil } // Existing CSV parsing logic keys := strings.Split(v, ",") result := make([]string, 0, len(keys)) for _, key := range keys { trimmed := strings.TrimSpace(key) if trimmed != "" { // Skip empty strings (fixes trailing comma issue) result = append(result, trimmed) } } return result, nil case []interface{}: result := make([]string, len(v)) for i, item := range v { if str, ok := item.(string); ok { result[i] = str } else { return nil, fmt.Errorf("invalid %s key configuration: expected string in list, got %T", fieldName, item) } } return result, nil case []string: return v, nil default: return nil, fmt.Errorf("invalid %s key configuration: expected string, []string, or nil, got %T", fieldName, field) } } func NewStoresConfig() *StoresConfig { storesConfig := &StoresConfig{} storesConfig.JSON.Indent = -1 storesConfig.JSONBinary.Indent = -1 return storesConfig } // Load loads a sops config file into a temporary struct func (f *configFile) load(bytes []byte) error { err := yaml.Unmarshal(bytes, f) if err != nil { return fmt.Errorf("Could not unmarshal config file: %s", err) } return nil } // Config is the configuration for a given SOPS file type Config struct { KeyGroups []sops.KeyGroup ShamirThreshold int UnencryptedSuffix string EncryptedSuffix string UnencryptedRegex string EncryptedRegex string UnencryptedCommentRegex string EncryptedCommentRegex string MACOnlyEncrypted bool Destination publish.Destination OmitExtensions bool } func deduplicateKeygroup(group sops.KeyGroup) sops.KeyGroup { var deduplicatedKeygroup sops.KeyGroup unique := make(map[string]bool) for _, v := range group { key := fmt.Sprintf("%T/%v", v, v.ToString()) if _, ok := unique[key]; ok { // key already contained, therefore not unique continue } deduplicatedKeygroup = append(deduplicatedKeygroup, v) unique[key] = true } return deduplicatedKeygroup } func extractMasterKeys(group keyGroup) (sops.KeyGroup, error) { var keyGroup sops.KeyGroup for _, k := range group.Merge { subKeyGroup, err := extractMasterKeys(k) if err != nil { return nil, err } keyGroup = append(keyGroup, subKeyGroup...) } for _, k := range group.Age { keys, err := age.MasterKeysFromRecipients(k) if err != nil { return nil, err } for _, key := range keys { keyGroup = append(keyGroup, key) } } for _, k := range group.PGP { keyGroup = append(keyGroup, pgp.NewMasterKeyFromFingerprint(k)) } for _, k := range group.KMS { keyGroup = append(keyGroup, kms.NewMasterKeyWithProfile(k.Arn, k.Role, k.Context, k.AwsProfile)) } for _, k := range group.GCPKMS { keyGroup = append(keyGroup, gcpkms.NewMasterKeyFromResourceID(k.ResourceID)) } for _, k := range group.HCKms { key, err := hckms.NewMasterKey(k.KeyID) if err != nil { return nil, err } keyGroup = append(keyGroup, key) } for _, k := range group.AzureKV { if key, err := azkv.NewMasterKeyWithOptionalVersion(k.VaultURL, k.Key, k.Version); err == nil { keyGroup = append(keyGroup, key) } else { return nil, err } } for _, k := range group.Vault { if masterKey, err := hcvault.NewMasterKeyFromURI(k); err == nil { keyGroup = append(keyGroup, masterKey) } else { return nil, err } } return deduplicateKeygroup(keyGroup), nil } func getKeysWithValidation(getKeysFunc func() ([]string, error), keyType string) ([]string, error) { keys, err := getKeysFunc() if err != nil { return nil, fmt.Errorf("invalid %s key configuration: %w", keyType, err) } return keys, nil } func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[string]*string) ([]sops.KeyGroup, error) { var groups []sops.KeyGroup if len(cRule.KeyGroups) > 0 { for _, group := range cRule.KeyGroups { keyGroup, err := extractMasterKeys(group) if err != nil { return nil, err } groups = append(groups, keyGroup) } } else { var keyGroup sops.KeyGroup ageKeys, err := getKeysWithValidation(cRule.GetAgeKeys, "age") if err != nil { return nil, err } if len(ageKeys) > 0 { ageKeys, err := age.MasterKeysFromRecipients(strings.Join(ageKeys, ",")) if err != nil { return nil, err } else { for _, ak := range ageKeys { keyGroup = append(keyGroup, ak) } } } pgpKeys, err := getKeysWithValidation(cRule.GetPGPKeys, "pgp") if err != nil { return nil, err } for _, k := range pgp.MasterKeysFromFingerprintString(strings.Join(pgpKeys, ",")) { keyGroup = append(keyGroup, k) } kmsKeys, err := getKeysWithValidation(cRule.GetKMSKeys, "kms") if err != nil { return nil, err } for _, k := range kms.MasterKeysFromArnString(strings.Join(kmsKeys, ","), kmsEncryptionContext, cRule.AwsProfile) { keyGroup = append(keyGroup, k) } gcpkmsKeys, err := getKeysWithValidation(cRule.GetGCPKMSKeys, "gcpkms") if err != nil { return nil, err } for _, k := range gcpkms.MasterKeysFromResourceIDString(strings.Join(gcpkmsKeys, ",")) { keyGroup = append(keyGroup, k) } hckmsMasterKeys, err := hckms.NewMasterKeyFromKeyIDString(strings.Join(cRule.HCKms, ",")) if err != nil { return nil, err } for _, k := range hckmsMasterKeys { keyGroup = append(keyGroup, k) } azKeys, err := getKeysWithValidation(cRule.GetAzureKeyVaultKeys, "azure_keyvault") if err != nil { return nil, err } azureKeys, err := azkv.MasterKeysFromURLs(strings.Join(azKeys, ",")) if err != nil { return nil, err } for _, k := range azureKeys { keyGroup = append(keyGroup, k) } vaultKeyUris, err := getKeysWithValidation(cRule.GetVaultURIs, "vault") if err != nil { return nil, err } vaultKeys, err := hcvault.NewMasterKeysFromURIs(strings.Join(vaultKeyUris, ",")) if err != nil { return nil, err } for _, k := range vaultKeys { keyGroup = append(keyGroup, k) } groups = append(groups, keyGroup) } return groups, nil } func loadConfigFile(confPath string) (*configFile, error) { confBytes, err := os.ReadFile(confPath) if err != nil { return nil, fmt.Errorf("could not read config file: %s", err) } conf := &configFile{} conf.Stores = *NewStoresConfig() err = conf.load(confBytes) if err != nil { return nil, fmt.Errorf("error loading config: %s", err) } return conf, nil } func configFromRule(rule *creationRule, kmsEncryptionContext map[string]*string) (*Config, error) { cryptRuleCount := 0 if rule.UnencryptedSuffix != "" { cryptRuleCount++ } if rule.EncryptedSuffix != "" { cryptRuleCount++ } if rule.UnencryptedRegex != "" { cryptRuleCount++ } if rule.EncryptedRegex != "" { cryptRuleCount++ } if rule.UnencryptedCommentRegex != "" { cryptRuleCount++ } if rule.EncryptedCommentRegex != "" { cryptRuleCount++ } if cryptRuleCount > 1 { return nil, fmt.Errorf("error loading config: cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex for the same rule") } groups, err := getKeyGroupsFromCreationRule(rule, kmsEncryptionContext) if err != nil { return nil, err } return &Config{ KeyGroups: groups, ShamirThreshold: rule.ShamirThreshold, UnencryptedSuffix: rule.UnencryptedSuffix, EncryptedSuffix: rule.EncryptedSuffix, UnencryptedRegex: rule.UnencryptedRegex, EncryptedRegex: rule.EncryptedRegex, UnencryptedCommentRegex: rule.UnencryptedCommentRegex, EncryptedCommentRegex: rule.EncryptedCommentRegex, MACOnlyEncrypted: rule.MACOnlyEncrypted, }, nil } func parseDestinationRuleForFile(conf *configFile, filePath string, kmsEncryptionContext map[string]*string) (*Config, error) { var rule *creationRule var dRule *destinationRule if len(conf.DestinationRules) > 0 { for _, r := range conf.DestinationRules { if r.PathRegex == "" { dRule = &r rule = &dRule.RecreationRule break } if r.PathRegex != "" { if match, _ := regexp.MatchString(r.PathRegex, filePath); match { dRule = &r rule = &dRule.RecreationRule break } } } } if dRule == nil { return nil, fmt.Errorf("error loading config: no matching destination found in config") } var dest publish.Destination destinationCount := 0 if dRule.S3Bucket != "" { destinationCount++ } if dRule.GCSBucket != "" { destinationCount++ } if dRule.VaultPath != "" { destinationCount++ } if destinationCount > 1 { return nil, fmt.Errorf("error loading config: more than one destinations were found in a single destination rule, you can only use one per rule") } if dRule.S3Bucket != "" { dest = publish.NewS3Destination(dRule.S3Bucket, dRule.S3Prefix) } if dRule.GCSBucket != "" { dest = publish.NewGCSDestination(dRule.GCSBucket, dRule.GCSPrefix) } if dRule.VaultPath != "" { dest = publish.NewVaultDestination(dRule.VaultAddress, dRule.VaultPath, dRule.VaultKVMountName, dRule.VaultKVVersion) } config, err := configFromRule(rule, kmsEncryptionContext) if err != nil { return nil, err } config.Destination = dest config.OmitExtensions = dRule.OmitExtensions return config, nil } func parseCreationRuleForFile(conf *configFile, confPath, filePath string, kmsEncryptionContext map[string]*string) (*Config, error) { // If config file doesn't contain CreationRules (it's empty or only contains DestionationRules), assume it does not exist if conf.CreationRules == nil { return nil, nil } configDir, err := filepath.Abs(filepath.Dir(confPath)) if err != nil { return nil, err } // compare file path relative to path of config file filePath = strings.TrimPrefix(filePath, configDir+string(filepath.Separator)) var rule *creationRule for _, r := range conf.CreationRules { if r.PathRegex == "" { rule = &r break } reg, err := regexp.Compile(r.PathRegex) if err != nil { return nil, fmt.Errorf("can not compile regexp: %w", err) } if reg.MatchString(filePath) { rule = &r break } } if rule == nil { return nil, fmt.Errorf("error loading config: no matching creation rules found") } config, err := configFromRule(rule, kmsEncryptionContext) if err != nil { return nil, err } return config, nil } // LoadCreationRuleForFile load the configuration for a given SOPS file from the config file at confPath. A kmsEncryptionContext // should be provided for configurations that do not contain key groups, as there's no way to specify context inside // a SOPS config file outside of key groups. func LoadCreationRuleForFile(confPath string, filePath string, kmsEncryptionContext map[string]*string) (*Config, error) { conf, err := loadConfigFile(confPath) if err != nil { return nil, err } return parseCreationRuleForFile(conf, confPath, filePath, kmsEncryptionContext) } // LoadDestinationRuleForFile works the same as LoadCreationRuleForFile, but gets the "creation_rule" from the matching destination_rule's // "recreation_rule". func LoadDestinationRuleForFile(confPath string, filePath string, kmsEncryptionContext map[string]*string) (*Config, error) { conf, err := loadConfigFile(confPath) if err != nil { return nil, err } return parseDestinationRuleForFile(conf, filePath, kmsEncryptionContext) } func LoadStoresConfig(confPath string) (*StoresConfig, error) { conf, err := loadConfigFile(confPath) if err != nil { return nil, err } return &conf.Stores, nil }