diff --git a/packages/cmd/export.go b/packages/cmd/export.go index 776074f2..7f5703ae 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "github.com/Infisical/infisical-merge/packages/models" @@ -27,12 +28,20 @@ const ( FormatDotEnvEval string = "dotenv-eval" ) +const ( + QuoteCharSingle string = "'" + QuoteCharDouble string = `"` +) + +// POSIX portable variable name, the only shape a shell can bind a value to. +var shellVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + // exportCmd represents the export command var exportCmd = &cobra.Command{ Use: "export", Short: "Used to export environment variables to a file", DisableFlagsInUseLine: true, - Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json", + Example: "infisical export --env=prod --format=json > secrets.json\ninfisical export --env=prod --format=json --output-file=secrets.json\ninfisical export --env=prod --dotenv-quote-char='\"' > .env", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") @@ -93,6 +102,19 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + dotEnvQuoteChar, err := cmd.Flags().GetString("dotenv-quote-char") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + // Validated up front, before any secrets are fetched, so that a typo is + // reported immediately instead of after a network round trip. This runs + // regardless of the chosen format, so that an unusable value is never + // silently accepted just because the format happens to ignore it. + if err := validateDotEnvQuoteChar(dotEnvQuoteChar); err != nil { + util.HandleError(err) + } + request := models.GetAllSecretsParameters{ Environment: environmentName, TagSlugs: tagSlugs, @@ -142,7 +164,7 @@ var exportCmd = &cobra.Command{ secrets = util.FilterSecretsByTag(secrets, tagSlugs) secrets = util.SortSecretsByKeys(secrets) - output, err = formatEnvs(secrets, format) + output, err = formatEnvs(secrets, format, dotEnvQuoteChar) if err != nil { util.HandleError(err) } @@ -275,17 +297,20 @@ func init() { exportCmd.Flags().String("path", "/", "get secrets within a folder path") exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") exportCmd.Flags().StringP("output-file", "o", "", "The path to write the output file to. Can be a full file path, directory, or filename. If not specified, output will be printed to stdout") + exportCmd.Flags().String("dotenv-quote-char", QuoteCharSingle, `Set the character used to wrap values in the dotenv format (' or "). Double quotes let dotenv parsers interpret escape sequences such as \n. Ignored by every other format, including dotenv-export, whose values are always wrapped so they are safe to source in a shell`) } -// Format according to the format flag -func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string, error) { +// Format according to the format flag. quoteChar is the character used to wrap +// values in the dotenv format, and is ignored by every other format, including +// dotenv-export. It is validated by the caller before any secrets are fetched. +func formatEnvs(envs []models.SingleEnvironmentVariable, format string, quoteChar string) (string, error) { switch strings.ToLower(format) { case FormatDotenv: - return formatAsDotEnv(envs), nil + return formatAsDotEnv(envs, quoteChar), nil case FormatDotEnvExport: - return formatAsDotEnvExport(envs), nil + return formatAsDotEnvExport(envs) case FormatDotEnvEval: - return formatAsDotEnvEval(envs), nil + return formatAsDotEnvEval(envs) case FormatJson: return formatAsJson(envs), nil case FormatCSV: @@ -310,37 +335,80 @@ func formatAsCSV(envs []models.SingleEnvironmentVariable) string { } // Format environment variables as a dotenv file -func formatAsDotEnv(envs []models.SingleEnvironmentVariable) string { +func formatAsDotEnv(envs []models.SingleEnvironmentVariable, quoteChar string) string { var dotenv string for _, env := range envs { - dotenv += fmt.Sprintf("%s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) + dotenv += fmt.Sprintf("%s=%s\n", env.Key, quoteDotEnvValue(env, quoteChar)) } return dotenv } -// Format environment variables as a dotenv file with export at the beginning -func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) string { - var dotenv string - for _, env := range envs { - dotenv += fmt.Sprintf("export %s='%s'\n", env.Key, escapeNewLinesIfRequired(env)) +// Format environment variables as a dotenv file with export at the beginning. +// Every line is meant to be sourced by a shell, so values are always quoted the +// way dotenv-eval quotes them and never with the dotenv quote character: a +// value containing that character would otherwise close the wrapping early and +// let the rest of the value run as shell code. +func formatAsDotEnvExport(envs []models.SingleEnvironmentVariable) (string, error) { + return formatAsDotEnvEval(envs) +} + +// validateDotEnvQuoteChar checks that the quote character used by the dotenv +// format is one that dotenv parsers actually understand. +func validateDotEnvQuoteChar(quoteChar string) error { + if quoteChar != QuoteCharSingle && quoteChar != QuoteCharDouble { + return fmt.Errorf("invalid quote character: %q. Available quote characters are [%s]", quoteChar, strings.Join([]string{QuoteCharSingle, QuoteCharDouble}, ", ")) } - return dotenv + + return nil +} + +// quoteDotEnvValue wraps a secret value in quoteChar. The value itself is +// written verbatim, so the quote character is the only difference between the +// two styles. +// +// No backslash or quote escaping is applied, deliberately. Dotenv parsers +// extract a value by finding the quotes that delimit it and then treat what is +// between them as opaque; they do not generally undo escape sequences on read. +// Escaping on write would therefore never be unescaped again, and would only +// leave stray backslashes in the parsed value without protecting anything. +// +// The double quote style exists purely so that the "\n" produced by +// escapeNewLinesIfRequired is decoded back into a real newline, which is +// something parsers only do for double quoted values. That is the whole reason +// to pick it over the single quote default. +func quoteDotEnvValue(env models.SingleEnvironmentVariable, quoteChar string) string { + return quoteChar + escapeNewLinesIfRequired(env) + quoteChar } // Format environment variables for shell eval/source. Values are wrapped in // single quotes with POSIX escaping so the output is safe to evaluate via // `eval "$(infisical export --format=dotenv-eval)"` regardless of value // contents (newlines, single quotes, $, ", \, etc.). -func formatAsDotEnvEval(envs []models.SingleEnvironmentVariable) string { +// +// Secret names are not restricted to shell variable names, so they are checked +// against the portable syntax rather than escaped. Escaping cannot help there: +// the name is re-parsed by `export` after quote removal, and several shapes are +// assignments to a different variable rather than errors. `PATH=x` assigns to +// PATH because the split is on the first `=`, and `PATH+` appends to PATH +// because bash reads the result as `PATH+=`. A name outside the portable syntax +// cannot be bound by a shell at all, so the whole batch is rejected instead. +// +// The `--` is redundant with that check and kept as a second layer, so that a +// name starting with `-` can never be read as a flag. +func formatAsDotEnvEval(envs []models.SingleEnvironmentVariable) (string, error) { var dotenv string for _, env := range envs { - dotenv += fmt.Sprintf("export %s=%s\n", env.Key, posixShellQuote(env.Value)) + if !shellVariableName.MatchString(env.Key) { + return "", fmt.Errorf("cannot export secret %q to a shell format: a shell variable name may only contain ASCII letters, digits and underscores, and may not start with a digit. Other names are either rejected by the shell or silently applied to a different variable, the way names like PATH= and PATH+ are. Rename the secret, or export it with a format other than %s and %s", env.Key, FormatDotEnvExport, FormatDotEnvEval) + } + + dotenv += fmt.Sprintf("export -- %s=%s\n", env.Key, posixShellQuote(env.Value)) } - return dotenv + return dotenv, nil } // posixShellQuote wraps a value in single quotes and escapes any embedded -// single quotes using the standard `'\”` sequence. Single-quoted POSIX +// single quotes using the standard `'\''` sequence. Single-quoted POSIX // strings preserve every other character verbatim (including newlines, // backslashes, $, and "), so this is sufficient for eval/source. func posixShellQuote(value string) string { diff --git a/packages/cmd/export_test.go b/packages/cmd/export_test.go index 0e3921ec..62003998 100644 --- a/packages/cmd/export_test.go +++ b/packages/cmd/export_test.go @@ -78,77 +78,429 @@ func TestFormatAsYaml(t *testing.T) { } } -func TestFormatAsDotEnvEval(t *testing.T) { +func TestFormatAsDotEnv(t *testing.T) { tests := []struct { - name string - input []models.SingleEnvironmentVariable - expected string + name string + input []models.SingleEnvironmentVariable + quoteChar string + expected string }{ { - name: "Empty input", - input: []models.SingleEnvironmentVariable{}, - expected: "", + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + quoteChar: QuoteCharSingle, + expected: "", + }, + { + name: "Single quotes are the default wrapping", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + quoteChar: QuoteCharSingle, + expected: "KEY1='VALUE1'\nKEY2='VALUE2'\n", }, { - name: "Simple value", + name: "Double quotes wrap the value", input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "simple"}, + {Key: "KEY1", Value: "VALUE1"}, }, - expected: "export KEY1='simple'\n", + quoteChar: QuoteCharDouble, + expected: "KEY1=\"VALUE1\"\n", }, { - name: "Value containing single quote", + name: "Encoded newlines are left intact so double quoted values stay multiline", input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "it's a value"}, + {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, }, - expected: "export KEY1='it'\\''s a value'\n", + quoteChar: QuoteCharDouble, + expected: "KEY1=\"line1\\nline2\"\n", }, { - name: "Multiline value is preserved verbatim", + name: "Embedded double quotes are written verbatim in double quote mode", input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "line1\nline2"}, + {Key: "KEY1", Value: `say "hi"`}, }, - expected: "export KEY1='line1\nline2'\n", + quoteChar: QuoteCharDouble, + expected: `KEY1="say "hi""` + "\n", }, { - name: "Multiline value with skipMultilineEncoding set still emits real newlines", + name: "Embedded double quotes are written verbatim in single quote mode", input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, + {Key: "KEY1", Value: `say "hi"`}, }, - expected: "export KEY1='line1\nline2'\n", + quoteChar: QuoteCharSingle, + expected: `KEY1='say "hi"'` + "\n", }, { - name: "Shell metacharacters are preserved literally inside single quotes", + name: "Backslashes are written verbatim in double quote mode", input: []models.SingleEnvironmentVariable{ - {Key: "KEY1", Value: `$(rm -rf /) "quotes" \backslash`}, + {Key: "KEY1", Value: `C:\Users\`}, }, - expected: "export KEY1='$(rm -rf /) \"quotes\" \\backslash'\n", + quoteChar: QuoteCharDouble, + expected: `KEY1="C:\Users\"` + "\n", + }, + { + name: "Backslash quote sequences are written verbatim", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `say \"hi\"`}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="say \"hi\""` + "\n", + }, + { + name: "Multiline encoding still applies alongside backslashes", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "C:\\dir\nmore", SkipMultilineEncoding: true}, + }, + quoteChar: QuoteCharDouble, + expected: `KEY1="C:\dir\nmore"` + "\n", + }, + { + name: "Backslashes are written verbatim in single quote mode", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: `C:\Users\`}, + }, + quoteChar: QuoteCharSingle, + expected: `KEY1='C:\Users\'` + "\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, formatAsDotEnvEval(tt.input)) + assert.Equal(t, tt.expected, formatAsDotEnv(tt.input, tt.quoteChar)) + }) + } +} + +func TestQuoteDotEnvValueOnlyChangesTheWrapper(t *testing.T) { + envs := []models.SingleEnvironmentVariable{ + {Key: "PLAIN", Value: "VALUE1"}, + {Key: "QUOTES", Value: `say "hi" and 'bye'`}, + {Key: "BACKSLASHES", Value: `C:\Users\`}, + {Key: "BACKSLASH_QUOTES", Value: `say \"hi\"`}, + {Key: "MULTILINE", Value: "line1\nline2", SkipMultilineEncoding: true}, + } + + for _, env := range envs { + t.Run(env.Key, func(t *testing.T) { + single := quoteDotEnvValue(env, QuoteCharSingle) + double := quoteDotEnvValue(env, QuoteCharDouble) + + assert.Equal(t, QuoteCharSingle, string(single[0])) + assert.Equal(t, QuoteCharSingle, string(single[len(single)-1])) + assert.Equal(t, QuoteCharDouble, string(double[0])) + assert.Equal(t, QuoteCharDouble, string(double[len(double)-1])) + + // Only the wrapping character may differ between the two styles. The + // value in between is never rewritten beyond the multiline encoding + // that both styles already share, so neither style can corrupt a + // round trip by introducing escape characters of its own. + singleInner := single[1 : len(single)-1] + doubleInner := double[1 : len(double)-1] + + assert.Equal(t, singleInner, doubleInner) + assert.Equal(t, escapeNewLinesIfRequired(env), singleInner) + }) + } +} + +// Shared by the dotenv-export and dotenv-eval tests, whose output must stay +// byte identical because both formats go through the same implementation. +var dotEnvShellCases = []struct { + name string + input []models.SingleEnvironmentVariable + expected string + expectError bool +}{ + { + name: "Empty input", + input: []models.SingleEnvironmentVariable{}, + expected: "", + }, + { + name: "Simple value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + }, + expected: "export -- KEY1='VALUE1'\n", + }, + { + name: "Value containing single quote", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "it's a value"}, + }, + expected: "export -- KEY1='it'\\''s a value'\n", + }, + { + name: "Value breaking out of the wrapping is escaped instead of executed", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "x' ; touch pwned ; echo '"}, + }, + expected: "export -- KEY1='x'\\'' ; touch pwned ; echo '\\'''\n", + }, + { + name: "Shell metacharacters in the value are preserved literally", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "$(rm -rf /) `id` \"quotes\" \\backslash"}, + }, + expected: "export -- KEY1='$(rm -rf /) `id` \"quotes\" \\backslash'\n", + }, + { + name: "Empty value", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: ""}, + }, + expected: "export -- KEY1=''\n", + }, + { + name: "Multiline value is preserved verbatim", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "line1\nline2"}, + }, + expected: "export -- KEY1='line1\nline2'\n", + }, + { + name: "Multiline value with skipMultilineEncoding set still emits real newlines", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "line1\nline2", SkipMultilineEncoding: true}, + }, + expected: "export -- KEY1='line1\nline2'\n", + }, + { + name: "Every shape of portable variable name is accepted", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "API_KEY", Value: "VALUE2"}, + {Key: "_PRIVATE", Value: "VALUE3"}, + {Key: "lowercase", Value: "VALUE4"}, + {Key: "MiXeD_9", Value: "VALUE5"}, + }, + expected: "export -- KEY1='VALUE1'\nexport -- API_KEY='VALUE2'\nexport -- _PRIVATE='VALUE3'\nexport -- lowercase='VALUE4'\nexport -- MiXeD_9='VALUE5'\n", + }, + { + name: "Key assigning to another variable is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "PATH=/tmp/evil", Value: "ignored"}, + }, + expectError: true, + }, + { + name: "Key appending to another variable is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "PATH+", Value: ":/tmp/evil"}, + }, + expectError: true, + }, + { + name: "Key with an array subscript is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "path[1]", Value: "/tmp/evil"}, + }, + expectError: true, + }, + { + name: "Key ending the assignment is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "FOO=bar; touch pwned #", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key breaking out of the wrapping is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "FOO' ; touch pwned ; echo '", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key with shell metacharacters is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "$(id)`id`", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key starting with a dash is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "-p", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "Key starting with a digit is rejected", + input: []models.SingleEnvironmentVariable{ + {Key: "1KEY", Value: "VALUE1"}, + }, + expectError: true, + }, + { + name: "A rejected key emits no output at all for the other secrets", + input: []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + {Key: "LD_PRELOAD+", Value: "/tmp/evil.so"}, + {Key: "KEY2", Value: "VALUE2"}, + }, + expectError: true, + }, +} + +func TestFormatAsDotEnvExport(t *testing.T) { + for _, tt := range dotEnvShellCases { + t.Run(tt.name, func(t *testing.T) { + result, err := formatAsDotEnvExport(tt.input) + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, result) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatEnvsQuoteChar(t *testing.T) { + envs := []models.SingleEnvironmentVariable{ + {Key: "KEY1", Value: "VALUE1"}, + } + + tests := []struct { + name string + format string + quoteChar string + expected string + }{ + { + name: "dotenv honours the quote character", + format: FormatDotenv, + quoteChar: QuoteCharDouble, + expected: "KEY1=\"VALUE1\"\n", + }, + { + name: "dotenv-export keeps its shell safe quoting", + format: FormatDotEnvExport, + quoteChar: QuoteCharDouble, + expected: "export -- KEY1='VALUE1'\n", + }, + { + name: "dotenv-eval keeps its shell safe quoting", + format: FormatDotEnvEval, + quoteChar: QuoteCharDouble, + expected: "export -- KEY1='VALUE1'\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := formatEnvs(envs, tt.format, tt.quoteChar) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } + + // Every format that does not wrap values in a configurable quote must + // produce byte identical output no matter what the flag is set to. + for _, format := range []string{FormatJson, FormatYaml, FormatCSV, FormatDotEnvExport, FormatDotEnvEval} { + t.Run(format+" ignores the quote character", func(t *testing.T) { + withSingle, err := formatEnvs(envs, format, QuoteCharSingle) + assert.NoError(t, err) + + withDouble, err := formatEnvs(envs, format, QuoteCharDouble) + assert.NoError(t, err) + + assert.Equal(t, withSingle, withDouble) + }) + } +} + +func TestFormatEnvsKeyThatIsNotAShellName(t *testing.T) { + for _, key := range []string{"PATH=/tmp/evil", "PATH+", "path[1]"} { + envs := []models.SingleEnvironmentVariable{{Key: key, Value: "ignored"}} + + for _, format := range []string{FormatDotEnvExport, FormatDotEnvEval} { + t.Run(format+" rejects "+key, func(t *testing.T) { + result, err := formatEnvs(envs, format, QuoteCharSingle) + assert.Error(t, err) + assert.Empty(t, result) + }) + } + + // The other formats are not sourced by a shell, so the same key stays a + // plain field there and must keep working. + for _, format := range []string{FormatDotenv, FormatJson, FormatYaml, FormatCSV} { + t.Run(format+" still accepts "+key, func(t *testing.T) { + result, err := formatEnvs(envs, format, QuoteCharSingle) + assert.NoError(t, err) + assert.Contains(t, result, key) + }) + } + } +} + +func TestValidateDotEnvQuoteChar(t *testing.T) { + tests := []struct { + name string + quoteChar string + expectError bool + }{ + {name: "Single quote is allowed", quoteChar: QuoteCharSingle}, + {name: "Double quote is allowed", quoteChar: QuoteCharDouble}, + {name: "Empty value is rejected", quoteChar: "", expectError: true}, + {name: "Backtick is rejected", quoteChar: "`", expectError: true}, + {name: "Arbitrary character is rejected", quoteChar: "x", expectError: true}, + {name: "Multiple characters are rejected", quoteChar: `""`, expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDotEnvQuoteChar(tt.quoteChar) + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestFormatAsDotEnvEval(t *testing.T) { + for _, tt := range dotEnvShellCases { + t.Run(tt.name, func(t *testing.T) { + result, err := formatAsDotEnvEval(tt.input) + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, result) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) }) } } func TestPosixShellQuote(t *testing.T) { tests := []struct { + name string input string expected string }{ - {input: "", expected: "''"}, - {input: "plain", expected: "'plain'"}, - {input: "it's", expected: `'it'\''s'`}, - {input: "'leading", expected: `''\''leading'`}, - {input: "trailing'", expected: `'trailing'\'''`}, - {input: "a'b'c", expected: `'a'\''b'\''c'`}, - {input: "with\nnewline", expected: "'with\nnewline'"}, + {name: "Empty string", input: "", expected: "''"}, + {name: "No escaping needed", input: "plain", expected: "'plain'"}, + {name: "Embedded single quote", input: "it's", expected: `'it'\''s'`}, + {name: "Leading single quote", input: "'leading", expected: `''\''leading'`}, + {name: "Trailing single quote", input: "trailing'", expected: `'trailing'\'''`}, + {name: "Several single quotes", input: "a'b'c", expected: `'a'\''b'\''c'`}, + {name: "Newline is kept verbatim", input: "with\nnewline", expected: "'with\nnewline'"}, + {name: "Leading dash", input: "-p", expected: "'-p'"}, + {name: "Whitespace only", input: " \t ", expected: "' \t '"}, } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, posixShellQuote(tt.input)) }) }