Skip to content

Commit

Permalink
feat: Add exec --strict (#227)
Browse files Browse the repository at this point in the history
Strict mode only fills env vars that are already set with a sentinel value (default `chamberme`)
  • Loading branch information
nickatsegment authored Sep 24, 2019
1 parent 4c1903f commit 40375db
Show file tree
Hide file tree
Showing 3 changed files with 393 additions and 17 deletions.
85 changes: 70 additions & 15 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ import (
// When true, only use variables retrieved from the backend, do not inherit existing environment variables
var pristine bool

// When true, enable strict mode, which checks that all secrets replace env vars with a special sentinel value
var strict bool

// Value to expect in strict mode
var strictValue string

// Default value to expect in strict mode
const strictValueDefault = "chamberme"

// execCmd represents the exec command
var execCmd = &cobra.Command{
Use: "exec <service...> -- <command> [<arg...>]",
Expand All @@ -32,10 +41,32 @@ var execCmd = &cobra.Command{
return nil
},
RunE: execRun,
Example: `
Given a secret store like this:
$ echo '{"db_username": "root", "db_password": "hunter22"}' | chamber import -
--strict will fail with unfilled env vars
$ HOME=/tmp DB_USERNAME=chamberme DB_PASSWORD=chamberme EXTRA=chamberme chamber exec --strict service exec -- env
chamber: extra unfilled env var EXTRA
exit 1
--pristine takes effect after checking for --strict values
$ HOME=/tmp DB_USERNAME=chamberme DB_PASSWORD=chamberme chamber exec --strict --pristine service exec -- env
DB_USERNAME=root
DB_PASSWORD=hunter22
`,
}

func init() {
execCmd.Flags().BoolVar(&pristine, "pristine", false, "only use variables retrieved from the backend, do not inherit existing environment variables")
execCmd.Flags().BoolVar(&pristine, "pristine", false, "only use variables retrieved from the backend; do not inherit existing environment variables")
execCmd.Flags().BoolVar(&strict, "strict", false, `enable strict mode:
only inject secrets for which there is a corresponding env var with value
<strict-value>, and fail if there are any env vars with that value missing
from secrets`)
execCmd.Flags().StringVar(&strictValue, "strict-value", strictValueDefault, "value to expect in --strict mode")
RootCmd.AddCommand(execCmd)
}

Expand All @@ -55,33 +86,57 @@ func execRun(cmd *cobra.Command, args []string) error {
})
}

env := environ.Environ{}
if !pristine {
env = environ.Environ(os.Environ())
for _, service := range services {
if err := validateService(service); err != nil {
return errors.Wrap(err, "Failed to validate service")
}
}

secretStore, err := getSecretStore()
if err != nil {
return errors.Wrap(err, "Failed to get secret store")
}
for _, service := range services {
if err := validateService(service); err != nil {
return errors.Wrap(err, "Failed to validate service")
}
_, noPaths := os.LookupEnv("CHAMBER_NO_PATHS")

collisions := make([]string, 0)
if pristine && verbose {
fmt.Fprintf(os.Stderr, "chamber: pristine mode engaged\n")
}

var env environ.Environ
if strict {
if verbose {
fmt.Fprintf(os.Stderr, "chamber: strict mode engaged\n")
}
var err error
if _, noPaths := os.LookupEnv("CHAMBER_NO_PATHS"); noPaths {
err = env.LoadNoPaths(secretStore, service, &collisions)
env = environ.Environ(os.Environ())
if noPaths {
err = env.LoadStrictNoPaths(secretStore, strictValue, pristine, services...)
} else {
err = env.Load(secretStore, service, &collisions)
err = env.LoadStrict(secretStore, strictValue, pristine, services...)
}
if err != nil {
return errors.Wrap(err, "Failed to list store contents")
return err
}
} else {
if !pristine {
env = environ.Environ(os.Environ())
}
for _, service := range services {
collisions := make([]string, 0)
var err error
// TODO: these interfaces should look the same as Strict*, so move pristine in there
if noPaths {
err = env.LoadNoPaths(secretStore, service, &collisions)
} else {
err = env.Load(secretStore, service, &collisions)
}
if err != nil {
return errors.Wrap(err, "Failed to list store contents")
}

for _, c := range collisions {
fmt.Fprintf(os.Stderr, "warning: service %s overwriting environment variable %s\n", service, c)
for _, c := range collisions {
fmt.Fprintf(os.Stderr, "warning: service %s overwriting environment variable %s\n", service, c)
}
}
}

Expand Down
142 changes: 140 additions & 2 deletions environ/environ.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package environ

import (
"fmt"
"strings"

"github.com/segmentio/chamber/store"
Expand Down Expand Up @@ -36,6 +37,31 @@ func (e *Environ) Set(key, val string) {
*e = append(*e, key+"="+val)
}

// Map squashes the list-like environ, taking the latter value when there are
// collisions, like a shell would. Invalid items (e.g., missing `=`) are dropped
func (e *Environ) Map() map[string]string {
ret := map[string]string{}
for _, kv := range []string(*e) {
s := strings.SplitN(kv, "=", 2)
if len(s) != 2 {
// drop invalid kv pairs
// I guess this could happen in theory
continue
}
ret[s[0]] = s[1]
}
return ret
}

func fromMap(m map[string]string) Environ {
e := make([]string, 0, len(m))

for k, v := range m {
e = append(e, k+"="+v)
}
return Environ(e)
}

// like cmd/list.key, but without the env var lookup
func key(s string, noPaths bool) string {
sep := "/"
Expand All @@ -47,6 +73,15 @@ func key(s string, noPaths bool) string {
return secretKey
}

// transforms a secret key to an env var name, i.e. upppercase, substitute `-` -> `_`
func secretKeyToEnvVarName(k string, noPaths bool) string {
return normalizeEnvVarName(key(k, noPaths))
}

func normalizeEnvVarName(k string) string {
return strings.Replace(strings.ToUpper(k), "-", "_", -1)
}

// load loads environment variables into e from s given a service
// collisions will be populated with any keys that get overwritten
// noPaths enables the behavior as if CHAMBER_NO_PATHS had been set
Expand All @@ -57,8 +92,7 @@ func (e *Environ) load(s store.Store, service string, collisions *[]string, noPa
}
envVarKeys := make([]string, 0)
for _, rawSecret := range rawSecrets {
envVarKey := strings.ToUpper(key(rawSecret.Key, noPaths))
envVarKey = strings.Replace(envVarKey, "-", "_", -1)
envVarKey := secretKeyToEnvVarName(rawSecret.Key, noPaths)

envVarKeys = append(envVarKeys, envVarKey)

Expand All @@ -82,3 +116,107 @@ func (e *Environ) Load(s store.Store, service string, collisions *[]string) erro
func (e *Environ) LoadNoPaths(s store.Store, service string, collisions *[]string) error {
return e.load(s, service, collisions, true)
}

// LoadStrict loads all services from s in strict mode: env vars in e with value equal to valueExpected
// are the only ones substituted. If there are any env vars in s that are also in e, but don't have their value
// set to valueExpected, this is an error.
func (e *Environ) LoadStrict(s store.Store, valueExpected string, pristine bool, services ...string) error {
return e.loadStrict(s, valueExpected, pristine, false, services...)
}

// LoadNoPathsStrict is identical to LoadStrict, but uses v1-style "."-separated paths
//
// Deprecated like all noPaths functionality
func (e *Environ) LoadStrictNoPaths(s store.Store, valueExpected string, pristine bool, services ...string) error {
return e.loadStrict(s, valueExpected, pristine, true, services...)
}

func (e *Environ) loadStrict(s store.Store, valueExpected string, pristine bool, noPaths bool, services ...string) error {
for _, service := range services {
rawSecrets, err := s.ListRaw(strings.ToLower(service))
if err != nil {
return err
}
err = e.loadStrictOne(rawSecrets, valueExpected, pristine, noPaths)
if err != nil {
return err
}
}
return nil
}

func (e *Environ) loadStrictOne(rawSecrets []store.RawSecret, valueExpected string, pristine bool, noPaths bool) error {
parentMap := e.Map()
parentExpects := map[string]struct{}{}
for k, v := range parentMap {
if v == valueExpected {
if k != normalizeEnvVarName(k) {
return ErrExpectedKeyUnnormalized{Key: k, ValueExpected: valueExpected}
}
// TODO: what if this key isn't chamber-compatible but could collide? MY_cool_var vs my-cool-var
parentExpects[k] = struct{}{}
}
}

envVarKeysAdded := map[string]struct{}{}
for _, rawSecret := range rawSecrets {
envVarKey := secretKeyToEnvVarName(rawSecret.Key, noPaths)

parentVal, parentOk := parentMap[envVarKey]
// skip injecting secrets that are not present in the parent
if !parentOk {
continue
}
delete(parentExpects, envVarKey)
if parentVal != valueExpected {
return ErrStoreUnexpectedValue{Key: envVarKey, ValueExpected: valueExpected, ValueActual: parentVal}
}
envVarKeysAdded[envVarKey] = struct{}{}
e.Set(envVarKey, rawSecret.Value)
}
for k, _ := range parentExpects {
return ErrStoreMissingKey{Key: k, ValueExpected: valueExpected}
}

if pristine {
// unset all envvars that were in the parent env but not in store
for k, _ := range parentMap {
if _, ok := envVarKeysAdded[k]; !ok {
e.Unset(k)
}
}
}

return nil
}

type ErrStoreUnexpectedValue struct {
// store-style key
Key string
ValueExpected string
ValueActual string
}

func (e ErrStoreUnexpectedValue) Error() string {
return fmt.Sprintf("parent env has %s, but was expecting value `%s`, not `%s`", e.Key, e.ValueExpected, e.ValueActual)
}

type ErrStoreMissingKey struct {
// env-style key
Key string
ValueExpected string
}

func (e ErrStoreMissingKey) Error() string {
return fmt.Sprintf("parent env was expecting %s=%s, but was not in store", e.Key, e.ValueExpected)
}

type ErrExpectedKeyUnnormalized struct {
Key string
ValueExpected string
}

func (e ErrExpectedKeyUnnormalized) Error() string {
return fmt.Sprintf("parent env has key `%s` with expected value `%s`, but key is not normalized like `%s`, so would never get substituted",
e.Key, e.ValueExpected, normalizeEnvVarName(e.Key))
}
Loading

0 comments on commit 40375db

Please sign in to comment.