From af616dba438f517187f376c6fe35930c873770dc Mon Sep 17 00:00:00 2001 From: lollinng <55660103+lollinng@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:23:57 +0530 Subject: [PATCH 1/2] feat(export): add --to-files to write per-path .env files Add a --to-files flag to `infisical export` that writes one output file per logical secret path, mirroring the folder path into the filesystem (e.g. apps/cli/.env). `--path="apps/*"` targets the immediate subfolders of a folder; omitting --path exports every folder in the tree. Reuses the existing formatters and folder-listing API. --to-files is mutually exclusive with --output-file. Implements Infisical/infisical#1068 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cmd/export.go | 16 +++ packages/cmd/export_to_files.go | 145 +++++++++++++++++++++++++++ packages/cmd/export_to_files_test.go | 98 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 packages/cmd/export_to_files.go create mode 100644 packages/cmd/export_to_files_test.go diff --git a/packages/cmd/export.go b/packages/cmd/export.go index a27c5a0c..ceb7dfa0 100644 --- a/packages/cmd/export.go +++ b/packages/cmd/export.go @@ -92,6 +92,11 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + toFiles, err := cmd.Flags().GetBool("to-files") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + request := models.GetAllSecretsParameters{ Environment: environmentName, TagSlugs: tagSlugs, @@ -131,6 +136,16 @@ var exportCmd = &cobra.Command{ return } + if toFiles { + if outputFile != "" { + util.PrintErrorMessageAndExit("--to-files cannot be combined with --output-file") + } + if err := runExportToFiles(request, format, tagSlugs, secretOverriding); err != nil { + util.HandleError(err, "Failed to export secrets to files") + } + return + } + secrets, err := util.GetAllEnvironmentVariables(request, "") if err != nil { util.HandleError(err, "Unable to fetch secrets") @@ -275,6 +290,7 @@ 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().Bool("to-files", false, "Write one file per logical path, mirroring the folder path into the filesystem (e.g. apps/cli/.env). Use with --path=\"apps/*\" to target the immediate subfolders of apps, or omit --path to export every folder. Cannot be combined with --output-file") } // Format according to the format flag diff --git a/packages/cmd/export_to_files.go b/packages/cmd/export_to_files.go new file mode 100644 index 00000000..bda78326 --- /dev/null +++ b/packages/cmd/export_to_files.go @@ -0,0 +1,145 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" +) + +func normalizeSecretPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if len(path) > 1 { + path = strings.TrimRight(path, "/") + } + return path +} + +// expandToFilePaths resolves the export --path pattern into the concrete secret +// paths a .env file should be written for: +// - a pattern ending in "/*" expands to the immediate child folders of its prefix +// (e.g. "apps/*" -> "/apps/cli", "/apps/api") +// - the root path ("/" or empty) expands to every folder in the tree, recursively +// - any other pattern is treated as a single concrete path +// +// listChildren returns the immediate child folder paths of the given parent path. +func expandToFilePaths(pattern string, listChildren func(parent string) ([]string, error)) ([]string, error) { + if strings.HasSuffix(strings.TrimSpace(pattern), "/*") { + parent := normalizeSecretPath(strings.TrimSuffix(strings.TrimSpace(pattern), "/*")) + return listChildren(parent) + } + + path := normalizeSecretPath(pattern) + if path == "/" { + return walkFolderTree("/", listChildren) + } + return []string{path}, nil +} + +func walkFolderTree(root string, listChildren func(parent string) ([]string, error)) ([]string, error) { + paths := []string{root} + children, err := listChildren(root) + if err != nil { + return nil, err + } + for _, child := range children { + descendants, err := walkFolderTree(child, listChildren) + if err != nil { + return nil, err + } + paths = append(paths, descendants...) + } + return paths, nil +} + +// mapPathToFile maps a secret path to the relative file path its output is written +// to, mirroring the logical folder path into the filesystem (e.g. "/apps/cli" with +// dotenv format -> "apps/cli/.env"). The root path maps to the default filename in +// the current directory. +func mapPathToFile(secretPath, format string) string { + clean := strings.Trim(secretPath, "/") + filename := getDefaultFilename(format) + if clean == "" { + return filename + } + return filepath.Join(clean, filename) +} + +func runExportToFiles(request models.GetAllSecretsParameters, format string, tagSlugs string, secretOverriding bool) error { + listChildren := func(parent string) ([]string, error) { + folders, err := util.GetAllFolders(models.GetAllFoldersParameters{ + WorkspaceId: request.WorkspaceId, + Environment: request.Environment, + FoldersPath: parent, + InfisicalToken: request.InfisicalToken, + UniversalAuthAccessToken: request.UniversalAuthAccessToken, + }) + if err != nil { + return nil, err + } + base := strings.TrimRight(parent, "/") + paths := make([]string, 0, len(folders)) + for _, folder := range folders { + paths = append(paths, base+"/"+folder.Name) + } + return paths, nil + } + + paths, err := expandToFilePaths(request.SecretsPath, listChildren) + if err != nil { + return err + } + if len(paths) == 0 { + util.PrintfStderr("No folders matched path %q; nothing to export\n", request.SecretsPath) + return nil + } + + for _, path := range paths { + pathRequest := request + pathRequest.SecretsPath = path + + secrets, err := util.GetAllEnvironmentVariables(pathRequest, "") + if err != nil { + return fmt.Errorf("unable to fetch secrets for path %s: %w", path, err) + } + + if secretOverriding { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL) + } else { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED) + } + secrets = util.FilterSecretsByTag(secrets, tagSlugs) + secrets = util.SortSecretsByKeys(secrets) + + output, err := formatEnvs(secrets, format) + if err != nil { + return err + } + + outputFile := mapPathToFile(path, format) + if dir := filepath.Dir(outputFile); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + if err := writeToFile(outputFile, output, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", outputFile, err) + } + + util.PrintfStderr("Exported %d secrets from %s to %s\n", len(secrets), path, outputFile) + } + + return nil +} diff --git a/packages/cmd/export_to_files_test.go b/packages/cmd/export_to_files_test.go new file mode 100644 index 00000000..d7345f12 --- /dev/null +++ b/packages/cmd/export_to_files_test.go @@ -0,0 +1,98 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// fakeFolderTree returns a listChildren func backed by a static folder tree, so the +// path-resolution logic can be unit-tested without a live Infisical backend. +func fakeFolderTree() func(string) ([]string, error) { + tree := map[string][]string{ + "/": {"/apps", "/packages"}, + "/apps": {"/apps/cli", "/apps/api", "/apps/web"}, + "/packages": {"/packages/eslint-config"}, + } + return func(parent string) ([]string, error) { + return tree[parent], nil + } +} + +func TestExpandToFilePaths(t *testing.T) { + listChildren := fakeFolderTree() + + tests := []struct { + name string + pattern string + expected []string + }{ + { + name: "glob expands to immediate children only", + pattern: "apps/*", + expected: []string{"/apps/cli", "/apps/api", "/apps/web"}, + }, + { + name: "glob normalizes a leading-slash prefix", + pattern: "/apps/*", + expected: []string{"/apps/cli", "/apps/api", "/apps/web"}, + }, + { + name: "root glob lists top-level folders, not recursively", + pattern: "/*", + expected: []string{"/apps", "/packages"}, + }, + { + name: "concrete path resolves to itself", + pattern: "/apps/cli", + expected: []string{"/apps/cli"}, + }, + { + name: "concrete path without leading slash is normalized", + pattern: "apps/cli", + expected: []string{"/apps/cli"}, + }, + { + name: "root path walks the whole tree recursively", + pattern: "/", + expected: []string{"/", "/apps", "/apps/cli", "/apps/api", "/apps/web", "/packages", "/packages/eslint-config"}, + }, + { + name: "empty path is treated as root", + pattern: "", + expected: []string{"/", "/apps", "/apps/cli", "/apps/api", "/apps/web", "/packages", "/packages/eslint-config"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := expandToFilePaths(tt.pattern, listChildren) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMapPathToFile(t *testing.T) { + tests := []struct { + name string + secretPath string + format string + expected string + }{ + {name: "dotenv mirrors the logical path", secretPath: "/apps/cli", format: "dotenv", expected: "apps/cli/.env"}, + {name: "root maps to default filename in cwd", secretPath: "/", format: "dotenv", expected: ".env"}, + {name: "json uses the json default filename", secretPath: "/apps/api", format: "json", expected: "apps/api/secrets.json"}, + {name: "yaml uses the yaml default filename", secretPath: "/packages/eslint-config", format: "yaml", expected: "packages/eslint-config/secrets.yaml"}, + {name: "trailing and leading slashes are trimmed", secretPath: "/apps/web/", format: "dotenv", expected: "apps/web/.env"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, mapPathToFile(tt.secretPath, tt.format)) + }) + } +} From f41ab80620082dfdfa47d876cfdb8a99cc7012e8 Mon Sep 17 00:00:00 2001 From: lollinng <55660103+lollinng@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:44:38 +0530 Subject: [PATCH 2/2] fix(export): harden --to-files against unsafe folder names and cycles Validate that server-returned folder names are safe path segments before mirroring them into the filesystem (reject "..", ".", and path separators) so a malicious or buggy API response cannot write secret files outside the target directory. Add cycle detection to the recursive folder walk to prevent infinite recursion on a cyclic folder graph. Drop the redundant MkdirAll since writeToFile already creates parent directories. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cmd/export_to_files.go | 36 +++++++++++++++++++++----- packages/cmd/export_to_files_test.go | 38 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/cmd/export_to_files.go b/packages/cmd/export_to_files.go index bda78326..6e70a920 100644 --- a/packages/cmd/export_to_files.go +++ b/packages/cmd/export_to_files.go @@ -5,7 +5,6 @@ package cmd import ( "fmt" - "os" "path/filepath" "strings" @@ -27,6 +26,16 @@ func normalizeSecretPath(path string) string { return path } +// isSafeFolderSegment guards against a server returning a folder name that would +// escape the target directory once mirrored into the filesystem (e.g. ".." or a +// name containing a path separator). +func isSafeFolderSegment(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + return !strings.ContainsAny(name, "/\\") +} + // expandToFilePaths resolves the export --path pattern into the concrete secret // paths a .env file should be written for: // - a pattern ending in "/*" expands to the immediate child folders of its prefix @@ -49,13 +58,26 @@ func expandToFilePaths(pattern string, listChildren func(parent string) ([]strin } func walkFolderTree(root string, listChildren func(parent string) ([]string, error)) ([]string, error) { + return walkFolderTreeVisited(root, listChildren, map[string]bool{}) +} + +func walkFolderTreeVisited( + root string, + listChildren func(parent string) ([]string, error), + visited map[string]bool, +) ([]string, error) { + if visited[root] { + return nil, nil + } + visited[root] = true + paths := []string{root} children, err := listChildren(root) if err != nil { return nil, err } for _, child := range children { - descendants, err := walkFolderTree(child, listChildren) + descendants, err := walkFolderTreeVisited(child, listChildren, visited) if err != nil { return nil, err } @@ -92,6 +114,11 @@ func runExportToFiles(request models.GetAllSecretsParameters, format string, tag base := strings.TrimRight(parent, "/") paths := make([]string, 0, len(folders)) for _, folder := range folders { + if !isSafeFolderSegment(folder.Name) { + return nil, fmt.Errorf( + "refusing to export: server returned unsafe folder name %q", folder.Name, + ) + } paths = append(paths, base+"/"+folder.Name) } return paths, nil @@ -129,11 +156,6 @@ func runExportToFiles(request models.GetAllSecretsParameters, format string, tag } outputFile := mapPathToFile(path, format) - if dir := filepath.Dir(outputFile); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } - } if err := writeToFile(outputFile, output, 0644); err != nil { return fmt.Errorf("failed to write %s: %w", outputFile, err) } diff --git a/packages/cmd/export_to_files_test.go b/packages/cmd/export_to_files_test.go index d7345f12..0d66c83d 100644 --- a/packages/cmd/export_to_files_test.go +++ b/packages/cmd/export_to_files_test.go @@ -96,3 +96,41 @@ func TestMapPathToFile(t *testing.T) { }) } } + +func TestExpandToFilePathsTerminatesOnCycle(t *testing.T) { + // A buggy/adversarial server could return a folder graph with a cycle. + tree := map[string][]string{ + "/": {"/a"}, + "/a": {"/a/b"}, + "/a/b": {"/a"}, // cycle back to an ancestor + } + listChildren := func(parent string) ([]string, error) { + return tree[parent], nil + } + + result, err := expandToFilePaths("/", listChildren) + assert.NoError(t, err) + assert.Equal(t, []string{"/", "/a", "/a/b"}, result) +} + +func TestIsSafeFolderSegment(t *testing.T) { + tests := []struct { + name string + safe bool + }{ + {name: "apps", safe: true}, + {name: "eslint-config", safe: true}, + {name: "", safe: false}, + {name: ".", safe: false}, + {name: "..", safe: false}, + {name: "../../.ssh", safe: false}, + {name: "a/b", safe: false}, + {name: "a\\b", safe: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.safe, isSafeFolderSegment(tt.name)) + }) + } +}