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
16 changes: 16 additions & 0 deletions packages/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions packages/cmd/export_to_files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
Copyright (c) 2023 Infisical Inc.
*/
package cmd

import (
"fmt"
"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
}

// 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
// (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
Comment on lines +47 to +57

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 Glob patterns with * anywhere but the end are silently treated as concrete paths

Only the suffix /* is detected as a glob. A user supplying apps/*/web or */cli will not get an error — the pattern is passed through normalizeSecretPath and treated as a literal Infisical path, almost certainly returning zero secrets with no diagnostic message. Consider returning an error for patterns that contain * in a position other than the trailing /*.

}

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 := walkFolderTreeVisited(child, listChildren, visited)
if err != nil {
return nil, err
}
paths = append(paths, descendants...)
}
return paths, nil
}
Comment on lines +60 to +87

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 No cycle detection in walkFolderTree

If the Infisical API returns a folder structure where a child path equals or prefixes an ancestor (e.g. listChildren("/apps") returns ["/apps"]), walkFolderTree recurses infinitely and will crash with a stack overflow. This could be triggered by a buggy or adversarial server response. A simple visited-set check on the path before recursing would prevent this.


// 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 {
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)
}
Comment on lines +115 to +123

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 security Path traversal via server-controlled folder names

folder.Name comes directly from the Infisical API and is concatenated into a path without any sanitization. filepath.Join resolves .. components, so a server returning a folder named .. or ../../etc would cause the CLI to write secret files outside the current working directory. For example, if the API returns Name: "../../.ssh", the resulting output path becomes ../../.ssh/.env, silently overwriting files in unintended locations.

Consider validating that folder.Name contains no path separators or .. components before appending it, e.g. using filepath.Base(folder.Name) to strip any directory components or explicitly rejecting names that contain / or ...

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 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
}
136 changes: 136 additions & 0 deletions packages/cmd/export_to_files_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
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))
})
}
}

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))
})
}
}