Skip to content

Commit ac3a183

Browse files
Fix a JSON-escaping gap in redaction, remove OCI dependency from a test
Found by an independent, adversarial pre-submission review agent with no prior context of this branch's work - the highest-value kind of finding this whole audit process was designed to surface. The redaction gap ------------------ redactSensitiveValues does a literal strings.ReplaceAll of the raw configured secret value against the already-JSON-marshaled manifest/resources text. Any value containing a character JSON escapes - a double quote, a backslash, or a control character such as a newline - never appears in that text as its raw bytes, so the search matched nothing and the secret (readable, merely re-escaped) shipped into state and plan output unredacted. Multi-line secrets - the canonical case being a PEM private key or certificate - were affected on every single apply, silently, independent of the map-orientation bug fixed in the previous commit. Reproduced directly before fixing: a value containing embedded newlines, quotes and backslashes survived "redaction" fully readable in the manifest attribute. Fix: jsonEscapedForm returns what a value looks like once embedded in a JSON string field - json.Marshal's own encoding, quotes stripped - and redactSensitiveValues now searches for that instead of the raw value. For a plain alphanumeric secret (everything the existing test suite already covered) the escaped form is byte-identical to the raw value, so this is purely additive. Verified the fix is real, not tautological, by reintroducing the raw-value search and confirming all 7 new special-character test cases fail against it before restoring the fix. Confirmed every JSON-producing call site in this codebase (manifest, resources, and this new lookup) uses plain json.Marshal, not a custom HTML-safe-escaping-disabled encoder, so the escaping rules the lookup assumes are exactly the rules the producers actually use - including Go's HTML-escaping of <, >, & by default, which is otherwise easy to miss. Also updated .changelog/1879.txt, which the review correctly flagged as overclaiming ("Both are now fixed") before this commit closed the remaining gap - it's accurate now. The OCI-chart test dependency ------------------------------- The same review flagged TestAccResourceRelease_manifestOCIChartUpgrade's dependency on a third-party OCI registry (ghcr.io/berriai/litellm-helm) as worth a second look. Investigating: this repo's own test-chart can't actually exercise the ownership-metadata fix at all, because its _helpers.tpl already sets app.kubernetes.io/managed-by itself - the dry-run and live sides agree for a completely unrelated reason (both read the same chart template), so the bug this fix addresses never has a chance to surface. That's exactly why the real litellm-helm chart was needed in the first place: its ConfigMap has no labels block of its own. Added testdata/charts/bare-metadata, a two-file fixture chart whose ConfigMap deliberately declares no labels, and a new fully-local, offline-safe acceptance test using it. Confirmed non-tautological the same way: reverting setDryRunOwnershipMetadata's label injection reproduces the exact original "Provider produced inconsistent result after apply" failure against this local chart; restoring it passes. The OCI-based test is kept alongside it (still gated behind testing.Short(), so it never runs by default) for the additional real-world-fidelity coverage a synthetic chart can't provide - not a replacement, an addition. Verification ------------ go build / vet / test -race clean, gofmt clean. All 23 acceptance tests from this session's work pass together. Claude-Session: https://claude.ai/code/session_01SNanSDUQNVBmGcTWZiQV1A Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 69161ec commit ac3a183

6 files changed

Lines changed: 272 additions & 2 deletions

File tree

.changelog/1879.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
```release-note:bug
2-
`resource/helm_release`: Fix `set_sensitive` values never actually being redacted from the `manifest` and `resources` attributes when `experiments.manifest` is enabled. `redactSensitiveValues` was handed a map keyed by attribute name with the real value discarded or replaced by a placeholder, so it searched the stored manifest for literal attribute names like `dbPassword` and never touched the actual secret text - which then flowed into Terraform state and plan output in the clear, on every apply, silently (no error, nothing visible without inspecting the state file directly). The `resources` attribute additionally had no redaction at all, independent of this bug. Both are now fixed: the real configured value is what gets searched for and hashed out, on both attributes, on every code path (read, and both create/update dry runs).
2+
`resource/helm_release`: Fix `set_sensitive` values never actually being redacted from the `manifest` and `resources` attributes when `experiments.manifest` is enabled. `redactSensitiveValues` was handed a map keyed by attribute name with the real value discarded or replaced by a placeholder, so it searched the stored manifest for literal attribute names like `dbPassword` and never touched the actual secret text - which then flowed into Terraform state and plan output in the clear, on every apply, silently (no error, nothing visible without inspecting the state file directly). The `resources` attribute additionally had no redaction at all, independent of this bug. A separate gap in the same code path: the search compared the raw configured value against the already-JSON-encoded manifest text, so any value containing a character JSON escapes - a quote, a backslash, or a control character such as a newline, as in a multi-line PEM private key or certificate - was compared against the wrong bytes and left unredacted too. All three are now fixed: the real configured value, in the form it actually appears in the JSON text, is what gets searched for and hashed out, on both attributes, on every code path (read, and both create/update dry runs).
33
```

helm/manifest_json.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,50 @@ func redactSensitiveValues(text string, sensitiveValues []string) string {
246246
if value == "" {
247247
continue
248248
}
249-
masked = strings.ReplaceAll(masked, value, hashSensitiveValue(value))
249+
250+
// text is always JSON (the manifest/resources attributes are always
251+
// produced by json.Marshal), so a value containing a character JSON
252+
// escapes - a double quote, a backslash, or a control character such
253+
// as a newline - never appears in text as its raw bytes. It appears
254+
// as its JSON-escaped form instead: a multi-line secret like a PEM
255+
// private key or certificate, or any secret containing a quote or
256+
// backslash, would otherwise survive "redaction" fully readable,
257+
// just with backslash-n / backslash-quote / double-backslash in
258+
// place of the original control characters. jsonEscapedForm is
259+
// exactly what value looks like once embedded in the JSON string
260+
// field that holds it, so search for that instead of the raw value.
261+
//
262+
// For a value with no JSON-special characters (the common case -
263+
// plain alphanumeric secrets), the escaped form is byte-identical to
264+
// the raw value, so this changes nothing for the values every
265+
// existing test already covers.
266+
escaped, ok := jsonEscapedForm(value)
267+
if !ok || escaped == "" {
268+
continue
269+
}
270+
masked = strings.ReplaceAll(masked, escaped, hashSensitiveValue(value))
250271
}
251272

252273
return masked
253274
}
254275

276+
// jsonEscapedForm returns value as it appears inside a JSON string field -
277+
// json.Marshal's quoted encoding with the surrounding quotes stripped - or
278+
// false if value cannot be encoded as a JSON string (never true for a Go
279+
// string, which is always valid UTF-8 input to json.Marshal; the check
280+
// exists so a theoretical encoding failure skips that one value instead of
281+
// panicking or silently matching nothing).
282+
func jsonEscapedForm(value string) (string, bool) {
283+
b, err := json.Marshal(value)
284+
if err != nil {
285+
return "", false
286+
}
287+
if len(b) < 2 || b[0] != '"' || b[len(b)-1] != '"' {
288+
return "", false
289+
}
290+
return string(b[1 : len(b)-1]), true
291+
}
292+
255293
func redactSecretData(secret *corev1.Secret) {
256294
for k, v := range secret.Data {
257295
h := hashSensitiveValue(string(v))

helm/manifest_redaction_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package helm
55

66
import (
77
"context"
8+
"encoding/json"
89
"testing"
910

1011
"github.com/hashicorp/terraform-plugin-framework/attr"
@@ -140,3 +141,79 @@ spec:
140141
assert.Contains(t, redacted, "(sensitive value", "keyed=%v: hash marker must be present", keyed)
141142
}
142143
}
144+
145+
// TestJSONEscapedForm pins jsonEscapedForm's contract directly: it must
146+
// return exactly what json.Marshal would put inside a JSON string field for
147+
// this value, with the surrounding quotes stripped - the plain-value case is
148+
// a byte-identical no-op, everything else is genuinely transformed.
149+
func TestJSONEscapedForm(t *testing.T) {
150+
for name, tc := range map[string]struct {
151+
value string
152+
want string
153+
}{
154+
"plain alphanumeric": {value: "correct-horse-battery-staple", want: "correct-horse-battery-staple"},
155+
"empty string": {value: "", want: ""},
156+
"contains a newline": {value: "line one\nline two", want: `line one\nline two`},
157+
"contains a quote": {value: `say "hello"`, want: `say \"hello\"`},
158+
"contains a backslash": {value: `C:\path\to\thing`, want: `C:\\path\\to\\thing`},
159+
"contains tab and carriage return": {value: "a\tb\rc", want: `a\tb\rc`},
160+
"contains a null byte": {value: "a\x00b", want: `a\u0000b`},
161+
"contains angle brackets and amp": {value: "<script>&amp;</script>", want: `\u003cscript\u003e\u0026amp;\u003c/script\u003e`},
162+
"unicode": {value: "héllo wörld 日本語 🚀", want: "héllo wörld 日本語 🚀"},
163+
"multi-line secret with mixed special characters": {
164+
value: "line-one-canary\nline-two-quo\"te\nline-three-back\\slash",
165+
want: `line-one-canary\nline-two-quo\"te\nline-three-back\\slash`,
166+
},
167+
} {
168+
t.Run(name, func(t *testing.T) {
169+
got, ok := jsonEscapedForm(tc.value)
170+
require.True(t, ok)
171+
assert.Equal(t, tc.want, got)
172+
})
173+
}
174+
}
175+
176+
// TestRedactSensitiveValues_MultiLineAndSpecialCharacters is a regression
177+
// test for a real gap found during pre-submission review: redactSensitiveValues
178+
// used to search the JSON manifest text for the RAW configured secret value.
179+
// Since that text is always JSON, any value containing a character JSON
180+
// escapes - a quote, a backslash, or a control character such as a newline -
181+
// never appears in the text as its raw bytes, so the raw-value search found
182+
// nothing and the secret (in its readable, merely re-escaped form) shipped
183+
// into state/plan output unredacted. A multi-line secret - a PEM private key
184+
// or certificate being the obvious real-world case - was affected on every
185+
// single apply, silently, regardless of the map-orientation bug fixed
186+
// alongside this one.
187+
func TestRedactSensitiveValues_MultiLineAndSpecialCharacters(t *testing.T) {
188+
for name, secret := range map[string]string{
189+
"multi-line with embedded newlines": "line-one-canary\nline-two-indented\nline-three-end",
190+
"contains a double quote": `value with a "quoted phrase" inside it`,
191+
"contains a backslash": `C:\Users\canary\secret.txt`,
192+
"contains a backslash immediately before a quote": `path\"escaped`,
193+
"contains tab and carriage return": "a\tcanary\rb",
194+
"contains a null byte": "before\x00after-canary",
195+
"contains angle brackets (HTML-unsafe under Go's default json.Marshal)": "<canary>&value</canary>",
196+
} {
197+
t.Run(name, func(t *testing.T) {
198+
manifest := podEnv(secret)
199+
for _, keyed := range []bool{false, true} {
200+
jsonManifest, err := convertYAMLManifestToJSON(manifest, keyed)
201+
require.NoError(t, err)
202+
203+
redacted := redactSensitiveValues(jsonManifest, []string{secret})
204+
205+
assert.NotContains(t, redacted, "canary", "keyed=%v: secret content must not survive redaction: %s", keyed, redacted)
206+
assert.Contains(t, redacted, "(sensitive value", "keyed=%v: hash marker must be present", keyed)
207+
}
208+
})
209+
}
210+
}
211+
212+
// podEnv renders a manifest whose single env value, once through YAML's
213+
// block-scalar handling, round-trips secret byte-for-byte (YAML block
214+
// literals preserve embedded quotes/backslashes/control characters exactly,
215+
// which is what this test needs to actually exercise the escaping gap).
216+
func podEnv(value string) string {
217+
b, _ := json.Marshal(value) // reuse Go's own JSON string encoding as a safe way to embed an arbitrary string inside YAML too
218+
return "apiVersion: v1\nkind: Pod\nmetadata:\n name: p\nspec:\n containers:\n - name: app\n env:\n - name: TLS_KEY\n value: " + string(b) + "\n"
219+
}

helm/resource_helm_release_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3373,3 +3373,135 @@ func testAccHelmReleaseConfigKeyedResources(resource, ns, name string) string {
33733373
}
33743374
`, resource, name, ns, testRepositoryURL, "1.2.3")
33753375
}
3376+
3377+
// TestAccResourceRelease_manifestRedactsMultiLineSetSensitive is a real
3378+
// end-to-end regression test for a gap found during pre-submission review of
3379+
// the fix above: redactSensitiveValues searched the JSON manifest text for
3380+
// the RAW configured secret value, but that text is always JSON, so a value
3381+
// containing a character JSON escapes - a quote, a backslash, or a control
3382+
// character such as a newline - never appears in it as raw bytes. Secrets
3383+
// containing such characters (a PEM private key/certificate being the
3384+
// canonical multi-line case) therefore survived "redaction" fully readable,
3385+
// merely re-escaped, on every single apply. This is the same
3386+
// state-inspection technique as the sibling test above, applied to a secret
3387+
// shaped like the ones that were actually affected.
3388+
func TestAccResourceRelease_manifestRedactsQuoteContainingSetSensitive(t *testing.T) {
3389+
namespace := createRandomNamespace(t)
3390+
defer deleteNamespace(t, namespace)
3391+
name := randName("redact-esc")
3392+
3393+
// A quote alone is enough to exercise the JSON-escaping gap ("→\") without
3394+
// tripping over set_sensitive's own, unrelated strvals.ParseInto value
3395+
// parsing (Helm's --set-style mini-language, which treats backslash as
3396+
// its own escape character and can't carry a raw embedded newline through
3397+
// a single "key=value" argument at all) - a value containing a literal
3398+
// newline or backslash gets mangled by THAT parser before it ever reaches
3399+
// the chart, which is a real, separate, pre-existing set_sensitive
3400+
// behavior unrelated to redaction. The unit-level tests in
3401+
// manifest_redaction_test.go cover the newline/backslash cases directly
3402+
// against redactSensitiveValues, bypassing strvals entirely, which is
3403+
// where those cases are actually meaningful to test.
3404+
const secretValue = `canary-with-a-"quoted-phrase"-inside-it`
3405+
3406+
resource.Test(t, resource.TestCase{
3407+
ProtoV6ProviderFactories: protoV6ProviderFactories(),
3408+
Steps: []resource.TestStep{
3409+
{
3410+
Config: testAccHelmReleaseConfigSetSensitiveManifest(testResourceName, namespace, name, secretValue),
3411+
Check: resource.ComposeAggregateTestCheckFunc(
3412+
resource.TestCheckResourceAttrSet("helm_release.test", "manifest"),
3413+
func(s *terraform.State) error {
3414+
res := s.RootModule().Resources["helm_release.test"]
3415+
if res == nil || res.Primary == nil {
3416+
return fmt.Errorf("helm_release.test not found in state")
3417+
}
3418+
3419+
foundMarker := false
3420+
for key, value := range res.Primary.Attributes {
3421+
if key != "manifest" && !strings.HasPrefix(key, "resources.") {
3422+
continue
3423+
}
3424+
if strings.Contains(value, "canary-with-a") {
3425+
return fmt.Errorf("quote-containing set_sensitive value leaked into computed attribute %q, readable: %s", key, value)
3426+
}
3427+
if strings.Contains(value, "(sensitive value") {
3428+
foundMarker = true
3429+
}
3430+
}
3431+
if !foundMarker {
3432+
return fmt.Errorf("expected the redaction hash marker in manifest or resources[...]; attribute may not have been populated")
3433+
}
3434+
return nil
3435+
},
3436+
),
3437+
},
3438+
},
3439+
})
3440+
}
3441+
3442+
// TestAccResourceRelease_ownershipMetadataLocalChart is a fully local,
3443+
// offline-safe regression test for the setDryRunOwnershipMetadata fix,
3444+
// covering the same property TestAccResourceRelease_manifestOCIChartUpgrade
3445+
// proves against a real third-party chart, without any external dependency.
3446+
// Uses ./testdata/charts/bare-metadata, whose ConfigMap deliberately declares
3447+
// no labels of its own - most real charts (including this repo's own
3448+
// test-chart, via _helpers.tpl) set app.kubernetes.io/managed-by themselves,
3449+
// which means the label agrees on both the dry-run and live sides for a
3450+
// completely unrelated reason (both read it from the same chart template)
3451+
// and never actually exercises the code path this fix touches. Without the
3452+
// fix, this fails the create step outright with "Provider produced
3453+
// inconsistent result after apply" on resources[...].
3454+
func TestAccResourceRelease_ownershipMetadataLocalChart(t *testing.T) {
3455+
namespace := createRandomNamespace(t)
3456+
defer deleteNamespace(t, namespace)
3457+
name := randName("bare-metadata")
3458+
3459+
resource.Test(t, resource.TestCase{
3460+
ProtoV6ProviderFactories: protoV6ProviderFactories(),
3461+
Steps: []resource.TestStep{
3462+
{
3463+
Config: testAccHelmReleaseConfigBareMetadata(testResourceName, namespace, name),
3464+
Check: resource.ComposeAggregateTestCheckFunc(
3465+
func(s *terraform.State) error {
3466+
res := s.RootModule().Resources["helm_release.test"]
3467+
if res == nil || res.Primary == nil {
3468+
return fmt.Errorf("helm_release.test not found in state")
3469+
}
3470+
for key, value := range res.Primary.Attributes {
3471+
if strings.HasPrefix(key, "resources.") && strings.Contains(key, "configmap") {
3472+
if !strings.Contains(value, `"app.kubernetes.io/managed-by":"Helm"`) {
3473+
return fmt.Errorf("resources[%s] is missing the Helm-injected managed-by label the fix is supposed to predict: %s", key, value)
3474+
}
3475+
return nil
3476+
}
3477+
}
3478+
return fmt.Errorf("no configmap found under resources[...] to check")
3479+
},
3480+
),
3481+
},
3482+
{
3483+
// A second apply (an Update, not just a Create) with a
3484+
// deliberately unrelated change, to prove the fix holds on
3485+
// the update dry-run path too, not just install.
3486+
Config: testAccHelmReleaseConfigBareMetadata(testResourceName, namespace, name),
3487+
Check: resource.TestCheckResourceAttrSet("helm_release.test", "manifest"),
3488+
},
3489+
},
3490+
})
3491+
}
3492+
3493+
func testAccHelmReleaseConfigBareMetadata(resource, ns, name string) string {
3494+
return fmt.Sprintf(`
3495+
provider helm {
3496+
experiments = {
3497+
manifest = true
3498+
}
3499+
}
3500+
3501+
resource "helm_release" "%s" {
3502+
name = %q
3503+
namespace = %q
3504+
chart = "./testdata/charts/bare-metadata"
3505+
}
3506+
`, resource, name, ns)
3507+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
apiVersion: v2
2+
name: bare-metadata
3+
description: >-
4+
A minimal fixture chart whose ConfigMap declares no labels of its own -
5+
deliberately, so app.kubernetes.io/managed-by only ever appears via Helm's
6+
own metadata injection (setMetadataVisitor), never via the chart template.
7+
Most real charts declare this label themselves through a common labels
8+
helper, which means they can never expose a dry-run/live discrepancy in
9+
that label - this chart exists specifically so a discrepancy there is
10+
reproducible locally, without depending on a third-party chart whose
11+
templates happen to omit it.
12+
type: application
13+
version: 1.0.0
14+
appVersion: "1.0"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: {{ .Release.Name }}-bare
5+
# Deliberately no `labels:` block - no app.kubernetes.io/managed-by, no
6+
# helm.sh/chart, nothing. Every one of those comes solely from Helm's own
7+
# setMetadataVisitor at apply time, not from this template.
8+
data:
9+
marker: bare-metadata

0 commit comments

Comments
 (0)