Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 91 additions & 3 deletions packages/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
FormatCSV string = "csv"
FormatYaml string = "yaml"
FormatDotEnvExport string = "dotenv-export"
FormatAppSettings string = "appsettings"
)

// exportCmd represents the export command
Expand Down Expand Up @@ -231,6 +232,8 @@ func getDefaultFilename(format string) string {
switch strings.ToLower(format) {
case FormatJson:
return "secrets.json"
case FormatAppSettings:
return "appsettings.json"
case FormatCSV:
return "secrets.csv"
case FormatYaml:
Expand All @@ -247,7 +250,7 @@ func getDefaultFilename(format string) string {
// getDefaultExtension returns the default file extension based on the format
func getDefaultExtension(format string) string {
switch strings.ToLower(format) {
case FormatJson:
case FormatJson, FormatAppSettings:
return ".json"
case FormatCSV:
return ".csv"
Expand All @@ -266,7 +269,7 @@ func init() {
RootCmd.AddCommand(exportCmd)
exportCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from")
exportCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets")
exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)")
exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv, yaml, appsettings)")
exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets")
exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets")
exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token")
Expand All @@ -290,8 +293,10 @@ func formatEnvs(envs []models.SingleEnvironmentVariable, format string) (string,
return formatAsCSV(envs), nil
case FormatYaml:
return formatAsYaml(envs)
case FormatAppSettings:
return formatAsAppSettingsJson(envs), nil
default:
return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport})
return "", fmt.Errorf("invalid format type: %s. Available format types are [%s]", format, []string{FormatDotenv, FormatJson, FormatCSV, FormatYaml, FormatDotEnvExport, FormatAppSettings})
}
}

Expand Down Expand Up @@ -350,6 +355,89 @@ func formatAsJson(envs []models.SingleEnvironmentVariable) string {
return string(json)
}

// Attempts to parse a string value as JSON (object, array, number, boolean, null)
// falls back to the raw string on error.
func parseSecretValue(val string) interface{} {
trimmed := strings.TrimSpace(val)
var parsed interface{}
err := json.Unmarshal([]byte(trimmed), &parsed)
if err != nil {
return val
}
if parsed == nil && trimmed != "null" {
return val
}
return parsed
}
Comment on lines +360 to +371
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The parseSecretValue function parses "null" correctly but the nil check is subtly redundant. json.Unmarshal sets parsed = nil only when the input is the JSON literal "null", so the guard trimmed != "null" can never fire — if unmarshal succeeded and parsed == nil, trimmed must equal "null". The double-negative condition adds no protection and can be removed.

Suggested change
func parseSecretValue(val string) interface{} {
trimmed := strings.TrimSpace(val)
var parsed interface{}
err := json.Unmarshal([]byte(trimmed), &parsed)
if err != nil {
return val
}
if parsed == nil && trimmed != "null" {
return val
}
return parsed
}
func parseSecretValue(val string) interface{} {
trimmed := strings.TrimSpace(val)
var parsed interface{}
err := json.Unmarshal([]byte(trimmed), &parsed)
if err != nil {
return val
}
// json.Unmarshal only sets parsed=nil when the input is the JSON literal "null"
return parsed
}


// Recursively merges two maps
func mergeMaps(dest, src map[string]interface{}) {
for k, v := range src {
destVal, exists := dest[k]
if !exists {
dest[k] = v
continue
}

destMap, destIsMap := destVal.(map[string]interface{})
srcMap, srcIsMap := v.(map[string]interface{})
if destIsMap && srcIsMap {
mergeMaps(destMap, srcMap)
} else {
dest[k] = v
}
}
}

// Formats environment variables into a nested JSON object structure compatible with ASP.NET Core appsettings.json
func formatAsAppSettingsJson(envs []models.SingleEnvironmentVariable) string {
result := make(map[string]interface{})

for _, env := range envs {
val := parseSecretValue(env.Value)

normalizedKey := strings.ReplaceAll(env.Key, "__", ":")
parts := strings.Split(normalizedKey, ":")

current := result
for i, part := range parts {
if i == len(parts)-1 {
existingVal, exists := current[part]
existingMap, existingIsMap := existingVal.(map[string]interface{})
newMap, newIsMap := val.(map[string]interface{})
if exists && existingIsMap && newIsMap {
mergeMaps(existingMap, newMap)
} else {
current[part] = val
Comment on lines +396 to +411
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent data loss on conflicting key paths

When two secrets produce conflicting key paths — one as a scalar value and another using that same key as a non-leaf prefix — one silently overwrites the other with no warning logged. Because util.SortSecretsByKeys runs before formatAsAppSettingsJson, AWS sorts before AWS__Region (or AWS:Region), so AWS=plain-string is processed first and set as a scalar, then AWS__Region=us-east-1 replaces AWS with a new nested map — discarding "plain-string". The reverse ordering (nested first, scalar second) hits the current[part] = val branch at line 411 and discards the map instead. Neither direction emits any warning, so users silently lose configuration values.

}
} else {
next, exists := current[part]
if !exists {
nextMap := make(map[string]interface{})
current[part] = nextMap
current = nextMap
} else {
nextMap, ok := next.(map[string]interface{})
if !ok {
nextMap = make(map[string]interface{})
current[part] = nextMap
current = nextMap
} else {
current = nextMap
}
}
}
}
}

jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil {
log.Err(err).Msgf("Unable to marshal environment variables to appsettings JSON")
return ""
}
return string(jsonBytes)
}

func escapeNewLinesIfRequired(env models.SingleEnvironmentVariable) string {
if env.IsMultilineEncodingEnabled() && strings.ContainsRune(env.Value, '\n') {
return strings.ReplaceAll(env.Value, "\n", "\\n")
Expand Down
113 changes: 113 additions & 0 deletions packages/cmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,116 @@ func TestFormatAsYaml(t *testing.T) {
})
}
}

func TestFormatAsAppSettingsJson(t *testing.T) {
tests := []struct {
name string
input []models.SingleEnvironmentVariable
expected string
}{
{
name: "Empty input",
input: []models.SingleEnvironmentVariable{},
expected: "{}\n",
},
{
name: "Flat primitive types parsing",
input: []models.SingleEnvironmentVariable{
{Key: "PORT", Value: "8080"},
{Key: "DEBUG", Value: "true"},
{Key: "NAME", Value: "MyApplication"},
{Key: "NULL_VAL", Value: "null"},
},
expected: `{
"DEBUG": true,
"NAME": "MyApplication",
"NULL_VAL": null,
"PORT": 8080
}`,
},
{
name: "Nested keys with colons",
input: []models.SingleEnvironmentVariable{
{Key: "AWS:Region", Value: "us-east-1"},
{Key: "ConnectionStrings:Default", Value: "Server=myServerAddress;Database=myDataBase;"},
},
expected: `{
"AWS": {
"Region": "us-east-1"
Comment on lines +84 to +115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Scalar-conflicts-with-nested-path case is untested

The test suite covers merging two maps and merging a JSON-object value with an explicit nested key, but there is no test for the scenario where a plain scalar key (AWS = "plain-string") conflicts with a dotted-path key that uses that prefix (AWS__Region = "us-east-1"). This is the scenario where silent data loss occurs. Adding a test case for this conflict in both orderings would surface the current silent-overwrite behavior and protect against regressions.

},
"ConnectionStrings": {
"Default": "Server=myServerAddress;Database=myDataBase;"
}
}`,
},
{
name: "Nested keys with double underscores",
input: []models.SingleEnvironmentVariable{
{Key: "AWS__Region", Value: "us-east-2"},
{Key: "ConnectionStrings__Default", Value: "Server=otherServer;"},
},
expected: `{
"AWS": {
"Region": "us-east-2"
},
"ConnectionStrings": {
"Default": "Server=otherServer;"
}
}`,
},
{
name: "Values as JSON objects/arrays",
input: []models.SingleEnvironmentVariable{
{Key: "AWS", Value: `{"Region":"us-west-2","Version":"v2"}`},
{Key: "TAGS", Value: `["production","web"]`},
},
expected: `{
"AWS": {
"Region": "us-west-2",
"Version": "v2"
},
"TAGS": [
"production",
"web"
]
}`,
},
{
name: "Deep nesting and merging objects",
input: []models.SingleEnvironmentVariable{
{Key: "AWS:Credentials:AccessKey", Value: "AKIAIOSFODNN7EXAMPLE"},
{Key: "AWS:Credentials:SecretKey", Value: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"},
{Key: "AWS:Region", Value: "us-east-1"},
},
expected: `{
"AWS": {
"Credentials": {
"AccessKey": "AKIAIOSFODNN7EXAMPLE",
"SecretKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
},
"Region": "us-east-1"
}
}`,
},
{
name: "Merging parsed JSON objects and explicit nested keys in any order",
input: []models.SingleEnvironmentVariable{
{Key: "AWS", Value: `{"Region":"us-east-1"}`},
{Key: "AWS:SecretKey", Value: "abc"},
},
expected: `{
"AWS": {
"Region": "us-east-1",
"SecretKey": "abc"
}
}`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatAsAppSettingsJson(tt.input)
assert.JSONEq(t, tt.expected, result)
})
}
}