Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions libs/dyn/convert/from_typed.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ func fromTypedStruct(src reflect.Value, ref dyn.Value, options ...fromTypedOptio
return dyn.InvalidValue, err
}

// Mark the value as sensitive if the field carries the bundle:"sensitive" tag.
if info.Sensitive[k] {
nv = nv.MarkSensitive()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "normalize" function doesn't retain the flag.

// Either if the key was set in the reference, the field is not zero-valued, OR it's forced
if ok || nv.Kind() != dyn.KindNil || isForced {
// If v isZero, it could be because it's a variable reference; so we check that nv is zero as well
Expand Down
26 changes: 26 additions & 0 deletions libs/dyn/convert/from_typed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,3 +926,29 @@ func TestFromTypedForceSendFieldsEmbedded(t *testing.T) {
assert.Equal(t, dyn.KindNil, field.Kind(), "embedded field should be present due to ForceSendFields")
assert.Equal(t, dyn.V("value"), other)
}

func TestFromTypedSensitiveField(t *testing.T) {
type Tmp struct {
Name string `json:"name"`
Token string `json:"token" bundle:"sensitive"`
}

src := Tmp{
Name: "my-resource",
Token: "super-secret",
}

nv, err := FromTyped(src, dyn.NilValue)
require.NoError(t, err)

name := nv.Get("name")
token := nv.Get("token")

assert.False(t, name.IsSensitive(), "name should not be sensitive")
assert.True(t, token.IsSensitive(), "token should be sensitive due to bundle:\"sensitive\" tag")

// MustString returns the real value.
assert.Equal(t, "super-secret", token.MustString())
// AsAny returns the redaction placeholder.
assert.Equal(t, "********", token.AsAny())
}
29 changes: 29 additions & 0 deletions libs/dyn/convert/struct_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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
Comment thread
andrewnester marked this conversation as resolved.

// 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 +72,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 +139,11 @@ func buildStructInfo(typ reflect.Type) structInfo {
}
out.GolangNames[name] = sf.Name

btag := structtag.BundleTag(sf.Tag.Get("bundle"))
if btag.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 +186,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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a few unit tests here in dyn/convert that demonstrates that this works in cases with embedded structs, anonymous structs, etc. Would also be good to include a user of these fields in the same package.

// 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
101 changes: 101 additions & 0 deletions libs/dyn/convert/struct_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestStructInfoPlain(t *testing.T) {
Expand Down Expand Up @@ -226,3 +227,103 @@ func TestStructInfoValueFieldMultiple(t *testing.T) {
getStructInfo(reflect.TypeFor[Tmp]())
})
}

func TestSensitiveFieldNamesPlain(t *testing.T) {
type Tmp struct {
Name string `json:"name"`
Token string `json:"token" bundle:"sensitive"`
}

fields := SensitiveFieldNames(reflect.TypeFor[Tmp]())
assert.True(t, fields["token"])
assert.False(t, fields["name"])
}

func TestSensitiveFieldNamesPointerDereference(t *testing.T) {
type Tmp struct {
Token string `json:"token" bundle:"sensitive"`
}

fields := SensitiveFieldNames(reflect.TypeFor[*Tmp]())
assert.True(t, fields["token"])
}

func TestSensitiveFieldNamesNilForNonStruct(t *testing.T) {
assert.Nil(t, SensitiveFieldNames(reflect.TypeFor[string]()))
}

func TestSensitiveFieldNamesNilWhenNone(t *testing.T) {
type Tmp struct {
Name string `json:"name"`
}

assert.Nil(t, SensitiveFieldNames(reflect.TypeFor[Tmp]()))
}

func TestSensitiveFieldNamesEmbeddedByValue(t *testing.T) {
type Inner struct {
Token string `json:"token" bundle:"sensitive"`
}

type Outer struct {
Name string `json:"name"`
Inner
}

fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
assert.True(t, fields["token"])
assert.False(t, fields["name"])
}

func TestSensitiveFieldNamesEmbeddedByPointer(t *testing.T) {
type Inner struct {
Token string `json:"token" bundle:"sensitive"`
}

type Outer struct {
Name string `json:"name"`
*Inner
}

fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
assert.True(t, fields["token"])
assert.False(t, fields["name"])
}

func TestSensitiveFieldNamesTopLevelPrecedence(t *testing.T) {
// A sensitive field in an embedded struct is shadowed by a non-sensitive
// field of the same JSON name at the top level — top level wins.
type Inner struct {
Token string `json:"token" bundle:"sensitive"`
}

type Outer struct {
Token string `json:"token"` // not sensitive; shadows Inner.Token
Inner
}

fields := SensitiveFieldNames(reflect.TypeFor[Outer]())
assert.False(t, fields["token"])
}

// TestSensitiveFieldNamesUsage demonstrates using SensitiveFieldNames with
// FromTyped: a sensitive field's value round-trips through dyn.Value and the
// caller can check which fields to mask before marshaling.
func TestSensitiveFieldNamesUsage(t *testing.T) {
type Resource struct {
Name string `json:"name"`
Token string `json:"token" bundle:"sensitive"`
}

src := Resource{Name: "my-resource", Token: "s3cr3t"}
v, err := FromTyped(src, dyn.NilValue)
require.NoError(t, err)

fields := SensitiveFieldNames(reflect.TypeFor[Resource]())
assert.True(t, fields["token"], "token should be identified as sensitive")

// Verify the value round-tripped correctly before masking.
tok, err := dyn.GetByPath(v, dyn.NewPath(dyn.Key("token")))
require.NoError(t, err)
assert.Equal(t, "s3cr3t", tok.MustString())
}
19 changes: 17 additions & 2 deletions libs/dyn/dynvar/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,18 +153,29 @@ func (r *resolver) resolveRef(ref Ref, seen []string) (dyn.Value, error) {
// of where it is used. This also means that relative path resolution is done
// relative to where a variable is used, not where it is defined.
//
return dyn.NewValue(resolved[0].Value(), ref.Value.Locations()), nil
result := dyn.NewValue(resolved[0].Value(), ref.Value.Locations())
if resolved[0].IsSensitive() {
result = result.MarkSensitive()
}
return result, nil
}

// Not pure; perform string interpolation.
// Track whether any resolved value is sensitive; if so, the result is also sensitive.
anySensitive := false
for j := range ref.Matches {
// The value is invalid if resolution returned [ErrSkipResolution].
// We must skip those and leave the original variable reference in place.
if !resolved[j].IsValid() {
continue
}

if resolved[j].IsSensitive() {
anySensitive = true
}

// Try to turn the resolved value into a string.
// Use AsString (not AsAny) to get the real value even for sensitive strings.
s, ok := resolved[j].AsString()
if !ok {
// Only allow primitive types to be converted to string.
Expand All @@ -179,7 +190,11 @@ func (r *resolver) resolveRef(ref Ref, seen []string) (dyn.Value, error) {
ref.Str = strings.Replace(ref.Str, ref.Matches[j][0], s, 1)
}

return dyn.NewValue(ref.Str, ref.Value.Locations()), nil
result := dyn.NewValue(ref.Str, ref.Value.Locations())
if anySensitive {
result = result.MarkSensitive()
}
return result, nil
}

func (r *resolver) resolveKey(key string, seen []string) (dyn.Value, error) {
Expand Down
33 changes: 33 additions & 0 deletions libs/dyn/dynvar/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,39 @@ func TestResolveMapVariable(t *testing.T) {
assert.Equal(t, "value2", getByPath(t, mapVal, "key2").MustString())
}

func TestResolveSensitivePureSubstitution(t *testing.T) {
// A pure reference to a sensitive value must produce a sensitive result.
in := dyn.V(map[string]dyn.Value{
"secret": dyn.V("top-secret").MarkSensitive(),
"ref": dyn.V("${secret}"),
})

out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)

result := getByPath(t, out, "ref")
assert.True(t, result.IsSensitive())
// MustString returns the real value even when sensitive.
assert.Equal(t, "top-secret", result.MustString())
}

func TestResolveSensitiveStringInterpolation(t *testing.T) {
// When any resolved value is sensitive, the interpolated result must also be sensitive.
in := dyn.V(map[string]dyn.Value{
"secret": dyn.V("password123").MarkSensitive(),
"prefix": dyn.V("token"),
"ref": dyn.V("${prefix}:${secret}"),
})

out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)

result := getByPath(t, out, "ref")
assert.True(t, result.IsSensitive())
// The real interpolated string is still accessible.
assert.Equal(t, "token:password123", result.MustString())
}

func TestResolveSequenceVariable(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"seq": dyn.V([]dyn.Value{
Expand Down
11 changes: 11 additions & 0 deletions libs/dyn/jsonsaver/marshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ func (w wrap) MarshalJSON() ([]byte, error) {

// marshalValue recursively writes JSON for a [dyn.Value] to the buffer.
func marshalValue(buf *bytes.Buffer, v dyn.Value) error {
if v.IsSensitive() {
out, err := marshalNoEscape("********")
if err != nil {
return err
}
// The encoder writes a trailing newline, so we need to remove it.
out = out[:len(out)-1]
buf.Write(out)
return nil
}

switch v.Kind() {
case dyn.KindString, dyn.KindBool, dyn.KindInt, dyn.KindFloat, dyn.KindTime, dyn.KindNil:
out, err := marshalNoEscape(v.AsAny())
Expand Down
19 changes: 19 additions & 0 deletions libs/dyn/jsonsaver/marshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ func TestMarshal_Sequence(t *testing.T) {
}
}

func TestMarshal_Sensitive(t *testing.T) {
v := dyn.V("real-secret").MarkSensitive()
b, err := Marshal(v)
if assert.NoError(t, err) {
assert.JSONEq(t, `"********"`, string(b))
}
}

func TestMarshal_SensitiveInMap(t *testing.T) {
m := dyn.NewMapping()
m.SetLoc("token", nil, dyn.V("my-token").MarkSensitive())
m.SetLoc("name", nil, dyn.V("public"))

b, err := Marshal(dyn.V(m))
if assert.NoError(t, err) {
assert.JSONEq(t, `{"token":"********","name":"public"}`, string(b))
}
}

func TestMarshal_Complex(t *testing.T) {
map1 := dyn.NewMapping()
map1.SetLoc("str1", nil, dyn.V("value1"))
Expand Down
Loading
Loading