Skip to content

Commit

Permalink
Merge pull request #290 from DopplerHQ/mount-secrets-file
Browse files Browse the repository at this point in the history
Add support for mounting secrets to file path
  • Loading branch information
Piccirello authored May 5, 2022
2 parents a2894d4 + 34f8155 commit c298514
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 44 deletions.
154 changes: 116 additions & 38 deletions pkg/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,26 @@ var defaultFallbackDir string

const defaultFallbackFileMaxAge = 14 * 24 * time.Hour // 14 days

type fallbackOptions struct {
enable bool
path string
legacyPath string
readonly bool
exclusive bool
exitOnWriteFailure bool
passphrase string
}

var runCmd = &cobra.Command{
Use: "run [command]",
Short: "Run a command with secrets injected into the environment",
Long: `Run a command with secrets injected into the environment
Long: `Run a command with secrets injected into the environment.
Secrets can also be mounted to an ephemeral file using the --mount flag.
To view the CLI's active configuration, run ` + "`doppler configure debug`",
Example: `doppler run -- YOUR_COMMAND --YOUR-FLAG
doppler run --command "YOUR_COMMAND && YOUR_OTHER_COMMAND"`,
doppler run --command "YOUR_COMMAND && YOUR_OTHER_COMMAND"
doppler run --mount secrets.json -- cat secrets.json`,
Args: func(cmd *cobra.Command, args []string) error {
// The --command flag and args are mututally exclusive
usingCommandFlag := cmd.Flags().Changed("command")
Expand Down Expand Up @@ -112,52 +124,114 @@ doppler run --command "YOUR_COMMAND && YOUR_OTHER_COMMAND"`,
}
}

secrets := fetchSecrets(localConfig, enableCache, enableFallback, fallbackPath, legacyFallbackPath, metadataPath, fallbackReadonly, fallbackOnly, exitOnWriteFailure, passphrase, nameTransformer, dynamicSecretsTTL)
fallbackOpts := fallbackOptions{
enable: enableFallback,
path: fallbackPath,
legacyPath: legacyFallbackPath,
readonly: fallbackReadonly,
exclusive: fallbackOnly,
exitOnWriteFailure: exitOnWriteFailure,
passphrase: passphrase,
}
// retrieve secrets
dopplerSecrets := fetchSecrets(localConfig, enableCache, fallbackOpts, metadataPath, nameTransformer, dynamicSecretsTTL)

mountPath := cmd.Flag("mount").Value.String()
mountFormat := cmd.Flag("mount-format").Value.String()
maxReads := utils.GetIntFlag(cmd, "mount-max-reads", 32)
// only auto-detect the format if it hasn't been explicitly specified
shouldAutoDetectFormat := !cmd.Flags().Changed("mount-format")
shouldMountFile := mountPath != ""

if preserveEnv {
utils.LogWarning("Ignoring Doppler secrets already defined in the environment due to --preserve-env flag")
if shouldMountFile {
utils.LogWarning("--preserve-env has no effect when used with --mount")
} else {
utils.LogWarning("Ignoring Doppler secrets already defined in the environment due to --preserve-env flag")
}
}

env := os.Environ()
originalEnv := os.Environ()
existingEnvKeys := map[string]bool{}
for _, envVar := range env {
for _, envVar := range originalEnv {
// key=value format
parts := strings.SplitN(envVar, "=", 2)
key := parts[0]
existingEnvKeys[key] = true
}

secrets := map[string]string{}
excludedKeys := []string{"PATH", "PS1", "HOME"}
for name, value := range secrets {
for name, value := range dopplerSecrets {
useSecret := true
for _, excludedKey := range excludedKeys {
if excludedKey == name {
useSecret = false
break
if !shouldMountFile {
// ignore secrets that might conflict with the environment
for _, excludedKey := range excludedKeys {
if excludedKey == name {
utils.LogDebug(fmt.Sprintf("Ignoring restricted secret %s", name))
useSecret = false
break
}
}
}

if useSecret && preserveEnv {
// skip secret if environment already contains variable w/ same name
if existingEnvKeys[name] == true {
if preserveEnv && existingEnvKeys[name] == true {
utils.LogDebug(fmt.Sprintf("Ignoring Doppler secret %s", name))
useSecret = false
}
}

if useSecret {
env = append(env, fmt.Sprintf("%s=%s", name, value))
if !useSecret {
continue
}

secrets[name] = value
}

var env []string
var onExit func()
if shouldMountFile {
if shouldAutoDetectFormat {
if strings.HasSuffix(mountPath, ".env") {
mountFormat = "env"
utils.LogDebug(fmt.Sprintf("Detected %s format", mountFormat))
} else if strings.HasSuffix(mountPath, ".json") {
mountFormat = "json"
utils.LogDebug(fmt.Sprintf("Detected %s format", mountFormat))
} else {
parts := strings.Split(mountPath, ".")
detectedFormat := parts[len(parts)-1]
utils.LogWarning(fmt.Sprintf("Detected \"%s\" file format, which is not supported. Using default JSON format for mounted secrets", detectedFormat))
}
}

absMountPath, handler, err := controllers.MountSecrets(secrets, mountFormat, mountPath, maxReads)
if !err.IsNil() {
utils.HandleError(err.Unwrap(), err.Message)
}
mountPath = absMountPath
onExit = handler

// export path to mounted file
env = append(env, fmt.Sprintf("%s=%s", "DOPPLER_CLI_SECRETS_PATH", mountPath))
} else {
// export doppler secrets
for _, envVar := range controllers.MapToEnvFormat(secrets, false) {
env = append(env, envVar)
}
}

// include original environment variables
env = append(env, originalEnv...)

exitCode := 0
var err error

if cmd.Flags().Changed("command") {
command := cmd.Flag("command").Value.String()
exitCode, err = utils.RunCommandString(command, env, os.Stdin, os.Stdout, os.Stderr, forwardSignals)
exitCode, err = utils.RunCommandString(command, env, os.Stdin, os.Stdout, os.Stderr, forwardSignals, onExit)
} else {
exitCode, err = utils.RunCommand(args, env, os.Stdin, os.Stdout, os.Stderr, forwardSignals)
exitCode, err = utils.RunCommand(args, env, os.Stdin, os.Stdout, os.Stderr, forwardSignals, onExit)
}

if err != nil {
Expand Down Expand Up @@ -250,38 +324,38 @@ var runCleanCmd = &cobra.Command{
},
}

// fetchSecrets fetches secrets, including all reading and writing of fallback files
func fetchSecrets(localConfig models.ScopedOptions, enableCache bool, enableFallback bool, fallbackPath string, legacyFallbackPath string, metadataPath string, fallbackReadonly bool, fallbackOnly bool, exitOnWriteFailure bool, passphrase string, nameTransformer *models.SecretsNameTransformer, dynamicSecretsTTL time.Duration) map[string]string {
if fallbackOnly {
if !enableFallback {
// fetchSecrets from Doppler and handle fallback file
func fetchSecrets(localConfig models.ScopedOptions, enableCache bool, fallbackOpts fallbackOptions, metadataPath string, nameTransformer *models.SecretsNameTransformer, dynamicSecretsTTL time.Duration) map[string]string {
if fallbackOpts.exclusive {
if !fallbackOpts.enable {
utils.HandleError(errors.New("Conflict: unable to specify --no-fallback with --fallback-only"))
}
if nameTransformer != nil {
utils.HandleError(errors.New("Conflict: unable to specify --name-transformer with --fallback-only"))
}
return readFallbackFile(fallbackPath, legacyFallbackPath, passphrase, false)
return readFallbackFile(fallbackOpts.path, fallbackOpts.legacyPath, fallbackOpts.passphrase, false)
}

// this scenario likely isn't possible, but just to be safe, disable using cache when there's no metadata file
enableCache = enableCache && nameTransformer == nil && metadataPath != ""
etag := ""
if enableCache {
etag = getCacheFileETag(metadataPath, fallbackPath)
etag = getCacheFileETag(metadataPath, fallbackOpts.path)
}

statusCode, respHeaders, response, httpErr := http.DownloadSecrets(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value, models.JSON, nameTransformer, etag, dynamicSecretsTTL)
if !httpErr.IsNil() {
if enableFallback {
if fallbackOpts.enable {
utils.Log("Unable to fetch secrets from the Doppler API")
utils.LogError(httpErr.Unwrap())
return readFallbackFile(fallbackPath, legacyFallbackPath, passphrase, false)
return readFallbackFile(fallbackOpts.path, fallbackOpts.legacyPath, fallbackOpts.passphrase, false)
}
utils.HandleError(httpErr.Unwrap(), httpErr.Message)
}

if enableCache && statusCode == 304 {
utils.LogDebug("Using cached secrets from fallback file")
cache, err := controllers.SecretsCacheFile(fallbackPath, passphrase)
cache, err := controllers.SecretsCacheFile(fallbackOpts.path, fallbackOpts.passphrase)
if !err.IsNil() {
utils.LogDebugError(err.Unwrap())
utils.LogDebug(err.Message)
Expand All @@ -298,38 +372,38 @@ func fetchSecrets(localConfig models.ScopedOptions, enableCache bool, enableFall
if err != nil {
utils.LogDebugError(err)

if enableFallback {
if fallbackOpts.enable {
utils.Log("Unable to parse the Doppler API response")
utils.LogError(httpErr.Unwrap())
return readFallbackFile(fallbackPath, legacyFallbackPath, passphrase, false)
return readFallbackFile(fallbackOpts.path, fallbackOpts.legacyPath, fallbackOpts.passphrase, false)
}
utils.HandleError(err, "Unable to parse API response")
}

writeFallbackFile := enableFallback && !fallbackReadonly && nameTransformer == nil
writeFallbackFile := fallbackOpts.enable && !fallbackOpts.readonly && nameTransformer == nil
if writeFallbackFile {
utils.LogDebug("Encrypting secrets")
encryptedResponse, err := crypto.Encrypt(passphrase, response, "base64")
encryptedResponse, err := crypto.Encrypt(fallbackOpts.passphrase, response, "base64")
if err != nil {
utils.HandleError(err, "Unable to encrypt your secrets. No fallback file has been written.")
}

utils.LogDebug(fmt.Sprintf("Writing to fallback file %s", fallbackPath))
if err := utils.WriteFile(fallbackPath, []byte(encryptedResponse), utils.RestrictedFilePerms()); err != nil {
utils.LogDebug(fmt.Sprintf("Writing to fallback file %s", fallbackOpts.path))
if err := utils.WriteFile(fallbackOpts.path, []byte(encryptedResponse), utils.RestrictedFilePerms()); err != nil {
utils.Log("Unable to write to fallback file")
if exitOnWriteFailure {
if fallbackOpts.exitOnWriteFailure {
utils.HandleError(err, "", strings.Join(writeFailureMessage(), "\n"))
} else {
utils.LogDebugError(err)
}
}

// TODO remove this when releasing CLI v4 (DPLR-435)
if legacyFallbackPath != "" && localConfig.EnclaveProject.Value != "" && localConfig.EnclaveConfig.Value != "" {
utils.LogDebug(fmt.Sprintf("Writing to legacy fallback file %s", legacyFallbackPath))
if err := utils.WriteFile(legacyFallbackPath, []byte(encryptedResponse), utils.RestrictedFilePerms()); err != nil {
if fallbackOpts.legacyPath != "" && localConfig.EnclaveProject.Value != "" && localConfig.EnclaveConfig.Value != "" {
utils.LogDebug(fmt.Sprintf("Writing to legacy fallback file %s", fallbackOpts.legacyPath))
if err := utils.WriteFile(fallbackOpts.legacyPath, []byte(encryptedResponse), utils.RestrictedFilePerms()); err != nil {
utils.Log("Unable to write to legacy fallback file")
if exitOnWriteFailure {
if fallbackOpts.exitOnWriteFailure {
utils.HandleError(err, "", strings.Join(writeFailureMessage(), "\n"))
} else {
utils.LogDebugError(err)
Expand Down Expand Up @@ -572,6 +646,10 @@ func init() {
runCmd.Flags().Bool("fallback-only", false, "read all secrets directly from the fallback file, without contacting Doppler. secrets will not be updated. (implies --fallback-readonly)")
runCmd.Flags().Bool("no-exit-on-write-failure", false, "do not exit if unable to write the fallback file")
runCmd.Flags().Bool("forward-signals", forwardSignals, "forward signals to the child process (defaults to false when STDOUT is a TTY)")
// secrets mount flags
runCmd.Flags().String("mount", "", "write secrets to an ephemeral file, accessible at DOPPLER_CLI_SECRETS_PATH. when enabled, secrets are NOT injected into the environment")
runCmd.Flags().String("mount-format", "json", "file format to use. if not specified, will be auto-detected from mount name. one of [json, env]")
runCmd.Flags().Int("mount-max-reads", 0, "maximum number of times the mounted secrets file can be read (0 for unlimited)")

// deprecated
runCmd.Flags().Bool("silent-exit", false, "disable error output if the supplied command exits non-zero")
Expand Down
12 changes: 11 additions & 1 deletion pkg/cmd/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,17 @@ func downloadSecrets(cmd *cobra.Command, args []string) {
if enableCache {
metadataPath = controllers.MetadataFilePath(localConfig.Token.Value, localConfig.EnclaveProject.Value, localConfig.EnclaveConfig.Value)
}
secrets := fetchSecrets(localConfig, enableCache, enableFallback, fallbackPath, legacyFallbackPath, metadataPath, fallbackReadonly, fallbackOnly, exitOnWriteFailure, fallbackPassphrase, nameTransformer, dynamicSecretsTTL)

fallbackOpts := fallbackOptions{
enable: enableFallback,
path: fallbackPath,
legacyPath: legacyFallbackPath,
readonly: fallbackReadonly,
exclusive: fallbackOnly,
exitOnWriteFailure: exitOnWriteFailure,
passphrase: fallbackPassphrase,
}
secrets := fetchSecrets(localConfig, enableCache, fallbackOpts, metadataPath, nameTransformer, dynamicSecretsTTL)

var err error
body, err = json.Marshal(secrets)
Expand Down
Loading

0 comments on commit c298514

Please sign in to comment.