Skip to content

Commit ddc565e

Browse files
Error instead of silently truncating split set values
helm's value parser treats "," as a separator between assignments, so a set, set_sensitive or set_list value containing an unescaped comma is split. The key keeps only the text before the comma and the remainder is applied as unrelated keys. When that remainder contains no "=", strvals reports "key ... has no value" and the apply fails, which is how this is usually noticed. When it does contain one -- a PEM bundle, a base64 blob, a comment header -- it parses cleanly instead, so the release is applied with a truncated value and helm exits 0. Nothing in the plan or the apply output indicates anything was dropped. A single set entry is expected to produce exactly one leaf, and a set_list entry as many elements as it was given. Both are now checked before the value is parsed into the release config, and a mismatch is reported with the escape that fixes it. Brace list syntax ("{a,b}") still parses to one leaf, so it keeps working. The error path leaked as well, and is fixed with it. The parser errors quote the fragment they choked on, which for a split value is the part after the comma, so interpolating one into a diagnostic printed part of a set_sensitive or set_wo value in plan output: Failed parsing key "secrets.token": key " MORE-SECRET-MATERIAL" has no value getValue now takes whether the entry is sensitive and withholds the underlying error for those, giving the escaping hint instead; set_wo counts, since write-only values are secrets by construction. Non-sensitive entries keep the parser error, which is the more useful diagnostic where nothing is at stake. The set_sensitive loop also logged its whole model at debug level, and %v on it renders the value, so it now logs the key name alone. Verified against chart 1.3.13 driven with helm: a 248KB, 146-certificate bundle containing commas and backslashes renders byte-identical when escaped, and the same bundle unescaped reproduces the truncation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a4f1c1d commit ddc565e

3 files changed

Lines changed: 382 additions & 9 deletions

File tree

.changelog/1863.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```release-note:bug
2+
`resource/helm_release`: Report an error instead of silently truncating a `set`, `set_sensitive` or `set_list` value that helm's value parser would split on an unescaped comma.
3+
```
4+
5+
```release-note:bug
6+
`resource/helm_release`: Stop `set_sensitive` and `set_wo` values from reaching plan output and debug logs through parser error messages.
7+
```

helm/resource_helm_release.go

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,7 +1420,8 @@ func getWriteOnlyValues(ctx context.Context, model *HelmReleaseModel) (map[strin
14201420
return nil, diags
14211421
}
14221422
for _, set := range setvals {
1423-
setDiags := getValue(base, set)
1423+
// Write-only values are secrets by construction.
1424+
setDiags := getValue(base, set, true)
14241425
diags.Append(setDiags...)
14251426
if diags.HasError() {
14261427
return nil, diags
@@ -1473,7 +1474,7 @@ func getValues(ctx context.Context, model *HelmReleaseModel) (map[string]interfa
14731474

14741475
for i, set := range setList {
14751476
tflog.Debug(ctx, fmt.Sprintf("Processing Set element at index %d: %v", i, set))
1476-
setDiags := getValue(base, set)
1477+
setDiags := getValue(base, set, false)
14771478
diags.Append(setDiags...)
14781479
if diags.HasError() {
14791480
tflog.Debug(ctx, fmt.Sprintf("Error occurred while processing Set element at index %d", i))
@@ -1516,8 +1517,9 @@ func getValues(ctx context.Context, model *HelmReleaseModel) (map[string]interfa
15161517
}
15171518

15181519
for i, setSensitive := range setSensitiveList {
1519-
tflog.Debug(ctx, fmt.Sprintf("Processing Set_Sensitive element at index %d: %v", i, setSensitive))
1520-
setSensitiveDiags := getValue(base, setSensitive)
1520+
// Logged by name only: %v on the model renders the value, which is the secret.
1521+
tflog.Debug(ctx, fmt.Sprintf("Processing Set_Sensitive element at index %d: %s", i, setSensitive.Name))
1522+
setSensitiveDiags := getValue(base, setSensitive, true)
15211523
diags.Append(setSensitiveDiags...)
15221524
if diags.HasError() {
15231525
tflog.Debug(ctx, fmt.Sprintf("Error occurred while processing Set_Sensitive element at index %d", i))
@@ -1537,7 +1539,128 @@ func getValues(ctx context.Context, model *HelmReleaseModel) (map[string]interfa
15371539
return base, diags
15381540
}
15391541

1540-
func getValue(base map[string]interface{}, set setResourceModel) diag.Diagnostics {
1542+
// checkValueIsNotSplit reports whether a single set entry would be parsed as more than one
1543+
// assignment. helm's strvals parser treats "," as an assignment separator, so a value containing an
1544+
// unescaped comma is split: the key keeps only the text before it and the remainder becomes further
1545+
// assignments. When that remainder happens to contain an "=" -- a PEM bundle, a base64 blob, a
1546+
// comment header -- it parses cleanly, so the release is applied with a truncated value and helm
1547+
// exits 0. Nothing in the plan or the apply output shows that anything was dropped.
1548+
//
1549+
// A single entry is expected to produce exactly one leaf. Brace list syntax ("{a,b}") still counts
1550+
// as one leaf, so that stays supported; a split value produces two or more, which is reported here
1551+
// instead of being silently accepted.
1552+
//
1553+
// The diagnostic deliberately does not include the value: this runs for set_sensitive too.
1554+
func checkValueIsNotSplit(name, value string, asString bool) *diag.ErrorDiagnostic {
1555+
probe := map[string]interface{}{}
1556+
assignment := fmt.Sprintf("%s=%s", name, value)
1557+
1558+
var err error
1559+
if asString {
1560+
err = strvals.ParseIntoString(assignment, probe)
1561+
} else {
1562+
err = strvals.ParseInto(assignment, probe)
1563+
}
1564+
if err != nil {
1565+
// Left to the real parse below, which reports it.
1566+
return nil
1567+
}
1568+
1569+
if countLeaves(probe) <= 1 {
1570+
return nil
1571+
}
1572+
1573+
d := diag.NewErrorDiagnostic(
1574+
"Value would be truncated",
1575+
fmt.Sprintf("The value for %q contains an unescaped %q, which helm's value parser treats as a "+
1576+
"separator between assignments. Only the text before it would reach the chart, and the "+
1577+
"remainder would be applied as unrelated keys.\n\n"+
1578+
"Escape it as %q to pass it through unchanged, or supply the value through the `values` "+
1579+
"attribute instead, which is parsed as YAML and needs no escaping.", name, ",", `\,`),
1580+
)
1581+
return &d
1582+
}
1583+
1584+
// checkListIsNotSplit reports whether a set_list entry would gain elements. The elements are joined
1585+
// with "," into helm's list syntax, so an element containing an unescaped comma silently becomes two
1586+
// elements rather than one.
1587+
//
1588+
// The diagnostic deliberately does not include the elements, which may be sensitive.
1589+
func checkListIsNotSplit(name string, elements []string) *diag.ErrorDiagnostic {
1590+
// An empty list parses as a single empty element, which is pre-existing behaviour and not a
1591+
// split, so there is nothing to compare against.
1592+
if len(elements) == 0 {
1593+
return nil
1594+
}
1595+
1596+
probe := map[string]interface{}{}
1597+
if err := strvals.ParseInto(fmt.Sprintf("%s={%s}", name, strings.Join(elements, ",")), probe); err != nil {
1598+
// Left to the real parse below, which reports it.
1599+
return nil
1600+
}
1601+
1602+
parsed, ok := lookupPath(probe, name).([]interface{})
1603+
if !ok || len(parsed) == len(elements) {
1604+
return nil
1605+
}
1606+
1607+
d := diag.NewErrorDiagnostic(
1608+
"List value would gain elements",
1609+
fmt.Sprintf("The list for %q parses as %d elements rather than the %d supplied, because an "+
1610+
"element contains an unescaped %q, which helm's value parser treats as an element "+
1611+
"separator.\n\nEscape it as %q to keep the element intact, or supply the list through the "+
1612+
"`values` attribute instead, which is parsed as YAML and needs no escaping.",
1613+
name, len(parsed), len(elements), ",", `\,`),
1614+
)
1615+
return &d
1616+
}
1617+
1618+
// lookupPath resolves a dotted strvals key against a parsed result, returning nil if any segment is
1619+
// missing. Keys containing escaped separators are not resolved, which only costs a check.
1620+
func lookupPath(m map[string]interface{}, path string) interface{} {
1621+
segments := strings.Split(path, ".")
1622+
var current interface{} = m
1623+
for _, segment := range segments {
1624+
asMap, ok := current.(map[string]interface{})
1625+
if !ok {
1626+
return nil
1627+
}
1628+
current, ok = asMap[segment]
1629+
if !ok {
1630+
return nil
1631+
}
1632+
}
1633+
return current
1634+
}
1635+
1636+
// countLeaves counts the non-map values in a parsed strvals result.
1637+
func countLeaves(m map[string]interface{}) int {
1638+
n := 0
1639+
for _, v := range m {
1640+
if child, ok := v.(map[string]interface{}); ok {
1641+
n += countLeaves(child)
1642+
continue
1643+
}
1644+
n++
1645+
}
1646+
return n
1647+
}
1648+
1649+
// parseErrorDetail formats a parse failure. The underlying parser errors quote the fragment they
1650+
// choked on, which for a sensitive entry is part of the secret, so they are withheld there and the
1651+
// escaping hint given instead. Diagnostics are shown in plan output, so this is the difference
1652+
// between a secret being printed and not.
1653+
func parseErrorDetail(name string, err error, sensitive bool) string {
1654+
if sensitive {
1655+
return fmt.Sprintf("Failed parsing key %q. The parser error is withheld because the value is "+
1656+
"sensitive. It is usually an unescaped %q, which helm's value parser treats as a separator "+
1657+
"between assignments; escape it as %q, or supply the value through the `values` attribute "+
1658+
"instead, which is parsed as YAML and needs no escaping.", name, ",", `\,`)
1659+
}
1660+
return fmt.Sprintf("Failed parsing key %q: %s", name, err)
1661+
}
1662+
1663+
func getValue(base map[string]interface{}, set setResourceModel, sensitive bool) diag.Diagnostics {
15411664
var diags diag.Diagnostics
15421665

15431666
name := set.Name.ValueString()
@@ -1546,19 +1669,27 @@ func getValue(base map[string]interface{}, set setResourceModel) diag.Diagnostic
15461669

15471670
switch valueType {
15481671
case "auto", "":
1672+
if d := checkValueIsNotSplit(name, value, false); d != nil {
1673+
diags.Append(d)
1674+
return diags
1675+
}
15491676
if err := strvals.ParseInto(fmt.Sprintf("%s=%s", name, value), base); err != nil {
1550-
diags.AddError("Failed parsing value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, value, err))
1677+
diags.AddError("Failed parsing value", parseErrorDetail(name, err, sensitive))
15511678
return diags
15521679
}
15531680
case "string":
1681+
if d := checkValueIsNotSplit(name, value, true); d != nil {
1682+
diags.Append(d)
1683+
return diags
1684+
}
15541685
if err := strvals.ParseIntoString(fmt.Sprintf("%s=%s", name, value), base); err != nil {
1555-
diags.AddError("Failed parsing string value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, value, err))
1686+
diags.AddError("Failed parsing string value", parseErrorDetail(name, err, sensitive))
15561687
return diags
15571688
}
15581689
case "literal":
15591690
var literal interface{}
15601691
if err := yaml.Unmarshal([]byte(fmt.Sprintf("%s: %s", name, value)), &literal); err != nil {
1561-
diags.AddError("Failed parsing literal value", fmt.Sprintf("Key %q with literal value %s: %s", name, value, err))
1692+
diags.AddError("Failed parsing literal value", parseErrorDetail(name, err, sensitive))
15621693
return diags
15631694
}
15641695

@@ -1650,8 +1781,13 @@ func getListValue(ctx context.Context, base map[string]interface{}, set set_list
16501781
// Join the list into a single string
16511782
listString := strings.Join(listStringArray, ",")
16521783

1784+
if d := checkListIsNotSplit(name, listStringArray); d != nil {
1785+
diags.Append(d)
1786+
return diags
1787+
}
1788+
16531789
if err := strvals.ParseInto(fmt.Sprintf("%s={%s}", name, listString), base); err != nil {
1654-
diags.AddError("Error parsing list value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, listString, err))
1790+
diags.AddError("Error parsing list value", fmt.Sprintf("Failed parsing key %q: %s", name, err))
16551791
return diags
16561792
}
16571793

0 commit comments

Comments
 (0)