diff --git a/cmd/helm/get_all.go b/cmd/helm/get_all.go index 53f8d5905..a0b8b9f81 100644 --- a/cmd/helm/get_all.go +++ b/cmd/helm/get_all.go @@ -59,7 +59,7 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return tpl(template, data, out) } - return output.Table.Write(out, &statusPrinter{res, true, false}) + return output.Table.Write(out, &statusPrinter{res, true, false, settings.HideSecrets}) }, } diff --git a/cmd/helm/install.go b/cmd/helm/install.go index 7edd98091..a8d8bd58e 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -122,7 +122,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } - return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false}) + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, settings.HideSecrets}) }, } diff --git a/cmd/helm/release_testing.go b/cmd/helm/release_testing.go index e4e09ef3b..0620744a6 100644 --- a/cmd/helm/release_testing.go +++ b/cmd/helm/release_testing.go @@ -61,7 +61,7 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command return runErr } - if err := outfmt.Write(out, &statusPrinter{rel, settings.Debug, false}); err != nil { + if err := outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, settings.HideSecrets}); err != nil { return err } diff --git a/cmd/helm/status.go b/cmd/helm/status.go index 7a3204cb9..45b660d72 100644 --- a/cmd/helm/status.go +++ b/cmd/helm/status.go @@ -29,6 +29,7 @@ import ( "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/cli/sanitize" "helm.sh/helm/v3/pkg/release" ) @@ -70,7 +71,7 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { // strip chart metadata from the output rel.Chart = nil - return outfmt.Write(out, &statusPrinter{rel, false, client.ShowDescription}) + return outfmt.Write(out, &statusPrinter{rel, false, client.ShowDescription, false}) }, } @@ -99,6 +100,7 @@ type statusPrinter struct { release *release.Release debug bool showDescription bool + hideSecrets bool } func (s statusPrinter) WriteJSON(out io.Writer) error { @@ -170,7 +172,16 @@ func (s statusPrinter) WriteTable(out io.Writer) error { for _, h := range s.release.Hooks { fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest) } - fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest) + var err error + manifest := s.release.Manifest + if s.hideSecrets { + manifest, err = sanitize.HideSecrets(manifest) + if err != nil { + return err + } + } + + fmt.Fprintf(out, "MANIFEST:\n%s\n", manifest) } if len(s.release.Info.Notes) > 0 { diff --git a/cmd/helm/upgrade.go b/cmd/helm/upgrade.go index 12d797545..26a94c8fe 100644 --- a/cmd/helm/upgrade.go +++ b/cmd/helm/upgrade.go @@ -115,7 +115,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false}) + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, settings.HideSecrets}) } else if err != nil { return err } @@ -160,7 +160,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { fmt.Fprintf(out, "Release %q has been upgraded. Happy Helming!\n", args[0]) } - return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false}) + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, settings.HideSecrets}) }, } diff --git a/go.mod b/go.mod index b636d700d..979ace752 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 + gopkg.in/yaml.v2 v2.3.0 k8s.io/api v0.20.0 k8s.io/apiextensions-apiserver v0.20.0 k8s.io/apimachinery v0.20.0 diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index ee60d981f..6bb75574f 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -58,6 +58,8 @@ type EnvSettings struct { KubeCaFile string // Debug indicates whether or not Helm is running in Debug mode. Debug bool + // HideSecrets indicates whether Secret values should be hidden. + HideSecrets bool // RegistryConfig is the path to the registry config file. RegistryConfig string // RepositoryConfig is the path to the repositories file. @@ -86,6 +88,7 @@ func New() *EnvSettings { RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) + env.HideSecrets, _ = strconv.ParseBool(os.Getenv("HELM_HIDE_SECRETS")) // bind to kubernetes config flags env.config = &genericclioptions.ConfigFlags{ @@ -112,6 +115,7 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "the address and the port for the Kubernetes API server") fs.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "the certificate authority file for the Kubernetes API server connection") fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output") + fs.BoolVar(&s.HideSecrets, "hide-secrets", s.HideSecrets, "hide Secret values in printed manifests") fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file") fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs") fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the file containing cached repository indexes") diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index 31ba7a237..89862dd11 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -36,6 +36,7 @@ func TestEnvSettings(t *testing.T) { // expected values ns, kcontext string debug bool + hideSecrets bool maxhistory int kAsUser string kAsGroups []string @@ -47,35 +48,38 @@ func TestEnvSettings(t *testing.T) { maxhistory: defaultMaxHistory, }, { - name: "with flags set", - args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/tmp/ca.crt", - ns: "myns", - debug: true, - maxhistory: defaultMaxHistory, - kAsUser: "poro", - kAsGroups: []string{"admins", "teatime", "snackeaters"}, - kCaFile: "/tmp/ca.crt", + name: "with flags set", + args: "--debug --hide-secrets --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/tmp/ca.crt", + ns: "myns", + debug: true, + hideSecrets: true, + maxhistory: defaultMaxHistory, + kAsUser: "poro", + kAsGroups: []string{"admins", "teatime", "snackeaters"}, + kCaFile: "/tmp/ca.crt", }, { - name: "with envvars set", - envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt"}, - ns: "yourns", - maxhistory: 5, - debug: true, - kAsUser: "pikachu", - kAsGroups: []string{"operators", "snackeaters", "partyanimals"}, - kCaFile: "/tmp/ca.crt", + name: "with envvars set", + envvars: map[string]string{"HELM_DEBUG": "1", "HELM_HIDE_SECRETS": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt"}, + ns: "yourns", + maxhistory: 5, + debug: true, + hideSecrets: true, + kAsUser: "pikachu", + kAsGroups: []string{"operators", "snackeaters", "partyanimals"}, + kCaFile: "/tmp/ca.crt", }, { - name: "with flags and envvars set", - args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/my/ca.crt", - envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt"}, - ns: "myns", - debug: true, - maxhistory: 5, - kAsUser: "poro", - kAsGroups: []string{"admins", "teatime", "snackeaters"}, - kCaFile: "/my/ca.crt", + name: "with flags and envvars set", + args: "--debug --hide-secrets --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/my/ca.crt", + envvars: map[string]string{"HELM_DEBUG": "1", "HELM_HIDE_SECRETS": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt"}, + ns: "myns", + debug: true, + hideSecrets: true, + maxhistory: 5, + kAsUser: "poro", + kAsGroups: []string{"admins", "teatime", "snackeaters"}, + kCaFile: "/my/ca.crt", }, } @@ -96,6 +100,9 @@ func TestEnvSettings(t *testing.T) { if settings.Debug != tt.debug { t.Errorf("expected debug %t, got %t", tt.debug, settings.Debug) } + if settings.HideSecrets != tt.hideSecrets { + t.Errorf("expected hide-secrets %t, got %t", tt.hideSecrets, settings.HideSecrets) + } if settings.Namespace() != tt.ns { t.Errorf("expected namespace %q, got %q", tt.ns, settings.Namespace()) } diff --git a/pkg/cli/sanitize/hide_secrets.go b/pkg/cli/sanitize/hide_secrets.go new file mode 100644 index 000000000..5fe619dfe --- /dev/null +++ b/pkg/cli/sanitize/hide_secrets.go @@ -0,0 +1,131 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sanitize + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v2" +) + +const ( + hiddenSecretValue = "[HIDDEN]" +) + +// HideSecrets replaces values in Secrets in the chart manifest with +// `[HIDDEN]` value. +func HideSecrets(manifest string) (string, error) { + resources := strings.Split(manifest, "\n---") + outRes := make([]string, 0, len(resources)) + + for _, r := range resources { + var resourceMap map[string]interface{} + err := yaml.Unmarshal([]byte(r), &resourceMap) + if err != nil { + return "", err + } + + if isSecret(resourceMap) { + r = hideSecretData(r, resourceMap) + } + + outRes = append(outRes, r) + } + + return strings.Join(outRes, "\n---"), nil +} + +func isSecret(resource map[string]interface{}) bool { + kind, ok := resource["kind"].(string) + if !ok || kind != "Secret" { + return false + } + + apiVersion, ok := resource["apiVersion"].(string) + if !ok || apiVersion != "v1" { + return false + } + + return true +} + +func hideSecretData(raw string, resource map[string]interface{}) string { + dataRaw, ok := resource["data"].(map[interface{}]interface{}) + if !ok || len(dataRaw) == 0 { + return raw + } + + data := toMapOfStrings(dataRaw) + + lines := strings.Split(raw, "\n") + outLines := make([]string, len(lines)) + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + + // If line is part of secret.data, sanitize line by replacing the value part + if key, matches := matchKeyValPair(data, trimmed); matches { + sanitizedLine := strings.Replace(line, trimmed, formatHiddenValue(key), 1) + outLines[i] = sanitizedLine + continue + } + + outLines[i] = line + } + + return strings.Join(outLines, "\n") +} + +func toMapOfStrings(rawMap map[interface{}]interface{}) map[string]string { + stringsMap := make(map[string]string, len(rawMap)) + for k, v := range rawMap { + key, ok := k.(string) + if !ok { + continue + } + val, ok := v.(string) + if !ok { + continue + } + stringsMap[key] = val + } + return stringsMap +} + +// matchKeyValPair checks if data contains joined key value pair in format +// `key: value` equal to specified string. +// Returns key with which string matched and indicator if it matched any. +func matchKeyValPair(data map[string]string, str string) (string, bool) { + for k, v := range data { + joined := joinKeyVal(k, v) + + if joined == str { + return k, true + } + } + + return "", false +} + +func joinKeyVal(key, val string) string { + return fmt.Sprintf("%s: %s", key, val) +} + +func formatHiddenValue(key string) string { + return joinKeyVal(key, hiddenSecretValue) +} diff --git a/pkg/cli/sanitize/hide_secrets_test.go b/pkg/cli/sanitize/hide_secrets_test.go new file mode 100644 index 000000000..ec47f2cff --- /dev/null +++ b/pkg/cli/sanitize/hide_secrets_test.go @@ -0,0 +1,50 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package sanitize + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHideSecrets(t *testing.T) { + + t.Run("hide secret values", func(t *testing.T) { + manifestRaw, err := ioutil.ReadFile("testdata/manifest-input.yaml") + require.NoError(t, err) + expectedManifestRaw, err := ioutil.ReadFile("testdata/manifest-sanitized.yaml") + require.NoError(t, err) + + sanitizedManifest, err := HideSecrets(string(manifestRaw)) + require.NoError(t, err) + + assert.Equal(t, string(expectedManifestRaw), sanitizedManifest) + }) + + t.Run("do not modify, when no secret values", func(t *testing.T) { + manifestRaw, err := ioutil.ReadFile("testdata/manifest-no-secret.yaml") + require.NoError(t, err) + + sanitizedManifest, err := HideSecrets(string(manifestRaw)) + require.NoError(t, err) + + assert.Equal(t, string(manifestRaw), sanitizedManifest) + }) +} diff --git a/pkg/cli/sanitize/testdata/manifest-input.yaml b/pkg/cli/sanitize/testdata/manifest-input.yaml new file mode 100644 index 000000000..c9773c0ac --- /dev/null +++ b/pkg/cli/sanitize/testdata/manifest-input.yaml @@ -0,0 +1,63 @@ +--- +# Source: test/templates/sa.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: build-robot +--- +# Source: test/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: secret-sample +data: + test: YmFyCg== + password: bXktcGFzc3dvcmQ= +--- +# Source: test/templates/cm.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + creationTimestamp: 2016-02-18T18:52:05Z + name: game-config + namespace: default + resourceVersion: "516" + uid: b4952dc3-d670-11e5-8cd0-68f728db1985 +data: + test: YmFyCg== + game.properties: | + enemies=aliens + lives=3 + enemies.cheat=true + enemies.cheat.level=noGoodRotten + secret.code.passphrase=UUDDLRLRBABAS + secret.code.allowed=true + secret.code.lives=30 + ui.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true + how.nice.to.look=fairlyNice +--- +# Source: test/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80 diff --git a/pkg/cli/sanitize/testdata/manifest-no-secret.yaml b/pkg/cli/sanitize/testdata/manifest-no-secret.yaml new file mode 100644 index 000000000..94c6b067d --- /dev/null +++ b/pkg/cli/sanitize/testdata/manifest-no-secret.yaml @@ -0,0 +1,40 @@ +--- +# Source: test/templates/sa.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: build-robot +--- +# Source: test/templates/secret.yaml +apiVersion: my.custom.secret.com/v1 +kind: Secret +metadata: + name: secret-sample +data: + test: YmFyCg== + password: bXktcGFzc3dvcmQ= +--- +# Source: test/templates/cm.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + creationTimestamp: 2016-02-18T18:52:05Z + name: game-config + namespace: default + resourceVersion: "516" + uid: b4952dc3-d670-11e5-8cd0-68f728db1985 +data: + test: YmFyCg== + game.properties: | + enemies=aliens + lives=3 + enemies.cheat=true + enemies.cheat.level=noGoodRotten + secret.code.passphrase=UUDDLRLRBABAS + secret.code.allowed=true + secret.code.lives=30 + ui.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true + how.nice.to.look=fairlyNice diff --git a/pkg/cli/sanitize/testdata/manifest-sanitized.yaml b/pkg/cli/sanitize/testdata/manifest-sanitized.yaml new file mode 100644 index 000000000..acc9a9e20 --- /dev/null +++ b/pkg/cli/sanitize/testdata/manifest-sanitized.yaml @@ -0,0 +1,63 @@ +--- +# Source: test/templates/sa.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: build-robot +--- +# Source: test/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: secret-sample +data: + test: [HIDDEN] + password: [HIDDEN] +--- +# Source: test/templates/cm.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + creationTimestamp: 2016-02-18T18:52:05Z + name: game-config + namespace: default + resourceVersion: "516" + uid: b4952dc3-d670-11e5-8cd0-68f728db1985 +data: + test: YmFyCg== + game.properties: | + enemies=aliens + lives=3 + enemies.cheat=true + enemies.cheat.level=noGoodRotten + secret.code.passphrase=UUDDLRLRBABAS + secret.code.allowed=true + secret.code.lives=30 + ui.properties: | + color.good=purple + color.bad=yellow + allow.textmode=true + how.nice.to.look=fairlyNice +--- +# Source: test/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80