From 017a72be20d28e8917e1cc49393211c384cb5a8c Mon Sep 17 00:00:00 2001 From: Aris Buzachis Date: Fri, 12 Jan 2024 16:23:04 +0200 Subject: [PATCH] feat: implement Bunnyshell secrets Signed-off-by: Aris Buzachis --- cmd/root.go | 2 + cmd/secret/decrypt.go | 86 ++++++++++++++++ cmd/secret/decrypt_definition.go | 56 +++++++++++ cmd/secret/definition.go | 108 +++++++++++++++++++++ cmd/secret/encrypt.go | 86 ++++++++++++++++ cmd/secret/encrypt_definition.go | 46 +++++++++ cmd/secret/root.go | 33 +++++++ pkg/api/secret/decrypt.go | 47 +++++++++ pkg/api/secret/encrypt.go | 47 +++++++++ pkg/api/secret/transcript_configuration.go | 75 ++++++++++++++ pkg/config/manager.go | 16 +-- pkg/formatter/stylish.go | 4 + pkg/formatter/stylish.secret.go | 16 +++ pkg/util/os.go | 16 +++ 14 files changed, 624 insertions(+), 14 deletions(-) create mode 100644 cmd/secret/decrypt.go create mode 100644 cmd/secret/decrypt_definition.go create mode 100644 cmd/secret/definition.go create mode 100644 cmd/secret/encrypt.go create mode 100644 cmd/secret/encrypt_definition.go create mode 100644 cmd/secret/root.go create mode 100644 pkg/api/secret/decrypt.go create mode 100644 pkg/api/secret/encrypt.go create mode 100644 pkg/api/secret/transcript_configuration.go create mode 100644 pkg/formatter/stylish.secret.go create mode 100644 pkg/util/os.go diff --git a/cmd/root.go b/cmd/root.go index f028149..f36dc5b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "bunnyshell.com/cli/cmd/project" "bunnyshell.com/cli/cmd/project_variable" "bunnyshell.com/cli/cmd/registry_integration" + "bunnyshell.com/cli/cmd/secret" "bunnyshell.com/cli/cmd/template" "bunnyshell.com/cli/cmd/utils" "bunnyshell.com/cli/cmd/variable" @@ -97,6 +98,7 @@ func init() { variable.GetMainCommand(), k8sIntegration.GetMainCommand(), pipeline.GetMainCommand(), + secret.GetMainCommand(), template.GetMainCommand(), }, ) diff --git a/cmd/secret/decrypt.go b/cmd/secret/decrypt.go new file mode 100644 index 0000000..78ef5d0 --- /dev/null +++ b/cmd/secret/decrypt.go @@ -0,0 +1,86 @@ +package secret + +import ( + "errors" + "io" + "os" + + "bunnyshell.com/cli/pkg/api/secret" + "bunnyshell.com/cli/pkg/build" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/lib" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +var ( + errMissingEncryptedExpression = errors.New("the encrypted expression must be provided") + errMultipleEncryptedExpressionInputs = errors.New("the encrypted expression must be provided either by argument or by stdin, not both") +) + +var decryptCommandExample = heredoc.Docf(` + %[1]s%[2]s secret decrypt --organization dMVwZO5jGN "ENCRYPTED[F67baQQZ6XXHNxcmRz]" + %[1]scat encrypted.txt | %[2]s secret decrypt --organization dMVwZO5jGN +`, "\t", build.Name) + +func init() { + settings := config.GetSettings() + + decryptOptions := secret.NewDecryptOptions() + + command := &cobra.Command{ + Use: "decrypt ", + + Short: "Decrypts a secret expression of the given organization", + Example: decryptCommandExample, + + Args: cobra.MaximumNArgs(1), + ArgAliases: []string{"expression"}, + ValidArgsFunction: cobra.NoFileCompletions, + + PreRunE: func(cmd *cobra.Command, args []string) error { + hasStdin, err := isStdinPresent() + if err != nil { + return err + } + + if len(args) == 0 && !hasStdin { + return errMissingEncryptedExpression + } + + if len(args) == 1 && hasStdin { + return errMultipleEncryptedExpressionInputs + } + + return nil + }, + + RunE: func(cmd *cobra.Command, args []string) error { + if decryptOptions.Organization == "" { + decryptOptions.Organization = settings.Profile.Context.Organization + } + + if len(args) == 1 { + decryptOptions.Expression = args[0] + } else { + buf, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + decryptOptions.Expression = string(buf) + } + + decryptedSecret, err := secret.Decrypt(decryptOptions) + if err != nil { + return lib.FormatCommandError(cmd, err) + } + + return lib.FormatCommandData(cmd, decryptedSecret) + }, + } + + decryptOptions.UpdateSelfFlags(command.Flags()) + + mainCmd.AddCommand(command) +} diff --git a/cmd/secret/decrypt_definition.go b/cmd/secret/decrypt_definition.go new file mode 100644 index 0000000..9ed8a18 --- /dev/null +++ b/cmd/secret/decrypt_definition.go @@ -0,0 +1,56 @@ +package secret + +import ( + "bunnyshell.com/cli/pkg/api/secret" + "bunnyshell.com/cli/pkg/build" + "bunnyshell.com/cli/pkg/lib" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +var decryptDefinitionCommandExample = heredoc.Docf(` + %[1]s%[2]s secret decrypt-definition --organization dMVwZO5jGN --file plain.txt + %[1]scat plain.txt | %[2]s secret decrypt-definition --organization dMVwZO5jGN +`, "\t", build.Name) + +var resolvedExpressions bool + +func init() { + transcriptConfigurationOptions := secret.NewTranscriptConfigurationOptions() + + command := &cobra.Command{ + Use: "decrypt-definition", + + Short: "Decrypts an environment definition for the given organization", + Example: decryptDefinitionCommandExample, + + ValidArgsFunction: cobra.NoFileCompletions, + + PreRunE: func(cmd *cobra.Command, args []string) error { + return validateDefinitionCommand(transcriptConfigurationOptions) + }, + + RunE: func(cmd *cobra.Command, args []string) error { + mode := secret.TranscriptModeExposed + if resolvedExpressions { + mode = secret.TranscriptModeResolved + } + + decryptedDefinition, err := executeTranscriptConfiguration(transcriptConfigurationOptions, mode) + if err != nil { + return lib.FormatCommandError(cmd, err) + } + + cmd.Println(decryptedDefinition) + + return nil + }, + } + + flags := command.Flags() + + transcriptConfigurationOptions.UpdateSelfFlags(flags) + flags.BoolVar(&resolvedExpressions, "resolved", false, "Resolve the expressions and include directly the value") + + mainCmd.AddCommand(command) +} diff --git a/cmd/secret/definition.go b/cmd/secret/definition.go new file mode 100644 index 0000000..91625a3 --- /dev/null +++ b/cmd/secret/definition.go @@ -0,0 +1,108 @@ +package secret + +import ( + "errors" + "io" + "os" + + "bunnyshell.com/cli/pkg/api/secret" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/util" +) + +var ( + errYamlBlankValue = errors.New("the environment definition provided is blank") + errYamlMissingValue = errors.New("the environment definition must be provided") + errYamlFileNotFound = errors.New("the provided filepath does not exist") + errMultipleYamlValueInputs = errors.New("the environment definition must be provided either by argument or by stdin, not both") +) + +func executeTranscriptConfiguration(options *secret.TranscriptConfigurationOptions, mode secret.TranscriptMode) (string, error) { + settings := config.GetSettings() + + options.Mode = string(mode) + if options.Organization == "" { + options.Organization = settings.Profile.Context.Organization + } + + err := loadDefinition(options) + if err != nil { + return "", err + } + + if options.Yaml == "" { + return "", errYamlBlankValue + } + + resultedDefinition, err := secret.TranscriptConfiguration(options) + if err != nil { + return "", err + } + + return string(resultedDefinition.Bytes), nil +} + +func validateDefinitionCommand(options *secret.TranscriptConfigurationOptions) error { + hasStdin, err := isStdinPresent() + if err != nil { + return err + } + + if options.DefinitionFilePath == "" && !hasStdin { + return errYamlMissingValue + } + + if options.DefinitionFilePath != "" { + if hasStdin { + return errMultipleYamlValueInputs + } + + exists, err := util.FileExists(options.DefinitionFilePath) + if err != nil { + return err + } + if !exists { + return errYamlFileNotFound + } + } + + return nil +} + +func loadDefinition(options *secret.TranscriptConfigurationOptions) error { + if options.DefinitionFilePath != "" { + contents, err := loadDefinitionFromFile(options.DefinitionFilePath) + if err != nil { + return err + } + + options.Yaml = contents + } else { + buf, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + options.Yaml = string(buf) + } + + return nil +} + +func loadDefinitionFromFile(filepath string) (string, error) { + exists, err := util.FileExists(filepath) + if err != nil { + return "", err + } + + if !exists { + return "", errors.New("the provided file does not exist") + } + + fileContents, err := os.ReadFile(filepath) + if err != nil { + return "", err + } + + return string(fileContents), nil +} diff --git a/cmd/secret/encrypt.go b/cmd/secret/encrypt.go new file mode 100644 index 0000000..9d8e3fe --- /dev/null +++ b/cmd/secret/encrypt.go @@ -0,0 +1,86 @@ +package secret + +import ( + "errors" + "io" + "os" + + "bunnyshell.com/cli/pkg/api/secret" + "bunnyshell.com/cli/pkg/build" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/lib" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +var ( + errMissingValue = errors.New("the plain value must be provided") + errMultipleValueInputs = errors.New("the value must be provided either by argument or by stdin, not both") +) + +var encryptCommandExample = heredoc.Docf(` + %[1]s%[2]s secret encrypt --organization dMVwZO5jGN "my plain secret value" + %[1]scat plain.txt | %[2]s secret encrypt --organization dMVwZO5jGN +`, "\t", build.Name) + +func init() { + settings := config.GetSettings() + + encryptOptions := secret.NewEncryptOptions() + + command := &cobra.Command{ + Use: "encrypt ", + + Short: "Encrypts a secret for the given organization", + Example: encryptCommandExample, + + Args: cobra.MaximumNArgs(1), + ArgAliases: []string{"value"}, + ValidArgsFunction: cobra.NoFileCompletions, + + PreRunE: func(cmd *cobra.Command, args []string) error { + hasStdin, err := isStdinPresent() + if err != nil { + return err + } + + if len(args) == 0 && !hasStdin { + return errMissingValue + } + + if len(args) == 1 && hasStdin { + return errMultipleValueInputs + } + + return nil + }, + + RunE: func(cmd *cobra.Command, args []string) error { + if encryptOptions.Organization == "" { + encryptOptions.Organization = settings.Profile.Context.Organization + } + + if len(args) == 1 { + encryptOptions.PlainText = args[0] + } else { + buf, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + + encryptOptions.PlainText = string(buf) + } + + encryptedSecret, err := secret.Encrypt(encryptOptions) + if err != nil { + return lib.FormatCommandError(cmd, err) + } + + return lib.FormatCommandData(cmd, encryptedSecret) + }, + } + + encryptOptions.UpdateSelfFlags(command.Flags()) + + mainCmd.AddCommand(command) +} diff --git a/cmd/secret/encrypt_definition.go b/cmd/secret/encrypt_definition.go new file mode 100644 index 0000000..3a4aaf0 --- /dev/null +++ b/cmd/secret/encrypt_definition.go @@ -0,0 +1,46 @@ +package secret + +import ( + "bunnyshell.com/cli/pkg/api/secret" + "bunnyshell.com/cli/pkg/build" + "bunnyshell.com/cli/pkg/lib" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +var encryptDefinitionCommandExample = heredoc.Docf(` + %[1]s%[2]s secret encrypt-definition --organization dMVwZO5jGN --file plain.txt + %[1]scat plain.txt | %[2]s secret encrypt-definition --organization dMVwZO5jGN +`, "\t", build.Name) + +func init() { + transcriptConfigurationOptions := secret.NewTranscriptConfigurationOptions() + + command := &cobra.Command{ + Use: "encrypt-definition", + + Short: "Encrypts an environment definition for the given organization", + Example: encryptDefinitionCommandExample, + + ValidArgsFunction: cobra.NoFileCompletions, + + PreRunE: func(cmd *cobra.Command, args []string) error { + return validateDefinitionCommand(transcriptConfigurationOptions) + }, + + RunE: func(cmd *cobra.Command, args []string) error { + encryptedDefinition, err := executeTranscriptConfiguration(transcriptConfigurationOptions, secret.TranscriptModeObfuscated) + if err != nil { + return lib.FormatCommandError(cmd, err) + } + + cmd.Println(encryptedDefinition) + + return nil + }, + } + + transcriptConfigurationOptions.UpdateSelfFlags(command.Flags()) + + mainCmd.AddCommand(command) +} diff --git a/cmd/secret/root.go b/cmd/secret/root.go new file mode 100644 index 0000000..9c2a7e9 --- /dev/null +++ b/cmd/secret/root.go @@ -0,0 +1,33 @@ +package secret + +import ( + "os" + + "bunnyshell.com/cli/pkg/config" + "github.com/spf13/cobra" +) + +var mainCmd = &cobra.Command{ + Use: "secrets", + Aliases: []string{"sec"}, + + Short: "Secrets", + Long: "Bunnyshell Secrets", +} + +func init() { + config.MainManager.CommandWithAPI(mainCmd) +} + +func GetMainCommand() *cobra.Command { + return mainCmd +} + +func isStdinPresent() (bool, error) { + fi, err := os.Stdin.Stat() + if err != nil { + return false, err + } + + return (fi.Mode() & os.ModeCharDevice) == 0, nil +} diff --git a/pkg/api/secret/decrypt.go b/pkg/api/secret/decrypt.go new file mode 100644 index 0000000..545f4eb --- /dev/null +++ b/pkg/api/secret/decrypt.go @@ -0,0 +1,47 @@ +package secret + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" + "github.com/spf13/pflag" +) + +type DecryptOptions struct { + common.Options + + sdk.SecretDecryptAction +} + +func NewDecryptOptions() *DecryptOptions { + return &DecryptOptions{} +} + +func (options *DecryptOptions) UpdateSelfFlags(flags *pflag.FlagSet) { + flags.StringVar(&options.Organization, "organization", options.Organization, "The Organization this secret was encrypted for") +} + +func Decrypt(options *DecryptOptions) (*sdk.SecretDecryptedItem, error) { + model, resp, err := DecryptRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +func DecryptRaw(options *DecryptOptions) (*sdk.SecretDecryptedItem, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile). + SecretAPI.SecretDecrypt(ctx). + SecretDecryptAction(options.SecretDecryptAction) + + return request.Execute() +} diff --git a/pkg/api/secret/encrypt.go b/pkg/api/secret/encrypt.go new file mode 100644 index 0000000..7e61b49 --- /dev/null +++ b/pkg/api/secret/encrypt.go @@ -0,0 +1,47 @@ +package secret + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" + "github.com/spf13/pflag" +) + +type EncryptOptions struct { + common.Options + + sdk.SecretEncryptAction +} + +func NewEncryptOptions() *EncryptOptions { + return &EncryptOptions{} +} + +func (options *EncryptOptions) UpdateSelfFlags(flags *pflag.FlagSet) { + flags.StringVar(&options.Organization, "organization", options.Organization, "The Organization for which to encrypt the secret") +} + +func Encrypt(options *EncryptOptions) (*sdk.SecretEncryptedItem, error) { + model, resp, err := EncryptRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +func EncryptRaw(options *EncryptOptions) (*sdk.SecretEncryptedItem, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile). + SecretAPI.SecretEncrypt(ctx). + SecretEncryptAction(options.SecretEncryptAction) + + return request.Execute() +} diff --git a/pkg/api/secret/transcript_configuration.go b/pkg/api/secret/transcript_configuration.go new file mode 100644 index 0000000..3672877 --- /dev/null +++ b/pkg/api/secret/transcript_configuration.go @@ -0,0 +1,75 @@ +package secret + +import ( + "io" + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" + "github.com/spf13/pflag" +) + +type DefinitionData map[string]any + +type DefinitionItem struct { + Data DefinitionData + + Bytes []byte +} + +type TranscriptMode string + +const ( + TranscriptModeExposed TranscriptMode = "exposed" + TranscriptModeObfuscated TranscriptMode = "obfuscated" + TranscriptModeResolved TranscriptMode = "resolved" +) + +type TranscriptConfigurationOptions struct { + common.Options + + DefinitionFilePath string + + sdk.SecretTranscryptConfigurationAction +} + +func NewTranscriptConfigurationOptions() *TranscriptConfigurationOptions { + return &TranscriptConfigurationOptions{} +} + +func (options *TranscriptConfigurationOptions) UpdateSelfFlags(flags *pflag.FlagSet) { + flags.StringVar(&options.Organization, "organization", options.Organization, "The Organization this secret was encrypted for") + flags.StringVar(&options.DefinitionFilePath, "file", options.DefinitionFilePath, "The filepath to the environment definition file") +} + +func TranscriptConfiguration(options *TranscriptConfigurationOptions) (*DefinitionItem, error) { + model, resp, err := TranscriptConfigurationRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return &DefinitionItem{ + Data: model, + Bytes: bytes, + }, nil +} + +func TranscriptConfigurationRaw(options *TranscriptConfigurationOptions) (DefinitionData, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile). + SecretAPI.SecretTranscryptConfiguration(ctx). + SecretTranscryptConfigurationAction(options.SecretTranscryptConfigurationAction) + + return request.Execute() +} diff --git a/pkg/config/manager.go b/pkg/config/manager.go index 7eb4220..6e70454 100644 --- a/pkg/config/manager.go +++ b/pkg/config/manager.go @@ -7,6 +7,7 @@ import ( "bunnyshell.com/cli/pkg/build" "bunnyshell.com/cli/pkg/formatter" + "bunnyshell.com/cli/pkg/util" "github.com/spf13/pflag" "github.com/spf13/viper" ) @@ -84,7 +85,7 @@ func (manager *Manager) Save() error { } func (manager *Manager) SafeSave() error { - exists, err := fileExists(manager.settings.ConfigFile) + exists, err := util.FileExists(manager.settings.ConfigFile) if err != nil { return err } @@ -124,16 +125,3 @@ func getFormatForFile(file string) string { return "yaml" } } - -func fileExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - - if os.IsNotExist(err) { - return false, nil - } - - return false, err -} diff --git a/pkg/formatter/stylish.go b/pkg/formatter/stylish.go index ca653cb..1ef0cb7 100644 --- a/pkg/formatter/stylish.go +++ b/pkg/formatter/stylish.go @@ -70,6 +70,10 @@ func stylish(data interface{}) ([]byte, error) { tabulatePipelineItem(writer, dataType) case *sdk.ComponentGitItem: tabulateComponentGitItem(writer, dataType) + case *sdk.SecretDecryptedItem: + tabulateSecretDecryptedItem(writer, dataType) + case *sdk.SecretEncryptedItem: + tabulateSecretEncryptedItem(writer, dataType) case *sdk.TemplateItem: tabulateTemplateItem(writer, dataType) case *sdk.TemplatesRepositoryItem: diff --git a/pkg/formatter/stylish.secret.go b/pkg/formatter/stylish.secret.go new file mode 100644 index 0000000..9ef5a16 --- /dev/null +++ b/pkg/formatter/stylish.secret.go @@ -0,0 +1,16 @@ +package formatter + +import ( + "fmt" + "text/tabwriter" + + "bunnyshell.com/sdk" +) + +func tabulateSecretEncryptedItem(writer *tabwriter.Writer, item *sdk.SecretEncryptedItem) { + fmt.Fprintf(writer, "%v\t %v\n", "Expression", item.GetExpression()) +} + +func tabulateSecretDecryptedItem(writer *tabwriter.Writer, item *sdk.SecretDecryptedItem) { + fmt.Fprintf(writer, "%v\t %v\n", "Value", item.GetPlainText()) +} diff --git a/pkg/util/os.go b/pkg/util/os.go new file mode 100644 index 0000000..bf97acc --- /dev/null +++ b/pkg/util/os.go @@ -0,0 +1,16 @@ +package util + +import "os" + +func FileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + + if os.IsNotExist(err) { + return false, nil + } + + return false, err +}