From 0cce77e107882435183889dc8620c3f16578fe9f Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Fri, 10 Jul 2026 15:05:51 +0200 Subject: [PATCH 1/4] internal: Add support for 'sensitive' JSON tag for bundle config fields --- bundle/config/mask.go | 81 ++++++++++++++++ bundle/config/mask_test.go | 76 +++++++++++++++ cmd/bundle/plan.go | 117 ++++++++++++++++++++++- cmd/bundle/plan_test.go | 42 ++++++++ cmd/bundle/validate.go | 7 +- libs/dyn/convert/struct_info.go | 28 ++++++ libs/structs/structtag/bundletag.go | 6 ++ libs/structs/structtag/bundletag_test.go | 10 +- 8 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 bundle/config/mask.go create mode 100644 bundle/config/mask_test.go create mode 100644 cmd/bundle/plan_test.go diff --git a/bundle/config/mask.go b/bundle/config/mask.go new file mode 100644 index 00000000000..55d60b0559d --- /dev/null +++ b/bundle/config/mask.go @@ -0,0 +1,81 @@ +package config + +import ( + "sync" + + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" +) + +const sensitiveValueMask = "********" + +// sensitiveFieldsCache caches sensitive field names per resource type key. +var ( + sensitiveFieldsCache map[string]map[string]bool + sensitiveFieldsCacheOnce sync.Once +) + +// sensitiveFields returns a map of JSON field names → true for resource type +// key (e.g. "secrets"). Built once from the convert.SensitiveFieldNames helper. +func sensitiveFields(resourceTypeKey string) map[string]bool { + sensitiveFieldsCacheOnce.Do(func() { + sensitiveFieldsCache = make(map[string]map[string]bool) + for name, typ := range ResourcesTypes { + if fields := convert.SensitiveFieldNames(typ); len(fields) > 0 { + sensitiveFieldsCache[name] = fields + } + } + }) + return sensitiveFieldsCache[resourceTypeKey] +} + +// SensitiveFieldsForResourceType returns the set of JSON field names that are +// tagged `bundle:"sensitive"` for the given resource type key (e.g. "secrets"). +// Returns nil when the type has no sensitive fields or is unknown. +func SensitiveFieldsForResourceType(resourceTypeKey string) map[string]bool { + return sensitiveFields(resourceTypeKey) +} + +// MaskSensitiveFields returns a copy of v with all fields tagged +// `bundle:"sensitive"` replaced by [sensitiveValueMask]. +// +// Only the display copy of a dyn.Value should be passed here — +// the live config value that feeds the deployment pipeline must never be masked. +func MaskSensitiveFields(v dyn.Value) (dyn.Value, error) { + // Pattern: resources.. + resourcesPattern := dyn.NewPattern( + dyn.Key("resources"), + dyn.AnyKey(), // resource type (e.g. "secrets") + dyn.AnyKey(), // resource name + ) + + return dyn.MapByPattern(v, resourcesPattern, func(p dyn.Path, resource dyn.Value) (dyn.Value, error) { + if len(p) < 2 { + return resource, nil + } + resourceType := p[1].Key() + fields := sensitiveFields(resourceType) + if len(fields) == 0 { + return resource, nil + } + + for fieldName := range fields { + fv, err := dyn.GetByPath(resource, dyn.NewPath(dyn.Key(fieldName))) + if err != nil { + // Field not present — nothing to mask. + continue + } + s, ok := fv.AsString() + if !ok || s == "" { + // Not a non-empty string — nothing to mask. + continue + } + resource, err = dyn.SetByPath(resource, dyn.NewPath(dyn.Key(fieldName)), + dyn.NewValue(sensitiveValueMask, fv.Locations())) + if err != nil { + return dyn.InvalidValue, err + } + } + return resource, nil + }) +} diff --git a/bundle/config/mask_test.go b/bundle/config/mask_test.go new file mode 100644 index 00000000000..a7581e0e189 --- /dev/null +++ b/bundle/config/mask_test.go @@ -0,0 +1,76 @@ +package config_test + +import ( + "reflect" + "testing" + + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaskSensitiveFieldsNoOp(t *testing.T) { + root := config.Root{ + Resources: config.Resources{}, + } + v, err := convert.FromTyped(root, dyn.NilValue) + require.NoError(t, err) + + masked, err := config.MaskSensitiveFields(v) + require.NoError(t, err) + assert.Equal(t, v, masked) +} + +// testSensitiveResource mimics a resource type with a sensitive field, used to +// verify SensitiveFieldNames without relying on resources.Secret (which lives +// on a different branch). +type testSensitiveResource struct { + Name string `json:"name"` + Token string `json:"token" bundle:"sensitive"` +} + +func TestSensitiveFieldNamesReadsTag(t *testing.T) { + fields := convert.SensitiveFieldNames(reflect.TypeFor[testSensitiveResource]()) + assert.True(t, fields["token"], "token should be sensitive") + assert.False(t, fields["name"], "name should not be sensitive") +} + +func TestSensitiveFieldNamesNilForNonStruct(t *testing.T) { + fields := convert.SensitiveFieldNames(reflect.TypeFor[string]()) + assert.Nil(t, fields) +} + +func TestSensitiveFieldNamesPointerDereference(t *testing.T) { + fields := convert.SensitiveFieldNames(reflect.TypeFor[*testSensitiveResource]()) + assert.True(t, fields["token"]) +} + +// TestMaskSensitiveFieldsOnDynValue tests the masking logic directly on a +// constructed dyn.Value tree, without needing resources.Secret to exist. +func TestMaskSensitiveFieldsOnDynValue(t *testing.T) { + // Build a minimal dyn.Value that looks like: + // resources: + // jobs: + // my_job: + // name: "hello" + // + // Since jobs have no sensitive fields, masking should leave it unchanged. + v := dyn.NewValue(map[string]dyn.Value{ + "resources": dyn.NewValue(map[string]dyn.Value{ + "jobs": dyn.NewValue(map[string]dyn.Value{ + "my_job": dyn.NewValue(map[string]dyn.Value{ + "name": dyn.NewValue("hello", nil), + }, nil), + }, nil), + }, nil), + }, nil) + + masked, err := config.MaskSensitiveFields(v) + require.NoError(t, err) + + name, err := dyn.GetByPath(masked, dyn.MustPathFromString("resources.jobs.my_job.name")) + require.NoError(t, err) + assert.Equal(t, "hello", name.MustString()) +} diff --git a/cmd/bundle/plan.go b/cmd/bundle/plan.go index 20df8cb5f0f..1cb67abaead 100644 --- a/cmd/bundle/plan.go +++ b/cmd/bundle/plan.go @@ -1,11 +1,13 @@ package bundle import ( + "bytes" "encoding/json" "fmt" "strings" "github.com/databricks/cli/bundle" + bundleconfig "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/cmd/bundle/utils" @@ -109,7 +111,7 @@ It is useful for previewing changes before running 'bundle deploy'.`, // Note, this string should not be changed, "bundle deployment migrate" depends on this format: fmt.Fprintf(out, "Plan: %d to add, %d to change, %d to delete, %d unchanged\n", createCount, updateCount, deleteCount, unchangedCount) case flags.OutputJSON: - buf, err := json.MarshalIndent(plan, "", " ") + buf, err := marshalPlanRedacted(plan) if err != nil { return err } @@ -129,3 +131,116 @@ It is useful for previewing changes before running 'bundle deploy'.`, return cmd } + +// marshalPlanRedacted encodes plan as indented JSON with sensitive field values +// replaced by "********". It operates on a decoded copy of the plan and never +// mutates the live *deployplan.Plan used by the deployment pipeline. +func marshalPlanRedacted(plan *deployplan.Plan) ([]byte, error) { + // Step 1: encode the plan to JSON. + raw, err := json.Marshal(plan) + if err != nil { + return nil, err + } + + // Step 2: decode into a generic map using UseNumber to preserve int64 IDs. + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var m map[string]any + if err := dec.Decode(&m); err != nil { + return nil, err + } + + // Step 3: walk plan.plan entries and redact sensitive fields. + planEntries, _ := m["plan"].(map[string]any) + for resourceKey, entryAny := range planEntries { + // Resource key format: "resources..", e.g. "resources.secrets.my_secret". + parts := strings.SplitN(resourceKey, ".", 3) + if len(parts) < 2 || parts[0] != "resources" { + continue + } + sensitiveNames := bundleconfig.SensitiveFieldsForResourceType(parts[1]) + if len(sensitiveNames) == 0 { + continue + } + + entry, ok := entryAny.(map[string]any) + if !ok { + continue + } + redactPlanEntry(entry, sensitiveNames) + } + + // Step 4: re-encode the redacted copy. + return json.MarshalIndent(m, "", " ") +} + +// redactPlanEntry masks sensitive field values inside a single decoded plan +// entry map in place. sensitiveNames is the set of JSON field names to mask. +func redactPlanEntry(entry map[string]any, sensitiveNames map[string]bool) { + // Mask fields inside new_state.value (JSON object of the resource state). + if ns, ok := entry["new_state"].(map[string]any); ok { + if valRaw, ok := ns["value"].(json.RawMessage); ok { + ns["value"] = redactJSONObject(valRaw, sensitiveNames) + } else if valMap, ok := ns["value"].(map[string]any); ok { + redactMapInPlace(valMap, sensitiveNames) + } + } + + // Mask fields inside changes[].{old, new, remote}. + // Each change key is the field path (e.g. "value") and the payload is a + // ChangeDesc object whose Old/New/Remote hold the raw field value. + if changes, ok := entry["changes"].(map[string]any); ok { + for fieldPath, changeAny := range changes { + // fieldPath may be "value" or a nested path like "config.value". + // Check the top-level segment only. + topField := fieldPath + if before, _, ok0 := strings.Cut(fieldPath, "."); ok0 { + topField = before + } + if !sensitiveNames[topField] { + continue + } + change, ok := changeAny.(map[string]any) + if !ok { + continue + } + for _, key := range []string{"old", "new", "remote"} { + if s, ok := change[key].(string); ok && s != "" { + change[key] = "********" + } + } + } + } + + // Mask fields inside remote_state (the full remote resource struct). + // In practice sensitive fields like Secret.Value are write-only and not + // returned by the API, so remote_state will already be empty here. We + // mask defensively in case a future resource type differs. + if rs, ok := entry["remote_state"].(map[string]any); ok { + redactMapInPlace(rs, sensitiveNames) + } +} + +// redactJSONObject decodes a json.RawMessage as a map, masks sensitive fields, +// and returns the result as a map[string]any. Falls back to returning raw on +// decode error. +func redactJSONObject(raw json.RawMessage, sensitiveNames map[string]bool) any { + var m map[string]any + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&m); err != nil { + return raw + } + redactMapInPlace(m, sensitiveNames) + return m +} + +// redactMapInPlace replaces non-empty string values for sensitive field names +// with "********", operating in place on a decoded JSON map. +func redactMapInPlace(m map[string]any, sensitiveNames map[string]bool) { + for name := range sensitiveNames { + if s, ok := m[name].(string); ok && s != "" { + m[name] = "********" + } + } +} diff --git a/cmd/bundle/plan_test.go b/cmd/bundle/plan_test.go new file mode 100644 index 00000000000..3bace49fc0d --- /dev/null +++ b/cmd/bundle/plan_test.go @@ -0,0 +1,42 @@ +package bundle + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRedactMapInPlace(t *testing.T) { + m := map[string]any{ + "value": "super-secret", + "comment": "visible", + } + redactMapInPlace(m, map[string]bool{"value": true}) + assert.Equal(t, "********", m["value"]) + assert.Equal(t, "visible", m["comment"]) +} + +func TestRedactMapInPlaceEmptyNotMasked(t *testing.T) { + m := map[string]any{ + "value": "", + } + redactMapInPlace(m, map[string]bool{"value": true}) + assert.Empty(t, m["value"]) +} + +func TestMarshalPlanRedactedNoSensitiveResources(t *testing.T) { + // A plan with a job (no sensitive fields) must not be altered. + raw := `{"plan_version":2,"plan":{"resources.jobs.my_job":{"action":"create","new_state":{"value":{"name":"my_job"}}}}}` + + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &m)) + + // Reconstruct as a minimal deployplan.Plan for marshaling. + buf, err := json.MarshalIndent(m, "", " ") + require.NoError(t, err) + + // The job name must not be masked. + assert.Contains(t, string(buf), `"my_job"`) +} diff --git a/cmd/bundle/validate.go b/cmd/bundle/validate.go index a2ec31f721b..eff442772be 100644 --- a/cmd/bundle/validate.go +++ b/cmd/bundle/validate.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/databricks/cli/bundle" + bundleconfig "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/render" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" @@ -17,7 +18,11 @@ func renderJsonOutput(cmd *cobra.Command, b *bundle.Bundle) error { if b == nil { return nil } - buf, err := json.MarshalIndent(b.Config.Value().AsAny(), "", " ") + v, err := bundleconfig.MaskSensitiveFields(b.Config.Value()) + if err != nil { + return err + } + buf, err := json.MarshalIndent(v.AsAny(), "", " ") if err != nil { return err } diff --git a/libs/dyn/convert/struct_info.go b/libs/dyn/convert/struct_info.go index 7e5b0bc741e..698d0cf316f 100644 --- a/libs/dyn/convert/struct_info.go +++ b/libs/dyn/convert/struct_info.go @@ -31,6 +31,10 @@ type structInfo struct { // ForceSendFieldsStructKey maps the JSON-name of the field to which ForceSendFields slice it belongs to: // -1 for direct fields, embedded struct index for embedded fields ForceSendFieldsStructKey map[string]int + + // Sensitive tracks fields tagged `bundle:"sensitive"` by their JSON name. + // Values for these fields should be masked in display output. + Sensitive map[string]bool } // structInfoCache caches type information. @@ -62,6 +66,7 @@ func buildStructInfo(typ reflect.Type) structInfo { ForceEmpty: make(map[string]bool), GolangNames: make(map[string]string), ForceSendFieldsStructKey: make(map[string]int), + Sensitive: make(map[string]bool), } // Queue holds the indexes of the structs to visit. @@ -119,6 +124,10 @@ func buildStructInfo(typ reflect.Type) structInfo { } out.GolangNames[name] = sf.Name + if structtag.BundleTag(sf.Tag.Get("bundle")).Sensitive() { + out.Sensitive[name] = true + } + // Determine which ForceSendFields this field belongs to if len(prefix) == 0 { // Direct field on the main struct @@ -192,6 +201,25 @@ func (s *structInfo) FieldValues(v reflect.Value) []FieldValue { // Type of [dyn.Value]. var configValueType = reflect.TypeFor[dyn.Value]() +// SensitiveFieldNames returns the JSON field names of typ that carry the +// `bundle:"sensitive"` tag. A pointer type is dereferenced before inspection. +// Returns nil for non-struct types. Callers use this to identify fields that +// must be masked in display output (validate -o json, plan -o json) without +// touching the typed values used by the actual deployment pipeline. +func SensitiveFieldNames(typ reflect.Type) map[string]bool { + for typ.Kind() == reflect.Pointer { + typ = typ.Elem() + } + if typ.Kind() != reflect.Struct { + return nil + } + si := getStructInfo(typ) + if len(si.Sensitive) == 0 { + return nil + } + return si.Sensitive +} + // getForceSendFieldsValues collects ForceSendFields reflect.Values // Returns map[structKey]reflect.Value where structKey is -1 for direct fields, embedded index for embedded fields func getForceSendFieldsValues(v reflect.Value) map[int]reflect.Value { diff --git a/libs/structs/structtag/bundletag.go b/libs/structs/structtag/bundletag.go index 9b7bb2d0ac2..f8254b53e07 100644 --- a/libs/structs/structtag/bundletag.go +++ b/libs/structs/structtag/bundletag.go @@ -11,3 +11,9 @@ func (tag BundleTag) ReadOnly() bool { func (tag BundleTag) Internal() bool { return hasOption(string(tag), "internal") } + +// Sensitive reports whether the field holds a value that must be masked +// when rendering configuration or plan output (e.g. secret values). +func (tag BundleTag) Sensitive() bool { + return hasOption(string(tag), "sensitive") +} diff --git a/libs/structs/structtag/bundletag_test.go b/libs/structs/structtag/bundletag_test.go index 3d2129ec764..ff3057cee93 100644 --- a/libs/structs/structtag/bundletag_test.go +++ b/libs/structs/structtag/bundletag_test.go @@ -8,16 +8,19 @@ import ( func TestBundleTagMethods(t *testing.T) { tests := []struct { - tag string - isReadOnly bool - isInternal bool + tag string + isReadOnly bool + isInternal bool + isSensitive bool }{ // only one annotation. {tag: "readonly", isReadOnly: true}, {tag: "internal", isInternal: true}, + {tag: "sensitive", isSensitive: true}, // multiple annotations. {tag: "readonly,internal", isReadOnly: true, isInternal: true}, + {tag: "readonly,sensitive", isReadOnly: true, isSensitive: true}, // unknown annotations are ignored. {tag: "something"}, @@ -32,6 +35,7 @@ func TestBundleTagMethods(t *testing.T) { assert.Equal(t, test.isReadOnly, tag.ReadOnly()) assert.Equal(t, test.isInternal, tag.Internal()) + assert.Equal(t, test.isSensitive, tag.Sensitive()) }) } } From 7abdf8a1378f2044bd9f5165d2c18aa9518a8359 Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Fri, 10 Jul 2026 16:40:11 +0200 Subject: [PATCH 2/4] no masking in plan --- bundle/config/mask.go | 7 --- cmd/bundle/plan.go | 117 +--------------------------------------- cmd/bundle/plan_test.go | 42 --------------- 3 files changed, 1 insertion(+), 165 deletions(-) delete mode 100644 cmd/bundle/plan_test.go diff --git a/bundle/config/mask.go b/bundle/config/mask.go index 55d60b0559d..61e04d21fde 100644 --- a/bundle/config/mask.go +++ b/bundle/config/mask.go @@ -29,13 +29,6 @@ func sensitiveFields(resourceTypeKey string) map[string]bool { return sensitiveFieldsCache[resourceTypeKey] } -// SensitiveFieldsForResourceType returns the set of JSON field names that are -// tagged `bundle:"sensitive"` for the given resource type key (e.g. "secrets"). -// Returns nil when the type has no sensitive fields or is unknown. -func SensitiveFieldsForResourceType(resourceTypeKey string) map[string]bool { - return sensitiveFields(resourceTypeKey) -} - // MaskSensitiveFields returns a copy of v with all fields tagged // `bundle:"sensitive"` replaced by [sensitiveValueMask]. // diff --git a/cmd/bundle/plan.go b/cmd/bundle/plan.go index 1cb67abaead..20df8cb5f0f 100644 --- a/cmd/bundle/plan.go +++ b/cmd/bundle/plan.go @@ -1,13 +1,11 @@ package bundle import ( - "bytes" "encoding/json" "fmt" "strings" "github.com/databricks/cli/bundle" - bundleconfig "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/cmd/bundle/utils" @@ -111,7 +109,7 @@ It is useful for previewing changes before running 'bundle deploy'.`, // Note, this string should not be changed, "bundle deployment migrate" depends on this format: fmt.Fprintf(out, "Plan: %d to add, %d to change, %d to delete, %d unchanged\n", createCount, updateCount, deleteCount, unchangedCount) case flags.OutputJSON: - buf, err := marshalPlanRedacted(plan) + buf, err := json.MarshalIndent(plan, "", " ") if err != nil { return err } @@ -131,116 +129,3 @@ It is useful for previewing changes before running 'bundle deploy'.`, return cmd } - -// marshalPlanRedacted encodes plan as indented JSON with sensitive field values -// replaced by "********". It operates on a decoded copy of the plan and never -// mutates the live *deployplan.Plan used by the deployment pipeline. -func marshalPlanRedacted(plan *deployplan.Plan) ([]byte, error) { - // Step 1: encode the plan to JSON. - raw, err := json.Marshal(plan) - if err != nil { - return nil, err - } - - // Step 2: decode into a generic map using UseNumber to preserve int64 IDs. - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - var m map[string]any - if err := dec.Decode(&m); err != nil { - return nil, err - } - - // Step 3: walk plan.plan entries and redact sensitive fields. - planEntries, _ := m["plan"].(map[string]any) - for resourceKey, entryAny := range planEntries { - // Resource key format: "resources..", e.g. "resources.secrets.my_secret". - parts := strings.SplitN(resourceKey, ".", 3) - if len(parts) < 2 || parts[0] != "resources" { - continue - } - sensitiveNames := bundleconfig.SensitiveFieldsForResourceType(parts[1]) - if len(sensitiveNames) == 0 { - continue - } - - entry, ok := entryAny.(map[string]any) - if !ok { - continue - } - redactPlanEntry(entry, sensitiveNames) - } - - // Step 4: re-encode the redacted copy. - return json.MarshalIndent(m, "", " ") -} - -// redactPlanEntry masks sensitive field values inside a single decoded plan -// entry map in place. sensitiveNames is the set of JSON field names to mask. -func redactPlanEntry(entry map[string]any, sensitiveNames map[string]bool) { - // Mask fields inside new_state.value (JSON object of the resource state). - if ns, ok := entry["new_state"].(map[string]any); ok { - if valRaw, ok := ns["value"].(json.RawMessage); ok { - ns["value"] = redactJSONObject(valRaw, sensitiveNames) - } else if valMap, ok := ns["value"].(map[string]any); ok { - redactMapInPlace(valMap, sensitiveNames) - } - } - - // Mask fields inside changes[].{old, new, remote}. - // Each change key is the field path (e.g. "value") and the payload is a - // ChangeDesc object whose Old/New/Remote hold the raw field value. - if changes, ok := entry["changes"].(map[string]any); ok { - for fieldPath, changeAny := range changes { - // fieldPath may be "value" or a nested path like "config.value". - // Check the top-level segment only. - topField := fieldPath - if before, _, ok0 := strings.Cut(fieldPath, "."); ok0 { - topField = before - } - if !sensitiveNames[topField] { - continue - } - change, ok := changeAny.(map[string]any) - if !ok { - continue - } - for _, key := range []string{"old", "new", "remote"} { - if s, ok := change[key].(string); ok && s != "" { - change[key] = "********" - } - } - } - } - - // Mask fields inside remote_state (the full remote resource struct). - // In practice sensitive fields like Secret.Value are write-only and not - // returned by the API, so remote_state will already be empty here. We - // mask defensively in case a future resource type differs. - if rs, ok := entry["remote_state"].(map[string]any); ok { - redactMapInPlace(rs, sensitiveNames) - } -} - -// redactJSONObject decodes a json.RawMessage as a map, masks sensitive fields, -// and returns the result as a map[string]any. Falls back to returning raw on -// decode error. -func redactJSONObject(raw json.RawMessage, sensitiveNames map[string]bool) any { - var m map[string]any - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&m); err != nil { - return raw - } - redactMapInPlace(m, sensitiveNames) - return m -} - -// redactMapInPlace replaces non-empty string values for sensitive field names -// with "********", operating in place on a decoded JSON map. -func redactMapInPlace(m map[string]any, sensitiveNames map[string]bool) { - for name := range sensitiveNames { - if s, ok := m[name].(string); ok && s != "" { - m[name] = "********" - } - } -} diff --git a/cmd/bundle/plan_test.go b/cmd/bundle/plan_test.go deleted file mode 100644 index 3bace49fc0d..00000000000 --- a/cmd/bundle/plan_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package bundle - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRedactMapInPlace(t *testing.T) { - m := map[string]any{ - "value": "super-secret", - "comment": "visible", - } - redactMapInPlace(m, map[string]bool{"value": true}) - assert.Equal(t, "********", m["value"]) - assert.Equal(t, "visible", m["comment"]) -} - -func TestRedactMapInPlaceEmptyNotMasked(t *testing.T) { - m := map[string]any{ - "value": "", - } - redactMapInPlace(m, map[string]bool{"value": true}) - assert.Empty(t, m["value"]) -} - -func TestMarshalPlanRedactedNoSensitiveResources(t *testing.T) { - // A plan with a job (no sensitive fields) must not be altered. - raw := `{"plan_version":2,"plan":{"resources.jobs.my_job":{"action":"create","new_state":{"value":{"name":"my_job"}}}}}` - - var m map[string]any - require.NoError(t, json.Unmarshal([]byte(raw), &m)) - - // Reconstruct as a minimal deployplan.Plan for marshaling. - buf, err := json.MarshalIndent(m, "", " ") - require.NoError(t, err) - - // The job name must not be masked. - assert.Contains(t, string(buf), `"my_job"`) -} From f930a69493418716ee31b8c73502e30afadb47b8 Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Fri, 10 Jul 2026 16:53:11 +0200 Subject: [PATCH 3/4] fix lint --- bundle/config/mask.go | 13 ++++++------- libs/dyn/convert/struct_info.go | 11 +++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/bundle/config/mask.go b/bundle/config/mask.go index 61e04d21fde..4cbb8020fc7 100644 --- a/bundle/config/mask.go +++ b/bundle/config/mask.go @@ -12,13 +12,7 @@ const sensitiveValueMask = "********" // sensitiveFieldsCache caches sensitive field names per resource type key. var ( sensitiveFieldsCache map[string]map[string]bool - sensitiveFieldsCacheOnce sync.Once -) - -// sensitiveFields returns a map of JSON field names → true for resource type -// key (e.g. "secrets"). Built once from the convert.SensitiveFieldNames helper. -func sensitiveFields(resourceTypeKey string) map[string]bool { - sensitiveFieldsCacheOnce.Do(func() { + sensitiveFieldsCacheOnce = sync.OnceFunc(func() { sensitiveFieldsCache = make(map[string]map[string]bool) for name, typ := range ResourcesTypes { if fields := convert.SensitiveFieldNames(typ); len(fields) > 0 { @@ -26,6 +20,11 @@ func sensitiveFields(resourceTypeKey string) map[string]bool { } } }) +) + +// sensitiveFields returns a map of JSON field names → true for resource type +// key (e.g. "secrets"). Built once from the convert.SensitiveFieldNames helper. +func sensitiveFields(resourceTypeKey string) map[string]bool { return sensitiveFieldsCache[resourceTypeKey] } diff --git a/libs/dyn/convert/struct_info.go b/libs/dyn/convert/struct_info.go index 81d8c580fef..ab13dfa6ad7 100644 --- a/libs/dyn/convert/struct_info.go +++ b/libs/dyn/convert/struct_info.go @@ -67,11 +67,11 @@ func getStructInfo(typ reflect.Type) structInfo { // buildStructInfo populates a new [structInfo] for the given type. func buildStructInfo(typ reflect.Type) structInfo { out := structInfo{ - Fields: make(map[string][]int), - ForceEmpty: make(map[string]bool), - GolangNames: make(map[string]string), - ForceSendFieldsIndex: make(map[string][]int), - Sensitive: make(map[string]bool), + Fields: make(map[string][]int), + ForceEmpty: make(map[string]bool), + GolangNames: make(map[string]string), + ForceSendFieldsIndex: make(map[string][]int), + Sensitive: make(map[string]bool), } // Queue holds the indexes of the structs to visit. @@ -184,7 +184,6 @@ func (s *structInfo) FieldValues(v reflect.Value) []FieldValue { return out } - // SensitiveFieldNames returns the JSON field names of typ that carry the // `bundle:"sensitive"` tag. A pointer type is dereferenced before inspection. // Returns nil for non-struct types. Callers use this to identify fields that From 687b636927ecc85cafb7c56be0c11ef0f2056c77 Mon Sep 17 00:00:00 2001 From: Andrew Nester Date: Fri, 10 Jul 2026 17:10:44 +0200 Subject: [PATCH 4/4] fixed missed cache call --- bundle/config/mask.go | 1 + 1 file changed, 1 insertion(+) diff --git a/bundle/config/mask.go b/bundle/config/mask.go index 4cbb8020fc7..0a83db5859b 100644 --- a/bundle/config/mask.go +++ b/bundle/config/mask.go @@ -25,6 +25,7 @@ var ( // sensitiveFields returns a map of JSON field names → true for resource type // key (e.g. "secrets"). Built once from the convert.SensitiveFieldNames helper. func sensitiveFields(resourceTypeKey string) map[string]bool { + sensitiveFieldsCacheOnce() return sensitiveFieldsCache[resourceTypeKey] }