From 0758ded16df0a550d9cc71a9868777a8bb9cd47f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 23 Jul 2026 23:19:22 -0500 Subject: [PATCH 01/19] feat(secret): managed secrets with env and file delivery Add a devsy secret command group (set/get/list/delete/attach/detach) that stores named secrets locally in the OS keyring, with an age-encrypted file fallback whose key is auto-generated (or passphrase-derived) and recorded in a metadata index. Secrets are per-context and can be bound to a context. Inject stored secrets into workspaces with 'up --secret NAME[,type=env|mount] [,target=X]': env delivery sets a lifecycle env var; mount delivery writes the value to a tmpfs at /run/secrets. Secret values travel to the container over the agent tunnel (not argv) and are redacted from lifecycle logs. A driver mount capability lets the Kubernetes driver map tmpfs to an in-memory emptyDir and rejects unsupported providers with a clear error. --secrets-file now expects flat JSON to match the devcontainer CLI. Includes CLI, desktop UI, docs, and e2e coverage. --- cmd/context/delete.go | 22 + cmd/internal/agentcontainer/deferred_hooks.go | 21 +- cmd/internal/agentcontainer/post_attach.go | 11 +- cmd/internal/agentcontainer/setup.go | 63 ++- cmd/root.go | 4 + cmd/secrets/bind.go | 115 ++++++ cmd/secrets/delete.go | 44 ++ cmd/secrets/get.go | 49 +++ cmd/secrets/list.go | 117 ++++++ cmd/secrets/secrets.go | 44 ++ cmd/secrets/set.go | 117 ++++++ cmd/workspace/up/secrets_test.go | 94 +++++ cmd/workspace/up/up.go | 1 + cmd/workspace/up/up_client.go | 121 +++++- cmd/workspace/up/up_flags.go | 5 +- desktop/src/main/cli.ts | 20 + desktop/src/main/ipc.ts | 37 ++ desktop/src/renderer/src/App.svelte | 4 + .../src/lib/components/layout/Sidebar.svelte | 8 + desktop/src/renderer/src/lib/ipc/commands.ts | 22 + .../renderer/src/lib/stores/secrets.test.ts | 49 +++ .../src/renderer/src/lib/stores/secrets.ts | 25 ++ desktop/src/renderer/src/lib/types/index.ts | 8 + .../src/renderer/src/pages/SecretsPage.svelte | 170 ++++++++ e2e/tests/up/helper.go | 30 ++ e2e/tests/up/provider_docker.go | 55 ++- e2e/tests/up/provider_podman.go | 12 +- .../.devcontainer.json | 4 + .../docker-managed-secret/.devcontainer.json | 4 + go.mod | 4 + go.sum | 10 + pkg/agent/tunnel/tunnel.pb.go | 343 +++++++++++----- pkg/agent/tunnel/tunnel.proto | 11 + pkg/agent/tunnel/tunnel_grpc.pb.go | 38 ++ pkg/agent/tunnelserver/options.go | 23 ++ pkg/agent/tunnelserver/tunnelserver.go | 8 + .../tunnelserver/tunnelserver_secrets_test.go | 43 ++ pkg/config/config.go | 3 + pkg/config/context.go | 8 + pkg/config/env.go | 4 + pkg/credentials/integration_test.go | 6 +- pkg/credentials/mock_tunnel_client_test.go | 86 +--- pkg/credentials/server.go | 33 +- pkg/credentials/server_test.go | 6 +- pkg/credentials/start.go | 3 +- pkg/devcontainer/compose_up.go | 8 + pkg/devcontainer/config/config.go | 4 + pkg/devcontainer/setup.go | 13 +- pkg/devcontainer/setup/lifecyclehooks.go | 25 +- pkg/devcontainer/setup/setup.go | 34 ++ pkg/devcontainer/single.go | 80 +++- pkg/driver/kubernetes/driver.go | 9 + pkg/driver/kubernetes/run.go | 25 +- pkg/driver/types.go | 27 ++ pkg/driver/types_test.go | 31 ++ pkg/flags/names/names.go | 1 + pkg/provider/workspace.go | 1 + pkg/secrets/file_backend.go | 115 ++++++ pkg/secrets/filekey.go | 136 +++++++ pkg/secrets/index.go | 94 +++++ pkg/secrets/keyring_backend.go | 57 +++ pkg/secrets/local_store.go | 234 +++++++++++ pkg/secrets/redact.go | 34 ++ pkg/secrets/redact_test.go | 47 +++ pkg/secrets/secrets.go | 41 +- pkg/secrets/secrets_test.go | 97 +---- pkg/secrets/store.go | 53 +++ pkg/secrets/store_internal_test.go | 378 ++++++++++++++++++ .../developing-in-workspaces/secrets.mdx | 162 ++++++++ sites/docs-devsy-sh/sidebars.js | 4 + 70 files changed, 3233 insertions(+), 382 deletions(-) create mode 100644 cmd/secrets/bind.go create mode 100644 cmd/secrets/delete.go create mode 100644 cmd/secrets/get.go create mode 100644 cmd/secrets/list.go create mode 100644 cmd/secrets/secrets.go create mode 100644 cmd/secrets/set.go create mode 100644 cmd/workspace/up/secrets_test.go create mode 100644 desktop/src/renderer/src/lib/stores/secrets.test.ts create mode 100644 desktop/src/renderer/src/lib/stores/secrets.ts create mode 100644 desktop/src/renderer/src/pages/SecretsPage.svelte create mode 100644 e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json create mode 100644 e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json create mode 100644 pkg/agent/tunnelserver/tunnelserver_secrets_test.go create mode 100644 pkg/driver/types_test.go create mode 100644 pkg/secrets/file_backend.go create mode 100644 pkg/secrets/filekey.go create mode 100644 pkg/secrets/index.go create mode 100644 pkg/secrets/keyring_backend.go create mode 100644 pkg/secrets/local_store.go create mode 100644 pkg/secrets/redact.go create mode 100644 pkg/secrets/redact_test.go create mode 100644 pkg/secrets/store.go create mode 100644 pkg/secrets/store_internal_test.go create mode 100644 sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx diff --git a/cmd/context/delete.go b/cmd/context/delete.go index c6b039394..d25aeb5e7 100644 --- a/cmd/context/delete.go +++ b/cmd/context/delete.go @@ -6,6 +6,8 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -59,6 +61,8 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { return fmt.Errorf("cannot delete 'default' context") } + deleteContextSecrets(devsyConfig, context) + delete(devsyConfig.Contexts, context) if devsyConfig.DefaultContext == context { devsyConfig.DefaultContext = "default" @@ -74,3 +78,21 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { return nil } + +func deleteContextSecrets(devsyConfig *config.Config, contextName string) { + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil || len(ctxConfig.Secrets) == 0 { + return + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + log.Warnf("skip deleting secrets for context %q: %v", contextName, err) + return + } + for _, name := range ctxConfig.Secrets { + if err := store.Delete(contextName, name); err != nil { + log.Warnf("delete secret %q for context %q: %v", name, contextName, err) + } + } +} diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index 5a32c0544..f4ef49b53 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -6,9 +6,12 @@ import ( "context" "encoding/json" "fmt" + "os" + "strings" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/compress" + config2 "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/setup" cliflags "github.com/devsy-org/devsy/pkg/flags" @@ -17,6 +20,15 @@ import ( "github.com/spf13/cobra" ) +func secretsEnvFromEnvironment() []string { + raw := os.Getenv(config2.EnvSecretsEnv) + if raw == "" { + return nil + } + + return strings.Split(raw, "\n") +} + // DeferredHooksCmd runs deferred lifecycle hooks as a detached background process. type DeferredHooksCmd struct { *flags.GlobalFlags @@ -24,7 +36,6 @@ type DeferredHooksCmd struct { Prebuild bool DotfilesRepo string DotfilesScript string - SecretsEnv []string } // NewDeferredHooksCmd creates a new command. @@ -51,12 +62,6 @@ func NewDeferredHooksCmd(flags *flags.GlobalFlags) *cobra.Command { "", "Dotfiles install script path", ), - cliflags.StringSlice( - &cmd.SecretsEnv, - names.SecretsEnv, - []string{}, - "Secrets to inject into lifecycle commands (KEY=VALUE)", - ), ) _ = deferredCmd.MarkFlagRequired(names.SetupInfo) return deferredCmd @@ -79,7 +84,7 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - }, cmd.SecretsEnv, setup.SkipPhases{}) + }, secretsEnvFromEnvironment(), setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) } diff --git a/cmd/internal/agentcontainer/post_attach.go b/cmd/internal/agentcontainer/post_attach.go index 012493280..250c4274f 100644 --- a/cmd/internal/agentcontainer/post_attach.go +++ b/cmd/internal/agentcontainer/post_attach.go @@ -19,8 +19,7 @@ import ( // PostAttachCmd runs postAttachCommand hooks as a detached background process. type PostAttachCmd struct { *flags.GlobalFlags - SetupInfo string - SecretsEnv []string + SetupInfo string } // NewPostAttachCmd creates a new command. @@ -39,12 +38,6 @@ func NewPostAttachCmd(flags *flags.GlobalFlags) *cobra.Command { cliflags.Add( postAttachCmd, cliflags.String(&cmd.SetupInfo, names.SetupInfo, "", "The container setup info"), - cliflags.StringSlice( - &cmd.SecretsEnv, - names.SecretsEnv, - []string{}, - "Secrets to inject into lifecycle commands (KEY=VALUE)", - ), ) _ = postAttachCmd.MarkFlagRequired(names.SetupInfo) return postAttachCmd @@ -63,7 +56,7 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error { } log.Debugf("running postAttachCommand hooks") - if err := setup.RunPostAttachHooks(ctx, setupInfo, cmd.SecretsEnv); err != nil { + if err := setup.RunPostAttachHooks(ctx, setupInfo, secretsEnvFromEnvironment()); err != nil { log.Errorf("postAttachCommand failed: %v", err) } diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index eeb0336b9..2c6f9ae55 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -132,6 +132,7 @@ type setupContext struct { workspaceInfo *provider2.ContainerWorkspaceInfo setupInfo *config.Result tunnelClient tunnel.TunnelClient + secretsEnv []string } // Run runs the command logic. @@ -212,11 +213,40 @@ func (cmd *SetupContainerCmd) prepareWorkspace(sctx *setupContext) error { return cloneErr } +// fetchSecrets pulls secrets over the tunnel. +func fetchSecrets( + ctx context.Context, + client tunnel.TunnelClient, +) (env, mount []string, err error) { + resp, err := client.Secrets(ctx, &tunnel.Empty{}) + if err != nil { + return nil, nil, fmt.Errorf("fetch secrets: %w", err) + } + + for _, secret := range resp.GetSecrets() { + entry := secret.GetName() + "=" + secret.GetValue() + if secret.GetMount() { + mount = append(mount, entry) + } else { + env = append(env, entry) + } + } + + return env, mount, nil +} + func (cmd *SetupContainerCmd) finalizeSetup(sctx *setupContext) error { + secretsEnv, secretsMount, err := fetchSecrets(sctx.ctx, sctx.tunnelClient) + if err != nil { + return err + } + sctx.secretsEnv = secretsEnv + cfg := &setup.ContainerSetupConfig{ SetupInfo: sctx.setupInfo, ExtraWorkspaceEnv: sctx.workspaceInfo.CLIOptions.WorkspaceEnv, - SecretsEnv: sctx.workspaceInfo.CLIOptions.SecretsEnv, + SecretsEnv: secretsEnv, + SecretsMount: secretsMount, ChownProjects: cmd.ChownWorkspace, PlatformOptions: &sctx.workspaceInfo.CLIOptions.Platform, TunnelClient: sctx.tunnelClient, @@ -285,7 +315,7 @@ func (cmd *SetupContainerCmd) setupPostAttach( if !deferred.Empty() { err = cmd.startDeferredHooks( resolvedSetupInfo, cmd.DotfilesRepo, cmd.DotfilesScript, - sctx.workspaceInfo.CLIOptions.SecretsEnv, + sctx.secretsEnv, ) if err != nil { log.Errorf("failed to start deferred lifecycle hooks: %v", err) @@ -345,14 +375,15 @@ func buildDeferredHooksCmd( if dotfilesScript != "" { args = append(args, names.Flag(names.DotfilesScript), dotfilesScript) } + cmd := &exec.Cmd{ + Path: binaryPath, + Args: append([]string{binaryPath}, args...), + } if len(secretsEnv) > 0 { - args = append(args, names.Flag(names.SecretsEnv), strings.Join(secretsEnv, ",")) + cmd.Env = append(os.Environ(), config2.EnvSecretsEnv+"="+strings.Join(secretsEnv, "\n")) } - return &exec.Cmd{ - Path: binaryPath, - Args: append([]string{binaryPath}, args...), - }, nil + return cmd, nil } func (cmd *SetupContainerCmd) initializeTunnelClient( @@ -532,18 +563,18 @@ func (cmd *SetupContainerCmd) startPostAttachHooks(sctx *setupContext) error { cmdInternal, cmdAgent, cmdContainer, "post-attach", names.Flag(names.SetupInfo), cmd.SetupInfo, } - if len(sctx.workspaceInfo.CLIOptions.SecretsEnv) > 0 { - args = append( - args, - names.Flag(names.SecretsEnv), - strings.Join(sctx.workspaceInfo.CLIOptions.SecretsEnv, ","), + execCmd := &exec.Cmd{ + Path: binaryPath, + Args: append([]string{binaryPath}, args...), + } + if secretsEnv := sctx.secretsEnv; len(secretsEnv) > 0 { + execCmd.Env = append( + os.Environ(), + config2.EnvSecretsEnv+"="+strings.Join(secretsEnv, "\n"), ) } - return &exec.Cmd{ - Path: binaryPath, - Args: append([]string{binaryPath}, args...), - }, nil + return execCmd, nil }) } diff --git a/cmd/root.go b/cmd/root.go index 29ac60e98..80daae878 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/devsy-org/devsy/cmd/mcp" "github.com/devsy-org/devsy/cmd/pro" "github.com/devsy-org/devsy/cmd/provider" + secretscmd "github.com/devsy-org/devsy/cmd/secrets" "github.com/devsy-org/devsy/cmd/self" "github.com/devsy-org/devsy/cmd/template" wsCmdPkg "github.com/devsy-org/devsy/cmd/workspace" @@ -316,6 +317,9 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) contextCmd := context.NewContextCmd(globalFlags) contextCmd.GroupID = groupConfig rootCmd.AddCommand(contextCmd) + secretsCmd := secretscmd.NewSecretsCmd(globalFlags) + secretsCmd.GroupID = groupConfig + rootCmd.AddCommand(secretsCmd) if proEnabled() { proCmd := pro.NewProCmd(globalFlags) proCmd.GroupID = groupPlatform diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go new file mode 100644 index 000000000..ea69e4b47 --- /dev/null +++ b/cmd/secrets/bind.go @@ -0,0 +1,115 @@ +package secrets + +import ( + "context" + "fmt" + "slices" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +// AttachCmd binds a secret to the active context. +type AttachCmd struct { + *flags.GlobalFlags +} + +func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &AttachCmd{GlobalFlags: flags} + attachCmd := &cobra.Command{ + Use: "attach NAME", + Short: "Bind a secret to the active context so it is injected automatically on up", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return attachCmd +} + +func (cmd *AttachCmd) Run(ctx context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if _, err := store.Get(contextName, name); err != nil { + return fmt.Errorf("cannot attach secret %q: %w", name, err) + } + + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + if slices.Contains(ctxConfig.Secrets, name) { + log.Infof("secret %q already attached to context %q", name, contextName) + return nil + } + ctxConfig.Secrets = append(ctxConfig.Secrets, name) + + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + + log.Infof("secret %q attached to context %q", name, contextName) + return nil +} + +// DetachCmd unbinds a secret from the active context. +type DetachCmd struct { + *flags.GlobalFlags +} + +func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DetachCmd{GlobalFlags: flags} + detachCmd := &cobra.Command{ + Use: "detach NAME", + Short: "Unbind a secret from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return detachCmd +} + +func (cmd *DetachCmd) Run(ctx context.Context, name string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + + idx := slices.Index(ctxConfig.Secrets, name) + if idx < 0 { + log.Infof("secret %q is not attached to context %q", name, contextName) + return nil + } + ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) + + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + + log.Infof("secret %q detached from context %q", name, contextName) + return nil +} diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go new file mode 100644 index 000000000..39a14dd17 --- /dev/null +++ b/cmd/secrets/delete.go @@ -0,0 +1,44 @@ +package secrets + +import ( + "context" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +type DeleteCmd struct { + *flags.GlobalFlags +} + +func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DeleteCmd{ + GlobalFlags: flags, + } + deleteCmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm"}, + Short: "Delete a secret from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return deleteCmd +} + +func (cmd *DeleteCmd) Run(ctx context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + if err := store.Delete(contextName, name); err != nil { + return err + } + + log.Infof("secret %q deleted from context %q", name, contextName) + return nil +} diff --git a/cmd/secrets/get.go b/cmd/secrets/get.go new file mode 100644 index 000000000..ad732f043 --- /dev/null +++ b/cmd/secrets/get.go @@ -0,0 +1,49 @@ +package secrets + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +type GetCmd struct { + *flags.GlobalFlags +} + +func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &GetCmd{ + GlobalFlags: flags, + } + getCmd := &cobra.Command{ + Use: "get NAME", + Short: "Print a secret's value", + Long: `Print a secret's value to standard output. + +Intended for scripting. The value is written without a trailing newline +alteration beyond a single terminating newline.`, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return getCmd +} + +func (cmd *GetCmd) Run(ctx context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + value, err := store.Get(contextName, name) + if err != nil { + return err + } + + //nolint:forbidigo // get prints the raw value to stdout for scripting. + fmt.Println(value) + return nil +} diff --git a/cmd/secrets/list.go b/cmd/secrets/list.go new file mode 100644 index 000000000..4d35b75c6 --- /dev/null +++ b/cmd/secrets/list.go @@ -0,0 +1,117 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +type ListCmd struct { + *flags.GlobalFlags +} + +func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &ListCmd{ + GlobalFlags: flags, + } + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List secrets in the active context", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + + return listCmd +} + +type secretEntry struct { + Name string `json:"name"` + Context string `json:"context"` + Created string `json:"created,omitempty"` + LastUsed string `json:"lastUsed,omitempty"` + Orphaned bool `json:"orphaned,omitempty"` +} + +func (cmd *ListCmd) Run(ctx context.Context) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + metas, err := store.List(contextName) + if err != nil { + return err + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + + switch mode { + case output.ModePlain: + renderPlain(metas) + case output.ModeJSON: + return renderJSON(metas) + } + + return nil +} + +func renderPlain(metas []secrets.SecretMeta) { + tableEntries := [][]string{} + for _, m := range metas { + tableEntries = append(tableEntries, []string{ + m.Name, + formatTime(m.Created), + formatTime(m.LastUsed), + orphanLabel(m.Orphaned), + }) + } + table.Print([]string{"Name", "Created", "Last Used", "Status"}, tableEntries) +} + +func renderJSON(metas []secrets.SecretMeta) error { + entries := []secretEntry{} + for _, m := range metas { + entries = append(entries, secretEntry{ + Name: m.Name, + Context: m.Context, + Created: formatTime(m.Created), + LastUsed: formatTime(m.LastUsed), + Orphaned: m.Orphaned, + }) + } + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + + //nolint:forbidigo // list --result-format json prints structured data to stdout. + fmt.Print(string(out)) + return nil +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func orphanLabel(orphaned bool) string { + if orphaned { + return "orphaned" + } + return "ok" +} diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go new file mode 100644 index 000000000..371c689bc --- /dev/null +++ b/cmd/secrets/secrets.go @@ -0,0 +1,44 @@ +package secrets + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +// NewSecretsCmd returns the `devsy secrets` command group. +func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command { + secretsCmd := &cobra.Command{ + Use: "secret", + Short: "Devsy Secrets commands", + Long: `Manage named secrets stored locally and injected into workspaces. + +Secret values are kept in the OS keyring (macOS Keychain, Windows Credential +Manager, or libsecret) when available, and in an age-encrypted file otherwise. +Only non-sensitive metadata is stored in the Devsy config directory.`, + } + + secretsCmd.AddCommand(NewSetCmd(flags)) + secretsCmd.AddCommand(NewListCmd(flags)) + secretsCmd.AddCommand(NewGetCmd(flags)) + secretsCmd.AddCommand(NewDeleteCmd(flags)) + secretsCmd.AddCommand(NewAttachCmd(flags)) + secretsCmd.AddCommand(NewDetachCmd(flags)) + return secretsCmd +} + +// resolveContext returns the active context name and a Store for it. +func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, error) { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return "", nil, err + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return "", nil, err + } + + return devsyConfig.DefaultContext, store, nil +} diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go new file mode 100644 index 000000000..430b61554 --- /dev/null +++ b/cmd/secrets/set.go @@ -0,0 +1,117 @@ +package secrets + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/survey" + "github.com/spf13/cobra" +) + +type SetCmd struct { + *flags.GlobalFlags + + Value string + FromFile string + Stdin bool +} + +func NewSetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &SetCmd{ + GlobalFlags: flags, + } + setCmd := &cobra.Command{ + Use: "set NAME", + Short: "Create or update a secret", + Long: `Create or update a secret in the active context. + +The value can be supplied with --value, piped via --stdin, read from a file +with --from-file, or entered at a hidden interactive prompt when none is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + setCmd.Flags(). + StringVar(&cmd.Value, "value", "", "The secret value (prefer --stdin for sensitive values)") + setCmd.Flags(). + StringVar(&cmd.FromFile, "from-file", "", "Read the secret value from a file") + setCmd.Flags(). + BoolVar(&cmd.Stdin, "stdin", false, "Read the secret value from standard input") + return setCmd +} + +func (cmd *SetCmd) Run(ctx context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + + value, err := cmd.resolveValue(name) + if err != nil { + return err + } + + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + if err := store.Set(contextName, name, value); err != nil { + return err + } + + log.Infof("secret %q set in context %q", name, contextName) + return nil +} + +func (cmd *SetCmd) resolveValue(name string) (string, error) { + sources := 0 + for _, on := range []bool{cmd.Value != "", cmd.FromFile != "", cmd.Stdin} { + if on { + sources++ + } + } + if sources > 1 { + return "", fmt.Errorf("specify only one of --value, --from-file, or --stdin") + } + + switch { + case cmd.Value != "": + return cmd.Value, nil + case cmd.FromFile != "": + return readTrimmedFile(cmd.FromFile) + case cmd.Stdin: + return readTrimmed(os.Stdin, "stdin") + default: + return survey.NewSurvey().Question(&survey.QuestionOptions{ + Question: fmt.Sprintf("Enter value for secret %q", name), + IsPassword: true, + }) + } +} + +func readTrimmedFile(path string) (string, error) { + f, err := os.Open(path) // #nosec G304 -- user-specified secret source file. + if err != nil { + return "", fmt.Errorf("read secret file: %w", err) + } + defer func() { _ = f.Close() }() + + return readTrimmed(f, "secret file") +} + +func readTrimmed(r io.Reader, source string) (string, error) { + data, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("read %s: %w", source, err) + } + + return strings.TrimRight(string(data), "\n"), nil +} diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go new file mode 100644 index 000000000..973c87678 --- /dev/null +++ b/cmd/workspace/up/secrets_test.go @@ -0,0 +1,94 @@ +package up + +import ( + "sort" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + secretAPIKey = "API_KEY" + secretToken = "TOKEN" + secretTLSKey = "TLS_KEY" +) + +func testConfig(bound ...string) *config.Config { + return &config.Config{ + DefaultContext: "default", + Contexts: map[string]*config.ContextConfig{ + "default": {Secrets: bound}, + }, + } +} + +func sortedRequests(reqs []secretRequest) []secretRequest { + sort.Slice(reqs, func(i, j int) bool { return reqs[i].name < reqs[j].name }) + return reqs +} + +func TestCollectSecretRequests_FlagsDefaultToEnv(t *testing.T) { + got, err := collectSecretRequests( + []string{secretAPIKey, "DB_PW,target=DATABASE_PASSWORD"}, + testConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretAPIKey, target: secretAPIKey, mount: false}, + {name: "DB_PW", target: "DATABASE_PASSWORD", mount: false}, + }, sortedRequests(got)) +} + +func TestCollectSecretRequests_MountType(t *testing.T) { + got, err := collectSecretRequests([]string{secretTLSKey + ",type=mount"}, testConfig()) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretTLSKey, target: secretTLSKey, mount: true}, + }, got) +} + +func TestCollectSecretRequests_MountWithTarget(t *testing.T) { + got, err := collectSecretRequests( + []string{secretTLSKey + ",type=mount,target=tls.key"}, + testConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretTLSKey, target: "tls.key", mount: true}, + }, got) +} + +func TestCollectSecretRequests_ContextBindings(t *testing.T) { + got, err := collectSecretRequests(nil, testConfig(secretToken, "SECRET_TWO")) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: "SECRET_TWO", target: "SECRET_TWO", mount: false}, + {name: secretToken, target: secretToken, mount: false}, + }, sortedRequests(got)) +} + +func TestCollectSecretRequests_FlagOverridesBinding(t *testing.T) { + got, err := collectSecretRequests([]string{"TOKEN,target=CI_TOKEN"}, testConfig(secretToken)) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretToken, target: "CI_TOKEN", mount: false}, + }, got) +} + +func TestCollectSecretRequests_Empty(t *testing.T) { + got, err := collectSecretRequests(nil, testConfig()) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestCollectSecretRequests_InvalidType(t *testing.T) { + _, err := collectSecretRequests([]string{"A,type=bogus"}, testConfig()) + require.Error(t, err) +} + +func TestCollectSecretRequests_InvalidOption(t *testing.T) { + _, err := collectSecretRequests([]string{"A,bogus"}, testConfig()) + require.Error(t, err) +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 3f8b7d44e..605f02b18 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -43,6 +43,7 @@ type UpCmd struct { SSHConfigPath string SecretsFile string FeatureSecretsFile string + Secrets []string WorkspaceFolder string DotfilesSource string diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 9f0fcddc1..0e66ccfd8 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/client/clientimplementation" @@ -68,7 +69,7 @@ func (cmd *UpCmd) prepareClient( log.Debug("Using error output stream") config.MergeContextOptions(devsyConfig.Current(), os.Environ()) } - if err := cmd.prepareSecrets(); err != nil { + if err := cmd.prepareSecrets(devsyConfig); err != nil { return nil, err } source, err := cmd.parseWorkspaceSource() @@ -119,7 +120,7 @@ func (cmd *UpCmd) resolveParams( } } -func (cmd *UpCmd) prepareSecrets() error { +func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { if err := mergeEnvFromFiles(&cmd.CLIOptions); err != nil { return err } @@ -134,6 +135,10 @@ func (cmd *UpCmd) prepareSecrets() error { } } + if err := cmd.resolveStoredSecrets(devsyConfig); err != nil { + return err + } + if cmd.FeatureSecretsFile == "" { cmd.FeatureSecretsFile = os.Getenv("DEVCONTAINER_SECRETS_FILE") } @@ -150,6 +155,118 @@ func (cmd *UpCmd) prepareSecrets() error { return nil } +// resolveStoredSecrets resolves secret and context-bound secrets. A missing +// secret is a hard error rather than a silent drop. +func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { + requests, err := collectSecretRequests(cmd.Secrets, devsyConfig) + if err != nil { + return err + } + if len(requests) == 0 { + return nil + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + + contextName := devsyConfig.DefaultContext + for _, req := range requests { + value, err := store.Get(contextName, req.name) + if err != nil { + return fmt.Errorf("resolve secret %q in context %q: %w", req.name, contextName, err) + } + if req.mount { + cmd.SecretsMount = append(cmd.SecretsMount, req.target+"="+value) + } else { + cmd.SecretsEnv = append(cmd.SecretsEnv, req.target+"="+value) + } + } + + return nil +} + +type secretRequest struct { + name string + target string + mount bool +} + +// collectSecretRequests merges context bindings with secret flags; flags override bindings. +func collectSecretRequests(flags []string, devsyConfig *config.Config) ([]secretRequest, error) { + byName := map[string]secretRequest{} + + if ctxConfig := devsyConfig.Contexts[devsyConfig.DefaultContext]; ctxConfig != nil { + for _, name := range ctxConfig.Secrets { + byName[name] = secretRequest{name: name, target: name} + } + } + + for _, entry := range flags { + req, err := parseSecretFlag(entry) + if err != nil { + return nil, err + } + byName[req.name] = req + } + + requests := make([]secretRequest, 0, len(byName)) + for _, req := range byName { + requests = append(requests, req) + } + + return requests, nil +} + +// parseSecretFlag parses NAME[,type=env|mount][,target=X]. +func parseSecretFlag(entry string) (secretRequest, error) { + parts := strings.Split(entry, ",") + req := secretRequest{name: parts[0]} + if req.name == "" { + return secretRequest{}, fmt.Errorf("invalid secret %q: missing name", entry) + } + + for _, opt := range parts[1:] { + key, value, ok := strings.Cut(opt, "=") + if !ok { + return secretRequest{}, fmt.Errorf( + "invalid secret option %q, expected key=value", + opt, + ) + } + if err := req.applyOption(key, value); err != nil { + return secretRequest{}, err + } + } + + if req.target == "" { + req.target = req.name + } + + return req, nil +} + +func (req *secretRequest) applyOption(key, value string) error { + switch key { + case "type": + switch value { + case "env": + req.mount = false + case "mount": + req.mount = true + default: + return fmt.Errorf("invalid secret type %q, expected env or mount", value) + } + case "target": + req.target = value + default: + return fmt.Errorf("invalid secret option %q, expected type or target", key) + } + + return nil +} + func (cmd *UpCmd) parseWorkspaceSource() (*provider2.WorkspaceSource, error) { if cmd.Source == "" { return nil, nil diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index a8dbd2751..7916c3cf4 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -198,7 +198,10 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { flags.StringSlice(&cmd.WorkspaceEnvFile, names.WorkspaceEnvFile, nil, "File of env vars for the workspace"), flags.String(&cmd.SecretsFile, names.SecretsFile, "", - "Dotenv file of KEY=VALUE secrets for lifecycle commands"), + `JSON file ({"KEY":"value"}) of secrets for lifecycle commands`), + flags.StringArray(&cmd.Secrets, names.Secret, nil, + "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ + "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), flags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", `JSON file of feature secret values, format: {"featureId": {"optionName": "value"}}`), flags.StringArray(&cmd.InitEnv, names.InitEnv, nil, diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index 76fd4fb3c..a7a983fc2 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -188,6 +188,26 @@ export class CliRunner { } } + /** + * Like runRaw, but writes `input` to the child's stdin. Used for secret + * values so they never appear in the process argument list (e.g. `ps`). + */ + async runRawStdin(args: string[], input: string): Promise { + await this.acquire() + try { + const { stdout } = await execFile( + this.execPath, + [...this.prefixArgs, ...args, "--log-output", "json"], + { env: this.env, input }, + ) + return stdout + } catch (error: unknown) { + throw this.wrapError(error) + } finally { + this.release() + } + } + private activeChildren = new Set() /** * Tracks streaming children by workspace so callers can cancel everything diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0ce2637b0..4ba9f5be6 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -29,6 +29,15 @@ import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) +// Matches the JSON emitted by `devsy secrets list`. +interface SecretEntry { + name: string + context: string + created?: string + lastUsed?: string + orphaned?: boolean +} + function dockerArch(nodeArch: string): string { if (nodeArch === "x64") return "amd64" if (nodeArch === "arm") return "arm" @@ -538,6 +547,34 @@ export function registerIpcHandlers(deps: IpcDependencies): { await cli.runRaw(["context", "delete", args.name]) }) + ipcMain.handle("secret_list", async () => + cli.run(["secrets", "list"]), + ) + + ipcMain.handle( + "secret_set", + async (_event, args: { name: string; value: string }) => { + trackEvent("secret_set") + await cli.runRawStdin( + ["secrets", "set", args.name, "--stdin"], + args.value, + ) + }, + ) + + ipcMain.handle("secret_delete", async (_event, args: { name: string }) => { + trackEvent("secret_delete") + await cli.runRaw(["secrets", "delete", args.name]) + }) + + ipcMain.handle("secret_attach", async (_event, args: { name: string }) => { + await cli.runRaw(["secrets", "attach", args.name]) + }) + + ipcMain.handle("secret_detach", async (_event, args: { name: string }) => { + await cli.runRaw(["secrets", "detach", args.name]) + }) + // ── System ── ipcMain.handle("devsy_version", async () => { return cli.runRaw(["--version"]) diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 3e3c05cbe..8293adb79 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -13,6 +13,7 @@ import { initWorkspaces, destroyWorkspaces } from "$lib/stores/workspaces.js" import { initProviders, destroyProviders } from "$lib/stores/providers.js" import { initMachines, destroyMachines } from "$lib/stores/machines.js" import { initContexts, destroyContexts } from "$lib/stores/contexts.js" +import { initSecrets } from "$lib/stores/secrets.js" import { initSettings, syncAutoUpdateFromMain, @@ -42,6 +43,7 @@ import ProviderAddPage from "./pages/ProviderAddPage.svelte" import MachinesPage from "./pages/MachinesPage.svelte" import MachineDetailPage from "./pages/MachineDetailPage.svelte" import ContextsPage from "./pages/ContextsPage.svelte" +import SecretsPage from "./pages/SecretsPage.svelte" import SettingsPage from "./pages/SettingsPage.svelte" import SshKeysPage from "./pages/SshKeysPage.svelte" import TerminalsPage from "./pages/TerminalsPage.svelte" @@ -58,6 +60,7 @@ const routes = { "/machines": MachinesPage, "/machines/:id": MachineDetailPage, "/contexts": ContextsPage, + "/secrets": SecretsPage, "/settings": SettingsPage, "/ssh-keys": SshKeysPage, "/terminals": TerminalsPage, @@ -134,6 +137,7 @@ onMount(async () => { initProviders() initMachines() initContexts() + initSecrets() destroySettings = initSettings() stopSessionTracking = initSessionTracking() diff --git a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte index c0277495f..f7ed1f630 100644 --- a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte +++ b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte @@ -4,6 +4,7 @@ import { Layers, LayoutDashboard, KeyRound, + Lock, Plug, Search, Server, @@ -17,6 +18,7 @@ import { workspaces } from "$lib/stores/workspaces.js" import { providers } from "$lib/stores/providers.js" import { machines } from "$lib/stores/machines.js" import { contexts } from "$lib/stores/contexts.js" +import { secrets } from "$lib/stores/secrets.js" import { togglePalette } from "$lib/stores/command-palette.js" import type { Component } from "svelte" @@ -82,6 +84,12 @@ let mainNav: NavItem[] = $derived([ badge: $contexts.length, shortcut: SHORTCUT_BY_HREF["/contexts"], }, + { + href: "/secrets", + label: "Secrets", + icon: Lock, + badge: $secrets.length, + }, { href: "/terminals", label: "Terminals", diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 3dc247784..1e62f3712 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -9,6 +9,7 @@ import type { ProviderOption, ProviderVersion, ProviderVersionCheckResult, + Secret, SshKeyInfo, Workspace, } from "$lib/types/index.js" @@ -247,6 +248,27 @@ export async function contextDelete(name: string): Promise { return invoke("context_delete", { name }) } +// Secret commands +export async function secretList(): Promise { + return invoke("secret_list") +} + +export async function secretSet(name: string, value: string): Promise { + return invoke("secret_set", { name, value }) +} + +export async function secretDelete(name: string): Promise { + return invoke("secret_delete", { name }) +} + +export async function secretAttach(name: string): Promise { + return invoke("secret_attach", { name }) +} + +export async function secretDetach(name: string): Promise { + return invoke("secret_detach", { name }) +} + // Audit commands export async function auditRecent(limit?: number): Promise { return invoke("audit_recent", { limit }) diff --git a/desktop/src/renderer/src/lib/stores/secrets.test.ts b/desktop/src/renderer/src/lib/stores/secrets.test.ts new file mode 100644 index 000000000..6cff95c59 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/secrets.test.ts @@ -0,0 +1,49 @@ +import { get } from "svelte/store" +import { beforeEach, describe, expect, it } from "vitest" +import { mockInvoke, resetTauriMocks } from "$lib/__mocks__/tauri.js" + +import { + initSecrets, + refreshSecrets, + secrets, + secretsLoading, +} from "./secrets.js" + +describe("secrets store", () => { + beforeEach(() => { + resetTauriMocks() + secrets.set([]) + secretsLoading.set(true) + }) + + it("loads secrets on init", async () => { + mockInvoke.mockResolvedValue([ + { name: "API_KEY", context: "default" }, + { name: "DB_PW", context: "default", orphaned: true }, + ]) + + await initSecrets() + + expect(get(secretsLoading)).toBe(false) + expect(get(secrets)).toHaveLength(2) + expect(get(secrets)[1].orphaned).toBe(true) + }) + + it("sets loading false even on error", async () => { + mockInvoke.mockRejectedValue(new Error("IPC not available")) + + await initSecrets() + + expect(get(secretsLoading)).toBe(false) + expect(get(secrets)).toEqual([]) + }) + + it("refreshSecrets updates the store", async () => { + mockInvoke.mockResolvedValue([{ name: "TOKEN", context: "default" }]) + + await refreshSecrets() + + expect(get(secrets)).toHaveLength(1) + expect(get(secrets)[0].name).toBe("TOKEN") + }) +}) diff --git a/desktop/src/renderer/src/lib/stores/secrets.ts b/desktop/src/renderer/src/lib/stores/secrets.ts new file mode 100644 index 000000000..14b5333d1 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/secrets.ts @@ -0,0 +1,25 @@ +import { writable } from "svelte/store" +import { secretList } from "$lib/ipc/commands.js" +import type { Secret } from "$lib/types/index.js" + +export const secrets = writable([]) +export const secretsLoading = writable(true) + +export async function refreshSecrets(): Promise { + try { + secrets.set(await secretList()) + } catch { + // IPC not available + } +} + +export async function initSecrets(): Promise { + secretsLoading.set(true) + try { + secrets.set(await secretList()) + } catch { + // IPC not available + } finally { + secretsLoading.set(false) + } +} diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index d700fcaa8..37d94d056 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -121,6 +121,14 @@ export interface Context { options?: Record } +export interface Secret { + name: string + context: string + created?: string + lastUsed?: string + orphaned?: boolean +} + export interface CommandProgress { commandId: string lines?: string[] diff --git a/desktop/src/renderer/src/pages/SecretsPage.svelte b/desktop/src/renderer/src/pages/SecretsPage.svelte new file mode 100644 index 000000000..ab1b004df --- /dev/null +++ b/desktop/src/renderer/src/pages/SecretsPage.svelte @@ -0,0 +1,170 @@ + + +
+
+

Secrets

+ + + {#snippet child({ props })} + + {/snippet} + + + + Add Secret + + Stored in your OS keyring and injected into workspaces on demand. + + +
{ e.preventDefault(); handleCreate() }} class="space-y-4"> +
+ + (newName = e.currentTarget.value)} + disabled={saving} + /> + {#if newName && !nameValid} +

+ Only letters, digits, and underscores are allowed. +

+ {/if} +
+
+ + (newValue = e.currentTarget.value)} + disabled={saving} + /> +
+ + + + +
+
+
+
+ + {#if $secretsLoading} +
+ {#each Array(2) as _} + + {/each} +
+ {:else if $secrets.length === 0} +
+ +

No secrets stored.

+
+ {:else} +
+ + (searchTerm = e.currentTarget.value)} + /> +
+ {#if filteredSecrets.length === 0} +
+ +

No secrets matching "{searchTerm}"

+
+ {:else} +
+ {#each filteredSecrets as secret (secret.name)} +
+
+
+ +

{secret.name}

+
+
+ {#if secret.orphaned} + orphaned + {/if} + +
+
+

+ Context: {secret.context} +

+
+ {/each} +
+ {/if} + {/if} +
diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index 2fbb84a38..c411cd1e8 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "strings" "github.com/devsy-org/devsy/e2e/framework" @@ -15,6 +16,35 @@ import ( "github.com/onsi/ginkgo/v2" ) +const secretCmd = "secret" + +// useFileSecretsBackend forces the file backend so tests do not depend on an OS +// keyring (unavailable in CI); env is restored on cleanup. +func useFileSecretsBackend() { + for k, v := range map[string]string{ + "DEVSY_SECRETS_BACKEND": "file", + "DEVSY_SECRETS_PASSPHRASE": "e2e-passphrase", + } { + prev, had := os.LookupEnv(k) + framework.ExpectNoError(os.Setenv(k, v)) + ginkgo.DeferCleanup(func() { + if had { + _ = os.Setenv(k, prev) + } else { + _ = os.Unsetenv(k) + } + }) + } +} + +func (dtc *dockerTestContext) storeSecret(ctx context.Context, name, value string) { + _, err := dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "set", name, "--value", value}) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "delete", name}) + }) +} + // logLine represents a single JSON log line from devsy output. // Only the fields we need for test assertions are included. type logLine struct { diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 654e02dde..d2455b983 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -678,10 +678,10 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte(`{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`), 0o600, ) framework.ExpectNoError(err) @@ -698,6 +698,57 @@ var _ = ginkgo.Describe( gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("second-secret-42")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + ginkgo.It( + "managed secret injects into lifecycle commands via --secret", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeSecret(ctx, "MANAGED_SECRET", "managed-value-99") + + err = dtc.f.DevsyUp(ctx, tempDir, "--secret", "MANAGED_SECRET") + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/managed-secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("managed-value-99")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "managed secret mounts as a file under /run/secrets via --secret type=mount", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret-mount", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeSecret(ctx, "MOUNTED_SECRET", "mounted-value-77") + + err = dtc.f.DevsyUp( + ctx, tempDir, + "--secret", "MOUNTED_SECRET,type=mount,target=mounted_secret", + ) + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/mounted-secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("mounted-value-77")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + ginkgo.It("multi devcontainer selection", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-multi-devcontainer", diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go index 27500a671..67a537b6f 100644 --- a/e2e/tests/up/provider_podman.go +++ b/e2e/tests/up/provider_podman.go @@ -348,10 +348,12 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), 0o600, ) framework.ExpectNoError(err) @@ -997,10 +999,12 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), 0o600, ) framework.ExpectNoError(err) diff --git a/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json new file mode 100644 index 000000000..644033a01 --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "cat /run/secrets/mounted_secret > /tmp/mounted-secret-check.out" +} diff --git a/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json new file mode 100644 index 000000000..605db16f2 --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "echo -n $MANAGED_SECRET > /tmp/managed-secret-check.out" +} diff --git a/go.mod b/go.mod index a96c43012..a169c5914 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( al.essio.dev/pkg/shellescape v1.6.0 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.5 + filippo.io/age v1.3.1 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 @@ -56,6 +57,7 @@ require ( github.com/tidwall/gjson v1.19.0 github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 github.com/u-root/u-root v0.16.0 + github.com/zalando/go-keyring v0.2.8 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.54.0 @@ -87,6 +89,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect code.gitea.io/sdk/gitea v0.23.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -145,6 +148,7 @@ require ( github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/creachadair/msync v0.7.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect diff --git a/go.sum b/go.sum index 4bafeded3..fae74c219 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= @@ -18,8 +20,12 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= @@ -220,6 +226,8 @@ github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -723,6 +731,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index ebb402802..55197c060 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -326,6 +326,110 @@ func (x *Message) GetMessage() string { return "" } +type Secret struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Mount bool `protobuf:"varint,3,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Secret) Reset() { + *x = Secret{} + mi := &file_tunnel_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Secret) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Secret) ProtoMessage() {} + +func (x *Secret) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Secret.ProtoReflect.Descriptor instead. +func (*Secret) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{6} +} + +func (x *Secret) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Secret) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *Secret) GetMount() bool { + if x != nil { + return x.Mount + } + return false +} + +type SecretsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Secrets []*Secret `protobuf:"bytes,1,rep,name=secrets,proto3" json:"secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecretsResponse) Reset() { + *x = SecretsResponse{} + mi := &file_tunnel_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecretsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsResponse) ProtoMessage() {} + +func (x *SecretsResponse) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsResponse.ProtoReflect.Descriptor instead. +func (*SecretsResponse) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{7} +} + +func (x *SecretsResponse) GetSecrets() []*Secret { + if x != nil { + return x.Secrets + } + return nil +} + type Chunk struct { state protoimpl.MessageState `protogen:"open.v1"` Content []byte `protobuf:"bytes,1,opt,name=Content,proto3" json:"Content,omitempty"` @@ -335,7 +439,7 @@ type Chunk struct { func (x *Chunk) Reset() { *x = Chunk{} - mi := &file_tunnel_proto_msgTypes[6] + mi := &file_tunnel_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -347,7 +451,7 @@ func (x *Chunk) String() string { func (*Chunk) ProtoMessage() {} func (x *Chunk) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[6] + mi := &file_tunnel_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -360,7 +464,7 @@ func (x *Chunk) ProtoReflect() protoreflect.Message { // Deprecated: Use Chunk.ProtoReflect.Descriptor instead. func (*Chunk) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{6} + return file_tunnel_proto_rawDescGZIP(), []int{8} } func (x *Chunk) GetContent() []byte { @@ -380,7 +484,7 @@ type LogMessage struct { func (x *LogMessage) Reset() { *x = LogMessage{} - mi := &file_tunnel_proto_msgTypes[7] + mi := &file_tunnel_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -392,7 +496,7 @@ func (x *LogMessage) String() string { func (*LogMessage) ProtoMessage() {} func (x *LogMessage) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[7] + mi := &file_tunnel_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -405,7 +509,7 @@ func (x *LogMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use LogMessage.ProtoReflect.Descriptor instead. func (*LogMessage) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{7} + return file_tunnel_proto_rawDescGZIP(), []int{9} } func (x *LogMessage) GetLogLevel() LogLevel { @@ -430,7 +534,7 @@ type Empty struct { func (x *Empty) Reset() { *x = Empty{} - mi := &file_tunnel_proto_msgTypes[8] + mi := &file_tunnel_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -442,7 +546,7 @@ func (x *Empty) String() string { func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[8] + mi := &file_tunnel_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -455,7 +559,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message { // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{8} + return file_tunnel_proto_rawDescGZIP(), []int{10} } var File_tunnel_proto protoreflect.FileDescriptor @@ -476,71 +580,83 @@ var file_tunnel_proto_rawDesc = string([]byte{ 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, - 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, - 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, - 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, - 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, - 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0x8b, 0x06, 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, - 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, - 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, - 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, - 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, - 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x65, 0x22, 0x48, 0x0a, 0x06, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x0f, 0x53, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, + 0x0a, 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0e, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, + 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, + 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, + 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, + 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, + 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, + 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, + 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, + 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0xc1, 0x06, + 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, + 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, + 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, + 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, + 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, + 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, + 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, - 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4c, 0x6f, 0x66, 0x74, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, - 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, + 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, + 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, - 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, - 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x33, 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, - 0x6e, 0x6b, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, - 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, - 0x22, 0x00, 0x30, 0x01, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x73, 0x6b, 0x65, 0x76, 0x65, 0x74, 0x74, 0x65, 0x72, 0x2f, 0x64, 0x65, 0x76, - 0x70, 0x6f, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x31, 0x0a, 0x0b, 0x44, 0x65, 0x76, 0x73, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x07, 0x53, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, + 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, + 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, 0x75, 0x6e, 0x6e, + 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, + 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, + 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, 0x00, 0x30, + 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x64, 0x65, 0x76, 0x73, 0x79, 0x2d, 0x6f, 0x72, 0x67, 0x2f, 0x64, 0x65, 0x76, 0x73, 0x79, 0x2f, + 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -556,7 +672,7 @@ func file_tunnel_proto_rawDescGZIP() []byte { } var file_tunnel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_tunnel_proto_goTypes = []any{ (LogLevel)(0), // 0: tunnel.LogLevel (*StreamMountRequest)(nil), // 1: tunnel.StreamMountRequest @@ -565,45 +681,50 @@ var file_tunnel_proto_goTypes = []any{ (*ForwardPortRequest)(nil), // 4: tunnel.ForwardPortRequest (*ForwardPortResponse)(nil), // 5: tunnel.ForwardPortResponse (*Message)(nil), // 6: tunnel.Message - (*Chunk)(nil), // 7: tunnel.Chunk - (*LogMessage)(nil), // 8: tunnel.LogMessage - (*Empty)(nil), // 9: tunnel.Empty + (*Secret)(nil), // 7: tunnel.Secret + (*SecretsResponse)(nil), // 8: tunnel.SecretsResponse + (*Chunk)(nil), // 9: tunnel.Chunk + (*LogMessage)(nil), // 10: tunnel.LogMessage + (*Empty)(nil), // 11: tunnel.Empty } var file_tunnel_proto_depIdxs = []int32{ - 0, // 0: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel - 9, // 1: tunnel.Tunnel.Ping:input_type -> tunnel.Empty - 8, // 2: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage - 6, // 3: tunnel.Tunnel.SendResult:input_type -> tunnel.Message - 6, // 4: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message - 6, // 5: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message - 6, // 6: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message - 9, // 7: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty - 6, // 8: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message - 6, // 9: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message - 6, // 10: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message - 4, // 11: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest - 2, // 12: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest - 9, // 13: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty - 1, // 14: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest - 9, // 15: tunnel.Tunnel.Ping:output_type -> tunnel.Empty - 9, // 16: tunnel.Tunnel.Log:output_type -> tunnel.Empty - 9, // 17: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty - 6, // 18: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message - 6, // 19: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message - 6, // 20: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message - 6, // 21: tunnel.Tunnel.GitUser:output_type -> tunnel.Message - 6, // 22: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message - 6, // 23: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message - 6, // 24: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message - 5, // 25: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse - 3, // 26: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse - 7, // 27: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk - 7, // 28: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk - 15, // [15:29] is the sub-list for method output_type - 1, // [1:15] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 7, // 0: tunnel.SecretsResponse.secrets:type_name -> tunnel.Secret + 0, // 1: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel + 11, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty + 10, // 3: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage + 6, // 4: tunnel.Tunnel.SendResult:input_type -> tunnel.Message + 6, // 5: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message + 6, // 6: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message + 6, // 7: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message + 11, // 8: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty + 6, // 9: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message + 6, // 10: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message + 6, // 11: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message + 11, // 12: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty + 4, // 13: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest + 2, // 14: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest + 11, // 15: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty + 1, // 16: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest + 11, // 17: tunnel.Tunnel.Ping:output_type -> tunnel.Empty + 11, // 18: tunnel.Tunnel.Log:output_type -> tunnel.Empty + 11, // 19: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty + 6, // 20: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message + 6, // 21: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message + 6, // 22: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message + 6, // 23: tunnel.Tunnel.GitUser:output_type -> tunnel.Message + 6, // 24: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message + 6, // 25: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message + 6, // 26: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message + 8, // 27: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse + 5, // 28: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse + 3, // 29: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse + 9, // 30: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk + 9, // 31: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk + 17, // [17:32] is the sub-list for method output_type + 2, // [2:17] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_tunnel_proto_init() } @@ -617,7 +738,7 @@ func file_tunnel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tunnel_proto_rawDesc), len(file_tunnel_proto_rawDesc)), NumEnums: 1, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index 775e82fa3..c8442294b 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -17,6 +17,7 @@ service Tunnel { rpc DevsyConfig(Message) returns (Message) {} rpc GPGPublicKeys(Message) returns (Message) {} rpc KubeConfig(Message) returns (Message) {} + rpc Secrets(Empty) returns (SecretsResponse) {} rpc ForwardPort(ForwardPortRequest) returns (ForwardPortResponse) {} rpc StopForwardPort(StopForwardPortRequest) returns (StopForwardPortResponse) {} @@ -49,6 +50,16 @@ message Message { string message = 1; } +message Secret { + string name = 1; + string value = 2; + bool mount = 3; +} + +message SecretsResponse { + repeated Secret secrets = 1; +} + message Chunk { bytes Content = 1; } diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index e6167ea99..ab5aac9e2 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -31,6 +31,7 @@ const ( Tunnel_DevsyConfig_FullMethodName = "/tunnel.Tunnel/DevsyConfig" Tunnel_GPGPublicKeys_FullMethodName = "/tunnel.Tunnel/GPGPublicKeys" Tunnel_KubeConfig_FullMethodName = "/tunnel.Tunnel/KubeConfig" + Tunnel_Secrets_FullMethodName = "/tunnel.Tunnel/Secrets" Tunnel_ForwardPort_FullMethodName = "/tunnel.Tunnel/ForwardPort" Tunnel_StopForwardPort_FullMethodName = "/tunnel.Tunnel/StopForwardPort" Tunnel_StreamWorkspace_FullMethodName = "/tunnel.Tunnel/StreamWorkspace" @@ -51,6 +52,7 @@ type TunnelClient interface { DevsyConfig(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GPGPublicKeys(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) KubeConfig(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) + Secrets(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SecretsResponse, error) ForwardPort(ctx context.Context, in *ForwardPortRequest, opts ...grpc.CallOption) (*ForwardPortResponse, error) StopForwardPort(ctx context.Context, in *StopForwardPortRequest, opts ...grpc.CallOption) (*StopForwardPortResponse, error) StreamWorkspace(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Chunk], error) @@ -165,6 +167,16 @@ func (c *tunnelClient) KubeConfig(ctx context.Context, in *Message, opts ...grpc return out, nil } +func (c *tunnelClient) Secrets(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SecretsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecretsResponse) + err := c.cc.Invoke(ctx, Tunnel_Secrets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *tunnelClient) ForwardPort(ctx context.Context, in *ForwardPortRequest, opts ...grpc.CallOption) (*ForwardPortResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardPortResponse) @@ -237,6 +249,7 @@ type TunnelServer interface { DevsyConfig(context.Context, *Message) (*Message, error) GPGPublicKeys(context.Context, *Message) (*Message, error) KubeConfig(context.Context, *Message) (*Message, error) + Secrets(context.Context, *Empty) (*SecretsResponse, error) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) StopForwardPort(context.Context, *StopForwardPortRequest) (*StopForwardPortResponse, error) StreamWorkspace(*Empty, grpc.ServerStreamingServer[Chunk]) error @@ -281,6 +294,9 @@ func (UnimplementedTunnelServer) GPGPublicKeys(context.Context, *Message) (*Mess func (UnimplementedTunnelServer) KubeConfig(context.Context, *Message) (*Message, error) { return nil, status.Errorf(codes.Unimplemented, "method KubeConfig not implemented") } +func (UnimplementedTunnelServer) Secrets(context.Context, *Empty) (*SecretsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Secrets not implemented") +} func (UnimplementedTunnelServer) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ForwardPort not implemented") } @@ -494,6 +510,24 @@ func _Tunnel_KubeConfig_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _Tunnel_Secrets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TunnelServer).Secrets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Tunnel_Secrets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TunnelServer).Secrets(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _Tunnel_ForwardPort_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ForwardPortRequest) if err := dec(in); err != nil { @@ -599,6 +633,10 @@ var Tunnel_ServiceDesc = grpc.ServiceDesc{ MethodName: "KubeConfig", Handler: _Tunnel_KubeConfig_Handler, }, + { + MethodName: "Secrets", + Handler: _Tunnel_Secrets_Handler, + }, { MethodName: "ForwardPort", Handler: _Tunnel_ForwardPort_Handler, diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index b238e2c2e..12cedee51 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -1,7 +1,10 @@ package tunnelserver import ( + "strings" + "github.com/devsy-org/api/pkg/devsy" + "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" @@ -57,3 +60,23 @@ func WithPlatformOptions(options *devsy.PlatformOptions) Option { return s } } + +func WithSecrets(env, mount []string) Option { + return func(s *tunnelServer) *tunnelServer { + s.secrets = append(toSecrets(env, false), toSecrets(mount, true)...) + return s + } +} + +func toSecrets(entries []string, mount bool) []*tunnel.Secret { + secrets := make([]*tunnel.Secret, 0, len(entries)) + for _, entry := range entries { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + continue + } + secrets = append(secrets, &tunnel.Secret{Name: name, Value: value, Mount: mount}) + } + + return secrets +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 23fde17fc..1238d2ca0 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -114,6 +114,7 @@ type tunnelServer struct { workspace *provider2.Workspace platformOptions *devsy.PlatformOptions + secrets []*tunnel.Secret } func (t *tunnelServer) RunWithResult( @@ -228,6 +229,13 @@ func (t *tunnelServer) DockerCredentials( return &tunnel.Message{Message: string(out)}, nil } +func (t *tunnelServer) Secrets( + _ context.Context, + _ *tunnel.Empty, +) (*tunnel.SecretsResponse, error) { + return &tunnel.SecretsResponse{Secrets: t.secrets}, nil +} + func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) { workingDir := "" if t.workspace != nil { diff --git a/pkg/agent/tunnelserver/tunnelserver_secrets_test.go b/pkg/agent/tunnelserver/tunnelserver_secrets_test.go new file mode 100644 index 000000000..272ec47fb --- /dev/null +++ b/pkg/agent/tunnelserver/tunnelserver_secrets_test.go @@ -0,0 +1,43 @@ +package tunnelserver + +import ( + "context" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecrets_ReturnsEnvAndMountEntries(t *testing.T) { + s := New(WithSecrets( + []string{"API_KEY=abc", "NO_EQUALS"}, + []string{"tls.key=keydata"}, + )) + + resp, err := s.Secrets(context.Background(), &tunnel.Empty{}) + require.NoError(t, err) + + got := map[string]*tunnel.Secret{} + for _, sec := range resp.GetSecrets() { + got[sec.GetName()] = sec + } + + require.Contains(t, got, "API_KEY") + assert.Equal(t, "abc", got["API_KEY"].GetValue()) + assert.False(t, got["API_KEY"].GetMount()) + + require.Contains(t, got, "tls.key") + assert.Equal(t, "keydata", got["tls.key"].GetValue()) + assert.True(t, got["tls.key"].GetMount()) + + assert.NotContains(t, got, "NO_EQUALS") +} + +func TestSecrets_EmptyWhenUnset(t *testing.T) { + s := New() + + resp, err := s.Secrets(context.Background(), &tunnel.Empty{}) + require.NoError(t, err) + assert.Empty(t, resp.GetSecrets()) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ffb0dfb88..37613219f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -43,6 +43,9 @@ type ContextConfig struct { // Providers holds the provider configuration Providers map[string]*ProviderConfig `json:"providers,omitempty"` + // Secrets are names of stored secrets bound to this context, injected on `up`. Names only, no values. + Secrets []string `json:"secrets,omitempty"` + // OriginalProvider is the original default provider OriginalProvider string `json:"-"` } diff --git a/pkg/config/context.go b/pkg/config/context.go index 1e0288de8..499b2ae93 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -24,6 +24,7 @@ const ( ContextOptionRegistryCache = "REGISTRY_CACHE" ContextOptionSSHStrictHostKeyChecking = "SSH_STRICT_HOST_KEY_CHECKING" ContextOptionSSHTunnelMode = "SSH_TUNNEL_MODE" + ContextOptionSecretsBackend = "SECRETS_BACKEND" ) var ContextOptions = []ContextOption{ @@ -117,6 +118,13 @@ var ContextOptions = []ContextOption{ Default: "false", Enum: []string{"true", "false"}, }, + { + Name: ContextOptionSecretsBackend, + Description: "Where secret values are stored: 'keyring' (OS keyring), " + + "'file' (age-encrypted file), or 'auto' (keyring when available, else file)", + Default: "auto", + Enum: []string{"auto", "keyring", "file"}, + }, } func MergeContextOptions(contextConfig *ContextConfig, environ []string) { diff --git a/pkg/config/env.go b/pkg/config/env.go index bce1752ac..486b7a9ac 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -63,6 +63,10 @@ const ( // EnvWorkspaceDaemonConfig holds the workspace daemon configuration. EnvWorkspaceDaemonConfig = "DEVSY_WORKSPACE_DAEMON_CONFIG" + // EnvSecretsEnv passes newline-separated KEY=VALUE secrets to agent + // subprocesses via the environment (not argv, to keep them out of ps). + EnvSecretsEnv = "DEVSY_SECRETS_ENV" // #nosec G101 -- env var name, not a credential. + // EnvWorkspaceCredentialsPort is the workspace credentials server port. EnvWorkspaceCredentialsPort = "DEVSY_WORKSPACE_CREDENTIALS_PORT" // #nosec G101 diff --git a/pkg/credentials/integration_test.go b/pkg/credentials/integration_test.go index a18dad850..8974c425c 100644 --- a/pkg/credentials/integration_test.go +++ b/pkg/credentials/integration_test.go @@ -17,7 +17,7 @@ import ( ) func TestIntegration_SigningFailure_SurfacesServerError(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return nil, fmt.Errorf( "failed to sign commit: exit status 1, stderr: Permission denied (publickey)", @@ -55,7 +55,7 @@ func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) { "-----BEGIN SSH SIGNATURE-----\ntest-signature\n-----END SSH SIGNATURE-----\n", ) - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { response := gitsshsigning.GitSSHSignatureResponse{Signature: expectedSig} jsonBytes, err := json.Marshal(response) @@ -94,7 +94,7 @@ func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) { func TestIntegration_SignatureRequest_IncludesPublicKeyContent(t *testing.T) { var receivedMessage string - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { receivedMessage = msg.Message sig := gitsshsigning.GitSSHSignatureResponse{ diff --git a/pkg/credentials/mock_tunnel_client_test.go b/pkg/credentials/mock_tunnel_client_test.go index 2861f4a59..5efe4d5dd 100644 --- a/pkg/credentials/mock_tunnel_client_test.go +++ b/pkg/credentials/mock_tunnel_client_test.go @@ -8,51 +8,11 @@ import ( "google.golang.org/grpc" ) -type mockTunnelClient struct { +type mockCredentialsClient struct { gitSSHSignatureFunc func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) } -func (m *mockTunnelClient) Ping( - ctx context.Context, - in *tunnel.Empty, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) Log( - ctx context.Context, - in *tunnel.LogMessage, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) SendResult( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) DockerCredentials( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Message, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) GitCredentials( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Message, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) GitSSHSignature( +func (m *mockCredentialsClient) GitSSHSignature( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -60,15 +20,15 @@ func (m *mockTunnelClient) GitSSHSignature( return m.gitSSHSignatureFunc(ctx, in) } -func (m *mockTunnelClient) GitUser( +func (m *mockCredentialsClient) GitCredentials( ctx context.Context, - in *tunnel.Empty, + in *tunnel.Message, opts ...grpc.CallOption, ) (*tunnel.Message, error) { return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) DevsyConfig( +func (m *mockCredentialsClient) DockerCredentials( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -76,7 +36,7 @@ func (m *mockTunnelClient) DevsyConfig( return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) GPGPublicKeys( +func (m *mockCredentialsClient) GPGPublicKeys( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -84,42 +44,10 @@ func (m *mockTunnelClient) GPGPublicKeys( return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) KubeConfig( +func (m *mockCredentialsClient) DevsyConfig( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, ) (*tunnel.Message, error) { return nil, fmt.Errorf("not implemented") } - -func (m *mockTunnelClient) ForwardPort( - ctx context.Context, - in *tunnel.ForwardPortRequest, - opts ...grpc.CallOption, -) (*tunnel.ForwardPortResponse, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StopForwardPort( - ctx context.Context, - in *tunnel.StopForwardPortRequest, - opts ...grpc.CallOption, -) (*tunnel.StopForwardPortResponse, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StreamWorkspace( - ctx context.Context, - in *tunnel.Empty, - opts ...grpc.CallOption, -) (grpc.ServerStreamingClient[tunnel.Chunk], error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StreamMount( - ctx context.Context, - in *tunnel.StreamMountRequest, - opts ...grpc.CallOption, -) (grpc.ServerStreamingClient[tunnel.Chunk], error) { - return nil, fmt.Errorf("not implemented") -} diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index ca8694799..8fe1b8018 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -14,14 +14,35 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "google.golang.org/grpc" ) const DefaultPort = "12049" +type CredentialsClient interface { + GitCredentials( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + DockerCredentials( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + GitSSHSignature( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + GPGPublicKeys( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + DevsyConfig( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) +} + +var _ CredentialsClient = (tunnel.TunnelClient)(nil) + func RunCredentialsServer( ctx context.Context, port int, - client tunnel.TunnelClient, + client CredentialsClient, ) error { var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { log.Debugf("incoming client connection: path=%s", request.URL.Path) @@ -95,7 +116,7 @@ func handleDockerCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -119,7 +140,7 @@ func handleGitCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -144,7 +165,7 @@ func handleGitSSHSignatureRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -180,7 +201,7 @@ func handleDevsyPlatformCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -204,7 +225,7 @@ func handleDevsyPlatformCredentialsRequest( func handleGPGPublicKeysRequest( ctx context.Context, writer http.ResponseWriter, - client tunnel.TunnelClient, + client CredentialsClient, ) error { response, err := client.GPGPublicKeys(ctx, &tunnel.Message{}) if err != nil { diff --git a/pkg/credentials/server_test.go b/pkg/credentials/server_test.go index 55859b55f..a5253430b 100644 --- a/pkg/credentials/server_test.go +++ b/pkg/credentials/server_test.go @@ -20,7 +20,7 @@ type errReader struct{ err error } func (e *errReader) Read([]byte) (int, error) { return 0, e.err } func TestHandleGitSSHSignature_GRPCError_ReturnsJSON500(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return nil, fmt.Errorf("Permission denied") }, @@ -47,7 +47,7 @@ func TestHandleGitSSHSignature_GRPCError_ReturnsJSON500(t *testing.T) { } func TestHandleGitSSHSignature_BodyReadError_ReturnsJSON500(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { t.Fatal("gRPC should not be called when body read fails") return nil, nil @@ -76,7 +76,7 @@ func TestHandleGitSSHSignature_BodyReadError_ReturnsJSON500(t *testing.T) { func TestHandleGitSSHSignature_GRPCSuccess_ReturnsJSON200(t *testing.T) { expectedMessage := `{"signature":"abc123"}` - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return &tunnel.Message{Message: expectedMessage}, nil }, diff --git a/pkg/credentials/start.go b/pkg/credentials/start.go index f1b6a4d23..db53a9e26 100644 --- a/pkg/credentials/start.go +++ b/pkg/credentials/start.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - "github.com/devsy-org/devsy/pkg/agent/tunnel" devsyhttp "github.com/devsy-org/devsy/pkg/http" "github.com/devsy-org/devsy/pkg/log" portpkg "github.com/devsy-org/devsy/pkg/port" @@ -16,7 +15,7 @@ import ( func StartCredentialsServer( ctx context.Context, - client tunnel.TunnelClient, + client CredentialsClient, ) (int, error) { port, err := portpkg.FindAvailablePort(random.InRange(13000, 17000)) if err != nil { diff --git a/pkg/devcontainer/compose_up.go b/pkg/devcontainer/compose_up.go index 4cfa54a27..5b7513981 100644 --- a/pkg/devcontainer/compose_up.go +++ b/pkg/devcontainer/compose_up.go @@ -23,6 +23,14 @@ func escapeComposeLabelValue(value string) string { } func (r *runner) extendedDockerComposeUp(params *composeUpParams) (string, error) { + secretsMount, err := r.secretsMount() + if err != nil { + return "", err + } + if secretsMount != nil { + params.mergedConfig.Mounts = append(params.mergedConfig.Mounts, secretsMount) + } + dockerComposeUpProject := r.generateDockerComposeUpProject(params) dockerComposeData, err := yaml.Marshal(dockerComposeUpProject) if err != nil { diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 017786d28..6ba630ed3 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -426,6 +426,10 @@ const ( ShutdownActionStopCompose = "stopCompose" ) +// SecretsMountDir is the in-container tmpfs directory where file-delivered +// secrets are written, matching the Docker/Podman convention. +const SecretsMountDir = "/run/secrets" + const ( AutoForwardIgnore = "ignore" AutoForwardNotify = "notify" diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 38fd71243..e5f92d0d0 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -287,9 +287,16 @@ func (r *runner) compressResult(result *config.Result) (string, error) { } func (r *runner) compressWorkspaceConfig() (string, error) { + // Strip secret values from the CLIOptions embedded in the setup command + // arguments; the container pulls them over the tunnel instead (see the + // Secrets RPC), so values never appear in process listings. + cliOptions := r.workspaceConfig.CLIOptions + cliOptions.SecretsEnv = nil + cliOptions.SecretsMount = nil + workspaceConfig := &provider2.ContainerWorkspaceInfo{ IDE: r.workspaceConfig.Workspace.IDE, - CLIOptions: r.workspaceConfig.CLIOptions, + CLIOptions: cliOptions, Dockerless: r.workspaceConfig.Agent.Dockerless, ContainerTimeout: r.workspaceConfig.Agent.ContainerTimeout, Source: r.workspaceConfig.Workspace.Source, @@ -445,6 +452,10 @@ func (r *runner) executeSetup( r.workspaceConfig.Agent.InjectDockerCredentials != stringFalse, config.GetMounts(result), tunnelserver.WithPlatformOptions(&r.workspaceConfig.CLIOptions.Platform), + tunnelserver.WithSecrets( + r.workspaceConfig.CLIOptions.SecretsEnv, + r.workspaceConfig.CLIOptions.SecretsMount, + ), ) } diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index 0351ce3df..4e77b0e82 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -18,9 +18,14 @@ import ( "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/types" ) +// hookRedactor masks injected secret values in lifecycle-hook logs. It is set +// once per process by mergeSecretsEnv, the single point where secrets enter. +var hookRedactor = secrets.NewRedactor(nil) + // LifecyclePhase identifies a devcontainer lifecycle command. type LifecyclePhase string @@ -136,6 +141,7 @@ type lifecycleEnv struct { // mergeSecretsEnv merges KEY=VALUE pairs from the --secrets-file flag into the // lifecycle env map. Existing keys are NOT overridden (config takes precedence). func mergeSecretsEnv(env map[string]string, secretsEnv []string) { + hookRedactor = secrets.NewRedactor(secretsEnv) for _, entry := range secretsEnv { key, value, ok := strings.Cut(entry, "=") if !ok { @@ -519,7 +525,10 @@ func runSingleHookCommand( remoteEnvArr []string, key string, c []string, ) error { - log.Infof("running %s lifecycle hook: %s %s", p.name, key, strings.Join(c, " ")) + log.Infof( + "running %s lifecycle hook: %s %s", + p.name, key, hookRedactor.Redact(strings.Join(c, " ")), + ) currentUser, err := user.Current() if err != nil { return err @@ -578,20 +587,26 @@ func executeAndLog(cmd *exec.Cmd, phaseName string, key string, c []string) erro log.Debugf( "failed running %s lifecycle script: command=%v, error=%v", key, - cmd.Args, + hookRedactor.Redact(strings.Join(cmd.Args, " ")), err, ) - return fmt.Errorf("%s: command %q failed: %w", phaseName, strings.Join(c, " "), err) + return fmt.Errorf( + "%s: command %q failed: %w", + phaseName, hookRedactor.Redact(strings.Join(c, " ")), err, + ) } - log.Infof("ran command: command=%s, args=%s", key, strings.Join(c, " ")) + log.Infof( + "ran command: command=%s, args=%s", + key, hookRedactor.Redact(strings.Join(c, " ")), + ) return nil } func logPipeOutput(pipe io.ReadCloser, isStdout bool) { scanner := bufio.NewScanner(pipe) for scanner.Scan() { - line := scanner.Text() + line := hookRedactor.Redact(scanner.Text()) if isStdout { log.Info(line) } else { diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 9c1e70d3f..2b4280fd0 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -39,6 +39,7 @@ type ContainerSetupConfig struct { SetupInfo *config.Result ExtraWorkspaceEnv []string SecretsEnv []string + SecretsMount []string ChownProjects bool Prebuild bool PlatformOptions *devsy.PlatformOptions @@ -70,6 +71,10 @@ func SetupContainerPreAttach( return DeferredHooks{}, err } + if err := writeSecretFiles(cfg); err != nil { + return DeferredHooks{}, err + } + setupOptionalFeatures(ctx, cfg) log.Debugf("running pre-attach lifecycle hooks") @@ -124,6 +129,35 @@ func validateContainerSetupConfig(cfg *ContainerSetupConfig) error { return nil } +// writeSecretFiles writes each TARGET=VALUE secret to SecretsMountDir (0600). +func writeSecretFiles(cfg *ContainerSetupConfig) error { + if len(cfg.SecretsMount) == 0 { + return nil + } + + if err := os.MkdirAll(config.SecretsMountDir, 0o755); err != nil { + return fmt.Errorf("create secrets mount dir: %w", err) + } + + user := config.GetRemoteUser(cfg.SetupInfo) + for _, entry := range cfg.SecretsMount { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + continue + } + + path := filepath.Join(config.SecretsMountDir, name) + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + return fmt.Errorf("write secret file %s: %w", name, err) + } + if err := copy2.Chown(path, user); err != nil { + log.Warnf("Error chowning secret file %s: %v", name, err) + } + } + + return nil +} + func writeResultFile(cfg *ContainerSetupConfig) { rawBytes, err := json.Marshal(cfg.SetupInfo) if err != nil { diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 9b1fdced7..8d2a7220c 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -651,6 +651,10 @@ func (r *runner) getDockerlessRunOptions( Source: "dockerless-" + r.id, Target: "/workspaces/.dockerless", }) + mounts, err = r.withSecretsMount(mounts) + if err != nil { + return nil, err + } // build run options return &driver.RunOptions{ @@ -715,21 +719,14 @@ func (r *runner) getRunOptions( user = mergedConfig.ContainerUser } - // Resolve any ${containerEnv:VAR} references in containerEnv values using - // the image's inspected environment. ${containerWorkspaceFolder} and - // ${containerWorkspaceFolderBasename} are already substituted upstream in - // runner.substitute, since the host fully knows those values. - var imageEnv []string - if buildInfo.ImageDetails != nil { - imageEnv = buildInfo.ImageDetails.Config.Env + if err := resolveContainerEnv(mergedConfig, buildInfo); err != nil { + return nil, err } - resolvedContainerEnv, err := config.ResolveContainerEnvFromImage( - mergedConfig.ContainerEnv, imageEnv, - ) + + mounts, err := r.withSecretsMount(mergedConfig.Mounts) if err != nil { - return nil, fmt.Errorf("resolve containerEnv from image: %w", err) + return nil, err } - mergedConfig.ContainerEnv = resolvedContainerEnv return &driver.RunOptions{ UID: r.workspaceUID(), @@ -744,7 +741,7 @@ func (r *runner) getRunOptions( Init: mergedConfig.Init, WorkspaceMount: workspaceMountPtr, SecurityOpt: mergedConfig.SecurityOpt, - Mounts: mergedConfig.Mounts, + Mounts: mounts, Userns: substitutionContext.Userns, UidMap: substitutionContext.UidMap, GidMap: substitutionContext.GidMap, @@ -752,6 +749,63 @@ func (r *runner) getRunOptions( }, nil } +// resolveContainerEnv resolves ${containerEnv:VAR} references in containerEnv +// against the image's inspected environment. ${containerWorkspaceFolder} and +// ${containerWorkspaceFolderBasename} are already substituted upstream. +func resolveContainerEnv( + mergedConfig *config.MergedDevContainerConfig, + buildInfo *config.BuildInfo, +) error { + var imageEnv []string + if buildInfo.ImageDetails != nil { + imageEnv = buildInfo.ImageDetails.Config.Env + } + resolved, err := config.ResolveContainerEnvFromImage(mergedConfig.ContainerEnv, imageEnv) + if err != nil { + return fmt.Errorf("resolve containerEnv from image: %w", err) + } + mergedConfig.ContainerEnv = resolved + + return nil +} + +// withSecretsMount appends the secrets tmpfs mount when secrets are +// file-delivered. It fails when the driver cannot honor a tmpfs mount rather +// than silently writing secrets to a durable layer. +func (r *runner) withSecretsMount(mounts []*config.Mount) ([]*config.Mount, error) { + mount, err := r.secretsMount() + if err != nil { + return nil, err + } + if mount == nil { + return mounts, nil + } + + return append(mounts, mount), nil +} + +// secretsMount returns the /run/secrets tmpfs mount when a secret is +// file-delivered, or nil when none is. Values must never touch a persistent +// layer, so an unsupported driver is an error rather than a silent skip. +func (r *runner) secretsMount() (*config.Mount, error) { + if r.workspaceConfig == nil || len(r.workspaceConfig.CLIOptions.SecretsMount) == 0 { + return nil, nil + } + + if !driver.DriverSupportsMountType(r.driver, driver.MountTypeTmpfs) { + return nil, fmt.Errorf( + "the current provider does not support mounting secrets as files (--secret type=mount); " + + "use type=env instead", + ) + } + + return &config.Mount{ + Type: driver.MountTypeTmpfs, + Target: config.SecretsMountDir, + Other: []string{"tmpfs-mode=0755"}, + }, nil +} + // add environment variables that signals that we are in a remote container // (vscode compatibility) and specifically that we are using devsy. func (r *runner) addExtraEnvVars(env map[string]string) map[string]string { diff --git a/pkg/driver/kubernetes/driver.go b/pkg/driver/kubernetes/driver.go index fc08684e5..3d6bc4b8c 100644 --- a/pkg/driver/kubernetes/driver.go +++ b/pkg/driver/kubernetes/driver.go @@ -58,6 +58,15 @@ func (k *KubernetesDriver) CanReprovision() bool { return true } +func (k *KubernetesDriver) SupportsMountType(mountType string) bool { + switch mountType { + case driver.MountTypeBind, driver.MountTypeVolume, driver.MountTypeTmpfs: + return true + default: + return false + } +} + func (k *KubernetesDriver) getDevContainerPvc( ctx context.Context, id string, diff --git a/pkg/driver/kubernetes/run.go b/pkg/driver/kubernetes/run.go index 5696bc96e..f7ad8d076 100644 --- a/pkg/driver/kubernetes/run.go +++ b/pkg/driver/kubernetes/run.go @@ -149,11 +149,26 @@ func (k *KubernetesDriver) runContainer( // loop over volume mounts volumeMounts := []corev1.VolumeMount{getVolumeMount(0, mount)} + var tmpfsVolumes []corev1.Volume for idx, mount := range options.Mounts { - volumeMount := getVolumeMount(idx+1, mount) - if mount.Type == "bind" || mount.Type == "volume" { - volumeMounts = append(volumeMounts, volumeMount) - } else { + switch mount.Type { + case driver.MountTypeBind, driver.MountTypeVolume: + volumeMounts = append(volumeMounts, getVolumeMount(idx+1, mount)) + case driver.MountTypeTmpfs: + name := fmt.Sprintf("%s-tmpfs-%d", DevContainerName, idx+1) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: name, + MountPath: mount.Target, + }) + tmpfsVolumes = append(tmpfsVolumes, corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }) + default: log.Warnf( "Unsupported mount type %q in mount %q, will skip", mount.Type, @@ -260,7 +275,7 @@ func (k *KubernetesDriver) runContainer( k.options.StrictSecurity, daemonConfigSecretName, ) - pod.Spec.Volumes = getVolumes(pod, id, daemonConfigSecretName) + pod.Spec.Volumes = append(getVolumes(pod, id, daemonConfigSecretName), tmpfsVolumes...) // avoids a problem where attaching volumes with large repositories would cause an extremely long pod startup time // because changing the ownership of all files takes longer than the kubelet expects it to if pod.Spec.SecurityContext == nil { diff --git a/pkg/driver/types.go b/pkg/driver/types.go index baf9b876a..a0c01fba9 100644 --- a/pkg/driver/types.go +++ b/pkg/driver/types.go @@ -46,6 +46,33 @@ type ReprovisioningDriver interface { CanReprovision() bool } +// Mount types understood by the drivers. +const ( + MountTypeBind = "bind" + MountTypeVolume = "volume" + MountTypeTmpfs = "tmpfs" +) + +// MountCapableDriver is implemented by drivers that can report which mount +// types they support. Drivers that do not implement it are assumed to support +// the bind/volume/tmpfs types the docker driver has always handled. +type MountCapableDriver interface { + Driver + + // SupportsMountType reports whether the driver can honor the given mount type. + SupportsMountType(mountType string) bool +} + +// DriverSupportsMountType reports whether the driver can honor mountType, +// defaulting to true for drivers that do not advertise the capability. +func DriverSupportsMountType(d Driver, mountType string) bool { + if mc, ok := d.(MountCapableDriver); ok { + return mc.SupportsMountType(mountType) + } + + return true +} + // CommandParams holds the parameters for running a command inside a devcontainer. type CommandParams struct { WorkspaceID string diff --git a/pkg/driver/types_test.go b/pkg/driver/types_test.go new file mode 100644 index 000000000..822cd660a --- /dev/null +++ b/pkg/driver/types_test.go @@ -0,0 +1,31 @@ +package driver + +import "testing" + +type mountCapableStub struct { + Driver + tmpfs bool +} + +func (m mountCapableStub) SupportsMountType(mountType string) bool { + return mountType == MountTypeTmpfs && m.tmpfs +} + +type plainDriverStub struct { + Driver +} + +func TestDriverSupportsMountType_DefaultsToTrue(t *testing.T) { + if !DriverSupportsMountType(plainDriverStub{}, MountTypeTmpfs) { + t.Fatal("drivers without the capability should default to supported") + } +} + +func TestDriverSupportsMountType_RespectsCapability(t *testing.T) { + if !DriverSupportsMountType(mountCapableStub{tmpfs: true}, MountTypeTmpfs) { + t.Fatal("tmpfs-capable driver should report supported") + } + if DriverSupportsMountType(mountCapableStub{tmpfs: false}, MountTypeTmpfs) { + t.Fatal("non-tmpfs driver should report unsupported") + } +} diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 08202c4bb..55c709787 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -52,6 +52,7 @@ const ( RemoteUser = "remote-user" RemoveVolumes = "remove-volumes" Reset = "reset" + Secret = "secret" SecretsFile = "secrets-file" SkipPro = "skip-pro" Source = "source" diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 92d813df6..d5fcf68d5 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -219,6 +219,7 @@ type CLIOptions struct { WorkspaceEnv []string `json:"workspaceEnv,omitempty"` WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"` SecretsEnv []string `json:"secretsEnv,omitempty"` + SecretsMount []string `json:"secretsMount,omitempty"` FeatureSecretsFile string `json:"featureSecretsFile,omitempty"` InitEnv []string `json:"initEnv,omitempty"` Recreate bool `json:"recreate,omitempty"` diff --git a/pkg/secrets/file_backend.go b/pkg/secrets/file_backend.go new file mode 100644 index 000000000..00368d93a --- /dev/null +++ b/pkg/secrets/file_backend.go @@ -0,0 +1,115 @@ +package secrets + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "filippo.io/age" +) + +// fileBackend stores all values in one age-encrypted file, re-encrypting the +// whole map on each write. +type fileBackend struct { + path string + key *fileKey +} + +func newFileBackend(path string, key *fileKey) *fileBackend { + return &fileBackend{path: path, key: key} +} + +func (f *fileBackend) load() (map[string]string, error) { + raw, err := os.ReadFile(f.path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + + return nil, fmt.Errorf("read secrets file: %w", err) + } + + reader, err := age.Decrypt(bytes.NewReader(raw), f.key.identity) + if err != nil { + return nil, fmt.Errorf("decrypt secrets file: %w", err) + } + + plaintext, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read decrypted secrets: %w", err) + } + + values := map[string]string{} + if err := json.Unmarshal(plaintext, &values); err != nil { + return nil, fmt.Errorf("parse secrets file: %w", err) + } + + return values, nil +} + +func (f *fileBackend) store(values map[string]string) error { + plaintext, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("marshal secrets: %w", err) + } + + var buf bytes.Buffer + w, err := age.Encrypt(&buf, f.key.recipient) + if err != nil { + return fmt.Errorf("encrypt secrets: %w", err) + } + if _, err := w.Write(plaintext); err != nil { + return fmt.Errorf("encrypt secrets: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("finalize encrypted secrets: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(f.path), 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + if err := os.WriteFile(f.path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("write secrets file: %w", err) + } + + return nil +} + +func (f *fileBackend) set(key, value string) error { + values, err := f.load() + if err != nil { + return err + } + values[key] = value + + return f.store(values) +} + +func (f *fileBackend) get(key string) (string, error) { + values, err := f.load() + if err != nil { + return "", err + } + value, ok := values[key] + if !ok { + return "", ErrSecretNotFound + } + + return value, nil +} + +func (f *fileBackend) remove(key string) error { + values, err := f.load() + if err != nil { + return err + } + if _, ok := values[key]; !ok { + return nil + } + delete(values, key) + + return f.store(values) +} diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go new file mode 100644 index 000000000..fc246e471 --- /dev/null +++ b/pkg/secrets/filekey.go @@ -0,0 +1,136 @@ +package secrets + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "filippo.io/age" + keyring "github.com/zalando/go-keyring" +) + +const KeyFileName = "secrets.key" + +const keyringKeyUser = "__filekey__" + +type keySource string + +const ( + keySourcePassphrase keySource = "passphrase" + keySourceKeyring keySource = "keyring" + keySourceAutoFile keySource = "file" +) + +type fileKey struct { + recipient age.Recipient + identity age.Identity + source keySource +} + +// resolveFileKey applies precedence: passphrase, then an auto-generated key in +// the OS keyring, then an auto-generated key in a local file. +func resolveFileKey(dir, passphrase string) (*fileKey, error) { + if passphrase != "" { + recipient, err := age.NewScryptRecipient(passphrase) + if err != nil { + return nil, fmt.Errorf("derive secrets key: %w", err) + } + identity, err := age.NewScryptIdentity(passphrase) + if err != nil { + return nil, fmt.Errorf("derive secrets key: %w", err) + } + + return &fileKey{recipient: recipient, identity: identity, source: keySourcePassphrase}, nil + } + + if keyringAvailable() { + return resolveAutoKey(keyringKeyStore{}, keySourceKeyring) + } + + return resolveAutoKey(fileKeyStore{path: filepath.Join(dir, KeyFileName)}, keySourceAutoFile) +} + +// resolveAutoKey reuses the persisted identity or creates one; it must never +// regenerate an existing key or previously encrypted secrets become unreadable. +func resolveAutoKey(store keyStore, source keySource) (*fileKey, error) { + encoded, err := store.load() + if err != nil { + return nil, err + } + + var identity *age.X25519Identity + if encoded == "" { + identity, err = age.GenerateX25519Identity() + if err != nil { + return nil, fmt.Errorf("generate secrets key: %w", err) + } + if err := store.save(identity.String()); err != nil { + return nil, err + } + } else { + identity, err = age.ParseX25519Identity(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("parse stored secrets key: %w", err) + } + } + + return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil +} + +type keyStore interface { + load() (string, error) // returns "" when no key is stored yet + save(value string) error +} + +type keyringKeyStore struct{} + +func (keyringKeyStore) load() (string, error) { + value, err := keyring.Get(keyringService, keyringKeyUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + + return "", fmt.Errorf("read secrets key from keyring: %w", err) + } + + return value, nil +} + +func (keyringKeyStore) save(value string) error { + if err := keyring.Set(keyringService, keyringKeyUser, value); err != nil { + return fmt.Errorf("write secrets key to keyring: %w", err) + } + + return nil +} + +type fileKeyStore struct { + path string +} + +func (s fileKeyStore) load() (string, error) { + data, err := os.ReadFile(s.path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + + return "", fmt.Errorf("read secrets key file: %w", err) + } + + return string(data), nil +} + +func (s fileKeyStore) save(value string) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + if err := os.WriteFile(s.path, []byte(value), 0o600); err != nil { + return fmt.Errorf("write secrets key file: %w", err) + } + + return nil +} diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go new file mode 100644 index 000000000..1c6c171b1 --- /dev/null +++ b/pkg/secrets/index.go @@ -0,0 +1,94 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "sigs.k8s.io/yaml" +) + +type indexData struct { + Contexts map[string]map[string]SecretMeta `json:"contexts"` + + KeySource string `json:"keySource,omitempty"` +} + +// index is a YAML metadata cache; it exists because OS keyrings cannot +// enumerate entries. The backend remains the source of truth. +type index struct { + path string + data indexData +} + +func loadIndex(path string) (*index, error) { + idx := &index{path: path, data: indexData{Contexts: map[string]map[string]SecretMeta{}}} + + raw, err := os.ReadFile(path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return idx, nil + } + + return nil, fmt.Errorf("read secrets index: %w", err) + } + + if err := yaml.Unmarshal(raw, &idx.data); err != nil { + return nil, fmt.Errorf("parse secrets index: %w", err) + } + if idx.data.Contexts == nil { + idx.data.Contexts = map[string]map[string]SecretMeta{} + } + + return idx, nil +} + +func (i *index) save() error { + out, err := yaml.Marshal(i.data) + if err != nil { + return fmt.Errorf("marshal secrets index: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(i.path), 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + + if err := os.WriteFile(i.path, out, 0o600); err != nil { + return fmt.Errorf("write secrets index: %w", err) + } + + return nil +} + +func (i *index) put(meta SecretMeta) { + if i.data.Contexts[meta.Context] == nil { + i.data.Contexts[meta.Context] = map[string]SecretMeta{} + } + meta.Orphaned = false + i.data.Contexts[meta.Context][meta.Name] = meta +} + +func (i *index) get(context, name string) (SecretMeta, bool) { + meta, ok := i.data.Contexts[context][name] + return meta, ok +} + +func (i *index) remove(context, name string) { + delete(i.data.Contexts[context], name) + if len(i.data.Contexts[context]) == 0 { + delete(i.data.Contexts, context) + } +} + +func (i *index) list(context string) []SecretMeta { + entries := make([]SecretMeta, 0, len(i.data.Contexts[context])) + for _, meta := range i.data.Contexts[context] { + entries = append(entries, meta) + } + sort.Slice(entries, func(a, b int) bool { + return entries[a].Name < entries[b].Name + }) + + return entries +} diff --git a/pkg/secrets/keyring_backend.go b/pkg/secrets/keyring_backend.go new file mode 100644 index 000000000..d934d2c6e --- /dev/null +++ b/pkg/secrets/keyring_backend.go @@ -0,0 +1,57 @@ +package secrets + +import ( + "errors" + "fmt" + + keyring "github.com/zalando/go-keyring" +) + +const keyringService = "sh.devsy.secrets" + +type keyringBackend struct{} + +func (keyringBackend) set(key, value string) error { + if err := keyring.Set(keyringService, key, value); err != nil { + return fmt.Errorf("write to keyring: %w", err) + } + + return nil +} + +func (keyringBackend) get(key string) (string, error) { + value, err := keyring.Get(keyringService, key) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrSecretNotFound + } + + return "", fmt.Errorf("read from keyring: %w", err) + } + + return value, nil +} + +func (keyringBackend) remove(key string) error { + if err := keyring.Delete(keyringService, key); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil + } + + return fmt.Errorf("delete from keyring: %w", err) + } + + return nil +} + +// keyringAvailable probes with a sentinel round-trip, since the keyring can be +// present but unusable (e.g. no D-Bus session on headless Linux). +func keyringAvailable() bool { + const probeKey = "__devsy_probe__" + if err := keyring.Set(keyringService, probeKey, "1"); err != nil { + return false + } + _ = keyring.Delete(keyringService, probeKey) + + return true +} diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go new file mode 100644 index 000000000..c75ef7df4 --- /dev/null +++ b/pkg/secrets/local_store.go @@ -0,0 +1,234 @@ +package secrets + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/devsy-org/devsy/pkg/config" +) + +const IndexFileName = "secrets.yaml" + +const EncryptedFileName = "secrets.enc" + +const EnvPassphrase = "DEVSY_SECRETS_PASSPHRASE" // #nosec G101 -- env var name, not a credential. + +const EnvBackend = "DEVSY_SECRETS_BACKEND" + +type Backend string + +const ( + BackendAuto Backend = "auto" + BackendKeyring Backend = "keyring" + BackendFile Backend = "file" +) + +type localStore struct { + backend backend + indexPath string + now func() time.Time + + // keySource is empty for the keyring backend; checked against the index so a + // key-source change is a clear error rather than a decryption failure. + keySource keySource +} + +func NewStore() (Store, error) { + devsyConfig, err := config.LoadConfig("", "") + if err != nil { + return nil, err + } + + return NewStoreForConfig(devsyConfig) +} + +func NewStoreForConfig(devsyConfig *config.Config) (Store, error) { + configPath, err := config.GetConfigPath() + if err != nil { + return nil, err + } + dir := filepath.Dir(configPath) + indexPath := filepath.Join(dir, IndexFileName) + + switch resolveBackend(devsyConfig) { + case BackendKeyring: + return newLocalStore(keyringBackend{}, indexPath), nil + case BackendFile: + return newFileStore(dir, indexPath) + default: + if keyringAvailable() { + return newLocalStore(keyringBackend{}, indexPath), nil + } + + return newFileStore(dir, indexPath) + } +} + +// resolveBackend applies precedence: env override, context option, then auto. +func resolveBackend(devsyConfig *config.Config) Backend { + if b := normalizeBackend(os.Getenv(EnvBackend)); b != "" { + return b + } + if devsyConfig != nil { + configured := devsyConfig.ContextOption(config.ContextOptionSecretsBackend) + if b := normalizeBackend(configured); b != "" { + return b + } + } + + return BackendAuto +} + +func normalizeBackend(value string) Backend { + switch Backend(value) { + case BackendKeyring, BackendFile, BackendAuto: + return Backend(value) + default: + return "" + } +} + +func newFileStore(dir, indexPath string) (Store, error) { + key, err := resolveFileKey(dir, os.Getenv(EnvPassphrase)) + if err != nil { + return nil, err + } + + fb := newFileBackend(filepath.Join(dir, EncryptedFileName), key) + s := newLocalStore(fb, indexPath) + s.keySource = key.source + + return s, nil +} + +func newLocalStore(b backend, indexPath string) *localStore { + return &localStore{backend: b, indexPath: indexPath, now: time.Now} +} + +func (s *localStore) Set(context, name, value string) error { + if err := ValidateName(name); err != nil { + return err + } + + idx, err := loadIndex(s.indexPath) + if err != nil { + return err + } + if err := s.checkKeySource(idx); err != nil { + return err + } + + // Write the value first; the backend is the source of truth. + if err := s.backend.set(backendKey(context, name), value); err != nil { + return err + } + + meta, exists := idx.get(context, name) + if !exists { + meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} + } + idx.put(meta) + if s.keySource != "" { + idx.data.KeySource = string(s.keySource) + } + + return idx.save() +} + +func (s *localStore) Get(context, name string) (string, error) { + if err := ValidateName(name); err != nil { + return "", err + } + + idx, err := loadIndex(s.indexPath) + if err != nil { + return "", err + } + if err := s.checkKeySource(idx); err != nil { + return "", err + } + + value, err := s.backend.get(backendKey(context, name)) + if err != nil { + return "", err + } + + // Best-effort last-used tracking; index errors never fail a Get. + if meta, ok := idx.get(context, name); ok { + meta.LastUsed = s.now().UTC() + idx.put(meta) + _ = idx.save() + } + + return value, nil +} + +func (s *localStore) Delete(context, name string) error { + if err := ValidateName(name); err != nil { + return err + } + + idx, err := loadIndex(s.indexPath) + if err != nil { + return err + } + if err := s.checkKeySource(idx); err != nil { + return err + } + + if err := s.backend.remove(backendKey(context, name)); err != nil { + return err + } + idx.remove(context, name) + + return idx.save() +} + +func (s *localStore) List(context string) ([]SecretMeta, error) { + return s.Reconcile(context) +} + +func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { + idx, err := loadIndex(s.indexPath) + if err != nil { + return nil, err + } + if err := s.checkKeySource(idx); err != nil { + return nil, err + } + + entries := idx.list(context) + for i := range entries { + _, err := s.backend.get(backendKey(context, entries[i].Name)) + switch { + case err == nil: + entries[i].Orphaned = false + case errors.Is(err, ErrSecretNotFound): + entries[i].Orphaned = true + default: + return nil, fmt.Errorf("reconcile secret %q: %w", entries[i].Name, err) + } + } + + return entries, nil +} + +// checkKeySource rejects a key-source change with a clear error instead of a +// decryption failure. +func (s *localStore) checkKeySource(idx *index) error { + if s.keySource == "" || idx.data.KeySource == "" { + return nil + } + if idx.data.KeySource != string(s.keySource) { + return fmt.Errorf( + "secrets were encrypted with the %q key source but the current source is %q; "+ + "restore the original DEVSY_SECRETS_PASSPHRASE (or unset it) or re-create the secrets", + idx.data.KeySource, s.keySource, + ) + } + + return nil +} diff --git a/pkg/secrets/redact.go b/pkg/secrets/redact.go new file mode 100644 index 000000000..5060bd54e --- /dev/null +++ b/pkg/secrets/redact.go @@ -0,0 +1,34 @@ +package secrets + +import "strings" + +const redactMask = "***" + +type Redactor struct { + replacer *strings.Replacer +} + +// NewRedactor masks the values (not keys) of KEY=VALUE entries; empty values are ignored. +func NewRedactor(secretsEnv []string) *Redactor { + pairs := make([]string, 0, len(secretsEnv)*2) + for _, entry := range secretsEnv { + _, value, ok := strings.Cut(entry, "=") + if !ok || value == "" { + continue + } + pairs = append(pairs, value, redactMask) + } + if len(pairs) == 0 { + return &Redactor{} + } + + return &Redactor{replacer: strings.NewReplacer(pairs...)} +} + +func (r *Redactor) Redact(s string) string { + if r == nil || r.replacer == nil { + return s + } + + return r.replacer.Replace(s) +} diff --git a/pkg/secrets/redact_test.go b/pkg/secrets/redact_test.go new file mode 100644 index 000000000..1a84548f6 --- /dev/null +++ b/pkg/secrets/redact_test.go @@ -0,0 +1,47 @@ +package secrets_test + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/secrets" +) + +func TestRedactor_MasksValues(t *testing.T) { + r := secrets.NewRedactor([]string{"DB_PASSWORD=hunter2", "TOKEN=abc123"}) + + got := r.Redact("connecting with hunter2 and token abc123") + want := "connecting with *** and token ***" + if got != want { + t.Errorf("Redact = %q, want %q", got, want) + } +} + +func TestRedactor_IgnoresEmptyValues(t *testing.T) { + r := secrets.NewRedactor([]string{"EMPTY=", "NO_EQUALS"}) + + got := r.Redact("nothing to mask here") + if got != "nothing to mask here" { + t.Errorf("Redact = %q, want unchanged", got) + } +} + +func TestRedactor_NilAndEmpty(t *testing.T) { + var r *secrets.Redactor + if got := r.Redact("passthrough"); got != "passthrough" { + t.Errorf("nil Redactor changed input: %q", got) + } + + empty := secrets.NewRedactor(nil) + if got := empty.Redact("passthrough"); got != "passthrough" { + t.Errorf("empty Redactor changed input: %q", got) + } +} + +func TestRedactor_ValueWithEquals(t *testing.T) { + r := secrets.NewRedactor([]string{"CONN=user=admin;pw=secret"}) + + got := r.Redact("dsn: user=admin;pw=secret end") + if got != "dsn: *** end" { + t.Errorf("Redact = %q, want masked full value", got) + } +} diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index 9acc43a07..10c387ddc 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -1,50 +1,21 @@ package secrets import ( - "bufio" + "encoding/json" "fmt" "os" - "strings" ) -// ParseSecretsFile reads a dotenv-style secrets file and returns the key-value -// pairs as a map. Lines starting with # are comments, blank lines are ignored, -// and values are split on the first = only (values may contain =). Whitespace -// around keys is trimmed. func ParseSecretsFile(path string) (map[string]string, error) { - f, err := os.Open(path) // #nosec G304 -- User-specified secrets file path is intentional. + // #nosec G304 -- User-specified secrets file path is intentional. + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("open secrets file: %w", err) + return nil, fmt.Errorf("read secrets file: %w", err) } - defer func() { _ = f.Close() }() secrets := map[string]string{} - scanner := bufio.NewScanner(f) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := scanner.Text() - - // Skip blank lines and comments. - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - - key, value, found := strings.Cut(trimmed, "=") - if !found { - return nil, fmt.Errorf("secrets file %s: line %d: missing '=' separator", path, lineNum) - } - - key = strings.TrimSpace(key) - if key == "" { - return nil, fmt.Errorf("secrets file %s: line %d: empty key", path, lineNum) - } - - secrets[key] = value - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read secrets file %s: %w", path, err) + if err := json.Unmarshal(data, &secrets); err != nil { + return nil, fmt.Errorf("parse secrets file %s: %w", path, err) } return secrets, nil diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index bde054450..ec73f40af 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -9,13 +9,11 @@ import ( ) func TestParseSecretsFile(t *testing.T) { - content := `# Database credentials -DB_HOST=localhost -DB_PASSWORD=s3cr3t - -# API key -API_KEY=abc123 -` + content := `{ + "DB_HOST": "localhost", + "DB_PASSWORD": "s3cr3t", + "API_KEY": "abc123" +}` path := writeTemp(t, content) got, err := secrets.ParseSecretsFile(path) @@ -36,59 +34,24 @@ API_KEY=abc123 } } -func TestParseSecretsFile_CommentsAndBlankLines(t *testing.T) { - content := ` -# This is a comment - -KEY=value - # indented comment - -OTHER=val -` - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("expected 2 entries, got %d", len(got)) - } - if got["KEY"] != "value" { - t.Errorf("KEY = %q, want %q", got["KEY"], "value") - } - if got["OTHER"] != "val" { - t.Errorf("OTHER = %q, want %q", got["OTHER"], "val") - } -} - -func TestParseSecretsFile_ValueWithEquals(t *testing.T) { - content := `CONNECTION_STRING=host=db port=5432 user=admin password=p=ss -TOKEN=abc==def -` +func TestParseSecretsFile_ValueWithSpecialChars(t *testing.T) { + content := `{"CONNECTION_STRING": "host=db port=5432 password=p=ss", "MULTILINE": "line1\nline2"}` path := writeTemp(t, content) got, err := secrets.ParseSecretsFile(path) if err != nil { t.Fatal(err) } - if got["CONNECTION_STRING"] != "host=db port=5432 user=admin password=p=ss" { + if got["CONNECTION_STRING"] != "host=db port=5432 password=p=ss" { t.Errorf("CONNECTION_STRING = %q", got["CONNECTION_STRING"]) } - if got["TOKEN"] != "abc==def" { - t.Errorf("TOKEN = %q, want %q", got["TOKEN"], "abc==def") - } -} - -func TestParseSecretsFile_MissingFile(t *testing.T) { - _, err := secrets.ParseSecretsFile("/nonexistent/path/secrets.env") - if err == nil { - t.Fatal("expected error for missing file") + if got["MULTILINE"] != "line1\nline2" { + t.Errorf("MULTILINE = %q, want two lines", got["MULTILINE"]) } } -func TestParseSecretsFile_EmptyFile(t *testing.T) { - path := writeTemp(t, "") +func TestParseSecretsFile_EmptyObject(t *testing.T) { + path := writeTemp(t, "{}") got, err := secrets.ParseSecretsFile(path) if err != nil { @@ -99,45 +62,25 @@ func TestParseSecretsFile_EmptyFile(t *testing.T) { } } -func TestParseSecretsFile_WhitespaceAroundKey(t *testing.T) { - content := " MY_KEY =some_value\n" - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if got["MY_KEY"] != "some_value" { - t.Errorf("MY_KEY = %q, want %q", got["MY_KEY"], "some_value") +func TestParseSecretsFile_MissingFile(t *testing.T) { + _, err := secrets.ParseSecretsFile("/nonexistent/path/secrets.json") + if err == nil { + t.Fatal("expected error for missing file") } } -func TestParseSecretsFile_MissingSeparator(t *testing.T) { - content := "INVALID_LINE\n" - path := writeTemp(t, content) +func TestParseSecretsFile_InvalidJSON(t *testing.T) { + path := writeTemp(t, "{not valid json") _, err := secrets.ParseSecretsFile(path) if err == nil { - t.Fatal("expected error for line without = separator") - } -} - -func TestParseSecretsFile_EmptyValue(t *testing.T) { - content := "EMPTY_VAL=\n" - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if got["EMPTY_VAL"] != "" { - t.Errorf("EMPTY_VAL = %q, want empty string", got["EMPTY_VAL"]) + t.Fatal("expected error for invalid JSON") } } func writeTemp(t *testing.T, content string) string { t.Helper() - path := filepath.Join(t.TempDir(), "secrets.env") + path := filepath.Join(t.TempDir(), "secrets.json") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go new file mode 100644 index 000000000..3b5523574 --- /dev/null +++ b/pkg/secrets/store.go @@ -0,0 +1,53 @@ +package secrets + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +var ErrSecretNotFound = errors.New("secret not found") + +var namePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +func ValidateName(name string) error { + if name == "" { + return errors.New("secret name must not be empty") + } + if !namePattern.MatchString(name) { + return fmt.Errorf( + "invalid secret name %q: only letters, digits, and underscores are allowed", + name, + ) + } + + return nil +} + +type SecretMeta struct { + Name string `json:"name"` + Context string `json:"context"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` + + Orphaned bool `json:"-"` +} + +type Store interface { + Set(context, name, value string) error + Get(context, name string) (string, error) + List(context string) ([]SecretMeta, error) + Delete(context, name string) error + Reconcile(context string) ([]SecretMeta, error) +} + +type backend interface { + set(key, value string) error + get(key string) (string, error) // returns ErrSecretNotFound when absent + remove(key string) error // idempotent +} + +func backendKey(context, name string) string { + return context + "/" + name +} diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go new file mode 100644 index 000000000..7f42d9a6d --- /dev/null +++ b/pkg/secrets/store_internal_test.go @@ -0,0 +1,378 @@ +package secrets + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "filippo.io/age" + "github.com/devsy-org/devsy/pkg/config" +) + +const testContext = "default" + +func TestResolveBackend_EnvOverridesContextOption(t *testing.T) { + t.Setenv(EnvBackend, "keyring") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "file"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendKeyring { + t.Fatalf("env override = %q, want keyring", got) + } +} + +func TestResolveBackend_ContextOption(t *testing.T) { + t.Setenv(EnvBackend, "") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "file"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendFile { + t.Fatalf("context option = %q, want file", got) + } +} + +func TestResolveBackend_DefaultsToAuto(t *testing.T) { + t.Setenv(EnvBackend, "") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{testContext: {}}, + } + if got := resolveBackend(cfg); got != BackendAuto { + t.Fatalf("empty preference = %q, want auto", got) + } +} + +func TestResolveBackend_IgnoresGarbageEnv(t *testing.T) { + t.Setenv(EnvBackend, "nonsense") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "keyring"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendKeyring { + t.Fatalf("garbage env should fall through to context option, got %q", got) + } +} + +type mapBackend struct { + values map[string]string +} + +func newMapBackend() *mapBackend { return &mapBackend{values: map[string]string{}} } + +func (m *mapBackend) set(key, value string) error { + m.values[key] = value + return nil +} + +func (m *mapBackend) get(key string) (string, error) { + v, ok := m.values[key] + if !ok { + return "", ErrSecretNotFound + } + return v, nil +} + +func (m *mapBackend) remove(key string) error { + delete(m.values, key) + return nil +} + +func newTestStore(t *testing.T, b backend) *localStore { + t.Helper() + return newLocalStore(b, filepath.Join(t.TempDir(), IndexFileName)) +} + +func TestStore_SetGetDelete(t *testing.T) { + s := newTestStore(t, newMapBackend()) + + if err := s.Set("default", "API_KEY", "abc123"); err != nil { + t.Fatal(err) + } + + got, err := s.Get("default", "API_KEY") + if err != nil { + t.Fatal(err) + } + if got != "abc123" { + t.Fatalf("Get = %q, want %q", got, "abc123") + } + + if err := s.Delete("default", "API_KEY"); err != nil { + t.Fatal(err) + } + if _, err := s.Get("default", "API_KEY"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get after delete = %v, want ErrSecretNotFound", err) + } +} + +func TestStore_GetMissing(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if _, err := s.Get("default", "NOPE"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get missing = %v, want ErrSecretNotFound", err) + } +} + +func TestStore_DeleteIdempotent(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Delete("default", "NEVER_EXISTED"); err != nil { + t.Fatalf("Delete of absent secret should be nil, got %v", err) + } +} + +func TestStore_ContextIsolation(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("ctx-a", "TOKEN", "a"); err != nil { + t.Fatal(err) + } + if err := s.Set("ctx-b", "TOKEN", "b"); err != nil { + t.Fatal(err) + } + + a, _ := s.Get("ctx-a", "TOKEN") + b, _ := s.Get("ctx-b", "TOKEN") + if a != "a" || b != "b" { + t.Fatalf("context isolation broken: a=%q b=%q", a, b) + } + + list, err := s.List("ctx-a") + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].Name != "TOKEN" { + t.Fatalf("List(ctx-a) = %+v, want single TOKEN", list) + } +} + +func TestStore_ListSorted(t *testing.T) { + s := newTestStore(t, newMapBackend()) + for _, n := range []string{"ZED", "ALPHA", "MIKE"} { + if err := s.Set("default", n, "v"); err != nil { + t.Fatal(err) + } + } + + list, err := s.List("default") + if err != nil { + t.Fatal(err) + } + want := []string{"ALPHA", "MIKE", "ZED"} + for i, meta := range list { + if meta.Name != want[i] { + t.Fatalf("List[%d] = %q, want %q", i, meta.Name, want[i]) + } + } +} + +func TestStore_ReconcileFlagsOrphans(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set("default", "PRESENT", "v"); err != nil { + t.Fatal(err) + } + if err := s.Set("default", "GONE", "v"); err != nil { + t.Fatal(err) + } + + delete(mb.values, backendKey("default", "GONE")) + + list, err := s.Reconcile("default") + if err != nil { + t.Fatal(err) + } + + byName := map[string]SecretMeta{} + for _, m := range list { + byName[m.Name] = m + } + if byName["PRESENT"].Orphaned { + t.Error("PRESENT should not be orphaned") + } + if !byName["GONE"].Orphaned { + t.Error("GONE should be flagged orphaned") + } +} + +func TestStore_SetUpdatePreservesCreated(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("default", "K", "v1"); err != nil { + t.Fatal(err) + } + first, _ := s.List("default") + created := first[0].Created + + if err := s.Set("default", "K", "v2"); err != nil { + t.Fatal(err) + } + second, _ := s.List("default") + if !second[0].Created.Equal(created) { + t.Errorf("Created changed on update: %v != %v", second[0].Created, created) + } + if v, _ := s.Get("default", "K"); v != "v2" { + t.Errorf("value not updated: %q", v) + } +} + +func TestValidateName(t *testing.T) { + valid := []string{"API_KEY", "db_password", "TOKEN2", "a"} + for _, n := range valid { + if err := ValidateName(n); err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", n, err) + } + } + + invalid := []string{"", "with-dash", "with space", "ctx/name", "dollar$", "emoji😀"} + for _, n := range invalid { + if err := ValidateName(n); err == nil { + t.Errorf("ValidateName(%q) = nil, want error", n) + } + } +} + +func TestStore_InvalidNameRejected(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("default", "bad name", "v"); err == nil { + t.Fatal("Set with invalid name should error") + } +} + +func newPassphraseBackend(t *testing.T, path, passphrase string) *fileBackend { + t.Helper() + key, err := resolveFileKey(t.TempDir(), passphrase) + if err != nil { + t.Fatal(err) + } + return newFileBackend(path, key) +} + +func TestFileBackend_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), EncryptedFileName) + fb := newPassphraseBackend(t, path, "correct horse battery staple") + + if err := fb.set("default/K", "value"); err != nil { + t.Fatal(err) + } + got, err := fb.get("default/K") + if err != nil { + t.Fatal(err) + } + if got != "value" { + t.Fatalf("get = %q, want %q", got, "value") + } + + wrong := newPassphraseBackend(t, path, "wrong passphrase") + if _, err := wrong.get("default/K"); err == nil { + t.Fatal("get with wrong passphrase should fail") + } + + if err := fb.remove("default/K"); err != nil { + t.Fatal(err) + } + if _, err := fb.get("default/K"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("get after remove = %v, want ErrSecretNotFound", err) + } +} + +func TestResolveFileKey_PassphraseSource(t *testing.T) { + key, err := resolveFileKey(t.TempDir(), "s3cr3t") + if err != nil { + t.Fatal(err) + } + if key.source != keySourcePassphrase { + t.Fatalf("source = %q, want %q", key.source, keySourcePassphrase) + } +} + +func TestResolveAutoKey_CreatesThenReuses(t *testing.T) { + store := fileKeyStore{path: filepath.Join(t.TempDir(), KeyFileName)} + + first, err := resolveAutoKey(store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(store.path); statErr != nil { + t.Fatalf("expected key file to be created: %v", statErr) + } + + second, err := resolveAutoKey(store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + + // The same persisted identity must be reused, or previously encrypted + // secrets would become permanently undecryptable. + if identityString(t, first) != identityString(t, second) { + t.Fatal("auto key was regenerated on second resolve; must be stable") + } +} + +func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, EncryptedFileName) + store := fileKeyStore{path: filepath.Join(dir, KeyFileName)} + + key, err := resolveAutoKey(store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + if err := newFileBackend(path, key).set("default/K", "auto-value"); err != nil { + t.Fatal(err) + } + + key2, err := resolveAutoKey(store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + got, err := newFileBackend(path, key2).get("default/K") + if err != nil { + t.Fatal(err) + } + if got != "auto-value" { + t.Fatalf("get = %q, want %q", got, "auto-value") + } +} + +func identityString(t *testing.T, key *fileKey) string { + t.Helper() + id, ok := key.identity.(*age.X25519Identity) + if !ok { + t.Fatalf("identity is not X25519: %T", key.identity) + } + return id.String() +} + +func TestStore_KeySourceMismatchIsClearError(t *testing.T) { + indexPath := filepath.Join(t.TempDir(), IndexFileName) + + s := newLocalStore(newMapBackend(), indexPath) + s.keySource = keySourceAutoFile + if err := s.Set(testContext, "K", "v"); err != nil { + t.Fatal(err) + } + + s2 := newLocalStore(newMapBackend(), indexPath) + s2.keySource = keySourcePassphrase + _, err := s2.Get(testContext, "K") + if err == nil { + t.Fatal("expected key-source mismatch error") + } + if !strings.Contains(err.Error(), "key source") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx new file mode 100644 index 000000000..beaa1a5fa --- /dev/null +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx @@ -0,0 +1,162 @@ +--- +title: Secrets in a Workspace +sidebar_label: Secrets +--- + +## Storing Secrets + +Devsy can store named secrets locally and inject them into your workspace's +lifecycle commands as environment variables. This lets you keep credentials out +of your `devcontainer.json`, shell history, and dotenv files. + +Secret **values** are stored in your operating system's keyring: + +- **macOS** — Keychain +- **Windows** — Credential Manager +- **Linux** — Secret Service (libsecret / GNOME Keyring / KWallet) + +Only non-sensitive metadata (the secret's name and timestamps) is written to the +Devsy config directory. Secrets are scoped to the active [context](../managing-providers/what-are-providers). + +### Creating a Secret + +``` +devsy secret set DB_PASSWORD +``` + +You will be prompted for the value without echoing it to the terminal. You can +also supply the value non-interactively: + +``` +# From standard input (recommended for scripts) +printf '%s' "$MY_VALUE" | devsy secret set DB_PASSWORD --stdin + +# From a file +devsy secret set TLS_KEY --from-file ./tls.key +``` + +Secret names may contain only letters, digits, and underscores, since they are +used directly as environment-variable names. + +### Listing and Reading Secrets + +``` +devsy secret list +devsy secret get DB_PASSWORD +``` + +`list` never prints values. A secret whose value has been removed from the +keyring out-of-band is shown with the `orphaned` status. + +### Deleting a Secret + +``` +devsy secret delete DB_PASSWORD +``` + +## Using Secrets in a Workspace + +### Per Workspace + +Reference a stored secret by name when bringing up a workspace with `--secret` +(repeatable). By default the secret is injected as an environment variable into +lifecycle commands: + +``` +devsy workspace up https://github.com/example/repo --secret DB_PASSWORD +``` + +Each `--secret` accepts options as `NAME[,type=env|mount][,target=X]`: + +- `type=env` (default) sets an environment variable; `target` overrides the + variable name. +- `type=mount` writes the value to a file at `/run/secrets/` on an + in-memory filesystem, matching the Docker/Podman convention. `target` + defaults to `NAME`. On Kubernetes this uses an in-memory `emptyDir`; providers + that cannot mount an in-memory filesystem reject `type=mount` with a clear + error, so use `type=env` there. + +``` +# Inject under a different environment-variable name +devsy workspace up ... --secret DB_PASSWORD,target=DATABASE_PASSWORD + +# Mount as a file at /run/secrets/tls.key +devsy workspace up ... --secret TLS_KEY,type=mount,target=tls.key +``` + +### For all Workspaces + +Bind a secret to the active context so it is injected automatically on every +`up`, without repeating `--secret`: + +``` +devsy secret attach DB_PASSWORD +devsy secret detach DB_PASSWORD +``` + +Bound secret names are stored in your context configuration; deleting a context +also deletes its secrets from the keyring. + +If a requested or bound secret cannot be found when a workspace is created, the +`up` fails rather than silently continuing without it. + +## How Secrets Are Protected + +Injected secret **values** are masked (`***`) in lifecycle-hook log output, so a +secret echoed by a `postCreateCommand` will not appear in the workspace logs. +Secret values are delivered to the workspace over Devsy's encrypted agent +tunnel, not embedded in any command line, so they are not exposed in process +listings on the host or inside the container. + +Secrets delivered with `type=mount` are written to an in-memory `tmpfs` at +`/run/secrets`, so they are never persisted to a container image layer or to +disk. + +## Choosing a Storage Backend + +Devsy supports two backends for secret values: + +- `keyring` — the OS keyring (Keychain / Credential Manager / libsecret). +- `file` — an [age](https://age-encryption.org)-encrypted file + (`secrets.enc`) stored alongside the Devsy config. + +By default the backend is `auto`: Devsy uses the keyring when one is available +and falls back to the encrypted file otherwise. + +The `file` backend works with no configuration: Devsy generates and manages an +encryption key for you (stored in the OS keyring when available, otherwise in +`secrets.key` next to `secrets.enc`, mode `0600`). This protects secrets from +accidental disclosure and casual inspection, but a key stored in `secrets.key` +offers no protection against someone who can read your Devsy config directory +(they have both the key and the ciphertext). For stronger at-rest protection — +for example against a stolen backup — set a passphrase (see below), which is +never written to disk. + +Set a persistent preference per context with the `SECRETS_BACKEND` option: + +``` +devsy context set -o SECRETS_BACKEND=file +``` + +Allowed values are `auto`, `keyring`, and `file`. To override the preference for +a single command, set the `DEVSY_SECRETS_BACKEND` environment variable, which +takes precedence over the context option. + +## Headless and CI Environments + +When the `file` backend is used (either by preference or because no OS keyring +is available, for example on headless Linux, containers, or CI), Devsy uses an +auto-generated key by default, so no setup is required. For at-rest protection +against a stolen backup or disk, set a passphrase — the encryption key is then +derived from it and never stored: + +``` +export DEVSY_SECRETS_PASSPHRASE="…" +devsy secret set DEPLOY_TOKEN --stdin < token.txt +``` + +The key source (auto-generated vs. passphrase) is recorded with your secrets. If +you later change it — for example by setting a passphrase for secrets that were +stored with an auto-generated key — Devsy reports a clear error rather than +failing to decrypt. Restore the original setting, or re-create the secrets under +the new one. diff --git a/sites/docs-devsy-sh/sidebars.js b/sites/docs-devsy-sh/sidebars.js index a39a69540..2bea10aae 100644 --- a/sites/docs-devsy-sh/sidebars.js +++ b/sites/docs-devsy-sh/sidebars.js @@ -65,6 +65,10 @@ module.exports = { type: "doc", id: "developing-in-workspaces/credentials", }, + { + type: "doc", + id: "developing-in-workspaces/secrets", + }, { type: "doc", id: "developing-in-workspaces/inactivity-timeout", From f71b5caec19bcbb36d49bf221ba817ce735d8691 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 23 Jul 2026 23:37:14 -0500 Subject: [PATCH 02/19] fix(secret): suppress G301 for the traversable /run/secrets dir --- pkg/devcontainer/setup/setup.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 2b4280fd0..9ea6c3e9a 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -135,6 +135,7 @@ func writeSecretFiles(cfg *ContainerSetupConfig) error { return nil } + // #nosec G301 -- dir must be traversable by the non-root remote user; per-file 0600 enforces secrecy. if err := os.MkdirAll(config.SecretsMountDir, 0o755); err != nil { return fmt.Errorf("create secrets mount dir: %w", err) } From b416783bdd612bdac3118c904587010270082285 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 23 Jul 2026 23:59:18 -0500 Subject: [PATCH 03/19] feat(secret): scaffold git-token and build-secret resolution --- cmd/workspace/up/up.go | 3 ++ cmd/workspace/up/up_client.go | 68 +++++++++++++++++++++++++++++++---- cmd/workspace/up/up_flags.go | 7 ++++ pkg/flags/names/names.go | 3 ++ pkg/provider/workspace.go | 10 ++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 605f02b18..c74d5fd13 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -44,6 +44,9 @@ type UpCmd struct { SecretsFile string FeatureSecretsFile string Secrets []string + BuildSecrets []string + GitTokenSecret string + GitTokenUsername string WorkspaceFolder string DotfilesSource string diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 0e66ccfd8..e61d86828 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -3,6 +3,7 @@ package up import ( "context" "fmt" + "net/url" "os" "strings" @@ -155,14 +156,14 @@ func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { return nil } -// resolveStoredSecrets resolves secret and context-bound secrets. A missing -// secret is a hard error rather than a silent drop. +// resolveStoredSecrets resolves lifecycle, build, and git-token secrets from +// the store. A missing secret is a hard error rather than a silent drop. func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { requests, err := collectSecretRequests(cmd.Secrets, devsyConfig) if err != nil { return err } - if len(requests) == 0 { + if len(requests) == 0 && len(cmd.BuildSecrets) == 0 && cmd.GitTokenSecret == "" { return nil } @@ -170,12 +171,20 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { if err != nil { return err } - contextName := devsyConfig.DefaultContext + + get := func(name string) (string, error) { + value, gErr := store.Get(contextName, name) + if gErr != nil { + return "", fmt.Errorf("resolve secret %q in context %q: %w", name, contextName, gErr) + } + return value, nil + } + for _, req := range requests { - value, err := store.Get(contextName, req.name) + value, err := get(req.name) if err != nil { - return fmt.Errorf("resolve secret %q in context %q: %w", req.name, contextName, err) + return err } if req.mount { cmd.SecretsMount = append(cmd.SecretsMount, req.target+"="+value) @@ -184,9 +193,56 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { } } + for _, name := range cmd.BuildSecrets { + value, err := get(name) + if err != nil { + return err + } + cmd.CLIOptions.BuildSecrets = append(cmd.CLIOptions.BuildSecrets, name+"="+value) + } + + if cmd.GitTokenSecret != "" { + token, err := get(cmd.GitTokenSecret) + if err != nil { + return err + } + cmd.CLIOptions.GitToken = cmd.buildGitToken(token) + } + return nil } +// buildGitToken assembles the host-scoped git token from the resolved secret, +// inferring the username from the source host when not set explicitly. +func (cmd *UpCmd) buildGitToken(token string) *provider2.GitToken { + host := gitHostFromSource(cmd.Source) + username := cmd.GitTokenUsername + if username == "" { + username = gitTokenUsernameForHost(host) + } + + return &provider2.GitToken{Host: host, Username: username, Token: token} +} + +// gitHostFromSource extracts the host from a workspace git source, or "" when +// it is not an HTTP(S) URL. +func gitHostFromSource(source string) string { + s := strings.TrimPrefix(source, "git:") + u, err := url.Parse(s) + if err != nil { + return "" + } + return u.Host +} + +// gitTokenUsernameForHost returns the conventional token username for a host. +func gitTokenUsernameForHost(host string) string { + if strings.Contains(host, "gitlab") { + return "oauth2" + } + return "x-access-token" +} + type secretRequest struct { name string target string diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index 7916c3cf4..d9f2ef995 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -202,6 +202,13 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { flags.StringArray(&cmd.Secrets, names.Secret, nil, "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), + flags.StringArray(&cmd.BuildSecrets, names.BuildSecret, nil, + "Stored Devsy secret exposed to the build via BuildKit "+ + "(RUN --mount=type=secret,id=NAME). Repeatable"), + flags.String(&cmd.GitTokenSecret, names.GitToken, "", + "Stored Devsy secret holding an access token for cloning a private HTTP repository"), + flags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", + "Username for --git-token (default inferred from the repo host)"), flags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", `JSON file of feature secret values, format: {"featureId": {"optionName": "value"}}`), flags.StringArray(&cmd.InitEnv, names.InitEnv, nil, diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 55c709787..3ec7d7bae 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -12,6 +12,7 @@ func FlagFalse(name string) string { return FlagValue(name, "false") } // Workspace. const ( + BuildSecret = "build-secret" CacheFrom = "cache-from" ContainerDataFolder = "container-data-folder" ContainerStatus = "container-status" @@ -102,6 +103,8 @@ const ( GitRecurseSubmodules = "git-recurse-submodules" GitLFSMode = "git-lfs-mode" GitSSHSigningKey = "git-ssh-signing-key" + GitToken = "git-token" + GitTokenUsername = "git-token-username" ) // Lifecycle. diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index d5fcf68d5..019e69b09 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -202,6 +202,14 @@ type AgentWorkspaceInfo struct { RegistryCache string `json:"registryCache,omitempty"` } +// GitToken is a resolved access token for cloning a private HTTP repository. +// It is scoped to a host so it is never offered to other hosts git contacts. +type GitToken struct { + Host string `json:"host,omitempty"` + Username string `json:"username,omitempty"` + Token string `json:"token,omitempty"` +} + type CLIOptions struct { // Platform are the platform options Platform devsy.PlatformOptions `json:"platformOptions"` @@ -220,6 +228,8 @@ type CLIOptions struct { WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"` SecretsEnv []string `json:"secretsEnv,omitempty"` SecretsMount []string `json:"secretsMount,omitempty"` + BuildSecrets []string `json:"buildSecrets,omitempty"` + GitToken *GitToken `json:"gitToken,omitempty"` FeatureSecretsFile string `json:"featureSecretsFile,omitempty"` InitEnv []string `json:"initEnv,omitempty"` Recreate bool `json:"recreate,omitempty"` From 6355e73af764f903dd86dd0e05098791e3ff1029 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 00:09:49 -0500 Subject: [PATCH 04/19] feat(secret): expose build secrets to BuildKit builds Wire --build-secret values into image builds as BuildKit secrets so Dockerfiles consume them via RUN --mount=type=secret,id=NAME. The internal BuildKit builder serves them through a session secret provider; the docker buildx strategy passes them as env-backed --secret references (never argv or layers). Docker compose builds, which cannot mount build secrets, reject them with a clear error. --- pkg/devcontainer/build/options.go | 7 ++ pkg/devcontainer/buildkit/buildkit.go | 8 ++ pkg/devcontainer/buildkit/remote.go | 8 ++ pkg/devcontainer/buildkit/secrets.go | 37 ++++++++++ pkg/devcontainer/buildkit/secrets_test.go | 90 +++++++++++++++++++++++ pkg/devcontainer/compose_build.go | 6 ++ pkg/docker/helper.go | 24 ++++++ pkg/driver/docker/build.go | 31 +++++++- pkg/driver/docker/build_test.go | 25 +++++++ 9 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 pkg/devcontainer/buildkit/secrets.go create mode 100644 pkg/devcontainer/buildkit/secrets_test.go diff --git a/pkg/devcontainer/build/options.go b/pkg/devcontainer/build/options.go index 2eb2c87f7..fa98f3a39 100644 --- a/pkg/devcontainer/build/options.go +++ b/pkg/devcontainer/build/options.go @@ -43,6 +43,11 @@ type BuildOptions struct { // Target specifies the target build stage in a multi-stage Dockerfile. Target string + // BuildSecrets are "NAME=VALUE" entries exposed to the build as BuildKit + // secrets (RUN --mount=type=secret,id=NAME). Values never become build args + // or land in an image layer. + BuildSecrets []string + // Load controls whether to load the built image into the local Docker daemon. // When true, uses BuildKit's "moby" exporter which creates a tar and imports it. Load bool @@ -107,6 +112,8 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { params.ExtendedBuildInfo, ) + buildOptions.BuildSecrets = params.Options.BuildSecrets + // get cli options buildOptions.CliOpts = params.ParsedConfig.Config.GetOptions() diff --git a/pkg/devcontainer/buildkit/buildkit.go b/pkg/devcontainer/buildkit/buildkit.go index a6300a874..1566f703e 100644 --- a/pkg/devcontainer/buildkit/buildkit.go +++ b/pkg/devcontainer/buildkit/buildkit.go @@ -49,6 +49,14 @@ func Build( ), ) + secretsAttachable, err := buildSecretsAttachable(options.BuildSecrets) + if err != nil { + return err + } + if secretsAttachable != nil { + attachable = append(attachable, secretsAttachable) + } + // create solve options solveOptions := buildkit.SolveOpt{ Frontend: "dockerfile.v0", diff --git a/pkg/devcontainer/buildkit/remote.go b/pkg/devcontainer/buildkit/remote.go index 77ad8bb15..d75ded84f 100644 --- a/pkg/devcontainer/buildkit/remote.go +++ b/pkg/devcontainer/buildkit/remote.go @@ -261,6 +261,14 @@ func prepareSolveOptions( return client.SolveOpt{}, err } + secretsAttachable, err := buildSecretsAttachable(opts.Options.BuildSecrets) + if err != nil { + return client.SolveOpt{}, err + } + if secretsAttachable != nil { + authSession = append(authSession, secretsAttachable) + } + buildOpts, err := build.NewOptions(build.NewOptionsParams{ DockerfilePath: opts.DockerfilePath, DockerfileContent: opts.DockerfileContent, diff --git a/pkg/devcontainer/buildkit/secrets.go b/pkg/devcontainer/buildkit/secrets.go new file mode 100644 index 000000000..885e076ba --- /dev/null +++ b/pkg/devcontainer/buildkit/secrets.go @@ -0,0 +1,37 @@ +package buildkit + +import ( + "fmt" + "strings" + + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/secrets/secretsprovider" +) + +// ParseBuildSecrets converts "NAME=VALUE" entries into a map keyed by the +// BuildKit secret id. The value is the resolved secret exposed to the build via +// RUN --mount=type=secret,id=NAME; it never becomes a build arg or image layer. +func ParseBuildSecrets(entries []string) (map[string][]byte, error) { + secrets := make(map[string][]byte, len(entries)) + for _, entry := range entries { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + return nil, fmt.Errorf("invalid build secret %q, expected NAME=VALUE", entry) + } + secrets[name] = []byte(value) + } + return secrets, nil +} + +// buildSecretsAttachable returns a session attachable serving the given +// "NAME=VALUE" build secrets, or nil when there are none. +func buildSecretsAttachable(entries []string) (session.Attachable, error) { + if len(entries) == 0 { + return nil, nil + } + secrets, err := ParseBuildSecrets(entries) + if err != nil { + return nil, err + } + return secretsprovider.FromMap(secrets), nil +} diff --git a/pkg/devcontainer/buildkit/secrets_test.go b/pkg/devcontainer/buildkit/secrets_test.go new file mode 100644 index 000000000..04fc4a740 --- /dev/null +++ b/pkg/devcontainer/buildkit/secrets_test.go @@ -0,0 +1,90 @@ +package buildkit + +import ( + "testing" +) + +func TestParseBuildSecrets(t *testing.T) { + tests := []struct { + name string + entries []string + want map[string]string + wantErr bool + }{ + { + name: "empty", + entries: nil, + want: map[string]string{}, + }, + { + name: "single", + entries: []string{"TOKEN=abc123"}, + want: map[string]string{"TOKEN": "abc123"}, + }, + { + name: "value with equals", + entries: []string{"URL=key=value&x=y"}, + want: map[string]string{"URL": "key=value&x=y"}, + }, + { + name: "empty value", + entries: []string{"EMPTY="}, + want: map[string]string{"EMPTY": ""}, + }, + { + name: "missing equals", + entries: []string{"NOEQUALS"}, + wantErr: true, + }, + { + name: "empty name", + entries: []string{"=value"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseBuildSecrets(tt.entries) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %d secrets, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if string(got[k]) != v { + t.Errorf("secret %q = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestBuildSecretsAttachable(t *testing.T) { + attachable, err := buildSecretsAttachable(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attachable != nil { + t.Fatalf("expected nil attachable for empty secrets") + } + + attachable, err = buildSecretsAttachable([]string{"TOKEN=abc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attachable == nil { + t.Fatalf("expected non-nil attachable for non-empty secrets") + } + + if _, err := buildSecretsAttachable([]string{"invalid"}); err == nil { + t.Fatalf("expected error for invalid entry") + } +} diff --git a/pkg/devcontainer/compose_build.go b/pkg/devcontainer/compose_build.go index 575a1f1fd..778980248 100644 --- a/pkg/devcontainer/compose_build.go +++ b/pkg/devcontainer/compose_build.go @@ -115,6 +115,12 @@ func (r *runner) buildAndExtendDockerCompose( ctx context.Context, params *buildAndExtendParams, ) (composeExtendResult, error) { + if r.workspaceConfig != nil && len(r.workspaceConfig.CLIOptions.BuildSecrets) > 0 { + return composeExtendResult{}, fmt.Errorf( + "build secrets require the BuildKit builder and are not supported for docker compose builds", + ) + } + prepared, err := r.prepareExtendedComposeBuild(ctx, params) if err != nil { return composeExtendResult{}, err diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index e2c574242..3af2f52e9 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -219,6 +219,30 @@ func (r *DockerHelper) RunWithDir( return cmd.Run() } +// RunWithEnv runs a command with extra environment variables for this +// invocation only. Used to pass build secrets to buildx via env-backed +// --secret references so values never appear in the argument list. +func (r *DockerHelper) RunWithEnv( + ctx context.Context, + extraEnv []string, + args []string, + stdin io.Reader, + stdout io.Writer, + stderr io.Writer, +) error { + cmd := r.buildCmd(ctx, args...) + if len(extraEnv) > 0 { + if cmd.Env == nil { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, extraEnv...) + } + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + func (r *DockerHelper) StartContainer(ctx context.Context, containerId string) error { out, err := r.buildCmd(ctx, "start", containerId).CombinedOutput() if err != nil { diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 184f5f559..836ed4daf 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -75,10 +75,22 @@ func (s *dockerBuildxStrategy) build( options *build.BuildOptions, ) error { args := buildDockerBuildxArgs(options, platform) + secretEnv, secretArgs, err := buildxSecretArgs(options.BuildSecrets) + if err != nil { + return err + } + args = append(args, secretArgs...) log.Debugf("running docker buildx build with args: %s", strings.Join(args, " ")) stderrBuf := &bytes.Buffer{} multiWriter := io.MultiWriter(writer, stderrBuf) - if err := s.driver.Docker.Run(ctx, args, nil, writer, multiWriter); err != nil { + if err := s.driver.Docker.RunWithEnv( + ctx, + secretEnv, + args, + nil, + writer, + multiWriter, + ); err != nil { if stderrBuf.Len() > 0 { return fmt.Errorf( "failed to build image: %w: %s", @@ -114,6 +126,23 @@ func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []strin return args } +// buildxSecretArgs maps NAME=VALUE build secrets to buildx `--secret +// id=NAME,env=VAR` references, returning the env assignments to pass out of band +// so secret values never appear in the argument list. +func buildxSecretArgs(buildSecrets []string) (env, args []string, err error) { + for _, entry := range buildSecrets { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + return nil, nil, fmt.Errorf("invalid build secret %q, expected NAME=VALUE", entry) + } + envVar := "DEVSY_BUILD_SECRET_" + name + env = append(env, envVar+"="+value) + args = append(args, "--secret", "id="+name+",env="+envVar) + } + + return env, args, nil +} + func appendBuildFlags(args []string, load, push bool) []string { if load { args = append(args, "--load") diff --git a/pkg/driver/docker/build_test.go b/pkg/driver/docker/build_test.go index a6442b937..a67805334 100644 --- a/pkg/driver/docker/build_test.go +++ b/pkg/driver/docker/build_test.go @@ -137,3 +137,28 @@ func TestBuildDockerBuildxArgs_PullAndNoCache(t *testing.T) { }) } } + +func TestBuildxSecretArgs(t *testing.T) { + env, args, err := buildxSecretArgs([]string{"NPM_TOKEN=abc123", "CONN=user=admin"}) + require.NoError(t, err) + assert.Equal(t, []string{ + "DEVSY_BUILD_SECRET_NPM_TOKEN=abc123", + "DEVSY_BUILD_SECRET_CONN=user=admin", + }, env) + assert.Equal(t, []string{ + "--secret", "id=NPM_TOKEN,env=DEVSY_BUILD_SECRET_NPM_TOKEN", + "--secret", "id=CONN,env=DEVSY_BUILD_SECRET_CONN", + }, args) +} + +func TestBuildxSecretArgs_Empty(t *testing.T) { + env, args, err := buildxSecretArgs(nil) + require.NoError(t, err) + assert.Empty(t, env) + assert.Empty(t, args) +} + +func TestBuildxSecretArgs_Invalid(t *testing.T) { + _, _, err := buildxSecretArgs([]string{"NOEQUALS"}) + require.Error(t, err) +} From 08954e1be688b8bb3d3f53203184af19c079a762 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 00:49:54 -0500 Subject: [PATCH 05/19] feat(secret): clone private HTTP repos with a managed access token Wire the resolved --git-token into the credential path: the tunnel GitCredentials RPC returns the token only for its own host (never leaked to other hosts git contacts), served at the up and setup clone sites. Foundation resolves the token secret and infers the username from the repo host. --- cmd/workspace/up/agent.go | 1 + pkg/agent/tunnelserver/options.go | 7 ++ pkg/agent/tunnelserver/tunnelserver.go | 13 ++++ .../tunnelserver_gittoken_test.go | 64 +++++++++++++++++++ .../clientimplementation/workspace_client.go | 3 +- pkg/devcontainer/setup.go | 1 + 6 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 pkg/agent/tunnelserver/tunnelserver_gittoken_test.go diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 8a5525974..d7e71b58d 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -98,6 +98,7 @@ func (cmd *UpCmd) devsyUpProxy( true, true, client.WorkspaceConfig(), + tunnelserver.WithGitToken(cmd.CLIOptions.GitToken), ) if err != nil { return nil, fmt.Errorf("run tunnel machine: %w", err) diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index 12cedee51..e3ce13527 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -68,6 +68,13 @@ func WithSecrets(env, mount []string) Option { } } +func WithGitToken(token *provider2.GitToken) Option { + return func(s *tunnelServer) *tunnelServer { + s.gitToken = token + return s + } +} + func toSecrets(entries []string, mount bool) []*tunnel.Secret { secrets := make([]*tunnel.Secret, 0, len(entries)) for _, entry := range entries { diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 1238d2ca0..fae0edf44 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -115,6 +115,7 @@ type tunnelServer struct { platformOptions *devsy.PlatformOptions secrets []*tunnel.Secret + gitToken *provider2.GitToken } func (t *tunnelServer) RunWithResult( @@ -275,6 +276,18 @@ func (t *tunnelServer) GitCredentials( return nil, fmt.Errorf("decode git credentials request: %w", err) } + // A configured git token is returned only for its own host, so it is never + // offered to other hosts git contacts (submodules, redirects). + if t.gitToken != nil && t.gitToken.Token != "" && credentials.Host == t.gitToken.Host { + credentials.Username = t.gitToken.Username + credentials.Password = t.gitToken.Token + out, err := json.Marshal(credentials) + if err != nil { + return nil, err + } + return &tunnel.Message{Message: string(out)}, nil + } + if t.platformOptions != nil && t.platformOptions.Enabled { gitHttpCredentials := slices.Concat( t.platformOptions.UserCredentials.GitHttp, diff --git a/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go b/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go new file mode 100644 index 000000000..9241e5381 --- /dev/null +++ b/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go @@ -0,0 +1,64 @@ +package tunnelserver + +import ( + "context" + "encoding/json" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/gitcredentials" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gitCredentialsRequest(t *testing.T, host string) *tunnel.Message { + t.Helper() + // #nosec G117 -- test request struct carries no secret. + raw, err := json.Marshal(gitcredentials.GitCredentials{Protocol: "https", Host: host}) + require.NoError(t, err) + return &tunnel.Message{Message: string(raw)} +} + +func TestGitCredentials_TokenReturnedForMatchingHost(t *testing.T) { + s := New( + WithAllowGitCredentials(true), + WithGitToken(&provider.GitToken{ + Host: "github.com", + Username: "x-access-token", + Token: "ghp_secret", + }), + ) + + resp, err := s.GitCredentials(context.Background(), gitCredentialsRequest(t, "github.com")) + require.NoError(t, err) + + var got gitcredentials.GitCredentials + require.NoError(t, json.Unmarshal([]byte(resp.Message), &got)) + assert.Equal(t, "x-access-token", got.Username) + assert.Equal(t, "ghp_secret", got.Password) +} + +func TestGitCredentials_TokenNotLeakedToOtherHost(t *testing.T) { + // A mismatched host must NOT receive the token; with no workspace and no + // platform options it falls through to normal resolution, which must not + // return our token. + s := New( + WithAllowGitCredentials(true), + WithGitToken(&provider.GitToken{ + Host: "github.com", + Username: "x-access-token", + Token: "ghp_secret", + }), + ) + + resp, err := s.GitCredentials( + context.Background(), + gitCredentialsRequest(t, "evil.example.com"), + ) + if err == nil { + var got gitcredentials.GitCredentials + require.NoError(t, json.Unmarshal([]byte(resp.Message), &got)) + assert.NotEqual(t, "ghp_secret", got.Password, "token must not be sent to a different host") + } +} diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 4d8626de9..273a3d1c1 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -1099,7 +1099,8 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectGitCredentials(opts.CLIOptions), opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), - opts.TunnelOptions..., + append(opts.TunnelOptions, + tunnelserver.WithGitToken(opts.CLIOptions.GitToken))..., ) if err != nil { return nil, fmt.Errorf("run tunnel server: %w", err) diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index e5f92d0d0..2974273fa 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -456,6 +456,7 @@ func (r *runner) executeSetup( r.workspaceConfig.CLIOptions.SecretsEnv, r.workspaceConfig.CLIOptions.SecretsMount, ), + tunnelserver.WithGitToken(r.workspaceConfig.CLIOptions.GitToken), ) } From aa253588a003ea4f3a059ebc8806cb7d8b86b25b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 00:58:46 -0500 Subject: [PATCH 06/19] feat(env): managed environment variables over the shared value store Add a per-entry Sensitive flag to the managed-value store: sensitive values go to the keyring/encrypted-file backend as before; non-sensitive values are stored inline in the index (no keyring, no encryption). Add a 'devsy env' facade (set/get/list/delete) over the same store for non-sensitive vars, and an 'up --env NAME[=TARGET]' flag to inject them. 'devsy secret' and 'devsy env' are thin facades differing only in sensitivity. --- cmd/env/env.go | 202 ++++++++++++++++++ cmd/root.go | 4 + cmd/secrets/set.go | 2 +- cmd/workspace/up/up.go | 1 + cmd/workspace/up/up_client.go | 15 +- cmd/workspace/up/up_flags.go | 2 + pkg/flags/names/names.go | 1 + pkg/secrets/local_store.go | 75 ++++--- pkg/secrets/store.go | 12 +- pkg/secrets/store_internal_test.go | 70 +++++- .../developing-in-workspaces/secrets.mdx | 26 +++ 11 files changed, 366 insertions(+), 44 deletions(-) create mode 100644 cmd/env/env.go diff --git a/cmd/env/env.go b/cmd/env/env.go new file mode 100644 index 000000000..a9688121b --- /dev/null +++ b/cmd/env/env.go @@ -0,0 +1,202 @@ +package env + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +// NewEnvCmd returns the `devsy env` command group: managed, non-sensitive +// environment variables stored per context and injected into workspaces. +func NewEnvCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + envCmd := &cobra.Command{ + Use: "env", + Short: "Devsy managed environment variables", + Long: `Manage named environment variables stored per context and injected +into workspaces. Values are stored in plaintext in the Devsy config directory; +use "devsy secret" for sensitive values.`, + } + envCmd.AddCommand(newSetCmd(globalFlags)) + envCmd.AddCommand(newListCmd(globalFlags)) + envCmd.AddCommand(newGetCmd(globalFlags)) + envCmd.AddCommand(newDeleteCmd(globalFlags)) + return envCmd +} + +func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, error) { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return "", nil, err + } + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return "", nil, err + } + return devsyConfig.DefaultContext, store, nil +} + +type cmdFlags struct { + *flags.GlobalFlags + value string +} + +func newSetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cmdFlags{GlobalFlags: globalFlags} + setCmd := &cobra.Command{ + Use: "set NAME=VALUE | set NAME --value VALUE", + Short: "Create or update an environment variable", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.runSet(cobraCmd.Context(), args[0]) + }, + } + setCmd.Flags().StringVar(&cmd.value, "value", "", "The value (alternative to NAME=VALUE)") + return setCmd +} + +func (cmd *cmdFlags) runSet(_ context.Context, arg string) error { + name, value := arg, cmd.value + if k, v, ok := splitKeyValue(arg); ok { + name, value = k, v + } + if err := secrets.ValidateName(name); err != nil { + return err + } + + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + if err := store.Set(contextName, name, value, false); err != nil { + return err + } + + log.Infof("env var %q set in context %q", name, contextName) + return nil +} + +func newGetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cmdFlags{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "get NAME", + Short: "Print an environment variable's value", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + value, err := store.Get(contextName, args[0]) + if err != nil { + return err + } + //nolint:forbidigo // get prints the raw value to stdout for scripting. + fmt.Println(value) + return nil + }, + } +} + +func newDeleteCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cmdFlags{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm"}, + Short: "Delete an environment variable from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + if err := store.Delete(contextName, args[0]); err != nil { + return err + } + log.Infof("env var %q deleted from context %q", args[0], contextName) + return nil + }, + } +} + +type envEntry struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func newListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cmdFlags{GlobalFlags: globalFlags} + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List environment variables in the active context", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.runList(cobraCmd.Context()) + }, + } + return listCmd +} + +func (cmd *cmdFlags) runList(_ context.Context) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + metas, err := store.List(contextName) + if err != nil { + return err + } + + entries := make([]envEntry, 0, len(metas)) + for _, m := range metas { + if m.Sensitive { + continue + } + entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return printJSON(entries) + } + printTable(entries) + return nil +} + +func printJSON(entries []envEntry) error { + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + //nolint:forbidigo // list --result-format json prints structured data to stdout. + fmt.Print(string(out)) + return nil +} + +func printTable(entries []envEntry) { + rows := make([][]string, 0, len(entries)) + for _, e := range entries { + rows = append(rows, []string{e.Name, e.Value}) + } + table.Print([]string{"Name", "Value"}, rows) +} + +func splitKeyValue(s string) (key, value string, ok bool) { + for i := range len(s) { + if s[i] == '=' { + return s[:i], s[i+1:], true + } + } + return "", "", false +} diff --git a/cmd/root.go b/cmd/root.go index 80daae878..39de946bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" "github.com/devsy-org/devsy/cmd/context" + envcmd "github.com/devsy-org/devsy/cmd/env" "github.com/devsy-org/devsy/cmd/feature" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/cmd/ide" @@ -320,6 +321,9 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) secretsCmd := secretscmd.NewSecretsCmd(globalFlags) secretsCmd.GroupID = groupConfig rootCmd.AddCommand(secretsCmd) + envCmd := envcmd.NewEnvCmd(globalFlags) + envCmd.GroupID = groupConfig + rootCmd.AddCommand(envCmd) if proEnabled() { proCmd := pro.NewProCmd(globalFlags) proCmd.GroupID = groupPlatform diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index 430b61554..0b3eed92f 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -63,7 +63,7 @@ func (cmd *SetCmd) Run(ctx context.Context, name string) error { return err } - if err := store.Set(contextName, name, value); err != nil { + if err := store.Set(contextName, name, value, true); err != nil { return err } diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index c74d5fd13..b9fa27616 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -44,6 +44,7 @@ type UpCmd struct { SecretsFile string FeatureSecretsFile string Secrets []string + EnvVars []string BuildSecrets []string GitTokenSecret string GitTokenUsername string diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index e61d86828..e7529dab2 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -163,7 +163,8 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { if err != nil { return err } - if len(requests) == 0 && len(cmd.BuildSecrets) == 0 && cmd.GitTokenSecret == "" { + if len(requests) == 0 && len(cmd.EnvVars) == 0 && + len(cmd.BuildSecrets) == 0 && cmd.GitTokenSecret == "" { return nil } @@ -193,6 +194,18 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { } } + for _, entry := range cmd.EnvVars { + name, target, remapped := strings.Cut(entry, "=") + if !remapped { + target = name + } + value, err := get(name) + if err != nil { + return err + } + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+value) + } + for _, name := range cmd.BuildSecrets { value, err := get(name) if err != nil { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index d9f2ef995..3df8b848d 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -202,6 +202,8 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { flags.StringArray(&cmd.Secrets, names.Secret, nil, "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), + flags.StringArray(&cmd.EnvVars, names.Env, nil, + "Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"), flags.StringArray(&cmd.BuildSecrets, names.BuildSecret, nil, "Stored Devsy secret exposed to the build via BuildKit "+ "(RUN --mount=type=secret,id=NAME). Repeatable"), diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 3ec7d7bae..718a11768 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -24,6 +24,7 @@ const ( DevContainerOverlay = "devcontainer-overlay" DevContainerPath = "devcontainer-path" DisableDaemon = "disable-daemon" + Env = "env" FallbackImage = "fallback-image" Features = "features" FeatureSecretsFile = "feature-secrets-file" diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index c75ef7df4..23c645a72 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -108,7 +108,7 @@ func newLocalStore(b backend, indexPath string) *localStore { return &localStore{backend: b, indexPath: indexPath, now: time.Now} } -func (s *localStore) Set(context, name, value string) error { +func (s *localStore) Set(context, name, value string, sensitive bool) error { if err := ValidateName(name); err != nil { return err } @@ -117,24 +117,31 @@ func (s *localStore) Set(context, name, value string) error { if err != nil { return err } - if err := s.checkKeySource(idx); err != nil { - return err - } - - // Write the value first; the backend is the source of truth. - if err := s.backend.set(backendKey(context, name), value); err != nil { - return err - } meta, exists := idx.get(context, name) if !exists { meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} } - idx.put(meta) - if s.keySource != "" { - idx.data.KeySource = string(s.keySource) + meta.Sensitive = sensitive + + if sensitive { + if err := s.checkKeySource(idx); err != nil { + return err + } + // Sensitive values live in the backend; the index holds only metadata. + if err := s.backend.set(backendKey(context, name), value); err != nil { + return err + } + meta.Value = "" + if s.keySource != "" { + idx.data.KeySource = string(s.keySource) + } + } else { + // Non-sensitive values are stored inline in the index. + meta.Value = value } + idx.put(meta) return idx.save() } @@ -147,21 +154,28 @@ func (s *localStore) Get(context, name string) (string, error) { if err != nil { return "", err } - if err := s.checkKeySource(idx); err != nil { - return "", err + + meta, ok := idx.get(context, name) + if !ok { + return "", ErrSecretNotFound } - value, err := s.backend.get(backendKey(context, name)) - if err != nil { - return "", err + var value string + if meta.Sensitive { + if err := s.checkKeySource(idx); err != nil { + return "", err + } + if value, err = s.backend.get(backendKey(context, name)); err != nil { + return "", err + } + } else { + value = meta.Value } // Best-effort last-used tracking; index errors never fail a Get. - if meta, ok := idx.get(context, name); ok { - meta.LastUsed = s.now().UTC() - idx.put(meta) - _ = idx.save() - } + meta.LastUsed = s.now().UTC() + idx.put(meta) + _ = idx.save() return value, nil } @@ -175,12 +189,15 @@ func (s *localStore) Delete(context, name string) error { if err != nil { return err } - if err := s.checkKeySource(idx); err != nil { - return err - } - if err := s.backend.remove(backendKey(context, name)); err != nil { - return err + // Only sensitive entries have a backend value to remove. + if meta, ok := idx.get(context, name); ok && meta.Sensitive { + if err := s.checkKeySource(idx); err != nil { + return err + } + if err := s.backend.remove(backendKey(context, name)); err != nil { + return err + } } idx.remove(context, name) @@ -202,6 +219,10 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { entries := idx.list(context) for i := range entries { + // Non-sensitive values live inline in the index; they cannot be orphaned. + if !entries[i].Sensitive { + continue + } _, err := s.backend.get(backendKey(context, entries[i].Name)) switch { case err == nil: diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go index 3b5523574..3237553ae 100644 --- a/pkg/secrets/store.go +++ b/pkg/secrets/store.go @@ -26,16 +26,18 @@ func ValidateName(name string) error { } type SecretMeta struct { - Name string `json:"name"` - Context string `json:"context"` - Created time.Time `json:"created"` - LastUsed time.Time `json:"lastUsed,omitzero"` + Name string `json:"name"` + Context string `json:"context"` + Sensitive bool `json:"sensitive"` + Value string `json:"value,omitempty"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` Orphaned bool `json:"-"` } type Store interface { - Set(context, name, value string) error + Set(context, name, value string, sensitive bool) error Get(context, name string) (string, error) List(context string) ([]SecretMeta, error) Delete(context, name string) error diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 7f42d9a6d..68f774d05 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -101,7 +101,7 @@ func newTestStore(t *testing.T, b backend) *localStore { func TestStore_SetGetDelete(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "API_KEY", "abc123"); err != nil { + if err := s.Set("default", "API_KEY", "abc123", true); err != nil { t.Fatal(err) } @@ -137,10 +137,10 @@ func TestStore_DeleteIdempotent(t *testing.T) { func TestStore_ContextIsolation(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("ctx-a", "TOKEN", "a"); err != nil { + if err := s.Set("ctx-a", "TOKEN", "a", true); err != nil { t.Fatal(err) } - if err := s.Set("ctx-b", "TOKEN", "b"); err != nil { + if err := s.Set("ctx-b", "TOKEN", "b", true); err != nil { t.Fatal(err) } @@ -162,7 +162,7 @@ func TestStore_ContextIsolation(t *testing.T) { func TestStore_ListSorted(t *testing.T) { s := newTestStore(t, newMapBackend()) for _, n := range []string{"ZED", "ALPHA", "MIKE"} { - if err := s.Set("default", n, "v"); err != nil { + if err := s.Set("default", n, "v", true); err != nil { t.Fatal(err) } } @@ -183,10 +183,10 @@ func TestStore_ReconcileFlagsOrphans(t *testing.T) { mb := newMapBackend() s := newTestStore(t, mb) - if err := s.Set("default", "PRESENT", "v"); err != nil { + if err := s.Set("default", "PRESENT", "v", true); err != nil { t.Fatal(err) } - if err := s.Set("default", "GONE", "v"); err != nil { + if err := s.Set("default", "GONE", "v", true); err != nil { t.Fatal(err) } @@ -211,13 +211,13 @@ func TestStore_ReconcileFlagsOrphans(t *testing.T) { func TestStore_SetUpdatePreservesCreated(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "K", "v1"); err != nil { + if err := s.Set("default", "K", "v1", true); err != nil { t.Fatal(err) } first, _ := s.List("default") created := first[0].Created - if err := s.Set("default", "K", "v2"); err != nil { + if err := s.Set("default", "K", "v2", true); err != nil { t.Fatal(err) } second, _ := s.List("default") @@ -247,7 +247,7 @@ func TestValidateName(t *testing.T) { func TestStore_InvalidNameRejected(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "bad name", "v"); err == nil { + if err := s.Set("default", "bad name", "v", true); err == nil { t.Fatal("Set with invalid name should error") } } @@ -362,7 +362,7 @@ func TestStore_KeySourceMismatchIsClearError(t *testing.T) { s := newLocalStore(newMapBackend(), indexPath) s.keySource = keySourceAutoFile - if err := s.Set(testContext, "K", "v"); err != nil { + if err := s.Set(testContext, "K", "v", true); err != nil { t.Fatal(err) } @@ -376,3 +376,53 @@ func TestStore_KeySourceMismatchIsClearError(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestStore_NonSensitiveStoredInline(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set(testContext, "FOO", "bar", false); err != nil { + t.Fatal(err) + } + + // Non-sensitive values must NOT touch the backend. + if _, ok := mb.values[backendKey(testContext, "FOO")]; ok { + t.Fatal("non-sensitive value must not be written to the backend") + } + + got, err := s.Get(testContext, "FOO") + if err != nil { + t.Fatal(err) + } + if got != "bar" { + t.Fatalf("Get = %q, want %q", got, "bar") + } +} + +func TestStore_NonSensitiveNeverOrphaned(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "FOO", "bar", false); err != nil { + t.Fatal(err) + } + + list, err := s.List(testContext) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].Orphaned { + t.Fatalf("non-sensitive entry should never be orphaned: %+v", list) + } +} + +func TestStore_DeleteNonSensitive(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "FOO", "bar", false); err != nil { + t.Fatal(err) + } + if err := s.Delete(testContext, "FOO"); err != nil { + t.Fatal(err) + } + if _, err := s.Get(testContext, "FOO"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get after delete = %v, want ErrSecretNotFound", err) + } +} diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx index beaa1a5fa..45301457d 100644 --- a/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx @@ -160,3 +160,29 @@ you later change it — for example by setting a passphrase for secrets that wer stored with an auto-generated key — Devsy reports a clear error rather than failing to decrypt. Restore the original setting, or re-create the secrets under the new one. + +## Managed Environment Variables + +For non-sensitive configuration you can store managed environment variables with +`devsy env`. They share the same per-context store as secrets, but their values +are kept in plaintext in the Devsy config directory (no keyring, no encryption) +and are freely readable: + +``` +devsy env set LOG_LEVEL=debug +devsy env set REGION --value us-east-1 +devsy env list +devsy env get LOG_LEVEL +devsy env delete LOG_LEVEL +``` + +Inject them into a workspace with `--env` (repeatable), optionally remapping the +variable name with `NAME=TARGET`: + +``` +devsy workspace up ... --env LOG_LEVEL --env REGION=AWS_REGION +``` + +Use `devsy secret` (not `devsy env`) for anything sensitive: secrets are stored +in the OS keyring or an encrypted file, masked in logs, and delivered over the +agent tunnel. From c3a8b62a83b0b2027c534f5f6b2795570390998b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 01:11:03 -0500 Subject: [PATCH 07/19] fix(up): resolve build-secret field collision and lint UpCmd.BuildSecrets shadowed the embedded CLIOptions.BuildSecrets, so resolved build secrets were written to the flag field and never reached CLIOptions (BuildKit saw none). Rename the flag field to BuildSecretNames and write resolved values to CLIOptions. Also split resolveStoredSecrets into per-type appliers (cyclop) and add RunWithEnv for buildx build secrets. --- cmd/workspace/up/agent.go | 2 +- cmd/workspace/up/up.go | 2 +- cmd/workspace/up/up_client.go | 54 ++++++++++++++++++++------ cmd/workspace/up/up_flags.go | 2 +- pkg/agent/tunnelserver/tunnelserver.go | 1 + pkg/docker/helper.go | 2 + 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index d7e71b58d..f72a9ec27 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -98,7 +98,7 @@ func (cmd *UpCmd) devsyUpProxy( true, true, client.WorkspaceConfig(), - tunnelserver.WithGitToken(cmd.CLIOptions.GitToken), + tunnelserver.WithGitToken(cmd.GitToken), ) if err != nil { return nil, fmt.Errorf("run tunnel machine: %w", err) diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index b9fa27616..b2a4969b4 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -45,7 +45,7 @@ type UpCmd struct { FeatureSecretsFile string Secrets []string EnvVars []string - BuildSecrets []string + BuildSecretNames []string GitTokenSecret string GitTokenUsername string WorkspaceFolder string diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index e7529dab2..23bdd544c 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -163,8 +163,7 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { if err != nil { return err } - if len(requests) == 0 && len(cmd.EnvVars) == 0 && - len(cmd.BuildSecrets) == 0 && cmd.GitTokenSecret == "" { + if !cmd.hasStoredValues(requests) { return nil } @@ -182,6 +181,30 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { return value, nil } + appliers := []func(func(string) (string, error)) error{ + func(g func(string) (string, error)) error { return cmd.applyLifecycleSecrets(requests, g) }, + cmd.applyEnvVars, + cmd.applyBuildSecrets, + cmd.applyGitToken, + } + for _, apply := range appliers { + if err := apply(get); err != nil { + return err + } + } + + return nil +} + +func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { + return len(requests) > 0 || len(cmd.EnvVars) > 0 || + len(cmd.BuildSecretNames) > 0 || cmd.GitTokenSecret != "" +} + +func (cmd *UpCmd) applyLifecycleSecrets( + requests []secretRequest, + get func(string) (string, error), +) error { for _, req := range requests { value, err := get(req.name) if err != nil { @@ -193,7 +216,10 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { cmd.SecretsEnv = append(cmd.SecretsEnv, req.target+"="+value) } } + return nil +} +func (cmd *UpCmd) applyEnvVars(get func(string) (string, error)) error { for _, entry := range cmd.EnvVars { name, target, remapped := strings.Cut(entry, "=") if !remapped { @@ -205,23 +231,29 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { } cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+value) } + return nil +} - for _, name := range cmd.BuildSecrets { +func (cmd *UpCmd) applyBuildSecrets(get func(string) (string, error)) error { + for _, name := range cmd.BuildSecretNames { value, err := get(name) if err != nil { return err } - cmd.CLIOptions.BuildSecrets = append(cmd.CLIOptions.BuildSecrets, name+"="+value) + cmd.BuildSecrets = append(cmd.BuildSecrets, name+"="+value) } + return nil +} - if cmd.GitTokenSecret != "" { - token, err := get(cmd.GitTokenSecret) - if err != nil { - return err - } - cmd.CLIOptions.GitToken = cmd.buildGitToken(token) +func (cmd *UpCmd) applyGitToken(get func(string) (string, error)) error { + if cmd.GitTokenSecret == "" { + return nil } - + token, err := get(cmd.GitTokenSecret) + if err != nil { + return err + } + cmd.GitToken = cmd.buildGitToken(token) return nil } diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index 3df8b848d..87fc019a6 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -204,7 +204,7 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), flags.StringArray(&cmd.EnvVars, names.Env, nil, "Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"), - flags.StringArray(&cmd.BuildSecrets, names.BuildSecret, nil, + flags.StringArray(&cmd.BuildSecretNames, names.BuildSecret, nil, "Stored Devsy secret exposed to the build via BuildKit "+ "(RUN --mount=type=secret,id=NAME). Repeatable"), flags.String(&cmd.GitTokenSecret, names.GitToken, "", diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index fae0edf44..4fd0fce53 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -281,6 +281,7 @@ func (t *tunnelServer) GitCredentials( if t.gitToken != nil && t.gitToken.Token != "" && credentials.Host == t.gitToken.Host { credentials.Username = t.gitToken.Username credentials.Password = t.gitToken.Token + // #nosec G117 -- git credential response legitimately carries the token. out, err := json.Marshal(credentials) if err != nil { return nil, err diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 3af2f52e9..fd4e2d6b9 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -222,6 +222,8 @@ func (r *DockerHelper) RunWithDir( // RunWithEnv runs a command with extra environment variables for this // invocation only. Used to pass build secrets to buildx via env-backed // --secret references so values never appear in the argument list. +// +//nolint:revive // IO stream params mirror Run/RunWithDir. func (r *DockerHelper) RunWithEnv( ctx context.Context, extraEnv []string, From f66d98949be9f786c6b9303d0268428b62944158 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 01:42:04 -0500 Subject: [PATCH 08/19] fix(secret): address code-review findings - Strip GitToken and BuildSecrets from the CLIOptions embedded in the container setup command argv (they were recoverable via ps/proc; now delivered only over the tunnel) [security] - Enforce the sensitive/non-sensitive boundary on reads: secret get/list/delete operate on secrets only, env get/delete on env vars only [security] - Guard the index so a sensitive value can never be serialized inline - Clear the stale backend value when an entry changes from sensitive to non-sensitive - Only verify the key source when a sensitive entry is actually accessed - Add Store.Meta for kind-aware facade checks --- cmd/env/env.go | 14 ++++ cmd/secrets/delete.go | 9 +++ cmd/secrets/get.go | 8 ++ cmd/secrets/list.go | 8 +- pkg/devcontainer/setup.go | 2 + pkg/devcontainer/setup_secrets_test.go | 52 +++++++++++++ pkg/secrets/index.go | 4 + pkg/secrets/local_store.go | 104 +++++++++++++++++++------ pkg/secrets/store.go | 1 + pkg/secrets/store_internal_test.go | 44 +++++++++++ 10 files changed, 221 insertions(+), 25 deletions(-) create mode 100644 pkg/devcontainer/setup_secrets_test.go diff --git a/cmd/env/env.go b/cmd/env/env.go index a9688121b..dfdd9d32a 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -94,6 +94,13 @@ func newGetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { if err != nil { return err } + meta, err := store.Meta(contextName, args[0]) + if err != nil { + return err + } + if meta.Sensitive { + return fmt.Errorf("%q is a secret; use \"devsy secret get\"", args[0]) + } value, err := store.Get(contextName, args[0]) if err != nil { return err @@ -117,6 +124,13 @@ func newDeleteCmd(globalFlags *flags.GlobalFlags) *cobra.Command { if err != nil { return err } + meta, err := store.Meta(contextName, args[0]) + if err != nil { + return err + } + if meta.Sensitive { + return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", args[0]) + } if err := store.Delete(contextName, args[0]); err != nil { return err } diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index 39a14dd17..c1ff9aa5b 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -2,6 +2,7 @@ package secrets import ( "context" + "fmt" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/log" @@ -35,6 +36,14 @@ func (cmd *DeleteCmd) Run(ctx context.Context, name string) error { return err } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if !meta.Sensitive { + return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) + } + if err := store.Delete(contextName, name); err != nil { return err } diff --git a/cmd/secrets/get.go b/cmd/secrets/get.go index ad732f043..54ec637b8 100644 --- a/cmd/secrets/get.go +++ b/cmd/secrets/get.go @@ -38,6 +38,14 @@ func (cmd *GetCmd) Run(ctx context.Context, name string) error { return err } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if !meta.Sensitive { + return fmt.Errorf("%q is an environment variable; use \"devsy env get\"", name) + } + value, err := store.Get(contextName, name) if err != nil { return err diff --git a/cmd/secrets/list.go b/cmd/secrets/list.go index 4d35b75c6..46c4c5084 100644 --- a/cmd/secrets/list.go +++ b/cmd/secrets/list.go @@ -48,10 +48,16 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - metas, err := store.List(contextName) + all, err := store.List(contextName) if err != nil { return err } + metas := make([]secrets.SecretMeta, 0, len(all)) + for _, m := range all { + if m.Sensitive { + metas = append(metas, m) + } + } mode, err := output.ResolveMode(cmd.ResultFormat) if err != nil { diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 2974273fa..be34da78a 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -293,6 +293,8 @@ func (r *runner) compressWorkspaceConfig() (string, error) { cliOptions := r.workspaceConfig.CLIOptions cliOptions.SecretsEnv = nil cliOptions.SecretsMount = nil + cliOptions.BuildSecrets = nil + cliOptions.GitToken = nil workspaceConfig := &provider2.ContainerWorkspaceInfo{ IDE: r.workspaceConfig.Workspace.IDE, diff --git a/pkg/devcontainer/setup_secrets_test.go b/pkg/devcontainer/setup_secrets_test.go new file mode 100644 index 000000000..45edea1e0 --- /dev/null +++ b/pkg/devcontainer/setup_secrets_test.go @@ -0,0 +1,52 @@ +package devcontainer + +import ( + "encoding/json" + "testing" + + "github.com/devsy-org/devsy/pkg/compress" + "github.com/devsy-org/devsy/pkg/provider" +) + +// Secret values must never ride in the compressed CLIOptions embedded in the +// setup command's arguments; the container pulls them over the tunnel instead. +func TestCompressWorkspaceConfig_StripsSecretValues(t *testing.T) { + r := &runner{ + workspaceConfig: &provider.AgentWorkspaceInfo{ + Workspace: &provider.Workspace{}, + CLIOptions: provider.CLIOptions{ + SecretsEnv: []string{"API_KEY=super-secret"}, + SecretsMount: []string{"tls.key=key-material"}, + BuildSecrets: []string{"NPM_TOKEN=npm-secret"}, + GitToken: &provider.GitToken{Host: "github.com", Token: "ghp_secret"}, + }, + }, + } + + compressed, err := r.compressWorkspaceConfig() + if err != nil { + t.Fatal(err) + } + decompressed, err := compress.Decompress(compressed) + if err != nil { + t.Fatal(err) + } + + var info provider.ContainerWorkspaceInfo + if err := json.Unmarshal([]byte(decompressed), &info); err != nil { + t.Fatal(err) + } + + if info.CLIOptions.SecretsEnv != nil { + t.Error("SecretsEnv must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.SecretsMount != nil { + t.Error("SecretsMount must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.BuildSecrets != nil { + t.Error("BuildSecrets must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.GitToken != nil { + t.Error("GitToken must be stripped from the setup-command CLIOptions") + } +} diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index 1c6c171b1..c0846f589 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -66,6 +66,10 @@ func (i *index) put(meta SecretMeta) { i.data.Contexts[meta.Context] = map[string]SecretMeta{} } meta.Orphaned = false + // A sensitive value must never be persisted inline; it lives in the backend. + if meta.Sensitive { + meta.Value = "" + } i.data.Contexts[meta.Context][meta.Name] = meta } diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 23c645a72..efc47944d 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -122,23 +122,15 @@ func (s *localStore) Set(context, name, value string, sensitive bool) error { if !exists { meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} } + wasSensitive := exists && meta.Sensitive meta.Sensitive = sensitive + meta.Value = value // cleared below for sensitive entries + if err := s.persistValue(idx, &meta, value, wasSensitive); err != nil { + return err + } if sensitive { - if err := s.checkKeySource(idx); err != nil { - return err - } - // Sensitive values live in the backend; the index holds only metadata. - if err := s.backend.set(backendKey(context, name), value); err != nil { - return err - } meta.Value = "" - if s.keySource != "" { - idx.data.KeySource = string(s.keySource) - } - } else { - // Non-sensitive values are stored inline in the index. - meta.Value = value } idx.put(meta) @@ -204,6 +196,21 @@ func (s *localStore) Delete(context, name string) error { return idx.save() } +// Meta returns the metadata for a single entry (no value), or ErrSecretNotFound. +func (s *localStore) Meta(context, name string) (SecretMeta, error) { + idx, err := loadIndex(s.indexPath) + if err != nil { + return SecretMeta{}, err + } + meta, ok := idx.get(context, name) + if !ok { + return SecretMeta{}, ErrSecretNotFound + } + meta.Value = "" + + return meta, nil +} + func (s *localStore) List(context string) ([]SecretMeta, error) { return s.Reconcile(context) } @@ -213,30 +220,79 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { if err != nil { return nil, err } + + entries := idx.list(context) + if !anySensitive(entries) { + return entries, nil + } + // Only verify the key source when there is a sensitive entry to read. if err := s.checkKeySource(idx); err != nil { return nil, err } - entries := idx.list(context) for i := range entries { - // Non-sensitive values live inline in the index; they cannot be orphaned. if !entries[i].Sensitive { - continue + continue // inline values cannot be orphaned } - _, err := s.backend.get(backendKey(context, entries[i].Name)) - switch { - case err == nil: - entries[i].Orphaned = false - case errors.Is(err, ErrSecretNotFound): - entries[i].Orphaned = true - default: - return nil, fmt.Errorf("reconcile secret %q: %w", entries[i].Name, err) + orphaned, err := s.isOrphaned(context, entries[i].Name) + if err != nil { + return nil, err } + entries[i].Orphaned = orphaned } return entries, nil } +func anySensitive(entries []SecretMeta) bool { + for i := range entries { + if entries[i].Sensitive { + return true + } + } + return false +} + +// isOrphaned reports whether a sensitive entry's value is missing from the backend. +func (s *localStore) isOrphaned(context, name string) (bool, error) { + _, err := s.backend.get(backendKey(context, name)) + switch { + case err == nil: + return false, nil + case errors.Is(err, ErrSecretNotFound): + return true, nil + default: + return false, fmt.Errorf("reconcile secret %q: %w", name, err) + } +} + +// persistValue writes a sensitive value to the backend, or removes a stale +// backend value when an entry becomes non-sensitive. The key source is verified +// before any backend access on either side of a sensitivity change. +func (s *localStore) persistValue( + idx *index, meta *SecretMeta, value string, wasSensitive bool, +) error { + key := backendKey(meta.Context, meta.Name) + if meta.Sensitive || wasSensitive { + if err := s.checkKeySource(idx); err != nil { + return err + } + } + if meta.Sensitive { + if err := s.backend.set(key, value); err != nil { + return err + } + if s.keySource != "" { + idx.data.KeySource = string(s.keySource) + } + return nil + } + if wasSensitive { + return s.backend.remove(key) + } + return nil +} + // checkKeySource rejects a key-source change with a clear error instead of a // decryption failure. func (s *localStore) checkKeySource(idx *index) error { diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go index 3237553ae..fc15fa380 100644 --- a/pkg/secrets/store.go +++ b/pkg/secrets/store.go @@ -39,6 +39,7 @@ type SecretMeta struct { type Store interface { Set(context, name, value string, sensitive bool) error Get(context, name string) (string, error) + Meta(context, name string) (SecretMeta, error) List(context string) ([]SecretMeta, error) Delete(context, name string) error Reconcile(context string) ([]SecretMeta, error) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index 68f774d05..e2cfce112 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -426,3 +426,47 @@ func TestStore_DeleteNonSensitive(t *testing.T) { t.Fatalf("Get after delete = %v, want ErrSecretNotFound", err) } } + +func TestStore_SensitiveToNonSensitiveClearsBackend(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set(testContext, "TOKEN", "secret123", true); err != nil { + t.Fatal(err) + } + if _, ok := mb.values[backendKey(testContext, "TOKEN")]; !ok { + t.Fatal("sensitive value should be in the backend") + } + + // Re-set as non-sensitive: the stale backend value must be removed. + if err := s.Set(testContext, "TOKEN", "plaintext", false); err != nil { + t.Fatal(err) + } + if _, ok := mb.values[backendKey(testContext, "TOKEN")]; ok { + t.Fatal("stale sensitive value must be removed from the backend") + } + got, err := s.Get(testContext, "TOKEN") + if err != nil { + t.Fatal(err) + } + if got != "plaintext" { + t.Fatalf("Get = %q, want %q", got, "plaintext") + } +} + +func TestStore_MetaHidesValueAndReportsKind(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "ENVVAR", "v", false); err != nil { + t.Fatal(err) + } + meta, err := s.Meta(testContext, "ENVVAR") + if err != nil { + t.Fatal(err) + } + if meta.Sensitive { + t.Error("env var should not be sensitive") + } + if meta.Value != "" { + t.Error("Meta must not return the value") + } +} From 34ec5b5ddae664e3ab30157634bb3de8a441d9a3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 01:46:20 -0500 Subject: [PATCH 09/19] fix(desktop): call renamed 'secret' command, not removed 'secrets' The CLI command group was renamed secrets->secret with no alias; the desktop IPC handlers still shelled to 'secrets', breaking every Secrets-page action. --- desktop/src/main/ipc.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 4ba9f5be6..768c3d780 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -548,7 +548,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { }) ipcMain.handle("secret_list", async () => - cli.run(["secrets", "list"]), + cli.run(["secret", "list"]), ) ipcMain.handle( @@ -556,7 +556,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { async (_event, args: { name: string; value: string }) => { trackEvent("secret_set") await cli.runRawStdin( - ["secrets", "set", args.name, "--stdin"], + ["secret", "set", args.name, "--stdin"], args.value, ) }, @@ -564,15 +564,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle("secret_delete", async (_event, args: { name: string }) => { trackEvent("secret_delete") - await cli.runRaw(["secrets", "delete", args.name]) + await cli.runRaw(["secret", "delete", args.name]) }) ipcMain.handle("secret_attach", async (_event, args: { name: string }) => { - await cli.runRaw(["secrets", "attach", args.name]) + await cli.runRaw(["secret", "attach", args.name]) }) ipcMain.handle("secret_detach", async (_event, args: { name: string }) => { - await cli.runRaw(["secrets", "detach", args.name]) + await cli.runRaw(["secret", "detach", args.name]) }) // ── System ── From b1509741bf91641f21b387d313f2a30f0f5e93fb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 02:06:52 -0500 Subject: [PATCH 10/19] refactor(secret): typed Kind, cmd/env parity, desktop env-var UI Apply idiomatic-Go and desktop-UI review findings: - Replace opaque sensitive bool in Store API with a typed Kind enum (KindSecret/KindEnv), matching the repo's Backend/DockerBuilder pattern - Drop hand-rolled splitKeyValue for strings.Cut - Register secret/env value flags via pkg/flags builder + names constants - Restructure cmd/env into one-file-per-subcommand with exported *Cmd types - Add desktop env-var UI: EnvVar type, env_* IPC handlers, invokers, store, EnvPage, /env route, sidebar nav (plaintext value UX with reveal toggle) - Fix stale 'devsy secrets list' comment in ipc.ts --- cmd/env/delete.go | 47 +++++ cmd/env/env.go | 188 +----------------- cmd/env/get.go | 47 +++++ cmd/env/list.go | 82 ++++++++ cmd/env/set.go | 57 ++++++ cmd/secrets/delete.go | 2 +- cmd/secrets/get.go | 2 +- cmd/secrets/list.go | 2 +- cmd/secrets/set.go | 19 +- cmd/workspace/up/up_client.go | 4 +- desktop/src/main/ipc.ts | 23 ++- desktop/src/renderer/src/App.svelte | 6 + .../src/lib/components/layout/Sidebar.svelte | 8 + desktop/src/renderer/src/lib/ipc/commands.ts | 14 ++ desktop/src/renderer/src/lib/stores/env.ts | 25 +++ desktop/src/renderer/src/lib/types/index.ts | 5 + desktop/src/renderer/src/pages/EnvPage.svelte | 178 +++++++++++++++++ pkg/flags/names/names.go | 7 + pkg/flags/names/names_test.go | 3 + pkg/secrets/index.go | 2 +- pkg/secrets/local_store.go | 20 +- pkg/secrets/store.go | 26 ++- pkg/secrets/store_internal_test.go | 34 ++-- 23 files changed, 570 insertions(+), 231 deletions(-) create mode 100644 cmd/env/delete.go create mode 100644 cmd/env/get.go create mode 100644 cmd/env/list.go create mode 100644 cmd/env/set.go create mode 100644 desktop/src/renderer/src/lib/stores/env.ts create mode 100644 desktop/src/renderer/src/pages/EnvPage.svelte diff --git a/cmd/env/delete.go b/cmd/env/delete.go new file mode 100644 index 000000000..14bb9d348 --- /dev/null +++ b/cmd/env/delete.go @@ -0,0 +1,47 @@ +package env + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +type DeleteCmd struct { + *flags.GlobalFlags +} + +func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DeleteCmd{GlobalFlags: flags} + deleteCmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm"}, + Short: "Delete an environment variable from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + return deleteCmd +} + +func (cmd *DeleteCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) + } + if err := store.Delete(contextName, name); err != nil { + return err + } + log.Infof("env var %q deleted from context %q", name, contextName) + return nil +} diff --git a/cmd/env/env.go b/cmd/env/env.go index dfdd9d32a..51d686c3f 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -1,16 +1,9 @@ package env import ( - "context" - "encoding/json" - "fmt" - "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" - "github.com/devsy-org/devsy/pkg/log" - "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/secrets" - "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -24,13 +17,14 @@ func NewEnvCmd(globalFlags *flags.GlobalFlags) *cobra.Command { into workspaces. Values are stored in plaintext in the Devsy config directory; use "devsy secret" for sensitive values.`, } - envCmd.AddCommand(newSetCmd(globalFlags)) - envCmd.AddCommand(newListCmd(globalFlags)) - envCmd.AddCommand(newGetCmd(globalFlags)) - envCmd.AddCommand(newDeleteCmd(globalFlags)) + envCmd.AddCommand(NewSetCmd(globalFlags)) + envCmd.AddCommand(NewListCmd(globalFlags)) + envCmd.AddCommand(NewGetCmd(globalFlags)) + envCmd.AddCommand(NewDeleteCmd(globalFlags)) return envCmd } +// resolveContext returns the active context name and a Store for it. func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, error) { devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) if err != nil { @@ -42,175 +36,3 @@ func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, erro } return devsyConfig.DefaultContext, store, nil } - -type cmdFlags struct { - *flags.GlobalFlags - value string -} - -func newSetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { - cmd := &cmdFlags{GlobalFlags: globalFlags} - setCmd := &cobra.Command{ - Use: "set NAME=VALUE | set NAME --value VALUE", - Short: "Create or update an environment variable", - Args: cobra.ExactArgs(1), - RunE: func(cobraCmd *cobra.Command, args []string) error { - return cmd.runSet(cobraCmd.Context(), args[0]) - }, - } - setCmd.Flags().StringVar(&cmd.value, "value", "", "The value (alternative to NAME=VALUE)") - return setCmd -} - -func (cmd *cmdFlags) runSet(_ context.Context, arg string) error { - name, value := arg, cmd.value - if k, v, ok := splitKeyValue(arg); ok { - name, value = k, v - } - if err := secrets.ValidateName(name); err != nil { - return err - } - - contextName, store, err := resolveContext(cmd.GlobalFlags) - if err != nil { - return err - } - if err := store.Set(contextName, name, value, false); err != nil { - return err - } - - log.Infof("env var %q set in context %q", name, contextName) - return nil -} - -func newGetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { - cmd := &cmdFlags{GlobalFlags: globalFlags} - return &cobra.Command{ - Use: "get NAME", - Short: "Print an environment variable's value", - Args: cobra.ExactArgs(1), - RunE: func(cobraCmd *cobra.Command, args []string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) - if err != nil { - return err - } - meta, err := store.Meta(contextName, args[0]) - if err != nil { - return err - } - if meta.Sensitive { - return fmt.Errorf("%q is a secret; use \"devsy secret get\"", args[0]) - } - value, err := store.Get(contextName, args[0]) - if err != nil { - return err - } - //nolint:forbidigo // get prints the raw value to stdout for scripting. - fmt.Println(value) - return nil - }, - } -} - -func newDeleteCmd(globalFlags *flags.GlobalFlags) *cobra.Command { - cmd := &cmdFlags{GlobalFlags: globalFlags} - return &cobra.Command{ - Use: "delete NAME", - Aliases: []string{"rm"}, - Short: "Delete an environment variable from the active context", - Args: cobra.ExactArgs(1), - RunE: func(cobraCmd *cobra.Command, args []string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) - if err != nil { - return err - } - meta, err := store.Meta(contextName, args[0]) - if err != nil { - return err - } - if meta.Sensitive { - return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", args[0]) - } - if err := store.Delete(contextName, args[0]); err != nil { - return err - } - log.Infof("env var %q deleted from context %q", args[0], contextName) - return nil - }, - } -} - -type envEntry struct { - Name string `json:"name"` - Value string `json:"value"` -} - -func newListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { - cmd := &cmdFlags{GlobalFlags: globalFlags} - listCmd := &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List environment variables in the active context", - Args: cobra.NoArgs, - RunE: func(cobraCmd *cobra.Command, args []string) error { - return cmd.runList(cobraCmd.Context()) - }, - } - return listCmd -} - -func (cmd *cmdFlags) runList(_ context.Context) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) - if err != nil { - return err - } - metas, err := store.List(contextName) - if err != nil { - return err - } - - entries := make([]envEntry, 0, len(metas)) - for _, m := range metas { - if m.Sensitive { - continue - } - entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) - } - - mode, err := output.ResolveMode(cmd.ResultFormat) - if err != nil { - return err - } - if mode == output.ModeJSON { - return printJSON(entries) - } - printTable(entries) - return nil -} - -func printJSON(entries []envEntry) error { - out, err := json.MarshalIndent(entries, "", " ") - if err != nil { - return err - } - //nolint:forbidigo // list --result-format json prints structured data to stdout. - fmt.Print(string(out)) - return nil -} - -func printTable(entries []envEntry) { - rows := make([][]string, 0, len(entries)) - for _, e := range entries { - rows = append(rows, []string{e.Name, e.Value}) - } - table.Print([]string{"Name", "Value"}, rows) -} - -func splitKeyValue(s string) (key, value string, ok bool) { - for i := range len(s) { - if s[i] == '=' { - return s[:i], s[i+1:], true - } - } - return "", "", false -} diff --git a/cmd/env/get.go b/cmd/env/get.go new file mode 100644 index 000000000..a0ef8871e --- /dev/null +++ b/cmd/env/get.go @@ -0,0 +1,47 @@ +package env + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +type GetCmd struct { + *flags.GlobalFlags +} + +func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &GetCmd{GlobalFlags: flags} + getCmd := &cobra.Command{ + Use: "get NAME", + Short: "Print an environment variable's value", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + return getCmd +} + +func (cmd *GetCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret get\"", name) + } + value, err := store.Get(contextName, name) + if err != nil { + return err + } + //nolint:forbidigo // get prints the raw value to stdout for scripting. + fmt.Println(value) + return nil +} diff --git a/cmd/env/list.go b/cmd/env/list.go new file mode 100644 index 000000000..5ff41d23e --- /dev/null +++ b/cmd/env/list.go @@ -0,0 +1,82 @@ +package env + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +type ListCmd struct { + *flags.GlobalFlags +} + +func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &ListCmd{GlobalFlags: flags} + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List environment variables in the active context", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + return listCmd +} + +type envEntry struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (cmd *ListCmd) Run(_ context.Context) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + metas, err := store.List(contextName) + if err != nil { + return err + } + + entries := make([]envEntry, 0, len(metas)) + for _, m := range metas { + if m.Sensitive() { + continue + } + entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return renderJSON(entries) + } + renderPlain(entries) + return nil +} + +func renderJSON(entries []envEntry) error { + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + //nolint:forbidigo // list --result-format json prints structured data to stdout. + fmt.Print(string(out)) + return nil +} + +func renderPlain(entries []envEntry) { + rows := make([][]string, 0, len(entries)) + for _, e := range entries { + rows = append(rows, []string{e.Name, e.Value}) + } + table.Print([]string{"Name", "Value"}, rows) +} diff --git a/cmd/env/set.go b/cmd/env/set.go new file mode 100644 index 000000000..c77c6d91a --- /dev/null +++ b/cmd/env/set.go @@ -0,0 +1,57 @@ +package env + +import ( + "context" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +type SetCmd struct { + *flags.GlobalFlags + + Value string +} + +func NewSetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &SetCmd{GlobalFlags: flags} + setCmd := &cobra.Command{ + Use: "set NAME=VALUE | set NAME --value VALUE", + Short: "Create or update an environment variable", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + cliflags.Add( + setCmd, + cliflags.String(&cmd.Value, names.Value, "", "The value (alternative to NAME=VALUE)"), + ) + return setCmd +} + +func (cmd *SetCmd) Run(_ context.Context, arg string) error { + name, value := arg, cmd.Value + if k, v, ok := strings.Cut(arg, "="); ok { + name, value = k, v + } + if err := secrets.ValidateName(name); err != nil { + return err + } + + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + if err := store.Set(contextName, name, value, secrets.KindEnv); err != nil { + return err + } + + log.Infof("env var %q set in context %q", name, contextName) + return nil +} diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index c1ff9aa5b..01ef4bdc2 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -40,7 +40,7 @@ func (cmd *DeleteCmd) Run(ctx context.Context, name string) error { if err != nil { return err } - if !meta.Sensitive { + if !meta.Sensitive() { return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) } diff --git a/cmd/secrets/get.go b/cmd/secrets/get.go index 54ec637b8..8fc3dbd1b 100644 --- a/cmd/secrets/get.go +++ b/cmd/secrets/get.go @@ -42,7 +42,7 @@ func (cmd *GetCmd) Run(ctx context.Context, name string) error { if err != nil { return err } - if !meta.Sensitive { + if !meta.Sensitive() { return fmt.Errorf("%q is an environment variable; use \"devsy env get\"", name) } diff --git a/cmd/secrets/list.go b/cmd/secrets/list.go index 46c4c5084..3b5c4175e 100644 --- a/cmd/secrets/list.go +++ b/cmd/secrets/list.go @@ -54,7 +54,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } metas := make([]secrets.SecretMeta, 0, len(all)) for _, m := range all { - if m.Sensitive { + if m.Sensitive() { metas = append(metas, m) } } diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index 0b3eed92f..2d99c22c9 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -8,6 +8,8 @@ import ( "strings" "github.com/devsy-org/devsy/cmd/flags" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/survey" @@ -39,12 +41,15 @@ with --from-file, or entered at a hidden interactive prompt when none is given.` }, } - setCmd.Flags(). - StringVar(&cmd.Value, "value", "", "The secret value (prefer --stdin for sensitive values)") - setCmd.Flags(). - StringVar(&cmd.FromFile, "from-file", "", "Read the secret value from a file") - setCmd.Flags(). - BoolVar(&cmd.Stdin, "stdin", false, "Read the secret value from standard input") + cliflags.Add( + setCmd, + cliflags.String( + &cmd.Value, names.Value, "", + "The secret value (prefer --stdin for sensitive values)", + ), + cliflags.String(&cmd.FromFile, names.FromFile, "", "Read the secret value from a file"), + cliflags.Bool(&cmd.Stdin, names.Stdin, false, "Read the secret value from standard input"), + ) return setCmd } @@ -63,7 +68,7 @@ func (cmd *SetCmd) Run(ctx context.Context, name string) error { return err } - if err := store.Set(contextName, name, value, true); err != nil { + if err := store.Set(contextName, name, value, secrets.KindSecret); err != nil { return err } diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 23bdd544c..44969213e 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -221,8 +221,8 @@ func (cmd *UpCmd) applyLifecycleSecrets( func (cmd *UpCmd) applyEnvVars(get func(string) (string, error)) error { for _, entry := range cmd.EnvVars { - name, target, remapped := strings.Cut(entry, "=") - if !remapped { + name, target, ok := strings.Cut(entry, "=") + if !ok { target = name } value, err := get(name) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 768c3d780..a3be40148 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -29,7 +29,7 @@ import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) -// Matches the JSON emitted by `devsy secrets list`. +// Matches the JSON emitted by `devsy secret list`. interface SecretEntry { name: string context: string @@ -38,6 +38,12 @@ interface SecretEntry { orphaned?: boolean } +// Matches the JSON emitted by `devsy env list`. +interface EnvEntry { + name: string + value: string +} + function dockerArch(nodeArch: string): string { if (nodeArch === "x64") return "amd64" if (nodeArch === "arm") return "arm" @@ -575,6 +581,21 @@ export function registerIpcHandlers(deps: IpcDependencies): { await cli.runRaw(["secret", "detach", args.name]) }) + ipcMain.handle("env_list", async () => cli.run(["env", "list"])) + + ipcMain.handle( + "env_set", + async (_event, args: { name: string; value: string }) => { + trackEvent("env_set") + await cli.runRaw(["env", "set", args.name, "--value", args.value]) + }, + ) + + ipcMain.handle("env_delete", async (_event, args: { name: string }) => { + trackEvent("env_delete") + await cli.runRaw(["env", "delete", args.name]) + }) + // ── System ── ipcMain.handle("devsy_version", async () => { return cli.runRaw(["--version"]) diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 8293adb79..950384e5f 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -14,6 +14,7 @@ import { initProviders, destroyProviders } from "$lib/stores/providers.js" import { initMachines, destroyMachines } from "$lib/stores/machines.js" import { initContexts, destroyContexts } from "$lib/stores/contexts.js" import { initSecrets } from "$lib/stores/secrets.js" +import { initEnv } from "$lib/stores/env.js" import { initSettings, syncAutoUpdateFromMain, @@ -44,6 +45,7 @@ import MachinesPage from "./pages/MachinesPage.svelte" import MachineDetailPage from "./pages/MachineDetailPage.svelte" import ContextsPage from "./pages/ContextsPage.svelte" import SecretsPage from "./pages/SecretsPage.svelte" +import EnvPage from "./pages/EnvPage.svelte" import SettingsPage from "./pages/SettingsPage.svelte" import SshKeysPage from "./pages/SshKeysPage.svelte" import TerminalsPage from "./pages/TerminalsPage.svelte" @@ -61,6 +63,7 @@ const routes = { "/machines/:id": MachineDetailPage, "/contexts": ContextsPage, "/secrets": SecretsPage, + "/env": EnvPage, "/settings": SettingsPage, "/ssh-keys": SshKeysPage, "/terminals": TerminalsPage, @@ -125,6 +128,8 @@ function screenName(path: string): string { "/machines": "machines", "/machines/:id": "machine_detail", "/contexts": "contexts", + "/secrets": "secrets", + "/env": "env", "/settings": "settings", "/ssh-keys": "ssh_keys", "/terminals": "terminals", @@ -138,6 +143,7 @@ onMount(async () => { initMachines() initContexts() initSecrets() + initEnv() destroySettings = initSettings() stopSessionTracking = initSessionTracking() diff --git a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte index f7ed1f630..92a38296c 100644 --- a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte +++ b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte @@ -1,6 +1,7 @@ + +
+
+

Environment Variables

+ + + {#snippet child({ props })} + + {/snippet} + + + + Add Environment Variable + + Stored in plaintext in your Devsy config and injected into workspaces. + + +
{ e.preventDefault(); handleCreate() }} class="space-y-4"> +
+ + (newName = e.currentTarget.value)} + disabled={saving} + /> + {#if newName && !nameValid} +

+ Only letters, digits, and underscores are allowed. +

+ {/if} +
+
+ + (newValue = e.currentTarget.value)} + disabled={saving} + /> +
+ + + + +
+
+
+
+ + {#if $envLoading} +
+ {#each Array(2) as _} + + {/each} +
+ {:else if $envVars.length === 0} +
+ +

No environment variables stored.

+
+ {:else} +
+ + (searchTerm = e.currentTarget.value)} + /> +
+ {#if filteredEnvVars.length === 0} +
+ +

No environment variables matching "{searchTerm}"

+
+ {:else} +
+ {#each filteredEnvVars as envVar (envVar.name)} +
+
+
+ +

{envVar.name}

+
+
+ + +
+
+

+ {revealed[envVar.name] ? envVar.value : "••••••••"} +

+
+ {/each} +
+ {/if} + {/if} +
diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 718a11768..840049124 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -180,6 +180,13 @@ const ( ForceDockerless = "force-dockerless" ) +// Secret / env values. +const ( + Value = "value" + FromFile = "from-file" + Stdin = "stdin" +) + // Miscellaneous. const ( Debug = "debug" diff --git a/pkg/flags/names/names_test.go b/pkg/flags/names/names_test.go index 79e52d973..23ad3852e 100644 --- a/pkg/flags/names/names_test.go +++ b/pkg/flags/names/names_test.go @@ -138,6 +138,9 @@ var wireNames = []struct { {names.ResultFormat, "result-format"}, {names.SetupInfo, "setup-info"}, {names.SecretsEnv, "secrets-env"}, + {names.Value, "value"}, + {names.FromFile, "from-file"}, + {names.Stdin, "stdin"}, {names.OwnerTrust, "ownertrust"}, {names.SocketPath, "socketpath"}, {names.GitKey, "gitkey"}, diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index c0846f589..5ef4e1a57 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -67,7 +67,7 @@ func (i *index) put(meta SecretMeta) { } meta.Orphaned = false // A sensitive value must never be persisted inline; it lives in the backend. - if meta.Sensitive { + if meta.Sensitive() { meta.Value = "" } i.data.Contexts[meta.Context][meta.Name] = meta diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index efc47944d..cb481914f 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -108,7 +108,7 @@ func newLocalStore(b backend, indexPath string) *localStore { return &localStore{backend: b, indexPath: indexPath, now: time.Now} } -func (s *localStore) Set(context, name, value string, sensitive bool) error { +func (s *localStore) Set(context, name, value string, kind Kind) error { if err := ValidateName(name); err != nil { return err } @@ -122,14 +122,14 @@ func (s *localStore) Set(context, name, value string, sensitive bool) error { if !exists { meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} } - wasSensitive := exists && meta.Sensitive - meta.Sensitive = sensitive + wasSensitive := exists && meta.Sensitive() + meta.Kind = kind meta.Value = value // cleared below for sensitive entries if err := s.persistValue(idx, &meta, value, wasSensitive); err != nil { return err } - if sensitive { + if meta.Sensitive() { meta.Value = "" } @@ -153,7 +153,7 @@ func (s *localStore) Get(context, name string) (string, error) { } var value string - if meta.Sensitive { + if meta.Sensitive() { if err := s.checkKeySource(idx); err != nil { return "", err } @@ -183,7 +183,7 @@ func (s *localStore) Delete(context, name string) error { } // Only sensitive entries have a backend value to remove. - if meta, ok := idx.get(context, name); ok && meta.Sensitive { + if meta, ok := idx.get(context, name); ok && meta.Sensitive() { if err := s.checkKeySource(idx); err != nil { return err } @@ -231,7 +231,7 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { } for i := range entries { - if !entries[i].Sensitive { + if !entries[i].Sensitive() { continue // inline values cannot be orphaned } orphaned, err := s.isOrphaned(context, entries[i].Name) @@ -246,7 +246,7 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { func anySensitive(entries []SecretMeta) bool { for i := range entries { - if entries[i].Sensitive { + if entries[i].Sensitive() { return true } } @@ -273,12 +273,12 @@ func (s *localStore) persistValue( idx *index, meta *SecretMeta, value string, wasSensitive bool, ) error { key := backendKey(meta.Context, meta.Name) - if meta.Sensitive || wasSensitive { + if meta.Sensitive() || wasSensitive { if err := s.checkKeySource(idx); err != nil { return err } } - if meta.Sensitive { + if meta.Sensitive() { if err := s.backend.set(key, value); err != nil { return err } diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go index fc15fa380..5049ea64a 100644 --- a/pkg/secrets/store.go +++ b/pkg/secrets/store.go @@ -25,19 +25,31 @@ func ValidateName(name string) error { return nil } +// Kind distinguishes the two facades over the store: sensitive secrets, whose +// values live in the backend, and plaintext environment variables stored inline. +type Kind string + +const ( + KindSecret Kind = "secret" + KindEnv Kind = "env" +) + type SecretMeta struct { - Name string `json:"name"` - Context string `json:"context"` - Sensitive bool `json:"sensitive"` - Value string `json:"value,omitempty"` - Created time.Time `json:"created"` - LastUsed time.Time `json:"lastUsed,omitzero"` + Name string `json:"name"` + Context string `json:"context"` + Kind Kind `json:"kind"` + Value string `json:"value,omitempty"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` Orphaned bool `json:"-"` } +// Sensitive reports whether the entry is a secret rather than an env var. +func (m SecretMeta) Sensitive() bool { return m.Kind == KindSecret } + type Store interface { - Set(context, name, value string, sensitive bool) error + Set(context, name, value string, kind Kind) error Get(context, name string) (string, error) Meta(context, name string) (SecretMeta, error) List(context string) ([]SecretMeta, error) diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index e2cfce112..db0178a39 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -101,7 +101,7 @@ func newTestStore(t *testing.T, b backend) *localStore { func TestStore_SetGetDelete(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "API_KEY", "abc123", true); err != nil { + if err := s.Set("default", "API_KEY", "abc123", KindSecret); err != nil { t.Fatal(err) } @@ -137,10 +137,10 @@ func TestStore_DeleteIdempotent(t *testing.T) { func TestStore_ContextIsolation(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("ctx-a", "TOKEN", "a", true); err != nil { + if err := s.Set("ctx-a", "TOKEN", "a", KindSecret); err != nil { t.Fatal(err) } - if err := s.Set("ctx-b", "TOKEN", "b", true); err != nil { + if err := s.Set("ctx-b", "TOKEN", "b", KindSecret); err != nil { t.Fatal(err) } @@ -162,7 +162,7 @@ func TestStore_ContextIsolation(t *testing.T) { func TestStore_ListSorted(t *testing.T) { s := newTestStore(t, newMapBackend()) for _, n := range []string{"ZED", "ALPHA", "MIKE"} { - if err := s.Set("default", n, "v", true); err != nil { + if err := s.Set("default", n, "v", KindSecret); err != nil { t.Fatal(err) } } @@ -183,10 +183,10 @@ func TestStore_ReconcileFlagsOrphans(t *testing.T) { mb := newMapBackend() s := newTestStore(t, mb) - if err := s.Set("default", "PRESENT", "v", true); err != nil { + if err := s.Set("default", "PRESENT", "v", KindSecret); err != nil { t.Fatal(err) } - if err := s.Set("default", "GONE", "v", true); err != nil { + if err := s.Set("default", "GONE", "v", KindSecret); err != nil { t.Fatal(err) } @@ -211,13 +211,13 @@ func TestStore_ReconcileFlagsOrphans(t *testing.T) { func TestStore_SetUpdatePreservesCreated(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "K", "v1", true); err != nil { + if err := s.Set("default", "K", "v1", KindSecret); err != nil { t.Fatal(err) } first, _ := s.List("default") created := first[0].Created - if err := s.Set("default", "K", "v2", true); err != nil { + if err := s.Set("default", "K", "v2", KindSecret); err != nil { t.Fatal(err) } second, _ := s.List("default") @@ -247,7 +247,7 @@ func TestValidateName(t *testing.T) { func TestStore_InvalidNameRejected(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set("default", "bad name", "v", true); err == nil { + if err := s.Set("default", "bad name", "v", KindSecret); err == nil { t.Fatal("Set with invalid name should error") } } @@ -362,7 +362,7 @@ func TestStore_KeySourceMismatchIsClearError(t *testing.T) { s := newLocalStore(newMapBackend(), indexPath) s.keySource = keySourceAutoFile - if err := s.Set(testContext, "K", "v", true); err != nil { + if err := s.Set(testContext, "K", "v", KindSecret); err != nil { t.Fatal(err) } @@ -381,7 +381,7 @@ func TestStore_NonSensitiveStoredInline(t *testing.T) { mb := newMapBackend() s := newTestStore(t, mb) - if err := s.Set(testContext, "FOO", "bar", false); err != nil { + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { t.Fatal(err) } @@ -401,7 +401,7 @@ func TestStore_NonSensitiveStoredInline(t *testing.T) { func TestStore_NonSensitiveNeverOrphaned(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set(testContext, "FOO", "bar", false); err != nil { + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { t.Fatal(err) } @@ -416,7 +416,7 @@ func TestStore_NonSensitiveNeverOrphaned(t *testing.T) { func TestStore_DeleteNonSensitive(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set(testContext, "FOO", "bar", false); err != nil { + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { t.Fatal(err) } if err := s.Delete(testContext, "FOO"); err != nil { @@ -431,7 +431,7 @@ func TestStore_SensitiveToNonSensitiveClearsBackend(t *testing.T) { mb := newMapBackend() s := newTestStore(t, mb) - if err := s.Set(testContext, "TOKEN", "secret123", true); err != nil { + if err := s.Set(testContext, "TOKEN", "secret123", KindSecret); err != nil { t.Fatal(err) } if _, ok := mb.values[backendKey(testContext, "TOKEN")]; !ok { @@ -439,7 +439,7 @@ func TestStore_SensitiveToNonSensitiveClearsBackend(t *testing.T) { } // Re-set as non-sensitive: the stale backend value must be removed. - if err := s.Set(testContext, "TOKEN", "plaintext", false); err != nil { + if err := s.Set(testContext, "TOKEN", "plaintext", KindEnv); err != nil { t.Fatal(err) } if _, ok := mb.values[backendKey(testContext, "TOKEN")]; ok { @@ -456,14 +456,14 @@ func TestStore_SensitiveToNonSensitiveClearsBackend(t *testing.T) { func TestStore_MetaHidesValueAndReportsKind(t *testing.T) { s := newTestStore(t, newMapBackend()) - if err := s.Set(testContext, "ENVVAR", "v", false); err != nil { + if err := s.Set(testContext, "ENVVAR", "v", KindEnv); err != nil { t.Fatal(err) } meta, err := s.Meta(testContext, "ENVVAR") if err != nil { t.Fatal(err) } - if meta.Sensitive { + if meta.Sensitive() { t.Error("env var should not be sensitive") } if meta.Value != "" { From a7617b1902dc993fdbff9ebb8327dded09f574ce Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 02:12:13 -0500 Subject: [PATCH 11/19] refactor: remove dead code (desktop attach/detach wiring, unused NewStore) - Drop secret_attach/secret_detach IPC handlers and secretAttach/secretDetach invokers: never referenced by the renderer. CLI attach/detach stays; it is a live feature (bound context.Secrets are read at up via collectSecretRequests). - Remove secrets.NewStore(): zero callers, all paths use NewStoreForConfig. --- desktop/src/main/ipc.ts | 8 -------- desktop/src/renderer/src/lib/ipc/commands.ts | 8 -------- pkg/secrets/local_store.go | 9 --------- 3 files changed, 25 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index a3be40148..ef3643983 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -573,14 +573,6 @@ export function registerIpcHandlers(deps: IpcDependencies): { await cli.runRaw(["secret", "delete", args.name]) }) - ipcMain.handle("secret_attach", async (_event, args: { name: string }) => { - await cli.runRaw(["secret", "attach", args.name]) - }) - - ipcMain.handle("secret_detach", async (_event, args: { name: string }) => { - await cli.runRaw(["secret", "detach", args.name]) - }) - ipcMain.handle("env_list", async () => cli.run(["env", "list"])) ipcMain.handle( diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 26b8ea256..20e92b586 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -262,14 +262,6 @@ export async function secretDelete(name: string): Promise { return invoke("secret_delete", { name }) } -export async function secretAttach(name: string): Promise { - return invoke("secret_attach", { name }) -} - -export async function secretDetach(name: string): Promise { - return invoke("secret_detach", { name }) -} - // Env var commands export async function envList(): Promise { return invoke("env_list") diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index cb481914f..b6ef144b9 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -36,15 +36,6 @@ type localStore struct { keySource keySource } -func NewStore() (Store, error) { - devsyConfig, err := config.LoadConfig("", "") - if err != nil { - return nil, err - } - - return NewStoreForConfig(devsyConfig) -} - func NewStoreForConfig(devsyConfig *config.Config) (Store, error) { configPath, err := config.GetConfigPath() if err != nil { From 23ed11dab2690be638e90fe28770747d6c945a18 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 02:34:57 -0500 Subject: [PATCH 12/19] fix(secret): address re-review findings - HIGH: redact build-secret values in debug log (build.go dumped BuildOptions with %+v, exposing NAME=VALUE plaintext despite out-of-band buildx delivery) - Harden auto key generation against a first-init TOCTOU: write the key file exclusively (O_EXCL, first-wins) and re-read after save so concurrent initializers converge on one persisted key instead of undecryptable splits - Fail --git-token when the source has no HTTP(S) host rather than building an unscoped token with Host="" - Align cmd/secrets Run methods with cmd/env: unused ctx -> _ context.Context --- cmd/secrets/bind.go | 4 ++-- cmd/secrets/delete.go | 2 +- cmd/secrets/get.go | 2 +- cmd/secrets/list.go | 2 +- cmd/secrets/set.go | 2 +- cmd/workspace/up/up_client.go | 19 +++++++++++---- pkg/driver/docker/build.go | 18 ++++++++++++++- pkg/secrets/filekey.go | 37 ++++++++++++++++++++++-------- pkg/secrets/store_internal_test.go | 21 +++++++++++++++++ 9 files changed, 87 insertions(+), 20 deletions(-) diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index ea69e4b47..367de9a82 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -31,7 +31,7 @@ func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { return attachCmd } -func (cmd *AttachCmd) Run(ctx context.Context, name string) error { +func (cmd *AttachCmd) Run(_ context.Context, name string) error { if err := secrets.ValidateName(name); err != nil { return err } @@ -87,7 +87,7 @@ func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { return detachCmd } -func (cmd *DetachCmd) Run(ctx context.Context, name string) error { +func (cmd *DetachCmd) Run(_ context.Context, name string) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index 01ef4bdc2..f3f5cff80 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -30,7 +30,7 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { return deleteCmd } -func (cmd *DeleteCmd) Run(ctx context.Context, name string) error { +func (cmd *DeleteCmd) Run(_ context.Context, name string) error { contextName, store, err := resolveContext(cmd.GlobalFlags) if err != nil { return err diff --git a/cmd/secrets/get.go b/cmd/secrets/get.go index 8fc3dbd1b..60f93d720 100644 --- a/cmd/secrets/get.go +++ b/cmd/secrets/get.go @@ -32,7 +32,7 @@ alteration beyond a single terminating newline.`, return getCmd } -func (cmd *GetCmd) Run(ctx context.Context, name string) error { +func (cmd *GetCmd) Run(_ context.Context, name string) error { contextName, store, err := resolveContext(cmd.GlobalFlags) if err != nil { return err diff --git a/cmd/secrets/list.go b/cmd/secrets/list.go index 3b5c4175e..90ba14014 100644 --- a/cmd/secrets/list.go +++ b/cmd/secrets/list.go @@ -42,7 +42,7 @@ type secretEntry struct { Orphaned bool `json:"orphaned,omitempty"` } -func (cmd *ListCmd) Run(ctx context.Context) error { +func (cmd *ListCmd) Run(_ context.Context) error { contextName, store, err := resolveContext(cmd.GlobalFlags) if err != nil { return err diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index 2d99c22c9..3ebd12766 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -53,7 +53,7 @@ with --from-file, or entered at a hidden interactive prompt when none is given.` return setCmd } -func (cmd *SetCmd) Run(ctx context.Context, name string) error { +func (cmd *SetCmd) Run(_ context.Context, name string) error { if err := secrets.ValidateName(name); err != nil { return err } diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 44969213e..8df2be69f 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -253,20 +253,31 @@ func (cmd *UpCmd) applyGitToken(get func(string) (string, error)) error { if err != nil { return err } - cmd.GitToken = cmd.buildGitToken(token) + gitToken, err := cmd.buildGitToken(token) + if err != nil { + return err + } + cmd.GitToken = gitToken return nil } // buildGitToken assembles the host-scoped git token from the resolved secret, -// inferring the username from the source host when not set explicitly. -func (cmd *UpCmd) buildGitToken(token string) *provider2.GitToken { +// inferring the username from the source host when not set explicitly. It +// fails when the host cannot be determined so the token is never left unscoped. +func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { host := gitHostFromSource(cmd.Source) + if host == "" { + return nil, fmt.Errorf( + "cannot use --git-token: workspace source %q has no HTTP(S) host to scope the token to", + cmd.Source, + ) + } username := cmd.GitTokenUsername if username == "" { username = gitTokenUsernameForHost(host) } - return &provider2.GitToken{Host: host, Username: username, Token: token} + return &provider2.GitToken{Host: host, Username: username, Token: token}, nil } // gitHostFromSource extracts the host from a workspace git source, or "" when diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 836ed4daf..f690b55dc 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -347,10 +347,26 @@ func (d *dockerDriver) prepareBuildOptions( return nil, err } - log.Debugf("prepared build options: %+v", buildOptions) + log.Debugf("prepared build options: %+v", redactBuildSecrets(buildOptions)) return buildOptions, nil } +// redactBuildSecrets returns a shallow copy of buildOptions with build-secret +// values masked to names only, so debug logs never expose secret values. +func redactBuildSecrets(buildOptions *build.BuildOptions) build.BuildOptions { + redacted := *buildOptions + if len(buildOptions.BuildSecrets) == 0 { + return redacted + } + names := make([]string, len(buildOptions.BuildSecrets)) + for i, entry := range buildOptions.BuildSecrets { + name, _, _ := strings.Cut(entry, "=") + names[i] = name + "=" + } + redacted.BuildSecrets = names + return redacted +} + func (d *dockerDriver) executeBuild( ctx context.Context, strategy buildStrategy, diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go index fc246e471..3e2d45c7c 100644 --- a/pkg/secrets/filekey.go +++ b/pkg/secrets/filekey.go @@ -54,28 +54,33 @@ func resolveFileKey(dir, passphrase string) (*fileKey, error) { // resolveAutoKey reuses the persisted identity or creates one; it must never // regenerate an existing key or previously encrypted secrets become unreadable. +// After generating a key it re-reads the store so that concurrent first-time +// initializers converge on the single persisted key rather than each trusting +// the identity it generated in memory. func resolveAutoKey(store keyStore, source keySource) (*fileKey, error) { encoded, err := store.load() if err != nil { return nil, err } - var identity *age.X25519Identity if encoded == "" { - identity, err = age.GenerateX25519Identity() - if err != nil { - return nil, fmt.Errorf("generate secrets key: %w", err) + identity, gErr := age.GenerateX25519Identity() + if gErr != nil { + return nil, fmt.Errorf("generate secrets key: %w", gErr) } if err := store.save(identity.String()); err != nil { return nil, err } - } else { - identity, err = age.ParseX25519Identity(strings.TrimSpace(encoded)) - if err != nil { - return nil, fmt.Errorf("parse stored secrets key: %w", err) + if encoded, err = store.load(); err != nil { + return nil, err } } + identity, err := age.ParseX25519Identity(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("parse stored secrets key: %w", err) + } + return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil } @@ -124,11 +129,25 @@ func (s fileKeyStore) load() (string, error) { return string(data), nil } +// save writes the key exclusively so a concurrent initializer that lost the +// race does not overwrite the winner's key; an existing file is left intact. func (s fileKeyStore) save(value string) error { if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return fmt.Errorf("create secrets dir: %w", err) } - if err := os.WriteFile(s.path, []byte(value), 0o600); err != nil { + f, err := os.OpenFile( + s.path, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) // #nosec G304 -- path derived from config dir. + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil + } + return fmt.Errorf("write secrets key file: %w", err) + } + defer func() { _ = f.Close() }() + if _, err := f.WriteString(value); err != nil { return fmt.Errorf("write secrets key file: %w", err) } diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index db0178a39..a56cf92fd 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -348,6 +348,27 @@ func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { } } +// A key file that already exists must never be overwritten, so a second +// initializer that generated its own identity still adopts the persisted one. +func TestFileKeyStore_SaveDoesNotOverwrite(t *testing.T) { + store := fileKeyStore{path: filepath.Join(t.TempDir(), KeyFileName)} + + if err := store.save("first-key"); err != nil { + t.Fatal(err) + } + if err := store.save("second-key"); err != nil { + t.Fatal(err) + } + + got, err := store.load() + if err != nil { + t.Fatal(err) + } + if got != "first-key" { + t.Fatalf("load = %q, want the first persisted key %q", got, "first-key") + } +} + func identityString(t *testing.T, key *fileKey) string { t.Helper() id, ok := key.identity.(*age.X25519Identity) From b2b32158398bc2283c30e97ec6a8d7a6cb723448 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 03:12:43 -0500 Subject: [PATCH 13/19] fix(secret): address third-pass review findings - HIGH: reject a sensitive secret passed via --env. applyEnvVars routed the value into WorkspaceEnv, which is NOT stripped from the compressed (gzip+ base64, reversible) setup argv, exposing it in the host process list. Now errors and directs to --secret (tunnel-delivered). - HIGH: path-traversal in writeSecretFiles. A mount target like target=../../etc/x escaped /run/secrets via filepath.Join; add secretMountPath containment guard. - HIGH: unset Kind silently downgraded a secret to a plaintext env var on load (empty kind => Sensitive()==false => Get returns ""). Normalize unknown Kind to KindSecret (fail-safe). - MEDIUM: index/backend writes were non-atomic and unlocked. Add atomicWriteFile (temp+rename) so a crash cannot corrupt the store, and a cross-process flock around store set/delete/last-used so concurrent writes cannot lose entries. - MEDIUM: key-gen convergence did not hold for the keyring backend; serialize auto-key resolution with a cross-process lock (covers both backends). - LOW: mount-secret values were absent from the lifecycle-hook log redactor; add them for redaction only (never injected into hook env). Adds tests for each. 1313 tests pass; golangci-lint clean. --- cmd/internal/agentcontainer/deferred_hooks.go | 4 +- cmd/internal/agentcontainer/post_attach.go | 8 ++- cmd/workspace/up/secrets_test.go | 23 ++++++++ cmd/workspace/up/up_client.go | 28 ++++++++- pkg/devcontainer/setup/lifecyclehooks.go | 18 ++++-- pkg/devcontainer/setup/lifecyclehooks_test.go | 26 ++++++--- pkg/devcontainer/setup/setup.go | 43 +++++++++++--- pkg/devcontainer/setup/setup_test.go | 27 +++++++++ pkg/secrets/atomicwrite.go | 41 +++++++++++++ pkg/secrets/file_backend.go | 10 +--- pkg/secrets/filekey.go | 39 +++++++++++-- pkg/secrets/index.go | 27 +++++---- pkg/secrets/local_store.go | 57 +++++++++++++++++-- pkg/secrets/store_internal_test.go | 33 +++++++++-- 14 files changed, 325 insertions(+), 59 deletions(-) create mode 100644 pkg/secrets/atomicwrite.go diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index f4ef49b53..4bef31543 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -84,7 +84,9 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - }, secretsEnvFromEnvironment(), setup.SkipPhases{}) + // Mount-secret values are not piped to this re-exec'd process (only env + // secrets are), so there is nothing extra to add to the log redactor here. + }, secretsEnvFromEnvironment(), nil, setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) } diff --git a/cmd/internal/agentcontainer/post_attach.go b/cmd/internal/agentcontainer/post_attach.go index 250c4274f..6b6a2e2ce 100644 --- a/cmd/internal/agentcontainer/post_attach.go +++ b/cmd/internal/agentcontainer/post_attach.go @@ -56,7 +56,13 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error { } log.Debugf("running postAttachCommand hooks") - if err := setup.RunPostAttachHooks(ctx, setupInfo, secretsEnvFromEnvironment()); err != nil { + // Mount-secret values are not piped to this re-exec'd process, so nil. + if err := setup.RunPostAttachHooks( + ctx, + setupInfo, + secretsEnvFromEnvironment(), + nil, + ); err != nil { log.Errorf("postAttachCommand failed: %v", err) } diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 973c87678..50c2257c5 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -92,3 +92,26 @@ func TestCollectSecretRequests_InvalidOption(t *testing.T) { _, err := collectSecretRequests([]string{"A,bogus"}, testConfig()) require.Error(t, err) } + +func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { + cmd := &UpCmd{} + cmd.EnvVars = []string{secretAPIKey} + get := func(string) (string, error) { return "plaintext", nil } + isSensitive := func(string) (bool, error) { return true, nil } + + err := cmd.applyEnvVars(get, isSensitive) + require.Error(t, err) + assert.Contains(t, err.Error(), "is a secret and cannot be passed with --env") + assert.Empty(t, cmd.WorkspaceEnv, "no value must reach the ps-visible WorkspaceEnv") +} + +func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { + cmd := &UpCmd{} + cmd.EnvVars = []string{"LOG_LEVEL=DEBUG_TARGET"} + get := func(string) (string, error) { return "debug", nil } + isSensitive := func(string) (bool, error) { return false, nil } + + err := cmd.applyEnvVars(get, isSensitive) + require.NoError(t, err) + assert.Equal(t, []string{"DEBUG_TARGET=debug"}, cmd.WorkspaceEnv) +} diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 8df2be69f..20296a47e 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -180,10 +180,17 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { } return value, nil } + isSensitive := func(name string) (bool, error) { + meta, mErr := store.Meta(contextName, name) + if mErr != nil { + return false, fmt.Errorf("resolve secret %q in context %q: %w", name, contextName, mErr) + } + return meta.Sensitive(), nil + } appliers := []func(func(string) (string, error)) error{ func(g func(string) (string, error)) error { return cmd.applyLifecycleSecrets(requests, g) }, - cmd.applyEnvVars, + func(g func(string) (string, error)) error { return cmd.applyEnvVars(g, isSensitive) }, cmd.applyBuildSecrets, cmd.applyGitToken, } @@ -219,12 +226,29 @@ func (cmd *UpCmd) applyLifecycleSecrets( return nil } -func (cmd *UpCmd) applyEnvVars(get func(string) (string, error)) error { +// applyEnvVars resolves --env entries into WorkspaceEnv. WorkspaceEnv is not +// stripped from the compressed setup argv, so a sensitive secret must never be +// routed here; --secret delivers those over the tunnel instead. +func (cmd *UpCmd) applyEnvVars( + get func(string) (string, error), + isSensitive func(string) (bool, error), +) error { for _, entry := range cmd.EnvVars { name, target, ok := strings.Cut(entry, "=") if !ok { target = name } + sensitive, err := isSensitive(name) + if err != nil { + return err + } + if sensitive { + return fmt.Errorf( + "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", + name, + name, + ) + } value, err := get(name) if err != nil { return err diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index 4e77b0e82..0cd756072 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -138,10 +138,14 @@ type lifecycleEnv struct { remoteEnv map[string]string } -// mergeSecretsEnv merges KEY=VALUE pairs from the --secrets-file flag into the -// lifecycle env map. Existing keys are NOT overridden (config takes precedence). -func mergeSecretsEnv(env map[string]string, secretsEnv []string) { - hookRedactor = secrets.NewRedactor(secretsEnv) +// mergeSecretsEnv merges KEY=VALUE pairs from secretsEnv into the lifecycle env +// map (existing keys are NOT overridden; config takes precedence) and builds the +// hook log redactor. secretsMount values are added to the redactor only — mount +// secrets are delivered as files under /run/secrets and must never be injected +// into the hook environment, but their values must still be masked if a hook +// reads and echoes them. +func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { + hookRedactor = secrets.NewRedactor(append(append([]string{}, secretsEnv...), secretsMount...)) for _, entry := range secretsEnv { key, value, ok := strings.Cut(entry, "=") if !ok { @@ -250,10 +254,11 @@ func RunPreAttachHooks( prebuild bool, dotfiles DotfilesConfig, secretsEnv []string, + secretsMount []string, skip SkipPhases, ) (DeferredHooks, error) { env := resolveLifecycleEnv(ctx, setupInfo) - mergeSecretsEnv(env.remoteEnv, secretsEnv) + mergeSecretsEnv(env.remoteEnv, secretsEnv, secretsMount) all := preAttachPhaseParams(setupInfo, env, prebuild) // Insert the dotfiles phase between postCreate and postStart. @@ -427,6 +432,7 @@ func RunPostAttachHooks( ctx context.Context, setupInfo *config.Result, secretsEnv []string, + secretsMount []string, skipPostAttach ...bool, ) error { if len(skipPostAttach) > 0 && skipPostAttach[0] { @@ -434,7 +440,7 @@ func RunPostAttachHooks( return nil } env := resolveLifecycleEnv(ctx, setupInfo) - mergeSecretsEnv(env.remoteEnv, secretsEnv) + mergeSecretsEnv(env.remoteEnv, secretsEnv, secretsMount) return runHook(hookRunParams{ commands: setupInfo.MergedConfig.PostAttachCommands, diff --git a/pkg/devcontainer/setup/lifecyclehooks_test.go b/pkg/devcontainer/setup/lifecyclehooks_test.go index d6b91f8f8..14f18fede 100644 --- a/pkg/devcontainer/setup/lifecyclehooks_test.go +++ b/pkg/devcontainer/setup/lifecyclehooks_test.go @@ -100,11 +100,11 @@ func (s *LifecycleHookTestSuite) TestLifecycleHooksNoOpWithEmptyConfig() { } // Both functions should return nil with empty config (no commands to run) - deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil, SkipPhases{}) + deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) - err = RunPostAttachHooks(ctx, result, nil) + err = RunPostAttachHooks(ctx, result, nil, nil) assert.NoError(s.T(), err) } @@ -252,7 +252,7 @@ func (s *LifecycleHookTestSuite) TestPrebuildIgnoresWaitFor() { } // In prebuild mode, no deferred hooks are returned regardless of waitFor. - deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil, SkipPhases{}) + deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) } @@ -644,7 +644,7 @@ func (s *LifecycleHookTestSuite) TestWaitForEmptyPhaseLogsWarning() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnv() { env := map[string]string{"EXISTING": "keep"} - mergeSecretsEnv(env, []string{"SECRET_KEY=secret_val", "OTHER=data"}) + mergeSecretsEnv(env, []string{"SECRET_KEY=secret_val", "OTHER=data"}, nil) assert.Equal(s.T(), "keep", env["EXISTING"]) assert.Equal(s.T(), "secret_val", env["SECRET_KEY"]) @@ -654,7 +654,7 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnv() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvDoesNotOverride() { env := map[string]string{"MY_VAR": "original"} - mergeSecretsEnv(env, []string{"MY_VAR=overridden"}) + mergeSecretsEnv(env, []string{"MY_VAR=overridden"}, nil) assert.Equal(s.T(), "original", env["MY_VAR"]) } @@ -662,7 +662,7 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnvDoesNotOverride() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvNil() { env := map[string]string{"KEY": "val"} - mergeSecretsEnv(env, nil) + mergeSecretsEnv(env, nil, nil) assert.Equal(s.T(), "val", env["KEY"]) assert.Len(s.T(), env, 1) @@ -671,11 +671,21 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnvNil() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvValueWithEquals() { env := map[string]string{} - mergeSecretsEnv(env, []string{"CONN=host=db port=5432"}) + mergeSecretsEnv(env, []string{"CONN=host=db port=5432"}, nil) assert.Equal(s.T(), "host=db port=5432", env["CONN"]) } +// Mount-secret values must be masked in hook logs but never injected into env. +func (s *LifecycleHookTestSuite) TestMergeSecretsEnvRedactsMountWithoutInjecting() { + env := map[string]string{} + + mergeSecretsEnv(env, nil, []string{"tls.key=mount_secret_val"}) + + assert.NotContains(s.T(), env, "tls.key", "mount secrets must not enter hook env") + assert.Equal(s.T(), "***", hookRedactor.Redact("mount_secret_val")) +} + func (s *LifecycleHookTestSuite) TestPostAttachHooksRunEveryTime() { t := s.T() currentUser, err := user.Current() @@ -708,7 +718,7 @@ func (s *LifecycleHookTestSuite) TestPostAttachHooksRunEveryTime() { // Run postAttachCommand multiple times — it must execute every time. for i := 1; i <= 3; i++ { - err := RunPostAttachHooks(context.Background(), result, nil) + err := RunPostAttachHooks(context.Background(), result, nil, nil) assert.NoError(t, err) content, readErr := os.ReadFile(counterFile) //nolint:gosec // test file from TempDir diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 9ea6c3e9a..896368854 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -84,6 +84,7 @@ func SetupContainerPreAttach( cfg.Prebuild, cfg.Dotfiles, cfg.SecretsEnv, + cfg.SecretsMount, SkipPhases{ PostCreate: cfg.SkipPostCreate, PostStart: cfg.SkipPostStart, @@ -106,6 +107,7 @@ func SetupContainerPostAttach(ctx context.Context, cfg *ContainerSetupConfig) er ctx, cfg.SetupInfo, cfg.SecretsEnv, + cfg.SecretsMount, cfg.SkipPostAttach, ); err != nil { return fmt.Errorf("lifecycle hooks post-attach: %w", err) @@ -146,19 +148,46 @@ func writeSecretFiles(cfg *ContainerSetupConfig) error { if !ok || name == "" { continue } - - path := filepath.Join(config.SecretsMountDir, name) - if err := os.WriteFile(path, []byte(value), 0o600); err != nil { - return fmt.Errorf("write secret file %s: %w", name, err) - } - if err := copy2.Chown(path, user); err != nil { - log.Warnf("Error chowning secret file %s: %v", name, err) + if err := writeSecretFile(name, value, user); err != nil { + return err } } return nil } +func writeSecretFile(name, value, user string) error { + path, err := secretMountPath(name) + if err != nil { + return err + } + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + return fmt.Errorf("write secret file %s: %w", name, err) + } + if err := copy2.Chown(path, user); err != nil { + log.Warnf("Error chowning secret file %s: %v", name, err) + } + + return nil +} + +// secretMountPath resolves a mount target within SecretsMountDir, rejecting any +// target that escapes the directory (e.g. via "..") so a crafted target cannot +// write an arbitrary file in the container. +func secretMountPath(target string) (string, error) { + path := filepath.Join(config.SecretsMountDir, target) + rel, err := filepath.Rel(config.SecretsMountDir, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf( + "invalid secret mount target %q: escapes %s", + target, + config.SecretsMountDir, + ) + } + + return path, nil +} + func writeResultFile(cfg *ContainerSetupConfig) { rawBytes, err := json.Marshal(cfg.SetupInfo) if err != nil { diff --git a/pkg/devcontainer/setup/setup_test.go b/pkg/devcontainer/setup/setup_test.go index 52e51443b..690df7c0e 100644 --- a/pkg/devcontainer/setup/setup_test.go +++ b/pkg/devcontainer/setup/setup_test.go @@ -2,14 +2,41 @@ package setup import ( "context" + "strings" "testing" "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" "go.uber.org/zap/zapcore" "google.golang.org/grpc" ) +// TestSecretMountPath_RejectsEscapes ensures a crafted mount target cannot +// write outside SecretsMountDir; nested but contained targets are allowed. +func TestSecretMountPath_RejectsEscapes(t *testing.T) { + contained := []string{"tls.key", "sub/tls.key", "a.b.c"} + for _, target := range contained { + path, err := secretMountPath(target) + if err != nil { + t.Errorf("secretMountPath(%q) unexpected error: %v", target, err) + continue + } + if !strings.HasPrefix(path, config.SecretsMountDir) { + t.Errorf("secretMountPath(%q) = %q, escaped %q", target, path, config.SecretsMountDir) + } + } + + // An absolute target is cleaned into SecretsMountDir by filepath.Join, so it + // stays contained; only ".."-based escapes leave the directory. + escapes := []string{"../evil", "../../etc/cron.d/x", "sub/../../../evil"} + for _, target := range escapes { + if path, err := secretMountPath(target); err == nil { + t.Errorf("secretMountPath(%q) = %q, want escape error", target, path) + } + } +} + // fakeTunnelClient is a minimal stub of tunnel.TunnelClient. Only KubeConfig // is exercised here; the other methods return zero values and are unused by // setupKubeConfig. diff --git a/pkg/secrets/atomicwrite.go b/pkg/secrets/atomicwrite.go new file mode 100644 index 000000000..d62f8798b --- /dev/null +++ b/pkg/secrets/atomicwrite.go @@ -0,0 +1,41 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" +) + +// atomicWriteFile writes data to path via a temp file in the same directory +// followed by a rename, so a crash mid-write can never leave a truncated file +// that would render the whole store unreadable. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp secrets file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp secrets file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp secrets file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp secrets file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replace secrets file: %w", err) + } + + return nil +} diff --git a/pkg/secrets/file_backend.go b/pkg/secrets/file_backend.go index 00368d93a..e5a9d78fc 100644 --- a/pkg/secrets/file_backend.go +++ b/pkg/secrets/file_backend.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "path/filepath" "filippo.io/age" ) @@ -68,14 +67,7 @@ func (f *fileBackend) store(values map[string]string) error { return fmt.Errorf("finalize encrypted secrets: %w", err) } - if err := os.MkdirAll(filepath.Dir(f.path), 0o700); err != nil { - return fmt.Errorf("create secrets dir: %w", err) - } - if err := os.WriteFile(f.path, buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("write secrets file: %w", err) - } - - return nil + return atomicWriteFile(f.path, buf.Bytes(), 0o600) } func (f *fileBackend) set(key, value string) error { diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go index 3e2d45c7c..39653d25f 100644 --- a/pkg/secrets/filekey.go +++ b/pkg/secrets/filekey.go @@ -8,6 +8,7 @@ import ( "strings" "filippo.io/age" + "github.com/gofrs/flock" keyring "github.com/zalando/go-keyring" ) @@ -46,18 +47,29 @@ func resolveFileKey(dir, passphrase string) (*fileKey, error) { } if keyringAvailable() { - return resolveAutoKey(keyringKeyStore{}, keySourceKeyring) + return resolveAutoKey(dir, keyringKeyStore{}, keySourceKeyring) } - return resolveAutoKey(fileKeyStore{path: filepath.Join(dir, KeyFileName)}, keySourceAutoFile) + return resolveAutoKey( + dir, + fileKeyStore{path: filepath.Join(dir, KeyFileName)}, + keySourceAutoFile, + ) } // resolveAutoKey reuses the persisted identity or creates one; it must never // regenerate an existing key or previously encrypted secrets become unreadable. -// After generating a key it re-reads the store so that concurrent first-time -// initializers converge on the single persisted key rather than each trusting -// the identity it generated in memory. -func resolveAutoKey(store keyStore, source keySource) (*fileKey, error) { +// The load->generate->save->reload sequence is serialized with a cross-process +// lock so concurrent first-time initializers converge on one persisted key +// instead of each trusting the identity it generated (which the keyring's +// last-writer-wins Set would otherwise split). +func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, error) { + unlock, err := lockKeyResolution(dir) + if err != nil { + return nil, err + } + defer unlock() + encoded, err := store.load() if err != nil { return nil, err @@ -84,6 +96,21 @@ func resolveAutoKey(store keyStore, source keySource) (*fileKey, error) { return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil } +// lockKeyResolution takes an exclusive cross-process lock in dir so key +// resolution is atomic across concurrent devsy processes. It returns a release +// function that is always safe to call. +func lockKeyResolution(dir string) (func(), error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create secrets dir: %w", err) + } + lock := flock.New(filepath.Join(dir, KeyFileName+".lock")) + if err := lock.Lock(); err != nil { + return nil, fmt.Errorf("lock secrets key: %w", err) + } + + return func() { _ = lock.Unlock() }, nil +} + type keyStore interface { load() (string, error) // returns "" when no key is stored yet save(value string) error diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index 5ef4e1a57..b9d2e7632 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -3,7 +3,6 @@ package secrets import ( "fmt" "os" - "path/filepath" "sort" "sigs.k8s.io/yaml" @@ -40,25 +39,33 @@ func loadIndex(path string) (*index, error) { if idx.data.Contexts == nil { idx.data.Contexts = map[string]map[string]SecretMeta{} } + idx.normalizeKinds() return idx, nil } +// normalizeKinds fails safe on an unset Kind by treating the entry as a secret. +// An unknown kind must never be read as a plaintext env var: entries written by +// an older layout (bool "sensitive", no "kind") would otherwise silently +// downgrade to env, returning an empty value and skipping the key-source check. +func (i *index) normalizeKinds() { + for _, entries := range i.data.Contexts { + for name, meta := range entries { + if meta.Kind != KindSecret && meta.Kind != KindEnv { + meta.Kind = KindSecret + entries[name] = meta + } + } + } +} + func (i *index) save() error { out, err := yaml.Marshal(i.data) if err != nil { return fmt.Errorf("marshal secrets index: %w", err) } - if err := os.MkdirAll(filepath.Dir(i.path), 0o700); err != nil { - return fmt.Errorf("create secrets dir: %w", err) - } - - if err := os.WriteFile(i.path, out, 0o600); err != nil { - return fmt.Errorf("write secrets index: %w", err) - } - - return nil + return atomicWriteFile(i.path, out, 0o600) } func (i *index) put(meta SecretMeta) { diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index b6ef144b9..8fc36b98c 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -8,6 +8,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/config" + "github.com/gofrs/flock" ) const IndexFileName = "secrets.yaml" @@ -104,6 +105,12 @@ func (s *localStore) Set(context, name, value string, kind Kind) error { return err } + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + idx, err := loadIndex(s.indexPath) if err != nil { return err @@ -155,10 +162,7 @@ func (s *localStore) Get(context, name string) (string, error) { value = meta.Value } - // Best-effort last-used tracking; index errors never fail a Get. - meta.LastUsed = s.now().UTC() - idx.put(meta) - _ = idx.save() + s.touchLastUsed(context, name) return value, nil } @@ -168,6 +172,12 @@ func (s *localStore) Delete(context, name string) error { return err } + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + idx, err := loadIndex(s.indexPath) if err != nil { return err @@ -235,6 +245,45 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { return entries, nil } +// lock serializes read-modify-write of the index and backend across concurrent +// devsy processes so a parallel set/delete cannot lose an entry (last-save-wins) +// or orphan a backend value. The returned release is always safe to call. +func (s *localStore) lock() (func(), error) { + dir := filepath.Dir(s.indexPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create secrets dir: %w", err) + } + l := flock.New(filepath.Join(dir, IndexFileName+".lock")) + if err := l.Lock(); err != nil { + return nil, fmt.Errorf("lock secrets store: %w", err) + } + + return func() { _ = l.Unlock() }, nil +} + +// touchLastUsed records access time under the store lock so it cannot clobber a +// concurrent set/delete. It reloads inside the lock and is entirely best-effort: +// any error (including a now-deleted entry) is ignored. +func (s *localStore) touchLastUsed(context, name string) { + unlock, err := s.lock() + if err != nil { + return + } + defer unlock() + + idx, err := loadIndex(s.indexPath) + if err != nil { + return + } + meta, ok := idx.get(context, name) + if !ok { + return + } + meta.LastUsed = s.now().UTC() + idx.put(meta) + _ = idx.save() +} + func anySensitive(entries []SecretMeta) bool { for i := range entries { if entries[i].Sensitive() { diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index a56cf92fd..c67322c08 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -300,9 +300,10 @@ func TestResolveFileKey_PassphraseSource(t *testing.T) { } func TestResolveAutoKey_CreatesThenReuses(t *testing.T) { - store := fileKeyStore{path: filepath.Join(t.TempDir(), KeyFileName)} + dir := t.TempDir() + store := fileKeyStore{path: filepath.Join(dir, KeyFileName)} - first, err := resolveAutoKey(store, keySourceAutoFile) + first, err := resolveAutoKey(dir, store, keySourceAutoFile) if err != nil { t.Fatal(err) } @@ -310,7 +311,7 @@ func TestResolveAutoKey_CreatesThenReuses(t *testing.T) { t.Fatalf("expected key file to be created: %v", statErr) } - second, err := resolveAutoKey(store, keySourceAutoFile) + second, err := resolveAutoKey(dir, store, keySourceAutoFile) if err != nil { t.Fatal(err) } @@ -327,7 +328,7 @@ func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { path := filepath.Join(dir, EncryptedFileName) store := fileKeyStore{path: filepath.Join(dir, KeyFileName)} - key, err := resolveAutoKey(store, keySourceAutoFile) + key, err := resolveAutoKey(dir, store, keySourceAutoFile) if err != nil { t.Fatal(err) } @@ -335,7 +336,7 @@ func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { t.Fatal(err) } - key2, err := resolveAutoKey(store, keySourceAutoFile) + key2, err := resolveAutoKey(dir, store, keySourceAutoFile) if err != nil { t.Fatal(err) } @@ -348,6 +349,28 @@ func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { } } +// An index entry with an unset Kind (e.g. written by an older layout) must be +// read as a secret, never silently downgraded to a plaintext env var. +func TestLoadIndex_UnsetKindNormalizesToSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n" + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + idx, err := loadIndex(path) + if err != nil { + t.Fatal(err) + } + meta, ok := idx.get("default", "LEGACY") + if !ok { + t.Fatal("expected entry to load") + } + if !meta.Sensitive() { + t.Fatalf("unset Kind must normalize to secret, got %q", meta.Kind) + } +} + // A key file that already exists must never be overwritten, so a second // initializer that generated its own identity still adopts the persisted one. func TestFileKeyStore_SaveDoesNotOverwrite(t *testing.T) { From 9ba327a589c6b9dcf72b3a5e9d2379ad4627c27d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 09:54:28 -0500 Subject: [PATCH 14/19] fix(secret): address fourth-pass review findings - MEDIUM: --git-token was silently dropped on the SSH machine-provider up path (agent.go devsyUpMachineSSH omitted WithGitToken while all other up paths wire it). Add tunnelserver.WithGitToken so the token is delivered there too. - MEDIUM: harden mount-secret writes against symlink redirection. secretMountPath now requires a plain filename (no separators/./..), and writeSecretFile does Remove-then-O_EXCL-create so a pre-planted symlink under /run/secrets cannot redirect the write+chown to an arbitrary file. Also fixes the nested-target wart (nested targets are now rejected at the guard, not silently at write). - LOW: redact mount-secret values in deferred/post-attach hook logs. Those re-exec'd processes lack the mount values in env; MountSecretsForRedaction reads them back from /run/secrets for the log redactor only (not injected). - LOW: normalizeKinds now clears any inline Value when flipping an unset Kind to secret, upholding the no-inline-plaintext invariant for hand-edited entries. - INFO: document that localStore.lock() is non-reentrant. Adds tests. Touched packages: 1082 tests pass; golangci-lint clean. --- cmd/internal/agentcontainer/deferred_hooks.go | 6 +-- cmd/internal/agentcontainer/post_attach.go | 5 ++- cmd/workspace/up/agent.go | 1 + pkg/devcontainer/setup/lifecyclehooks.go | 29 ++++++++++++++ pkg/devcontainer/setup/setup.go | 38 ++++++++++++++----- pkg/devcontainer/setup/setup_test.go | 27 +++++++------ pkg/secrets/index.go | 3 ++ pkg/secrets/local_store.go | 5 +++ pkg/secrets/store_internal_test.go | 24 ++++++++++++ 9 files changed, 112 insertions(+), 26 deletions(-) diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index 4bef31543..96411d8fb 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -84,9 +84,9 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - // Mount-secret values are not piped to this re-exec'd process (only env - // secrets are), so there is nothing extra to add to the log redactor here. - }, secretsEnvFromEnvironment(), nil, setup.SkipPhases{}) + // Mount secrets are not in this re-exec'd process's env; read them back + // from /run/secrets so hook logs still mask their values. + }, secretsEnvFromEnvironment(), setup.MountSecretsForRedaction(), setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) } diff --git a/cmd/internal/agentcontainer/post_attach.go b/cmd/internal/agentcontainer/post_attach.go index 6b6a2e2ce..b672ab49b 100644 --- a/cmd/internal/agentcontainer/post_attach.go +++ b/cmd/internal/agentcontainer/post_attach.go @@ -56,12 +56,13 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error { } log.Debugf("running postAttachCommand hooks") - // Mount-secret values are not piped to this re-exec'd process, so nil. + // Mount secrets are not in this re-exec'd process's env; read them back + // from /run/secrets so hook logs still mask their values. if err := setup.RunPostAttachHooks( ctx, setupInfo, secretsEnvFromEnvironment(), - nil, + setup.MountSecretsForRedaction(), ); err != nil { log.Errorf("postAttachCommand failed: %v", err) } diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index f72a9ec27..2e7ca9923 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -228,6 +228,7 @@ func (cmd *UpCmd) devsyUpMachineSSH( client.AgentInjectGitCredentials(cmd.CLIOptions), client.AgentInjectDockerCredentials(cmd.CLIOptions), client.WorkspaceConfig(), + tunnelserver.WithGitToken(cmd.GitToken), ) }, }) diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index 0cd756072..ca27175d7 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "os/user" + "path/filepath" "regexp" "slices" "strings" @@ -157,6 +158,34 @@ func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { } } +// MountSecretsForRedaction reads the mount-secret files under SecretsMountDir +// and returns their "name=value" entries so a re-exec'd hook process (deferred +// or post-attach), which does not receive the mount values in its environment, +// can still mask them in logs. The files persist for the container lifetime, so +// a later hook that echoes one must be redacted like the foreground phases. +// Best-effort: unreadable entries are skipped. +func MountSecretsForRedaction() []string { + dirEntries, err := os.ReadDir(config.SecretsMountDir) + if err != nil { + return nil + } + var entries []string + for _, e := range dirEntries { + if e.IsDir() { + continue + } + value, err := os.ReadFile( + filepath.Join(config.SecretsMountDir, e.Name()), + ) // #nosec G304 -- fixed secrets dir. + if err != nil { + continue + } + entries = append(entries, e.Name()+"="+string(value)) + } + + return entries +} + func resolveLifecycleEnv( ctx context.Context, setupInfo *config.Result, diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 896368854..695c4bbc5 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -161,7 +161,27 @@ func writeSecretFile(name, value, user string) error { if err != nil { return err } - if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + + // Drop any pre-existing entry (e.g. a symlink baked into the image) so the + // write cannot be redirected through it; Remove unlinks the symlink itself, + // not its target. Creating with O_EXCL then guarantees a fresh regular file + // and fails safely if a symlink is re-planted in the race window. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("prepare secret file %s: %w", name, err) + } + f, err := os.OpenFile( + path, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) // #nosec G304 -- path validated to a plain filename under SecretsMountDir. + if err != nil { + return fmt.Errorf("write secret file %s: %w", name, err) + } + if _, err := f.WriteString(value); err != nil { + _ = f.Close() + return fmt.Errorf("write secret file %s: %w", name, err) + } + if err := f.Close(); err != nil { return fmt.Errorf("write secret file %s: %w", name, err) } if err := copy2.Chown(path, user); err != nil { @@ -171,21 +191,21 @@ func writeSecretFile(name, value, user string) error { return nil } -// secretMountPath resolves a mount target within SecretsMountDir, rejecting any -// target that escapes the directory (e.g. via "..") so a crafted target cannot -// write an arbitrary file in the container. +// secretMountPath resolves a mount target to a flat file within SecretsMountDir. +// The target must be a single path element (no separators, "." or ".."), which +// both blocks traversal out of the directory and prevents writing through a +// symlinked intermediate directory. func secretMountPath(target string) (string, error) { - path := filepath.Join(config.SecretsMountDir, target) - rel, err := filepath.Rel(config.SecretsMountDir, path) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if target != filepath.Base(target) || target == "." || target == ".." || + strings.ContainsRune(target, '/') || strings.ContainsRune(target, filepath.Separator) { return "", fmt.Errorf( - "invalid secret mount target %q: escapes %s", + "invalid secret mount target %q: must be a plain filename under %s", target, config.SecretsMountDir, ) } - return path, nil + return filepath.Join(config.SecretsMountDir, target), nil } func writeResultFile(cfg *ContainerSetupConfig) { diff --git a/pkg/devcontainer/setup/setup_test.go b/pkg/devcontainer/setup/setup_test.go index 690df7c0e..8b283a9d2 100644 --- a/pkg/devcontainer/setup/setup_test.go +++ b/pkg/devcontainer/setup/setup_test.go @@ -2,7 +2,7 @@ package setup import ( "context" - "strings" + "path/filepath" "testing" "github.com/devsy-org/devsy/pkg/agent/tunnel" @@ -12,27 +12,30 @@ import ( "google.golang.org/grpc" ) -// TestSecretMountPath_RejectsEscapes ensures a crafted mount target cannot -// write outside SecretsMountDir; nested but contained targets are allowed. +// TestSecretMountPath_RejectsEscapes ensures only a plain filename is accepted: +// path separators, "." / ".." and traversal are rejected so a crafted target +// cannot escape SecretsMountDir or write through a symlinked intermediate dir. func TestSecretMountPath_RejectsEscapes(t *testing.T) { - contained := []string{"tls.key", "sub/tls.key", "a.b.c"} - for _, target := range contained { + valid := []string{"tls.key", "a.b.c", "TOKEN"} + for _, target := range valid { path, err := secretMountPath(target) if err != nil { t.Errorf("secretMountPath(%q) unexpected error: %v", target, err) continue } - if !strings.HasPrefix(path, config.SecretsMountDir) { - t.Errorf("secretMountPath(%q) = %q, escaped %q", target, path, config.SecretsMountDir) + if path != filepath.Join(config.SecretsMountDir, target) { + t.Errorf("secretMountPath(%q) = %q, want %q", target, path, + filepath.Join(config.SecretsMountDir, target)) } } - // An absolute target is cleaned into SecretsMountDir by filepath.Join, so it - // stays contained; only ".."-based escapes leave the directory. - escapes := []string{"../evil", "../../etc/cron.d/x", "sub/../../../evil"} - for _, target := range escapes { + rejected := []string{ + "../evil", "../../etc/cron.d/x", "sub/../../../evil", + "sub/tls.key", "/etc/passwd", ".", "..", "", + } + for _, target := range rejected { if path, err := secretMountPath(target); err == nil { - t.Errorf("secretMountPath(%q) = %q, want escape error", target, path) + t.Errorf("secretMountPath(%q) = %q, want rejection", target, path) } } } diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index b9d2e7632..45eb83428 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -53,6 +53,9 @@ func (i *index) normalizeKinds() { for name, meta := range entries { if meta.Kind != KindSecret && meta.Kind != KindEnv { meta.Kind = KindSecret + // Enforce the sensitive invariant (no inline plaintext) that + // put would apply, in case a hand-edited entry carried a value. + meta.Value = "" entries[name] = meta } } diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 8fc36b98c..3cc60e6e4 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -248,6 +248,11 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { // lock serializes read-modify-write of the index and backend across concurrent // devsy processes so a parallel set/delete cannot lose an entry (last-save-wins) // or orphan a backend value. The returned release is always safe to call. +// +// NOT reentrant: each call opens its own flock handle on the same path, so a +// second lock() while one is held in the same process self-deadlocks. A +// lock-holding method (Set/Delete/touchLastUsed) must therefore never call +// another locking method. func (s *localStore) lock() (func(), error) { dir := filepath.Dir(s.indexPath) if err := os.MkdirAll(dir, 0o700); err != nil { diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go index c67322c08..3f5e5dce2 100644 --- a/pkg/secrets/store_internal_test.go +++ b/pkg/secrets/store_internal_test.go @@ -371,6 +371,30 @@ func TestLoadIndex_UnsetKindNormalizesToSecret(t *testing.T) { } } +// A blank-kind entry carrying an inline value (e.g. hand-edited) must be +// normalized to a secret with the inline plaintext cleared, upholding the +// invariant that sensitive values never persist inline. +func TestLoadIndex_UnsetKindClearsInlineValue(t *testing.T) { + path := filepath.Join(t.TempDir(), IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n" + + " context: default\n value: leaked\n" + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + idx, err := loadIndex(path) + if err != nil { + t.Fatal(err) + } + meta, _ := idx.get("default", "LEGACY") + if !meta.Sensitive() { + t.Fatalf("unset Kind must normalize to secret, got %q", meta.Kind) + } + if meta.Value != "" { + t.Fatalf("inline value must be cleared on normalize, got %q", meta.Value) + } +} + // A key file that already exists must never be overwritten, so a second // initializer that generated its own identity still adopts the persisted one. func TestFileKeyStore_SaveDoesNotOverwrite(t *testing.T) { From 374c7b442ea5bf9229336f75a3afb0d38cf18b3d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 10:03:22 -0500 Subject: [PATCH 15/19] style(secret): trim non-essential comments Remove comments that restated the code and tighten the rest to non-obvious rationale only, per the self-documenting-code convention. Keeps security justifications (#nosec, argv/redaction invariants, lock non-reentrancy) and exported-symbol docs. --- cmd/internal/agentcontainer/deferred_hooks.go | 3 +- cmd/internal/agentcontainer/post_attach.go | 3 +- cmd/workspace/up/up_client.go | 16 +++------- pkg/devcontainer/buildkit/secrets.go | 7 ++--- pkg/devcontainer/setup.go | 5 ++- pkg/devcontainer/setup/lifecyclehooks.go | 18 ++++------- pkg/devcontainer/setup/setup.go | 12 +++---- pkg/docker/helper.go | 3 +- pkg/driver/docker/build.go | 9 +++--- pkg/secrets/atomicwrite.go | 5 ++- pkg/secrets/filekey.go | 13 +++----- pkg/secrets/index.go | 8 ++--- pkg/secrets/local_store.go | 31 ++++++------------- pkg/secrets/store.go | 3 +- 14 files changed, 44 insertions(+), 92 deletions(-) diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index 96411d8fb..760c6c35c 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -84,8 +84,7 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - // Mount secrets are not in this re-exec'd process's env; read them back - // from /run/secrets so hook logs still mask their values. + // Mount values aren't in this process's env; read them back for redaction. }, secretsEnvFromEnvironment(), setup.MountSecretsForRedaction(), setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) diff --git a/cmd/internal/agentcontainer/post_attach.go b/cmd/internal/agentcontainer/post_attach.go index b672ab49b..cb35e55fe 100644 --- a/cmd/internal/agentcontainer/post_attach.go +++ b/cmd/internal/agentcontainer/post_attach.go @@ -56,8 +56,7 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error { } log.Debugf("running postAttachCommand hooks") - // Mount secrets are not in this re-exec'd process's env; read them back - // from /run/secrets so hook logs still mask their values. + // Mount values aren't in this process's env; read them back for redaction. if err := setup.RunPostAttachHooks( ctx, setupInfo, diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 20296a47e..62d4c3790 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -156,8 +156,6 @@ func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { return nil } -// resolveStoredSecrets resolves lifecycle, build, and git-token secrets from -// the store. A missing secret is a hard error rather than a silent drop. func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { requests, err := collectSecretRequests(cmd.Secrets, devsyConfig) if err != nil { @@ -226,9 +224,8 @@ func (cmd *UpCmd) applyLifecycleSecrets( return nil } -// applyEnvVars resolves --env entries into WorkspaceEnv. WorkspaceEnv is not -// stripped from the compressed setup argv, so a sensitive secret must never be -// routed here; --secret delivers those over the tunnel instead. +// applyEnvVars resolves --env entries into WorkspaceEnv. A sensitive secret must +// never be routed here: WorkspaceEnv rides in the setup argv (ps-visible). func (cmd *UpCmd) applyEnvVars( get func(string) (string, error), isSensitive func(string) (bool, error), @@ -285,9 +282,8 @@ func (cmd *UpCmd) applyGitToken(get func(string) (string, error)) error { return nil } -// buildGitToken assembles the host-scoped git token from the resolved secret, -// inferring the username from the source host when not set explicitly. It -// fails when the host cannot be determined so the token is never left unscoped. +// buildGitToken host-scopes the token; it fails on an unresolvable host so the +// token is never left unscoped. func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { host := gitHostFromSource(cmd.Source) if host == "" { @@ -304,8 +300,7 @@ func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { return &provider2.GitToken{Host: host, Username: username, Token: token}, nil } -// gitHostFromSource extracts the host from a workspace git source, or "" when -// it is not an HTTP(S) URL. +// gitHostFromSource returns the host of an HTTP(S) git source, or "". func gitHostFromSource(source string) string { s := strings.TrimPrefix(source, "git:") u, err := url.Parse(s) @@ -315,7 +310,6 @@ func gitHostFromSource(source string) string { return u.Host } -// gitTokenUsernameForHost returns the conventional token username for a host. func gitTokenUsernameForHost(host string) string { if strings.Contains(host, "gitlab") { return "oauth2" diff --git a/pkg/devcontainer/buildkit/secrets.go b/pkg/devcontainer/buildkit/secrets.go index 885e076ba..8d4744be0 100644 --- a/pkg/devcontainer/buildkit/secrets.go +++ b/pkg/devcontainer/buildkit/secrets.go @@ -8,9 +8,7 @@ import ( "github.com/moby/buildkit/session/secrets/secretsprovider" ) -// ParseBuildSecrets converts "NAME=VALUE" entries into a map keyed by the -// BuildKit secret id. The value is the resolved secret exposed to the build via -// RUN --mount=type=secret,id=NAME; it never becomes a build arg or image layer. +// ParseBuildSecrets converts "NAME=VALUE" entries into a map keyed by secret id. func ParseBuildSecrets(entries []string) (map[string][]byte, error) { secrets := make(map[string][]byte, len(entries)) for _, entry := range entries { @@ -23,8 +21,7 @@ func ParseBuildSecrets(entries []string) (map[string][]byte, error) { return secrets, nil } -// buildSecretsAttachable returns a session attachable serving the given -// "NAME=VALUE" build secrets, or nil when there are none. +// buildSecretsAttachable serves the given build secrets, or nil when none. func buildSecretsAttachable(entries []string) (session.Attachable, error) { if len(entries) == 0 { return nil, nil diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index be34da78a..e4e8228b5 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -287,9 +287,8 @@ func (r *runner) compressResult(result *config.Result) (string, error) { } func (r *runner) compressWorkspaceConfig() (string, error) { - // Strip secret values from the CLIOptions embedded in the setup command - // arguments; the container pulls them over the tunnel instead (see the - // Secrets RPC), so values never appear in process listings. + // Strip secret values from the argv-embedded CLIOptions; the container pulls + // them over the tunnel (Secrets RPC) so they never appear in process listings. cliOptions := r.workspaceConfig.CLIOptions cliOptions.SecretsEnv = nil cliOptions.SecretsMount = nil diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index ca27175d7..1fa984007 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -139,12 +139,9 @@ type lifecycleEnv struct { remoteEnv map[string]string } -// mergeSecretsEnv merges KEY=VALUE pairs from secretsEnv into the lifecycle env -// map (existing keys are NOT overridden; config takes precedence) and builds the -// hook log redactor. secretsMount values are added to the redactor only — mount -// secrets are delivered as files under /run/secrets and must never be injected -// into the hook environment, but their values must still be masked if a hook -// reads and echoes them. +// mergeSecretsEnv injects secretsEnv into the hook env (config keys win) and +// builds the log redactor. secretsMount values are added to the redactor only: +// mount secrets are files, never injected into the env, but must still be masked. func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { hookRedactor = secrets.NewRedactor(append(append([]string{}, secretsEnv...), secretsMount...)) for _, entry := range secretsEnv { @@ -158,12 +155,9 @@ func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { } } -// MountSecretsForRedaction reads the mount-secret files under SecretsMountDir -// and returns their "name=value" entries so a re-exec'd hook process (deferred -// or post-attach), which does not receive the mount values in its environment, -// can still mask them in logs. The files persist for the container lifetime, so -// a later hook that echoes one must be redacted like the foreground phases. -// Best-effort: unreadable entries are skipped. +// MountSecretsForRedaction reads SecretsMountDir into "name=value" entries so a +// re-exec'd hook process (which lacks the mount values in its env) can still +// mask them in logs. Best-effort: unreadable entries are skipped. func MountSecretsForRedaction() []string { dirEntries, err := os.ReadDir(config.SecretsMountDir) if err != nil { diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 695c4bbc5..ff9adafd2 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -162,10 +162,8 @@ func writeSecretFile(name, value, user string) error { return err } - // Drop any pre-existing entry (e.g. a symlink baked into the image) so the - // write cannot be redirected through it; Remove unlinks the symlink itself, - // not its target. Creating with O_EXCL then guarantees a fresh regular file - // and fails safely if a symlink is re-planted in the race window. + // Remove any pre-planted symlink (unlinks the link, not its target), then + // O_EXCL-create so the write cannot be redirected outside SecretsMountDir. if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("prepare secret file %s: %w", name, err) } @@ -191,10 +189,8 @@ func writeSecretFile(name, value, user string) error { return nil } -// secretMountPath resolves a mount target to a flat file within SecretsMountDir. -// The target must be a single path element (no separators, "." or ".."), which -// both blocks traversal out of the directory and prevents writing through a -// symlinked intermediate directory. +// secretMountPath resolves target to a flat file under SecretsMountDir. Only a +// single path element is allowed, blocking both traversal and symlinked dirs. func secretMountPath(target string) (string, error) { if target != filepath.Base(target) || target == "." || target == ".." || strings.ContainsRune(target, '/') || strings.ContainsRune(target, filepath.Separator) { diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index fd4e2d6b9..8d7dc2365 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -220,8 +220,7 @@ func (r *DockerHelper) RunWithDir( } // RunWithEnv runs a command with extra environment variables for this -// invocation only. Used to pass build secrets to buildx via env-backed -// --secret references so values never appear in the argument list. +// invocation only (used for env-backed buildx secrets). // //nolint:revive // IO stream params mirror Run/RunWithDir. func (r *DockerHelper) RunWithEnv( diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index f690b55dc..af4e4a70e 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -126,9 +126,8 @@ func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []strin return args } -// buildxSecretArgs maps NAME=VALUE build secrets to buildx `--secret -// id=NAME,env=VAR` references, returning the env assignments to pass out of band -// so secret values never appear in the argument list. +// buildxSecretArgs maps NAME=VALUE build secrets to `--secret id=NAME,env=VAR` +// references, returning the env assignments so values stay out of the argv. func buildxSecretArgs(buildSecrets []string) (env, args []string, err error) { for _, entry := range buildSecrets { name, value, ok := strings.Cut(entry, "=") @@ -351,8 +350,8 @@ func (d *dockerDriver) prepareBuildOptions( return buildOptions, nil } -// redactBuildSecrets returns a shallow copy of buildOptions with build-secret -// values masked to names only, so debug logs never expose secret values. +// redactBuildSecrets copies buildOptions with build-secret values masked to +// names, so the debug log never exposes them. func redactBuildSecrets(buildOptions *build.BuildOptions) build.BuildOptions { redacted := *buildOptions if len(buildOptions.BuildSecrets) == 0 { diff --git a/pkg/secrets/atomicwrite.go b/pkg/secrets/atomicwrite.go index d62f8798b..36206a5b0 100644 --- a/pkg/secrets/atomicwrite.go +++ b/pkg/secrets/atomicwrite.go @@ -6,9 +6,8 @@ import ( "path/filepath" ) -// atomicWriteFile writes data to path via a temp file in the same directory -// followed by a rename, so a crash mid-write can never leave a truncated file -// that would render the whole store unreadable. +// atomicWriteFile writes via a same-dir temp file then rename, so a crash +// mid-write cannot leave a truncated store file. func atomicWriteFile(path string, data []byte, perm os.FileMode) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go index 39653d25f..7c2057ba0 100644 --- a/pkg/secrets/filekey.go +++ b/pkg/secrets/filekey.go @@ -59,10 +59,8 @@ func resolveFileKey(dir, passphrase string) (*fileKey, error) { // resolveAutoKey reuses the persisted identity or creates one; it must never // regenerate an existing key or previously encrypted secrets become unreadable. -// The load->generate->save->reload sequence is serialized with a cross-process -// lock so concurrent first-time initializers converge on one persisted key -// instead of each trusting the identity it generated (which the keyring's -// last-writer-wins Set would otherwise split). +// The read-generate-save-reload sequence is locked so concurrent first-time +// initializers converge on one persisted key. func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, error) { unlock, err := lockKeyResolution(dir) if err != nil { @@ -96,9 +94,6 @@ func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, err return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil } -// lockKeyResolution takes an exclusive cross-process lock in dir so key -// resolution is atomic across concurrent devsy processes. It returns a release -// function that is always safe to call. func lockKeyResolution(dir string) (func(), error) { if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("create secrets dir: %w", err) @@ -156,8 +151,8 @@ func (s fileKeyStore) load() (string, error) { return string(data), nil } -// save writes the key exclusively so a concurrent initializer that lost the -// race does not overwrite the winner's key; an existing file is left intact. +// save writes the key with O_EXCL so a losing concurrent initializer leaves the +// winner's key intact. func (s fileKeyStore) save(value string) error { if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { return fmt.Errorf("create secrets dir: %w", err) diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go index 45eb83428..a95d2e26e 100644 --- a/pkg/secrets/index.go +++ b/pkg/secrets/index.go @@ -44,17 +44,13 @@ func loadIndex(path string) (*index, error) { return idx, nil } -// normalizeKinds fails safe on an unset Kind by treating the entry as a secret. -// An unknown kind must never be read as a plaintext env var: entries written by -// an older layout (bool "sensitive", no "kind") would otherwise silently -// downgrade to env, returning an empty value and skipping the key-source check. +// normalizeKinds fails safe on an unset Kind by treating the entry as a secret, +// so an older/hand-edited entry is never read as a plaintext env var. func (i *index) normalizeKinds() { for _, entries := range i.data.Contexts { for name, meta := range entries { if meta.Kind != KindSecret && meta.Kind != KindEnv { meta.Kind = KindSecret - // Enforce the sensitive invariant (no inline plaintext) that - // put would apply, in case a hand-edited entry carried a value. meta.Value = "" entries[name] = meta } diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index 3cc60e6e4..fecca8d31 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -59,7 +59,6 @@ func NewStoreForConfig(devsyConfig *config.Config) (Store, error) { } } -// resolveBackend applies precedence: env override, context option, then auto. func resolveBackend(devsyConfig *config.Config) Backend { if b := normalizeBackend(os.Getenv(EnvBackend)); b != "" { return b @@ -122,7 +121,7 @@ func (s *localStore) Set(context, name, value string, kind Kind) error { } wasSensitive := exists && meta.Sensitive() meta.Kind = kind - meta.Value = value // cleared below for sensitive entries + meta.Value = value if err := s.persistValue(idx, &meta, value, wasSensitive); err != nil { return err @@ -183,7 +182,6 @@ func (s *localStore) Delete(context, name string) error { return err } - // Only sensitive entries have a backend value to remove. if meta, ok := idx.get(context, name); ok && meta.Sensitive() { if err := s.checkKeySource(idx); err != nil { return err @@ -197,7 +195,6 @@ func (s *localStore) Delete(context, name string) error { return idx.save() } -// Meta returns the metadata for a single entry (no value), or ErrSecretNotFound. func (s *localStore) Meta(context, name string) (SecretMeta, error) { idx, err := loadIndex(s.indexPath) if err != nil { @@ -226,14 +223,13 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { if !anySensitive(entries) { return entries, nil } - // Only verify the key source when there is a sensitive entry to read. if err := s.checkKeySource(idx); err != nil { return nil, err } for i := range entries { if !entries[i].Sensitive() { - continue // inline values cannot be orphaned + continue } orphaned, err := s.isOrphaned(context, entries[i].Name) if err != nil { @@ -245,14 +241,9 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { return entries, nil } -// lock serializes read-modify-write of the index and backend across concurrent -// devsy processes so a parallel set/delete cannot lose an entry (last-save-wins) -// or orphan a backend value. The returned release is always safe to call. -// -// NOT reentrant: each call opens its own flock handle on the same path, so a -// second lock() while one is held in the same process self-deadlocks. A -// lock-holding method (Set/Delete/touchLastUsed) must therefore never call -// another locking method. +// lock takes a cross-process file lock serializing store read-modify-write. +// NOT reentrant: a lock-holding method must never call another locking method, +// or it self-deadlocks on its own flock. func (s *localStore) lock() (func(), error) { dir := filepath.Dir(s.indexPath) if err := os.MkdirAll(dir, 0o700); err != nil { @@ -266,9 +257,7 @@ func (s *localStore) lock() (func(), error) { return func() { _ = l.Unlock() }, nil } -// touchLastUsed records access time under the store lock so it cannot clobber a -// concurrent set/delete. It reloads inside the lock and is entirely best-effort: -// any error (including a now-deleted entry) is ignored. +// touchLastUsed records access time under the store lock; best-effort, errors ignored. func (s *localStore) touchLastUsed(context, name string) { unlock, err := s.lock() if err != nil { @@ -298,7 +287,6 @@ func anySensitive(entries []SecretMeta) bool { return false } -// isOrphaned reports whether a sensitive entry's value is missing from the backend. func (s *localStore) isOrphaned(context, name string) (bool, error) { _, err := s.backend.get(backendKey(context, name)) switch { @@ -311,9 +299,8 @@ func (s *localStore) isOrphaned(context, name string) (bool, error) { } } -// persistValue writes a sensitive value to the backend, or removes a stale -// backend value when an entry becomes non-sensitive. The key source is verified -// before any backend access on either side of a sensitivity change. +// persistValue writes a sensitive value to the backend, or removes a stale one +// when an entry becomes non-sensitive. func (s *localStore) persistValue( idx *index, meta *SecretMeta, value string, wasSensitive bool, ) error { @@ -339,7 +326,7 @@ func (s *localStore) persistValue( } // checkKeySource rejects a key-source change with a clear error instead of a -// decryption failure. +// downstream decryption failure. func (s *localStore) checkKeySource(idx *index) error { if s.keySource == "" || idx.data.KeySource == "" { return nil diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go index 5049ea64a..be432e1c8 100644 --- a/pkg/secrets/store.go +++ b/pkg/secrets/store.go @@ -25,8 +25,7 @@ func ValidateName(name string) error { return nil } -// Kind distinguishes the two facades over the store: sensitive secrets, whose -// values live in the backend, and plaintext environment variables stored inline. +// Kind distinguishes secrets (values in the backend) from env vars (inline). type Kind string const ( From b250ea2c16727138d768fca8835ba5d7fbd36b51 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 10:32:12 -0500 Subject: [PATCH 16/19] fix(secret): preserve multiline values + idiom cleanups (5th review) - MEDIUM: multiline secret values (PEM keys, certs) were truncated at the first newline when passed to deferred/post-attach hooks via DEVSY_SECRETS_ENV (newline-joined). Base64-encode each entry symmetrically so any bytes survive; also stopped the dropped tail from escaping the log redactor. - LOW: reject two mount secrets sharing a target= at parse time with a clear message instead of a confusing O_EXCL 'file exists' error mid-setup. Also makes collectSecretRequests output deterministic (sorted). - Idiom: replace the confusing applier closure-slice in resolveStoredSecrets with a small secretResolver value type; append(append(...)) -> slices.Concat; extract the duplicated flock logic into a shared acquireFlock helper. Adds tests. 1428 tests pass; golangci-lint clean. --- cmd/internal/agentcontainer/deferred_hooks.go | 24 +++++- cmd/internal/agentcontainer/setup.go | 4 +- .../agentcontainer/setup_internal_test.go | 16 ++++ cmd/workspace/up/secrets_test.go | 9 +++ cmd/workspace/up/up_client.go | 76 +++++++++++++------ pkg/config/env.go | 4 +- pkg/devcontainer/setup/lifecyclehooks.go | 2 +- pkg/secrets/filekey.go | 15 +--- pkg/secrets/local_store.go | 17 +---- pkg/secrets/lock.go | 26 +++++++ 10 files changed, 135 insertions(+), 58 deletions(-) create mode 100644 pkg/secrets/lock.go diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index 760c6c35c..ee56cd846 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -4,6 +4,7 @@ package agentcontainer import ( "context" + "encoding/base64" "encoding/json" "fmt" "os" @@ -20,13 +21,34 @@ import ( "github.com/spf13/cobra" ) +// encodeSecretsEnv serializes KEY=VALUE secrets for the DEVSY_SECRETS_ENV var +// used to pass them to re-exec'd hook processes. Each entry is base64-encoded so +// values may contain newlines or any bytes (e.g. PEM keys) without corruption. +func encodeSecretsEnv(secretsEnv []string) string { + encoded := make([]string, len(secretsEnv)) + for i, entry := range secretsEnv { + encoded[i] = base64.StdEncoding.EncodeToString([]byte(entry)) + } + return strings.Join(encoded, "\n") +} + func secretsEnvFromEnvironment() []string { raw := os.Getenv(config2.EnvSecretsEnv) if raw == "" { return nil } - return strings.Split(raw, "\n") + parts := strings.Split(raw, "\n") + entries := make([]string, 0, len(parts)) + for _, p := range parts { + decoded, err := base64.StdEncoding.DecodeString(p) + if err != nil { + continue + } + entries = append(entries, string(decoded)) + } + + return entries } // DeferredHooksCmd runs deferred lifecycle hooks as a detached background process. diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index 2c6f9ae55..832a94417 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -380,7 +380,7 @@ func buildDeferredHooksCmd( Args: append([]string{binaryPath}, args...), } if len(secretsEnv) > 0 { - cmd.Env = append(os.Environ(), config2.EnvSecretsEnv+"="+strings.Join(secretsEnv, "\n")) + cmd.Env = append(os.Environ(), config2.EnvSecretsEnv+"="+encodeSecretsEnv(secretsEnv)) } return cmd, nil @@ -570,7 +570,7 @@ func (cmd *SetupContainerCmd) startPostAttachHooks(sctx *setupContext) error { if secretsEnv := sctx.secretsEnv; len(secretsEnv) > 0 { execCmd.Env = append( os.Environ(), - config2.EnvSecretsEnv+"="+strings.Join(secretsEnv, "\n"), + config2.EnvSecretsEnv+"="+encodeSecretsEnv(secretsEnv), ) } diff --git a/cmd/internal/agentcontainer/setup_internal_test.go b/cmd/internal/agentcontainer/setup_internal_test.go index 1b5387e78..97a5bacda 100644 --- a/cmd/internal/agentcontainer/setup_internal_test.go +++ b/cmd/internal/agentcontainer/setup_internal_test.go @@ -54,3 +54,19 @@ func TestCompressSetupInfoPreservesSubstitutedValues(t *testing.T) { require.NotNil(t, gotHome) assert.Equal(t, "/home/testuser", *gotHome) } + +func TestSecretsEnvRoundTripPreservesMultilineValues(t *testing.T) { + // PEM keys and certs carry newlines; the DEVSY_SECRETS_ENV round-trip must + // preserve them exactly rather than truncating at the first newline. + entries := []string{ + "TLS_KEY=-----BEGIN KEY-----\nline1\nline2\n-----END KEY-----", + "SIMPLE=value", + "WITH_EQUALS=a=b=c", + } + + encoded := encodeSecretsEnv(entries) + t.Setenv("DEVSY_SECRETS_ENV", encoded) + + got := secretsEnvFromEnvironment() + assert.Equal(t, entries, got) +} diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 50c2257c5..1a7bb37a0 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -93,6 +93,15 @@ func TestCollectSecretRequests_InvalidOption(t *testing.T) { require.Error(t, err) } +func TestCollectSecretRequests_DuplicateMountTargetRejected(t *testing.T) { + _, err := collectSecretRequests( + []string{"A,type=mount,target=tok", "B,type=mount,target=tok"}, + testConfig(), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "both mount to target") +} + func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { cmd := &UpCmd{} cmd.EnvVars = []string{secretAPIKey} diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 62d4c3790..95a1a0ebb 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "sort" "strings" client2 "github.com/devsy-org/devsy/pkg/client" @@ -169,36 +170,41 @@ func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { if err != nil { return err } - contextName := devsyConfig.DefaultContext + r := secretResolver{store: store, context: devsyConfig.DefaultContext} - get := func(name string) (string, error) { - value, gErr := store.Get(contextName, name) - if gErr != nil { - return "", fmt.Errorf("resolve secret %q in context %q: %w", name, contextName, gErr) - } - return value, nil + if err := cmd.applyLifecycleSecrets(requests, r.get); err != nil { + return err } - isSensitive := func(name string) (bool, error) { - meta, mErr := store.Meta(contextName, name) - if mErr != nil { - return false, fmt.Errorf("resolve secret %q in context %q: %w", name, contextName, mErr) - } - return meta.Sensitive(), nil + if err := cmd.applyEnvVars(r.get, r.sensitive); err != nil { + return err } - - appliers := []func(func(string) (string, error)) error{ - func(g func(string) (string, error)) error { return cmd.applyLifecycleSecrets(requests, g) }, - func(g func(string) (string, error)) error { return cmd.applyEnvVars(g, isSensitive) }, - cmd.applyBuildSecrets, - cmd.applyGitToken, + if err := cmd.applyBuildSecrets(r.get); err != nil { + return err } - for _, apply := range appliers { - if err := apply(get); err != nil { - return err - } + return cmd.applyGitToken(r.get) +} + +// secretResolver reads values and kinds for a single context, wrapping store +// errors with the secret name. +type secretResolver struct { + store secrets.Store + context string +} + +func (r secretResolver) get(name string) (string, error) { + value, err := r.store.Get(r.context, name) + if err != nil { + return "", fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) } + return value, nil +} - return nil +func (r secretResolver) sensitive(name string) (bool, error) { + meta, err := r.store.Meta(r.context, name) + if err != nil { + return false, fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) + } + return meta.Sensitive(), nil } func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { @@ -345,10 +351,32 @@ func collectSecretRequests(flags []string, devsyConfig *config.Config) ([]secret for _, req := range byName { requests = append(requests, req) } + sort.Slice(requests, func(i, j int) bool { return requests[i].name < requests[j].name }) + + if err := checkDuplicateMountTargets(requests); err != nil { + return nil, err + } return requests, nil } +func checkDuplicateMountTargets(requests []secretRequest) error { + targets := map[string]string{} + for _, req := range requests { + if !req.mount { + continue + } + if other, dup := targets[req.target]; dup { + return fmt.Errorf( + "secrets %q and %q both mount to target %q; give one a distinct target=", + other, req.name, req.target, + ) + } + targets[req.target] = req.name + } + return nil +} + // parseSecretFlag parses NAME[,type=env|mount][,target=X]. func parseSecretFlag(entry string) (secretRequest, error) { parts := strings.Split(entry, ",") diff --git a/pkg/config/env.go b/pkg/config/env.go index 486b7a9ac..9e930a08d 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -63,8 +63,8 @@ const ( // EnvWorkspaceDaemonConfig holds the workspace daemon configuration. EnvWorkspaceDaemonConfig = "DEVSY_WORKSPACE_DAEMON_CONFIG" - // EnvSecretsEnv passes newline-separated KEY=VALUE secrets to agent - // subprocesses via the environment (not argv, to keep them out of ps). + // EnvSecretsEnv passes base64-encoded KEY=VALUE secrets (newline-joined) to + // agent subprocesses via the environment (not argv, to keep them out of ps). EnvSecretsEnv = "DEVSY_SECRETS_ENV" // #nosec G101 -- env var name, not a credential. // EnvWorkspaceCredentialsPort is the workspace credentials server port. diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index 1fa984007..3d6a06421 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -143,7 +143,7 @@ type lifecycleEnv struct { // builds the log redactor. secretsMount values are added to the redactor only: // mount secrets are files, never injected into the env, but must still be masked. func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { - hookRedactor = secrets.NewRedactor(append(append([]string{}, secretsEnv...), secretsMount...)) + hookRedactor = secrets.NewRedactor(slices.Concat(secretsEnv, secretsMount)) for _, entry := range secretsEnv { key, value, ok := strings.Cut(entry, "=") if !ok { diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go index 7c2057ba0..0ec69f0ac 100644 --- a/pkg/secrets/filekey.go +++ b/pkg/secrets/filekey.go @@ -8,7 +8,6 @@ import ( "strings" "filippo.io/age" - "github.com/gofrs/flock" keyring "github.com/zalando/go-keyring" ) @@ -62,7 +61,7 @@ func resolveFileKey(dir, passphrase string) (*fileKey, error) { // The read-generate-save-reload sequence is locked so concurrent first-time // initializers converge on one persisted key. func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, error) { - unlock, err := lockKeyResolution(dir) + unlock, err := acquireFlock(dir, KeyFileName+".lock") if err != nil { return nil, err } @@ -94,18 +93,6 @@ func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, err return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil } -func lockKeyResolution(dir string) (func(), error) { - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("create secrets dir: %w", err) - } - lock := flock.New(filepath.Join(dir, KeyFileName+".lock")) - if err := lock.Lock(); err != nil { - return nil, fmt.Errorf("lock secrets key: %w", err) - } - - return func() { _ = lock.Unlock() }, nil -} - type keyStore interface { load() (string, error) // returns "" when no key is stored yet save(value string) error diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go index fecca8d31..5669745ff 100644 --- a/pkg/secrets/local_store.go +++ b/pkg/secrets/local_store.go @@ -8,7 +8,6 @@ import ( "time" "github.com/devsy-org/devsy/pkg/config" - "github.com/gofrs/flock" ) const IndexFileName = "secrets.yaml" @@ -241,20 +240,10 @@ func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { return entries, nil } -// lock takes a cross-process file lock serializing store read-modify-write. -// NOT reentrant: a lock-holding method must never call another locking method, -// or it self-deadlocks on its own flock. +// lock serializes store read-modify-write. NOT reentrant: a lock-holding method +// must never call another locking method (see acquireFlock). func (s *localStore) lock() (func(), error) { - dir := filepath.Dir(s.indexPath) - if err := os.MkdirAll(dir, 0o700); err != nil { - return nil, fmt.Errorf("create secrets dir: %w", err) - } - l := flock.New(filepath.Join(dir, IndexFileName+".lock")) - if err := l.Lock(); err != nil { - return nil, fmt.Errorf("lock secrets store: %w", err) - } - - return func() { _ = l.Unlock() }, nil + return acquireFlock(filepath.Dir(s.indexPath), IndexFileName+".lock") } // touchLastUsed records access time under the store lock; best-effort, errors ignored. diff --git a/pkg/secrets/lock.go b/pkg/secrets/lock.go new file mode 100644 index 000000000..680ef400d --- /dev/null +++ b/pkg/secrets/lock.go @@ -0,0 +1,26 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gofrs/flock" +) + +// acquireFlock takes an exclusive cross-process lock on / and returns +// a release func that is always safe to call. +// +// NOT reentrant: each call opens its own flock handle, so taking a lock while +// one is already held on the same path in this process self-deadlocks. +func acquireFlock(dir, name string) (func(), error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create secrets dir: %w", err) + } + l := flock.New(filepath.Join(dir, name)) + if err := l.Lock(); err != nil { + return nil, fmt.Errorf("lock %s: %w", name, err) + } + + return func() { _ = l.Unlock() }, nil +} From 755b1166283b8a515d75d8206397fc036691cf80 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 12:00:36 -0500 Subject: [PATCH 17/19] fix(desktop): deliver stdin to secret set (was hanging on Saving...) runRawStdin passed { input } to Node's promisified execFile, but that option is ignored by the async execFile (only execFileSync honors it). The CLI's 'secret set --stdin' then blocked forever on io.ReadAll(os.Stdin) with no value and no EOF, so the desktop 'Add Secret' spinner never finished; because the call holds a concurrency slot, retries hung too. Spawn the child and write+close stdin explicitly instead. --- desktop/src/main/cli.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index a7a983fc2..bf7625ec9 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -195,12 +195,10 @@ export class CliRunner { async runRawStdin(args: string[], input: string): Promise { await this.acquire() try { - const { stdout } = await execFile( - this.execPath, + return await this.spawnWithStdin( [...this.prefixArgs, ...args, "--log-output", "json"], - { env: this.env, input }, + input, ) - return stdout } catch (error: unknown) { throw this.wrapError(error) } finally { @@ -208,6 +206,37 @@ export class CliRunner { } } + /** + * Spawn the CLI, write `input` to its stdin and close it, then resolve with + * stdout. Node's async execFile ignores the `input` option (it only works on + * the *Sync variants), which would leave the child blocked reading stdin + * forever — so this uses spawn and drives stdin explicitly. + */ + private spawnWithStdin(args: string[], input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.execPath, args, { env: this.env }) + let stdout = "" + let stderr = "" + child.stdout.on("data", (d) => (stdout += d)) + child.stderr.on("data", (d) => (stderr += d)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout) + return + } + const err = new Error( + `command failed with exit code ${code}`, + ) as Error & { stderr: string; code: number | null } + err.stderr = stderr + err.code = code + reject(err) + }) + child.stdin.on("error", reject) + child.stdin.end(input) + }) + } + private activeChildren = new Set() /** * Tracks streaming children by workspace so callers can cancel everything From 70e81865042b28d2b85a6cdb161087740fd6e542 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 12:12:48 -0500 Subject: [PATCH 18/19] test(e2e): managed env var injects into workspace via --env Mirrors the existing --secret injection e2e for feature parity: env set a var, devsy up --env NAME, assert it lands in the workspace login-shell environment. Adds a storeEnv helper alongside storeSecret. --- e2e/tests/up/helper.go | 8 ++++++++ e2e/tests/up/provider_docker.go | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index c411cd1e8..e25bf48b6 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -45,6 +45,14 @@ func (dtc *dockerTestContext) storeSecret(ctx context.Context, name, value strin }) } +func (dtc *dockerTestContext) storeEnv(ctx context.Context, name, value string) { + _, err := dtc.f.ExecCommandOutput(ctx, []string{"env", "set", name, "--value", value}) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{"env", "delete", name}) + }) +} + // logLine represents a single JSON log line from devsy output. // Only the fields we need for test assertions are included. type logLine struct { diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index d2455b983..fddffd00c 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -749,6 +749,30 @@ var _ = ginkgo.Describe( ginkgo.SpecTimeout(framework.TimeoutShort()), ) + ginkgo.It( + "managed env var injects into the workspace via --env", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeEnv(ctx, "MANAGED_ENV", "env-value-55") + + err = dtc.f.DevsyUp(ctx, tempDir, "--env", "MANAGED_ENV") + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "bash -l -c 'echo -n $MANAGED_ENV'") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("env-value-55")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + ginkgo.It("multi devcontainer selection", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-multi-devcontainer", From fcb5882ec96d0cf88af18bda4bf679422557383f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Fri, 24 Jul 2026 12:18:48 -0500 Subject: [PATCH 19/19] test: cover secret set --stdin (CLI) and desktop runRawStdin Regression coverage for the desktop 'Saving...' hang: - cmd/secrets: resolveValue reads --stdin (incl. multiline PEM preservation and the trailing-newline trim), --value, and rejects multiple sources. - desktop cli.test.ts: runRawStdin writes the value to the child's stdin and resolves; rejects with stderr on non-zero exit. Guards against the execFile { input } no-op that left the CLI blocked reading stdin. --- cmd/secrets/set_internal_test.go | 74 ++++++++++++++++++++++++++ desktop/src/main/__tests__/cli.test.ts | 66 ++++++++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 cmd/secrets/set_internal_test.go diff --git a/cmd/secrets/set_internal_test.go b/cmd/secrets/set_internal_test.go new file mode 100644 index 000000000..8b813a798 --- /dev/null +++ b/cmd/secrets/set_internal_test.go @@ -0,0 +1,74 @@ +package secrets + +import ( + "os" + "strings" + "testing" +) + +// withStdin replaces os.Stdin with a pipe carrying input for the duration of fn. +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + + go func() { + _, _ = w.WriteString(input) + _ = w.Close() + }() + + fn() + _ = r.Close() +} + +func TestResolveValue_Stdin(t *testing.T) { + cmd := &SetCmd{Stdin: true} + var got string + var gotErr error + withStdin(t, "s3cr3t\n", func() { + got, gotErr = cmd.resolveValue("TOKEN") + }) + if gotErr != nil { + t.Fatal(gotErr) + } + // A single trailing newline is trimmed (piping `printf` vs `echo`). + if got != "s3cr3t" { + t.Fatalf("resolveValue(--stdin) = %q, want %q", got, "s3cr3t") + } +} + +func TestResolveValue_StdinPreservesInnerNewlines(t *testing.T) { + cmd := &SetCmd{Stdin: true} + pem := "-----BEGIN KEY-----\nline1\nline2\n-----END KEY-----" + var got string + withStdin(t, pem, func() { + got, _ = cmd.resolveValue("TLS_KEY") + }) + if got != pem { + t.Fatalf("resolveValue(--stdin) = %q, want multiline value preserved", got) + } +} + +func TestResolveValue_Value(t *testing.T) { + cmd := &SetCmd{Value: "plain"} + got, err := cmd.resolveValue("K") + if err != nil { + t.Fatal(err) + } + if got != "plain" { + t.Fatalf("resolveValue(--value) = %q, want %q", got, "plain") + } +} + +func TestResolveValue_RejectsMultipleSources(t *testing.T) { + cmd := &SetCmd{Value: "a", Stdin: true} + _, err := cmd.resolveValue("K") + if err == nil || !strings.Contains(err.Error(), "only one of") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} diff --git a/desktop/src/main/__tests__/cli.test.ts b/desktop/src/main/__tests__/cli.test.ts index 4f611cc3e..7693a6a5e 100644 --- a/desktop/src/main/__tests__/cli.test.ts +++ b/desktop/src/main/__tests__/cli.test.ts @@ -1,8 +1,31 @@ // @vitest-environment node -import { execFile } from "node:child_process" +import { execFile, spawn } from "node:child_process" +import { EventEmitter } from "node:events" import { beforeEach, describe, expect, it, vi } from "vitest" import { CliRunner } from "../cli.js" +// fakeChild models the slice of ChildProcess that runRawStdin drives: readable +// stdout/stderr, a stdin sink capturing what is written, and close/error events. +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: EventEmitter & { end: (input: string) => void; written: string } + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + const stdin = new EventEmitter() as EventEmitter & { + end: (input: string) => void + written: string + } + stdin.written = "" + stdin.end = (input: string) => { + stdin.written = input + } + child.stdin = stdin + return child +} + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal() return { @@ -125,6 +148,47 @@ describe("CliRunner", () => { }) }) + describe("runRawStdin", () => { + it("writes the value to the child's stdin and resolves with stdout", async () => { + const child = fakeChild() + const mockSpawn = vi.mocked(spawn) as unknown as ReturnType + mockSpawn.mockReturnValue(child) + + const promise = cli.runRawStdin(["secret", "set", "FOO", "--stdin"], "bar") + // runRawStdin awaits acquire() before spawning, so let microtasks flush + // until the child exists and stdin has been driven. + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()) + + // Regression guard: the value must actually reach stdin. The bug was that + // execFile's { input } option is a no-op on the async variant, so the CLI + // blocked forever reading stdin and the desktop hung on "Saving...". + expect(child.stdin.written).toBe("bar") + + child.stdout.emit("data", "ok\n") + child.emit("close", 0) + + await expect(promise).resolves.toBe("ok\n") + expect(mockSpawn).toHaveBeenCalledWith( + "/usr/local/bin/devsy", + ["secret", "set", "FOO", "--stdin", "--log-output", "json"], + expect.objectContaining({ env: expect.any(Object) }), + ) + }) + + it("rejects with stderr on non-zero exit", async () => { + const child = fakeChild() + const mockSpawn = vi.mocked(spawn) as unknown as ReturnType + mockSpawn.mockReturnValue(child) + + const promise = cli.runRawStdin(["secret", "set", "BAD", "--stdin"], "x") + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()) + child.stderr.emit("data", "boom") + child.emit("close", 1) + + await expect(promise).rejects.toThrow(/boom|exit code 1/) + }) + }) + describe("constructor with .cjs binary", () => { it("runs .cjs files through node from PATH", async () => { const jsCli = new CliRunner("/tmp/mock.cjs")