Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions bundle/config/mask.go
Original file line number Diff line number Diff line change
@@ -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.<type>.<name>
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
})
}
76 changes: 76 additions & 0 deletions bundle/config/mask_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
7 changes: 6 additions & 1 deletion cmd/bundle/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions libs/dyn/convert/struct_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions libs/structs/structtag/bundletag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
10 changes: 7 additions & 3 deletions libs/structs/structtag/bundletag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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())
})
}
}
Loading