diff --git a/bundle/config/mask.go b/bundle/config/mask.go new file mode 100644 index 00000000000..0a83db5859b --- /dev/null +++ b/bundle/config/mask.go @@ -0,0 +1,74 @@ +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.OnceFunc(func() { + sensitiveFieldsCache = make(map[string]map[string]bool) + for name, typ := range ResourcesTypes { + if fields := convert.SensitiveFieldNames(typ); len(fields) > 0 { + sensitiveFieldsCache[name] = fields + } + } + }) +) + +// 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] +} + +// 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/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 69e8f868ed5..ab13dfa6ad7 100644 --- a/libs/dyn/convert/struct_info.go +++ b/libs/dyn/convert/struct_info.go @@ -28,6 +28,9 @@ type structInfo struct { // Maps JSON-name of the field to Golang struct name GolangNames map[string]string + // Sensitive tracks fields tagged `bundle:"sensitive"` by their JSON name. + // Values for these fields should be masked in display output. + Sensitive map[string]bool // ForceSendFieldsIndex maps the JSON-name of the field to the index path (for // use with [reflect.Value.FieldByIndex]) of the ForceSendFields slice that // governs it: the one declared by the struct that also declares the field. @@ -68,6 +71,7 @@ func buildStructInfo(typ reflect.Type) structInfo { 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. @@ -134,6 +138,10 @@ func buildStructInfo(typ reflect.Type) structInfo { } out.GolangNames[name] = sf.Name + if structtag.BundleTag(sf.Tag.Get("bundle")).Sensitive() { + out.Sensitive[name] = true + } + // The field is declared directly in this struct, so it is governed by // this struct's ForceSendFields (if it has one). if forceSendFieldsIndex != nil { @@ -176,6 +184,25 @@ 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 +// 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 +} + // isForceSend reports whether the field named k is listed in the ForceSendFields // that governs it (see structInfo.ForceSendFieldsIndex). func (s *structInfo) isForceSend(v reflect.Value, k string) bool { 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()) }) } }