From f326e34d7f35be4fb06dc16a53acd7a416982458 Mon Sep 17 00:00:00 2001 From: Krasimir Kargov Date: Tue, 16 Dec 2025 16:05:40 +0200 Subject: [PATCH] Adding a new flag to the deploy command and the related new functionality in order to support the collecting of secrets and sending them to the backend LMCROSSITXSADEPLOY-2301 --- commands/deploy_command.go | 126 ++++++++++- commands/deploy_command_test.go | 117 +++++++++- commands/file_uploader.go | 27 +++ secure_parameters/secure_parameters_test.go | 224 ++++++++++++++++++++ secure_parameters/secure_parametes.go | 149 +++++++++++++ 5 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 secure_parameters/secure_parameters_test.go create mode 100644 secure_parameters/secure_parametes.go diff --git a/commands/deploy_command.go b/commands/deploy_command.go index e54344f..aaa6084 100644 --- a/commands/deploy_command.go +++ b/commands/deploy_command.go @@ -2,7 +2,9 @@ package commands import ( "bufio" + "crypto/rand" "encoding/base64" + "encoding/json" "errors" "flag" "fmt" @@ -23,6 +25,7 @@ import ( "github.com/cloudfoundry-incubator/multiapps-cli-plugin/commands/retrier" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/configuration" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/log" + "github.com/cloudfoundry-incubator/multiapps-cli-plugin/secure_parameters" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/ui" "github.com/cloudfoundry-incubator/multiapps-cli-plugin/util" "gopkg.in/cheggaaa/pb.v1" @@ -53,6 +56,7 @@ const ( applyNamespaceAsSuffix = "apply-namespace-as-suffix" maxNamespaceSize = 36 shouldBackupPreviousVersionOpt = "backup-previous-version" + requireSecureParameters = "require-secure-parameters" ) type listFlag struct { @@ -105,13 +109,13 @@ func (c *DeployCommand) GetPluginCommand() plugin.Command { UsageDetails: plugin.Usage{ Usage: `Deploy a multi-target app archive - cf deploy MTA [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u URL] [-f] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT] + cf deploy MTA [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u URL] [-f] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--require-secure-parameters] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT] Perform action on an active deploy operation cf deploy -i OPERATION_ID -a ACTION [-u URL] - Deploy a multi-target app archive referenced by a remote URL - | cf deploy [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u MTA_CONTROLLER_URL] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT]` + util.UploadEnvHelpText, + (EXPERIMENTAL) Deploy a multi-target app archive referenced by a remote URL + | cf deploy [-e EXT_DESCRIPTOR[,...]] [-t TIMEOUT] [--version-rule VERSION_RULE] [-u MTA_CONTROLLER_URL] [--retries RETRIES] [--no-start] [--namespace NAMESPACE] [--apply-namespace-app-names true/false] [--apply-namespace-service-names true/false] [--apply-namespace-app-routes true/false] [--apply-namespace-as-suffix true/false ] [--delete-services] [--delete-service-keys] [--delete-service-brokers] [--keep-files] [--no-restart-subscribed-apps] [--do-not-fail-on-missing-permissions] [--abort-on-error] [--strategy STRATEGY] [--skip-testing-phase] [--skip-idle-start] [require-secure-parameters] [--apps-start-timeout TIMEOUT] [--apps-stage-timeout TIMEOUT] [--apps-upload-timeout TIMEOUT] [--apps-task-execution-timeout TIMEOUT]` + util.UploadEnvHelpText, Options: map[string]string{ extDescriptorsOpt: "Extension descriptors", @@ -146,6 +150,7 @@ func (c *DeployCommand) GetPluginCommand() plugin.Command { util.GetShortOption(taskExecutionTimeoutOpt): "Task execution timeout in seconds", util.CombineFullAndShortParameters(startTimeoutOpt, timeoutOpt): "Start app timeout in seconds", util.GetShortOption(shouldBackupPreviousVersionOpt): "(EXPERIMENTAL) (STRATEGY: BLUE-GREEN, INCREMENTAL-BLUE-GREEN) Backup previous version of applications, use new cli command \"rollback-mta\" to rollback to the previous version", + util.GetShortOption(requireSecureParameters): "Pass secrets to the deploy service in a secure way", }, }, } @@ -171,6 +176,7 @@ func deployProcessParametersSetter() ProcessParametersSetter { processBuilder.Parameter("appsStageTimeout", GetStringOpt(stageTimeoutOpt, flags)) processBuilder.Parameter("appsUploadTimeout", GetStringOpt(uploadTimeoutOpt, flags)) processBuilder.Parameter("appsTaskExecutionTimeout", GetStringOpt(taskExecutionTimeoutOpt, flags)) + processBuilder.Parameter("isSecurityEnabled", strconv.FormatBool(GetBoolOpt(requireSecureParameters, flags))) var lastSetValue string = "" for i := 0; i < len(os.Args); i++ { @@ -225,6 +231,7 @@ func (c *DeployCommand) defineCommandOptions(flags *flag.FlagSet) { flags.String(uploadTimeoutOpt, "", "") flags.String(taskExecutionTimeoutOpt, "", "") flags.Bool(shouldBackupPreviousVersionOpt, false, "") + flags.Bool(requireSecureParameters, false, "") } func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, flags *flag.FlagSet, cfTarget util.CloudFoundryTarget) ExecutionStatus { @@ -348,6 +355,48 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, return Failure } + if GetBoolOpt(requireSecureParameters, flags) { + // Collect special ENVs: __MTA___, __MTA_JSON___, __MTA_CERT___ + parameters, err := secure_parameters.CollectFromEnv("__MTA") + if err != nil { + ui.Failed("Secure parameters error: %s", err) + return Failure + } + + if len(parameters) == 0 { + ui.Failed("No secure parameters found in environment. Set variables like __MTA___, __MTA_JSON___, or __MTA_CERT___.") + return Failure + } + + userProvidedServiceName := getUpsName(mtaId, namespace) + + isUpsCreated, _, err := c.validateUpsExistsOrElseCreateIt(userProvidedServiceName, "v1") + if err != nil { + ui.Failed("Could not ensure user-provided service %s: %v", userProvidedServiceName, err) + return Failure + } + + if isUpsCreated { + ui.Say("Created user-provided service %s for secure parameters.", terminal.EntityNameColor(userProvidedServiceName)) + } else { + ui.Say("Using existing user-provided service %s for secure parameters.", terminal.EntityNameColor(userProvidedServiceName)) + } + + schemaVer := "" + yamlBytes, err := secure_parameters.BuildSecureExtension(parameters, mtaId, schemaVer) + if err != nil { + ui.Failed("Could not build secure extension: %s", err) + return Failure + } + + secureFileID, err := fileUploader.UploadBytes("__mta.secure.mtaext", yamlBytes) + if err != nil { + ui.Failed("Could not upload secure extension: %s", err) + return Failure + } + uploadedExtDescriptorIDs = append(uploadedExtDescriptorIDs, secureFileID) + } + // Build the process instance processBuilder := NewDeploymentStrategy(flags, c.processTypeProvider).CreateProcessBuilder() processBuilder.Namespace(namespace) @@ -374,6 +423,77 @@ func (c *DeployCommand) executeInternal(positionalArgs []string, dsHost string, return executionMonitor.Monitor() } +func getUpsName(mtaID, namespace string) string { + if strings.TrimSpace(namespace) == "" { + return "__mta-secure-" + mtaID + } + return "__mta-secure-" + mtaID + "-" + namespace +} + +func (c *DeployCommand) validateUpsExistsOrElseCreateIt(userProvidedServiceName, keyID string) (upsCreatedByTheCli bool, encryptionKeyResult string, err error) { + doesUpsExist, err := c.doesUpsExist(userProvidedServiceName) + if err != nil { + return false, "", fmt.Errorf("Check if the UPS exists: %w", err) + } + if doesUpsExist { + return false, "", nil + } + + encryptionKey, err := getRandomEncryptionKey() + if err != nil { + return false, "", fmt.Errorf("Error while generating AES-256 encryption key: %w", err) + } + + upsCredentials := map[string]string{ + "encryptionKey": encryptionKey, + "keyId": keyID, + } + jsonBody, _ := json.Marshal(upsCredentials) + + if _, err := c.cliConnection.CliCommand("create-user-provided-service", userProvidedServiceName, "-p", string(jsonBody)); err != nil { + return false, "", fmt.Errorf("Command cf cups %s has failed: %w", userProvidedServiceName, err) + } + return true, encryptionKey, nil +} + +func getRandomEncryptionKey() (string, error) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + + encryptionKeyBytes := make([]byte, 32) + if _, err := rand.Read(encryptionKeyBytes); err != nil { + return "", err + } + + for i := range encryptionKeyBytes { + encryptionKeyBytes[i] = alphabet[int(encryptionKeyBytes[i]&63)] + } + + return string(encryptionKeyBytes), nil +} + +func (c *DeployCommand) doesUpsExist(userProvidedServiceName string) (bool, error) { + servicesOutput, err := c.cliConnection.CliCommandWithoutTerminalOutput("services") + if err != nil { + return false, fmt.Errorf("Error while checking if the UPS for secure encryption exists: %w", err) + } + stringTable := strings.Join(servicesOutput, "\n") + return findServiceName(stringTable, userProvidedServiceName), nil +} + +func findServiceName(servicesOutput, userProvidedServiceName string) bool { + userProvidedServiceNameToLower := strings.ToLower(userProvidedServiceName) + for _, currentLine := range strings.Split(servicesOutput, "\n") { + fields := strings.Fields(currentLine) + if len(fields) == 0 { + continue + } + if strings.ToLower(fields[0]) == userProvidedServiceNameToLower { + return true + } + } + return false +} + func parseMtaArchiveArgument(rawMtaArchive interface{}) (bool, string) { switch castedMtaArchive := rawMtaArchive.(type) { case *url.URL: diff --git a/commands/deploy_command_test.go b/commands/deploy_command_test.go index e89587a..0963785 100644 --- a/commands/deploy_command_test.go +++ b/commands/deploy_command_test.go @@ -78,6 +78,7 @@ var _ = Describe("DeployCommand", func() { const testArchive = "mtaArchive.mtar" const mtaArchivePath = testFilesLocation + testArchive const extDescriptorPath = testFilesLocation + "extDescriptor.mtaext" + const userProvidedServiceSecurityRelated = "__mta-secure-anatz" var name string var cliConnection *plugin_fakes.FakeCliConnection @@ -105,7 +106,7 @@ var _ = Describe("DeployCommand", func() { } } - var getOutputLines = func(extDescriptor, processAborted, fromUrl bool) []string { + var getOutputLines = func(extDescriptor, processAborted, fromUrl, existentUserProvidedServiceSecurity, createdUserProvidedServiceSecurity bool) []string { var lines []string mtaNameToPrint := mtaArchivePath if fromUrl { @@ -134,6 +135,14 @@ var _ = Describe("DeployCommand", func() { " "+fullExtDescriptorPath, "OK") } + if existentUserProvidedServiceSecurity { + lines = append(lines, + "Using existing user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } + if createdUserProvidedServiceSecurity { + lines = append(lines, + "Created user-provided service "+userProvidedServiceSecurityRelated+" for secure parameters.") + } lines = append(lines, "Test message", "Process finished.", @@ -246,7 +255,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, false, false)) }) }) @@ -348,7 +357,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -360,7 +369,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath, "-e", extDescriptorPath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(true, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(true, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -372,7 +381,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath, "-f", "-delete-services", "-no-start", "-keep-files", "-do-not-fail-on-missing-permissions"}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(true), operation.Parameters) }) @@ -412,7 +421,7 @@ var _ = Describe("DeployCommand", func() { output, status := oc.CaptureOutputAndStatus(func() int { return command.Execute([]string{mtaArchivePath}).ToInt() }) - ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false)) + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, false, false, false)) // operation := mtaClient.StartMtaOperationArgsForCall(1) // expectProcessParameters(getProcessParameters(false), operation.Parameters) }) @@ -494,5 +503,101 @@ var _ = Describe("DeployCommand", func() { ex.ExpectSuccessWithOutput(status, output, getLinesForAbortingProcess()) }) }) + + Context("with --require-secure-parameters flag and a user-provided service instance which already exists", func() { + It("should not create a new user-provided service", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + upsName := "__mta-secure-anatz" + cliConnection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { + if len(args) > 0 && args[0] == "services" { + table := fmt.Sprintf("%s user-provided fake-plan\nanother-service-instance managed fake-plan\n", upsName) + return []string{table}, nil + } + return []string{}, nil + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, true, false)) + Expect(output).To(ContainElement(ContainSubstring("Using existing user-provided service"))) + Expect(output).To(ContainElement(ContainSubstring(upsName))) + + callCount := mtaClient.StartMtaOperationCallCount() + Expect(callCount).To(BeNumerically(">", 0)) + operation := mtaClient.StartMtaOperationArgsForCall(callCount - 1) + Expect(operation.Parameters["isSecurityEnabled"]).To(Equal("true")) + }) + }) + + Context("with --require-secure-parameters flag and a user-provided service instance missing", func() { + It("should create a new user-provided service using the appropriate cf command", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + cliConnection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { + if len(args) > 0 && args[0] == "services" { + return []string{"another-service-instance managed fake-plan\n"}, nil + } + return []string{}, nil + } + + cliConnection.CliCommandStub = func(args ...string) ([]string, error) { + if len(args) > 0 && args[0] == "create-user-provided-service" { + return []string{}, nil + } + return []string{}, nil + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectSuccessWithOutput(status, output, getOutputLines(false, false, true, false, true)) + Expect(output).To(ContainElement(ContainSubstring("Created user-provided service"))) + Expect(output).To(ContainElement(ContainSubstring("__mta-secure-anatz"))) + + callCount := mtaClient.StartMtaOperationCallCount() + Expect(callCount).To(BeNumerically(">", 0)) + operation := mtaClient.StartMtaOperationArgsForCall(callCount - 1) + Expect(operation.Parameters["isSecurityEnabled"]).To(Equal("true")) + }) + }) + + Context("with --require-secure-parameters and `cf services` fails", func() { + It("should return an error from the UPS existence check", func() { + os.Setenv("__MTA___fake-variable", "fakeSecret") + defer os.Unsetenv("__MTA___fake-variable") + command.FileUrlReader = newMockFileReader(correctMtaUrl) + + cliConnection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { + if len(args) > 0 && args[0] == "services" { + return []string{"another-service-instance managed fake-plan\n"}, nil + } + return []string{}, nil + } + + cliConnection.CliCommandStub = func(args ...string) ([]string, error) { + if len(args) > 0 && args[0] == "create-user-provided-service" { + return nil, fmt.Errorf("error - could not be created") + } + return []string{}, nil + } + + output, status := oc.CaptureOutputAndStatus(func() int { + return command.Execute([]string{"--require-secure-parameters"}).ToInt() + }) + + ex.ExpectFailure(status, output, "") + Expect(output).To(ContainElement(ContainSubstring("Could not ensure user-provided service"))) + Expect(mtaClient.StartMtaOperationCallCount()).To(Equal(0)) + }) + }) + }) }) diff --git a/commands/file_uploader.go b/commands/file_uploader.go index 3320b20..28d7258 100644 --- a/commands/file_uploader.go +++ b/commands/file_uploader.go @@ -1,6 +1,7 @@ package commands import ( + "bytes" "fmt" "io" "os" @@ -63,6 +64,23 @@ func (r *progressBarReader) Close() error { return nil } +type namedBytesReader struct { + r *bytes.Reader + fileName string +} + +func (n *namedBytesReader) Read(p []byte) (int, error) { + return n.r.Read(p) +} + +func (n *namedBytesReader) Seek(o int64, w int) (int64, error) { + return n.r.Seek(o, w) +} + +func (n *namedBytesReader) Name() string { + return n.fileName +} + // NewFileUploader creates a new file uploader for the specified namespace func NewFileUploader(mtaClient mtaclient.MtaClientOperations, namespace string, uploadChunkSizeInMB uint64, sequentialUpload, shouldDisableProgressBar bool) *FileUploader { @@ -249,3 +267,12 @@ func (f *FileUploader) isFileAlreadyUploaded(newFilePath string, fileInfo os.Fil } return false } + +func (f *FileUploader) UploadBytes(filename string, content []byte) (string, error) { + nb := &namedBytesReader{r: bytes.NewReader(content), fileName: filename} + uploadedFile, err := f.mtaClient.UploadMtaFile(nb, int64(len(content)), &f.namespace) + if err != nil { + return "", fmt.Errorf("Could not upload in-memory file %s: %w", filename, err) + } + return uploadedFile.ID, nil +} diff --git a/secure_parameters/secure_parameters_test.go b/secure_parameters/secure_parameters_test.go new file mode 100644 index 0000000..b330620 --- /dev/null +++ b/secure_parameters/secure_parameters_test.go @@ -0,0 +1,224 @@ +package secure_parameters + +import ( + "encoding/base64" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func setEnv(t *testing.T, nameOfEnv, valueOfEnv string) { + t.Helper() + t.Setenv(nameOfEnv, valueOfEnv) +} + +func TestCollectFromEnv(t *testing.T) { + setEnv(t, "__MTA___fakePassword", "secretValue") + setEnv(t, "__MTA_JSON___fakeJson", `{"a":1,"b":"secretValueJson"}`) + testCertificate := "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n" + setEnv(t, "__MTA_CERT___fakeCertificate", base64.StdEncoding.EncodeToString([]byte(testCertificate))) + setEnv(t, "unrelatedEnvFirst", "exampleValueFirst") + setEnv(t, "unrelatedEnvSecond", "exampleValueSecond") + + resultToTest, err := CollectFromEnv("__MTA") + if err != nil { + t.Fatalf("Collecting environment variables has failed: %s", err.Error()) + } + + parameterValue, ok := resultToTest["fakePassword"] + if !ok { + t.Fatalf("Missing key 'fakePassword' in map") + } + + if parameterValue.Type != typeString || parameterValue.StringContent != "secretValue" { + t.Fatalf("The value of 'fakePassword' key is not correct") + } + + jsonValue, ok := resultToTest["fakeJson"] + if !ok { + t.Fatalf("Missing key 'fakeJson' in map") + } + + if jsonValue.Type != typeJSON { + t.Fatalf("The value of 'fakeJson' key is not correct") + } + + if firstJsonValue, ok := jsonValue.ObjectContent["a"].(float64); !ok || firstJsonValue != 1 { + t.Fatalf("The first value of the json is not what it should be: %v", jsonValue.ObjectContent["a"]) + } + + if jsonValue.ObjectContent["b"] != "secretValueJson" { + t.Fatalf("The second value of the json is not what it should be: %v", jsonValue.ObjectContent["b"]) + } + + certificateValue, ok := resultToTest["fakeCertificate"] + + if !ok { + t.Fatalf("The value of the certificate is not present") + } + + if certificateValue.Type != typeMultiline || certificateValue.StringContent != testCertificate { + t.Fatalf("The value of the certificate is not what it should be: %v", certificateValue) + } + + if _, exists := resultToTest["other"]; exists { + t.Fatalf("Unexpected value and environment variable") + } +} + +func TestCollectFromEnvWhenWrongName(t *testing.T) { + setEnv(t, "__MTA___fake spaced parameter", "x") + + _, err := CollectFromEnv("__MTA") + if err == nil || err.Error() != `invalid secure parameter name "fake spaced parameter"` { + t.Fatalf("Expected invalid name error: %v", err) + } +} + +func TestCollectFromEnvWhenInvalidJson(t *testing.T) { + setEnv(t, "__MTA_JSON___fakeJson", `{wrongFormat - fake}`) + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), "invalid JSON for fakeJson") { + t.Fatalf("Expected invalid JSON error: %v", err) + } +} + +func TestCollectFromEnvWhenDuplciateNames(t *testing.T) { + setEnv(t, "__MTA_JSON___duplicate", `{"fakeValueName":"value"}`) + setEnv(t, "__MTA___duplicate", "randomValue") + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), `secure parameter "duplicate" defined multiple ways`) { + t.Fatalf("Expected duplication error: %v", err) + } +} + +func TestCollectFromEnvWhenInvalidCertificate(t *testing.T) { + setEnv(t, "__MTA_CERT___fakeCertificate", "%**@&@#!#¬Base64*@&$)@!") + + _, err := CollectFromEnv("__MTA") + if err == nil || !strings.Contains(err.Error(), "invalid base64 for fakeCertificate") { + t.Fatalf("Expected invalid base64 error: %v", err) + } +} + +func TestCollectFromEnvWhenDifferentPrefix(t *testing.T) { + setEnv(t, "__MTA_JSON___myJson", `{"apple":"green"}`) + + result, err := CollectFromEnv("__OTHER") + + if err != nil { + t.Fatalf("Error while trying to collect environment variables with a different prefix: %s", err.Error()) + } + + if len(result) > 0 { + t.Fatalf("There should be zero environment variables collected, but there are: %d", len(result)) + } +} + +func TestBuildSecureExtension(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + "fakeJson": {Type: typeJSON, ObjectContent: map[string]interface{}{"secretParameterFirst": "secretValueOne", "secretParameterSecond": "secretValueTwo"}}, + "fakeCertificate": {Type: typeMultiline, StringContent: "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n"}, + } + + yamlResult, err := BuildSecureExtension(parameters, "test-mta", "") + + if err != nil { + t.Fatalf("Error while building the secure extension descriotor: %s", err.Error()) + } + + var unmarshaledBack map[string]interface{} + + err2 := yaml.Unmarshal(yamlResult, &unmarshaledBack) + + if err2 != nil { + t.Fatalf("Error while unmarshaling extension descriptor: %s", err.Error()) + } + + if unmarshaledBack["_schema-version"] != "3.3" { + t.Fatalf("Schema version is not what it should be: %v", unmarshaledBack["_schema-version"]) + } + + if unmarshaledBack["ID"] != "__mta.secure" { + t.Fatalf("ID of the secure extension descriptor is not what it should be: %v", unmarshaledBack["ID"]) + } + + if unmarshaledBack["extends"] != "test-mta" { + t.Fatalf("Extends of secure extension descriptor is not what it should be: %v", unmarshaledBack["extends"]) + } + + parametersUnmarshaled, ok := unmarshaledBack["parameters"].(map[string]interface{}) + + if !ok { + t.Fatalf("Parameters is not a map, but rather: %T", unmarshaledBack["parameters"]) + } + + if parametersUnmarshaled["password"] != "secretValue" { + t.Fatalf("Value of password is incorrect: %v", parametersUnmarshaled["password"]) + } + + fakeJson, ok := parametersUnmarshaled["fakeJson"].(map[string]interface{}) + + if !ok { + t.Fatalf("fakeJson is not an object but: %T", parametersUnmarshaled["fakeJson"]) + } + + if fakeJson["secretParameterFirst"] != "secretValueOne" { + t.Fatalf("fakeJson.secretParameterFirst is not what it should be: %v", fakeJson["secretParameterFirst"]) + } + + if fakeJson["secretParameterSecond"] != "secretValueTwo" { + t.Fatalf("fakeJson.secretParameterSecond is not what it should be: %v", fakeJson["secretParameterSecond"]) + } + + if parametersUnmarshaled["fakeCertificate"] != "-----BEGIN CERTIFICATE-----\nMIBgNVBAYTAPXwBc63heW9WrP3qnDEm+UZE4V0Au7OWnOeiobq\n-----END CERTIFICATE-----\n" { + t.Fatalf("fakeCertificate is not what it should be: %v", parametersUnmarshaled["fakeCertificate"]) + } +} + +func TestBuildSecureExtensionWhenExplicitSchema(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + } + + yamlResult, err := BuildSecureExtension(parameters, "test-mta", "3.1") + + if err != nil { + t.Fatalf("Error while building the secure extension descriotor: %s", err.Error()) + } + + var unmarshaledBack map[string]interface{} + + err2 := yaml.Unmarshal(yamlResult, &unmarshaledBack) + + if err2 != nil { + t.Fatalf("Error while unmarshaling extension descriptor: %s", err.Error()) + } + + if unmarshaledBack["_schema-version"] != "3.1" { + t.Fatalf("Schema version must be 3.1, but it is: %v", unmarshaledBack["_schema-version"]) + } +} + +func TestBuildSecureExtensionWhenNoParameters(t *testing.T) { + _, err := BuildSecureExtension(map[string]ParameterValue{}, "test-mta", "") + + if err == nil || err.Error() != "no secure parameters collected" { + t.Fatalf("Expected no parameters error, but rather got: %v", err) + } +} + +func TestBuildSecureExtensionWhenNoMtaId(t *testing.T) { + parameters := map[string]ParameterValue{ + "password": {Type: typeString, StringContent: "secretValue"}, + } + + _, err := BuildSecureExtension(parameters, "", "") + if err == nil || err.Error() != "mtaID is required for the extension descriptor's field 'extends'" { + t.Fatalf("Expected missing mta id error, but rather got: %v", err) + } +} diff --git a/secure_parameters/secure_parametes.go b/secure_parameters/secure_parametes.go new file mode 100644 index 0000000..8c28920 --- /dev/null +++ b/secure_parameters/secure_parametes.go @@ -0,0 +1,149 @@ +package secure_parameters + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +var nameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +type typeOfValue int + +const ( + typeString typeOfValue = iota + typeJSON + typeMultiline +) + +type ParameterValue struct { + Type typeOfValue + StringContent string + ObjectContent map[string]interface{} +} + +func nameDuplicated(name, prefix string, result map[string]ParameterValue) error { + _, ok := result[name] + if ok { + return fmt.Errorf("secure parameter %q defined multiple ways (collision with %s)", name, prefix) + } + + return nil +} + +func CollectFromEnv(prefix string) (map[string]ParameterValue, error) { + plainValue := prefix + "___" + jsonValue := prefix + "_JSON___" + certificateValue := prefix + "_CERT___" //X509value beacuse the certiciates are of type X509 (should be renamed) + + result := map[string]ParameterValue{} + + for _, nameValuePair := range os.Environ() { + equalsIndex := strings.IndexByte(nameValuePair, '=') + if equalsIndex < 0 { + continue + } + envName := nameValuePair[:equalsIndex] + envValue := nameValuePair[equalsIndex+1:] + + var name string + + switch { + case strings.HasPrefix(envName, jsonValue): + name = strings.TrimPrefix(envName, jsonValue) + + if !nameRegex.MatchString(name) { + return nil, fmt.Errorf("invalid secure parameter name %q", name) + } + + err := nameDuplicated(name, "__MTA_JSON", result) + if err != nil { + return nil, err + } + + var jsonObject map[string]interface{} + + err2 := json.Unmarshal([]byte(envValue), &jsonObject) + if err2 != nil { + return nil, fmt.Errorf("invalid JSON for %s: %w", name, err2) + } + result[name] = ParameterValue{Type: typeJSON, ObjectContent: jsonObject} + + case strings.HasPrefix(envName, certificateValue): + name = strings.TrimPrefix(envName, certificateValue) + + if !nameRegex.MatchString(name) { + return nil, fmt.Errorf("invalid secure parameter name %q", name) + } + + err := nameDuplicated(name, "__MTA_CERT", result) + if err != nil { + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(envValue) + if err != nil { + return nil, fmt.Errorf("invalid base64 for %s: %w", name, err) + } + result[name] = ParameterValue{Type: typeMultiline, StringContent: string(decoded)} + + case strings.HasPrefix(envName, plainValue): + name = strings.TrimPrefix(envName, plainValue) + + if !nameRegex.MatchString(name) { + return nil, fmt.Errorf("invalid secure parameter name %q", name) + } + + err := nameDuplicated(name, "__MTA", result) + if err != nil { + return nil, err + } + + result[name] = ParameterValue{Type: typeString, StringContent: envValue} + + default: + continue + } + } + + return result, nil +} + +func BuildSecureExtension(parameters map[string]ParameterValue, mtaID string, schemaVersion string) ([]byte, error) { + if len(parameters) == 0 { + return nil, errors.New("no secure parameters collected") + } + + if mtaID == "" { + return nil, errors.New("mtaID is required for the extension descriptor's field 'extends'") + } + + if schemaVersion == "" { + schemaVersion = "3.3" + } + + secureExtensionDescriptor := map[string]interface{}{ + "_schema-version": schemaVersion, + "ID": "__mta.secure", + "extends": mtaID, + "parameters": map[string]interface{}{}, + } + + parametersDescriptor := secureExtensionDescriptor["parameters"].(map[string]interface{}) + for name, currentParameterValue := range parameters { + switch currentParameterValue.Type { + case typeJSON: + parametersDescriptor[name] = currentParameterValue.ObjectContent + default: + parametersDescriptor[name] = currentParameterValue.StringContent + } + } + + return yaml.Marshal(secureExtensionDescriptor) +}