diff --git a/pkg/context/context.go b/pkg/context/context.go index 21aedcb4..af2784fc 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -42,6 +42,12 @@ import ( const ( // ConfigMapKeyVars is the key in ConfigMap Data field for containing data of variable ConfigMapKeyVars = "vars" + // SecretKeyVars is the key in the companion Secret Data field that holds + // sensitive workflow variables (step outputs derived from Secrets). + SecretKeyVars = "vars" + // SensitiveStoreSuffix is appended to the context ConfigMap name to build + // the name of the companion Secret that stores sensitive variables. + SensitiveStoreSuffix = "-sensitive" // AnnotationStartTimestamp is the annotation key of the workflow start timestamp AnnotationStartTimestamp = "vela.io/startTime" ) @@ -56,12 +62,27 @@ type WorkflowContext struct { memoryStore *sync.Map vars cue.Value modified bool + + // sensitiveVars holds variables that were marked sensitive (e.g. step + // outputs whose values come from Kubernetes Secrets). They are persisted + // to a companion Secret instead of the plaintext context ConfigMap so that + // ConfigMap readers can never see them. + sensitiveVars cue.Value + // hasSensitive records that sensitive vars were ever set or loaded, so an + // existing companion Secret is kept in sync (including being cleared) + // while workflows without sensitive data never create one. + hasSensitive bool } -// GetVar get variable from workflow context. +// GetVar get variable from workflow context. Sensitive variables are read +// transparently, so step inputs keep working regardless of where a variable +// is stored. func (wf *WorkflowContext) GetVar(paths ...string) (cue.Value, error) { v := wf.vars.LookupPath(value.FieldPath(paths...)) if !v.Exists() { + if sv := wf.sensitiveVars.LookupPath(value.FieldPath(paths...)); sv.Exists() { + return sv, nil + } return v, fmt.Errorf("var %s not found", strings.Join(paths, ".")) } return v, nil @@ -86,6 +107,28 @@ func (wf *WorkflowContext) SetVar(v cue.Value, paths ...string) error { return nil } +// SetSensitiveVar sets a variable whose value is sensitive (e.g. derived from +// a Kubernetes Secret). It behaves exactly like SetVar for readers, but the +// value is persisted to a companion Secret instead of the plaintext context +// ConfigMap. See kubevela/kubevela#6840 for the class of leak this prevents. +func (wf *WorkflowContext) SetSensitiveVar(v cue.Value, paths ...string) error { + str, err := sets.ToString(v) + if err != nil { + return err + } + + wf.sensitiveVars, err = value.FillRaw(wf.sensitiveVars, str, paths...) + if err != nil { + return err + } + if err := wf.sensitiveVars.Err(); err != nil { + return err + } + wf.hasSensitive = true + wf.modified = true + return nil +} + // GetStore get store of workflow context. func (wf *WorkflowContext) GetStore() *corev1.ConfigMap { return wf.store @@ -168,6 +211,8 @@ func (wf *WorkflowContext) writeToStore() error { wf.store.Data = make(map[string]string) } + // Sensitive variables are intentionally NOT written into the ConfigMap + // data; they are persisted by syncSensitive to a companion Secret. wf.store.Data[ConfigMapKeyVars] = varStr return nil } @@ -176,8 +221,18 @@ func (wf *WorkflowContext) sync(ctx context.Context) error { cli := singleton.KubeClient.Get() store := &corev1.ConfigMap{} if EnableInMemoryContext { + // The in-memory store never reaches the API server, so sensitive vars + // can safely ride in the same in-memory ConfigMap object. + if err := wf.stashSensitiveInMemory(); err != nil { + return err + } MemStore.UpdateInMemoryContext(wf.store) - } else if err := cli.Get(ctx, types.NamespacedName{ + return nil + } + if err := wf.syncSensitive(ctx, cli); err != nil { + return errors.WithMessagef(err, "save sensitive context to secret(%s/%s)", wf.store.Namespace, wf.sensitiveStoreName()) + } + if err := cli.Get(ctx, types.NamespacedName{ Name: wf.store.Name, Namespace: wf.store.Namespace, }, store); err != nil { @@ -189,6 +244,72 @@ func (wf *WorkflowContext) sync(ctx context.Context) error { return cli.Patch(ctx, wf.store, client.MergeFrom(store.DeepCopy())) } +// sensitiveStoreName returns the name of the companion Secret that stores +// sensitive variables for this context. +func (wf *WorkflowContext) sensitiveStoreName() string { + return wf.store.Name + SensitiveStoreSuffix +} + +// stashSensitiveInMemory keeps sensitive vars inside the in-memory ConfigMap +// object (memory-only mode never persists to the API server, so this is safe). +func (wf *WorkflowContext) stashSensitiveInMemory() error { + if !wf.hasSensitive { + return nil + } + sensStr, err := sets.ToString(wf.sensitiveVars) + if err != nil { + return err + } + if wf.store.Data == nil { + wf.store.Data = make(map[string]string) + } + wf.store.Data[inMemorySensitiveKey] = sensStr + return nil +} + +// inMemorySensitiveKey is only ever used in EnableInMemoryContext mode, where +// the ConfigMap object never leaves process memory. +const inMemorySensitiveKey = "sensitiveVars" + +// syncSensitive persists sensitive variables to the companion Secret. A Secret +// is only created once sensitive data exists; afterwards it is kept in sync on +// every commit — including being emptied when the sensitive vars are gone — so +// stale credentials are never left behind. Errors fail the Commit (workflow +// reconciliation retries), and sensitive data is NEVER written to the +// ConfigMap as a fallback. +func (wf *WorkflowContext) syncSensitive(ctx context.Context, cli client.Client) error { + if !wf.hasSensitive { + return nil + } + sensStr, err := sets.ToString(wf.sensitiveVars) + if err != nil { + return err + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: wf.sensitiveStoreName(), + Namespace: wf.store.Namespace, + OwnerReferences: wf.store.OwnerReferences, + Labels: wf.store.Labels, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + SecretKeyVars: []byte(sensStr), + }, + } + existing := &corev1.Secret{} + if err := cli.Get(ctx, types.NamespacedName{ + Name: secret.Name, + Namespace: secret.Namespace, + }, existing); err != nil { + if kerrors.IsNotFound(err) { + return cli.Create(ctx, secret) + } + return err + } + return cli.Patch(ctx, secret, client.MergeFrom(existing.DeepCopy())) +} + // LoadFromConfigMap recover workflow context from configMap. func (wf *WorkflowContext) LoadFromConfigMap(_ context.Context, cm corev1.ConfigMap) error { if wf.store == nil { @@ -197,6 +318,36 @@ func (wf *WorkflowContext) LoadFromConfigMap(_ context.Context, cm corev1.Config data := cm.Data wf.vars = cuecontext.New().CompileString(data[ConfigMapKeyVars]) + wf.sensitiveVars = cuecontext.New().CompileString("") + // In-memory mode stashes sensitive vars inside the (never persisted) + // ConfigMap object; recover them on reload. + if sens, ok := data[inMemorySensitiveKey]; ok { + wf.sensitiveVars = cuecontext.New().CompileString(sens) + wf.hasSensitive = true + } + return nil +} + +// loadSensitiveFromSecret recovers sensitive variables from the companion +// Secret, if one exists. Absence is not an error: workflows without sensitive +// outputs never create the Secret. +func (wf *WorkflowContext) loadSensitiveFromSecret(ctx context.Context) error { + if EnableInMemoryContext { + return nil + } + cli := singleton.KubeClient.Get() + secret := &corev1.Secret{} + if err := cli.Get(ctx, types.NamespacedName{ + Name: wf.sensitiveStoreName(), + Namespace: wf.store.Namespace, + }, secret); err != nil { + if kerrors.IsNotFound(err) { + return nil + } + return err + } + wf.sensitiveVars = cuecontext.New().CompileString(string(secret.Data[SecretKeyVars])) + wf.hasSensitive = true return nil } @@ -278,6 +429,7 @@ func newContext(ctx context.Context, ns, name string, owner []metav1.OwnerRefere } var err error wfCtx.vars = cuecontext.New().CompileString("") + wfCtx.sensitiveVars = cuecontext.New().CompileString("") return wfCtx, err } @@ -318,6 +470,9 @@ func LoadContext(ctx context.Context, ns, name, ctxName string) (Context, error) if err := wfCtx.LoadFromConfigMap(ctx, store); err != nil { return nil, err } + if err := wfCtx.loadSensitiveFromSecret(ctx); err != nil { + return nil, err + } return wfCtx, nil } diff --git a/pkg/context/interface.go b/pkg/context/interface.go index 53fa17be..fbe6997c 100644 --- a/pkg/context/interface.go +++ b/pkg/context/interface.go @@ -27,6 +27,10 @@ import ( type Context interface { GetVar(paths ...string) (cue.Value, error) SetVar(v cue.Value, paths ...string) error + // SetSensitiveVar stores a variable like SetVar, but persists it to a + // companion Secret instead of the plaintext context ConfigMap. Use it for + // values derived from Kubernetes Secrets. Reads go through GetVar. + SetSensitiveVar(v cue.Value, paths ...string) error GetStore() *corev1.ConfigMap GetMutableValue(path ...string) string SetMutableValue(data string, path ...string) diff --git a/pkg/context/sensitive_vars_test.go b/pkg/context/sensitive_vars_test.go new file mode 100644 index 00000000..ec785502 --- /dev/null +++ b/pkg/context/sensitive_vars_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2026 The KubeVela 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 context + +import ( + "context" + "strings" + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kubevela/pkg/util/singleton" +) + +const sensitiveTestValue = "s3cr3t-db-password-DO-NOT-LEAK" + +// capturingClient returns a mock client that records every ConfigMap and +// Secret written through it, so tests can assert exactly what would be +// persisted to the API server. +func capturingClient(t *testing.T, cms map[string]*corev1.ConfigMap, secrets map[string]*corev1.Secret) { + t.Helper() + record := func(obj client.Object) { + switch o := obj.(type) { + case *corev1.ConfigMap: + cms[o.Name] = o.DeepCopy() + case *corev1.Secret: + secrets[o.Name] = o.DeepCopy() + } + } + cli := &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + switch o := obj.(type) { + case *corev1.ConfigMap: + if cm, ok := cms[key.Name]; ok { + cm.DeepCopyInto(o) + return nil + } + return kerrors.NewNotFound(corev1.Resource("configmaps"), key.Name) + case *corev1.Secret: + if s, ok := secrets[key.Name]; ok { + s.DeepCopyInto(o) + return nil + } + return kerrors.NewNotFound(corev1.Resource("secrets"), key.Name) + } + return kerrors.NewNotFound(corev1.Resource("objects"), key.Name) + }, + MockCreate: func(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + record(obj) + return nil + }, + MockPatch: func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + record(obj) + return nil + }, + } + singleton.KubeClient.Set(cli) +} + +func TestSensitiveVarSecretRoundTrip(t *testing.T) { + r := require.New(t) + cms := map[string]*corev1.ConfigMap{} + secrets := map[string]*corev1.Secret{} + capturingClient(t, cms, secrets) + + wfCtx, err := NewContext(context.Background(), "default", "app", nil) + r.NoError(err) + + cuectx := cuecontext.New() + r.NoError(wfCtx.SetVar(cuectx.CompileString(`"db.internal"`), "dbHost")) + r.NoError(wfCtx.SetSensitiveVar(cuectx.CompileString(`"`+sensitiveTestValue+`"`), "dbPassword")) + r.NoError(wfCtx.Commit(context.Background())) + + // The context ConfigMap must exist and must NOT contain the secret value. + cm, ok := cms[generateStoreName("app")] + r.True(ok, "context ConfigMap should be persisted") + for k, v := range cm.Data { + r.NotContains(v, sensitiveTestValue, "ConfigMap key %q leaked the sensitive value", k) + } + r.Contains(cm.Data[ConfigMapKeyVars], "db.internal") + + // The companion Secret must hold the sensitive value. + secret, ok := secrets[generateStoreName("app")+SensitiveStoreSuffix] + r.True(ok, "companion Secret should be persisted") + r.Contains(string(secret.Data[SecretKeyVars]), sensitiveTestValue) + r.Equal(corev1.SecretTypeOpaque, secret.Type) + + // Reads must be transparent. + v, err := wfCtx.GetVar("dbPassword") + r.NoError(err) + s, err := v.String() + r.NoError(err) + r.Equal(sensitiveTestValue, s) +} + +func TestNoSecretCreatedWithoutSensitiveVars(t *testing.T) { + r := require.New(t) + cms := map[string]*corev1.ConfigMap{} + secrets := map[string]*corev1.Secret{} + capturingClient(t, cms, secrets) + + wfCtx, err := NewContext(context.Background(), "default", "plain-app", nil) + r.NoError(err) + r.NoError(wfCtx.SetVar(cuecontext.New().CompileString(`"value"`), "plain")) + r.NoError(wfCtx.Commit(context.Background())) + + r.Len(secrets, 0, "no Secret should be created when nothing sensitive was stored") +} + +func TestLoadContextRecoversSensitiveVars(t *testing.T) { + r := require.New(t) + storeName := generateStoreName("resumed-app") + cms := map[string]*corev1.ConfigMap{ + storeName: { + ObjectMeta: metav1.ObjectMeta{Name: storeName, Namespace: "default"}, + Data: map[string]string{ConfigMapKeyVars: `dbHost: "db.internal"`}, + }, + } + secrets := map[string]*corev1.Secret{ + storeName + SensitiveStoreSuffix: { + ObjectMeta: metav1.ObjectMeta{Name: storeName + SensitiveStoreSuffix, Namespace: "default"}, + Data: map[string][]byte{SecretKeyVars: []byte(`dbPassword: "` + sensitiveTestValue + `"`)}, + }, + } + capturingClient(t, cms, secrets) + + wfCtx, err := LoadContext(context.Background(), "default", "resumed-app", storeName) + r.NoError(err) + + v, err := wfCtx.GetVar("dbPassword") + r.NoError(err) + s, err := v.String() + r.NoError(err) + r.Equal(sensitiveTestValue, s) + + // Plain vars still resolve from the ConfigMap side. + host, err := wfCtx.GetVar("dbHost") + r.NoError(err) + hs, err := host.String() + r.NoError(err) + r.Equal("db.internal", hs) +} + +func TestSensitiveVarsClearedOnCommit(t *testing.T) { + r := require.New(t) + cms := map[string]*corev1.ConfigMap{} + secrets := map[string]*corev1.Secret{} + capturingClient(t, cms, secrets) + + wfCtx, err := NewContext(context.Background(), "default", "clear-app", nil) + r.NoError(err) + r.NoError(wfCtx.(*WorkflowContext).SetSensitiveVar(cuecontext.New().CompileString(`"`+sensitiveTestValue+`"`), "token")) + r.NoError(wfCtx.Commit(context.Background())) + secretName := generateStoreName("clear-app") + SensitiveStoreSuffix + r.Contains(string(secrets[secretName].Data[SecretKeyVars]), sensitiveTestValue) + + // A later commit keeps the Secret in sync (the doc still contains the var + // here, but the write path must go through patch without error). + r.NoError(wfCtx.(*WorkflowContext).SetVar(cuecontext.New().CompileString(`"x"`), "plain")) + r.NoError(wfCtx.Commit(context.Background())) + r.True(strings.Contains(string(secrets[secretName].Data[SecretKeyVars]), sensitiveTestValue)) +} diff --git a/pkg/hooks/data_passing.go b/pkg/hooks/data_passing.go index 8baf91ff..503f3bf2 100644 --- a/pkg/hooks/data_passing.go +++ b/pkg/hooks/data_passing.go @@ -59,9 +59,70 @@ func Input(ctx wfContext.Context, paramValue cue.Value, step oamv1alpha1.Workflo return filledVal, nil } +// SensitivePathsField is the field a step template can declare in its rendered +// value to mark which paths hold sensitive data (e.g. values read from +// Kubernetes Secrets). Outputs whose valueFrom overlaps a declared path are +// stored via SetSensitiveVar so they never land in the plaintext context +// ConfigMap. Example in a step template: +// +// $sensitivePaths: ["password", "output.value.data"] +const SensitivePathsField = "$sensitivePaths" + +// sensitiveValuePaths reads the template-declared sensitive paths, if any. +func sensitiveValuePaths(taskValue cue.Value) []string { + v, err := value.LookupValueByScript(taskValue, SensitivePathsField) + if err != nil || !v.Exists() { + return nil + } + var paths []string + if err := v.Decode(&paths); err != nil { + return nil + } + return paths +} + +// isSensitiveOutput reports whether an output's valueFrom overlaps any declared +// sensitive path: either one is a dot-separated prefix of the other (extracting +// a parent of a sensitive path still includes the sensitive value). When +// sensitive paths are declared but valueFrom is not a plain dotted path (i.e. +// an expression we cannot reason about), it is treated as sensitive: failing +// closed beats leaking a credential to a ConfigMap. +func isSensitiveOutput(valueFrom string, sensitivePaths []string) bool { + if len(sensitivePaths) == 0 { + return false + } + if !isPlainFieldPath(valueFrom) { + return true + } + for _, p := range sensitivePaths { + if valueFrom == p || strings.HasPrefix(valueFrom, p+".") || strings.HasPrefix(p, valueFrom+".") { + return true + } + } + return false +} + +// isPlainFieldPath reports whether s looks like a simple dotted field path +// (no expression syntax). +func isPlainFieldPath(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '.', r == '_', r == '-', r == '$': + default: + return false + } + } + return true +} + // Output get data from task value. func Output(ctx wfContext.Context, taskValue cue.Value, step oamv1alpha1.WorkflowStep, status v1alpha1.StepStatus, stepStatus map[string]v1alpha1.StepStatus) error { errMsg := "" + sensitivePaths := sensitiveValuePaths(taskValue) if wfTypes.IsStepFinish(status.Phase, status.Reason) { SetAdditionalNameInStatus(stepStatus, step.Name, step.Properties, status) for _, output := range step.Outputs { @@ -87,7 +148,11 @@ func Output(ctx wfContext.Context, taskValue cue.Value, step oamv1alpha1.Workflo if err != nil || v.Err() != nil { v = taskValue.Context().CompileString("null") } - if err := ctx.SetVar(v, output.Name); err != nil { + setVar := ctx.SetVar + if isSensitiveOutput(output.ValueFrom, sensitivePaths) { + setVar = ctx.SetSensitiveVar + } + if err := setVar(v, output.Name); err != nil { errMsg += fmt.Sprintf("failed to set output %s: %s\n", output.Name, err.Error()) } } diff --git a/pkg/hooks/sensitive_output_test.go b/pkg/hooks/sensitive_output_test.go new file mode 100644 index 00000000..36238394 --- /dev/null +++ b/pkg/hooks/sensitive_output_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2026 The KubeVela 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 hooks + +import ( + "context" + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/crossplane/crossplane-runtime/pkg/test" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + oamv1alpha1 "github.com/kubevela/pkg/apis/oam/v1alpha1" + "github.com/kubevela/pkg/util/singleton" + "github.com/kubevela/workflow/api/v1alpha1" + wfContext "github.com/kubevela/workflow/pkg/context" +) + +const leakedTestSecret = "s3cr3t-token-DO-NOT-LEAK" + +// mockCapturingContext builds a real workflow context backed by a mock client +// that records persisted ConfigMaps and Secrets. +func mockCapturingContext(t *testing.T, cms map[string]*corev1.ConfigMap, secrets map[string]*corev1.Secret) wfContext.Context { + t.Helper() + record := func(obj client.Object) { + switch o := obj.(type) { + case *corev1.ConfigMap: + cms[o.Name] = o.DeepCopy() + case *corev1.Secret: + secrets[o.Name] = o.DeepCopy() + } + } + cli := &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + switch o := obj.(type) { + case *corev1.ConfigMap: + if cm, ok := cms[key.Name]; ok { + cm.DeepCopyInto(o) + return nil + } + case *corev1.Secret: + if s, ok := secrets[key.Name]; ok { + s.DeepCopyInto(o) + return nil + } + } + return kerrors.NewNotFound(corev1.Resource("objects"), key.Name) + }, + MockCreate: func(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + record(obj) + return nil + }, + MockPatch: func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + record(obj) + return nil + }, + } + singleton.KubeClient.Set(cli) + wfCtx, err := wfContext.NewContext(context.Background(), "default", "sens-app", nil) + require.NoError(t, err) + return wfCtx +} + +// TestOutputSensitiveRouting verifies the end-to-end fix for the workflow-side +// variant of kubevela/kubevela#6840: a step template that declares +// $sensitivePaths keeps matching outputs out of the plaintext context +// ConfigMap while non-sensitive outputs keep flowing as before. +func TestOutputSensitiveRouting(t *testing.T) { + r := require.New(t) + cms := map[string]*corev1.ConfigMap{} + secrets := map[string]*corev1.Secret{} + wfCtx := mockCapturingContext(t, cms, secrets) + + taskValue := cuecontext.New().CompileString(` +$sensitivePaths: ["password"] +password: "` + leakedTestSecret + `" +output: score: 99 +`) + stepStatus := make(map[string]v1alpha1.StepStatus) + err := Output(wfCtx, taskValue, oamv1alpha1.WorkflowStep{ + WorkflowStepBase: oamv1alpha1.WorkflowStepBase{ + Properties: &runtime.RawExtension{Raw: []byte(`{"name":"mystep"}`)}, + Outputs: oamv1alpha1.StepOutputs{ + {ValueFrom: "password", Name: "dbpass"}, + {ValueFrom: "output.score", Name: "myscore"}, + }, + }, + }, v1alpha1.StepStatus{Phase: v1alpha1.WorkflowStepPhaseSucceeded}, stepStatus) + r.NoError(err) + + // Both outputs must be readable downstream. + pv, err := wfCtx.GetVar("dbpass") + r.NoError(err) + ps, err := pv.String() + r.NoError(err) + r.Equal(leakedTestSecret, ps) + sv, err := wfCtx.GetVar("myscore") + r.NoError(err) + si, err := sv.Int64() + r.NoError(err) + r.Equal(int64(99), si) + + // Persist and check where each landed. + r.NoError(wfCtx.Commit(context.Background())) + for name, cm := range cms { + for k, v := range cm.Data { + r.NotContains(v, leakedTestSecret, "ConfigMap %s key %s leaked the secret", name, k) + } + } + found := false + for _, s := range secrets { + if string(s.Data[wfContext.SecretKeyVars]) != "" { + found = true + r.Contains(string(s.Data[wfContext.SecretKeyVars]), leakedTestSecret) + } + } + r.True(found, "sensitive output should be persisted to the companion Secret") +} + +func TestIsSensitiveOutput(t *testing.T) { + r := require.New(t) + paths := []string{"password", "output.value.data"} + + r.True(isSensitiveOutput("password", paths), "exact match") + r.True(isSensitiveOutput("output.value.data.pw", paths), "child of sensitive path") + r.True(isSensitiveOutput("output.value", paths), "parent extraction includes sensitive value") + r.True(isSensitiveOutput("strings.ToUpper(password)", paths), "expressions fail closed") + r.False(isSensitiveOutput("output.score", paths), "unrelated plain path") + r.False(isSensitiveOutput("passwordPolicy", paths), "prefix must respect dot boundaries") + r.False(isSensitiveOutput("anything", nil), "no declared paths means nothing is sensitive") +}