Skip to content

Commit c0ab168

Browse files
committed
fix: mask serialized multiline secrets
1 parent e81e10a commit c0ab168

2 files changed

Lines changed: 85 additions & 0 deletions

File tree

pkg/io/masker.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ func (m *masker) Mask(input string) string {
183183
// Replace literals in order (longest first).
184184
for _, literal := range literals {
185185
masked = strings.ReplaceAll(masked, literal, m.replacement)
186+
masked = maskIndentedMultilineLiteral(masked, literal, m.replacement)
187+
masked = maskFoldedLiteral(masked, literal, m.replacement)
186188
}
187189

188190
// Mask regex patterns.
@@ -195,6 +197,55 @@ func (m *masker) Mask(input string) string {
195197
return masked
196198
}
197199

200+
// maskFoldedLiteral masks long scalar values after a YAML emitter folds an ordinary space into
201+
// a newline plus indentation. All non-whitespace bytes must still match exactly.
202+
func maskFoldedLiteral(input, literal, replacement string) string {
203+
if len(literal) < 32 || !strings.ContainsAny(literal, " \t") {
204+
return input
205+
}
206+
207+
parts := strings.FieldsFunc(literal, func(r rune) bool { return r == ' ' || r == '\t' })
208+
if len(parts) < 2 {
209+
return input
210+
}
211+
212+
var pattern strings.Builder
213+
for i, part := range parts {
214+
if i > 0 {
215+
pattern.WriteString(`(?:[ \t]+|\r?\n[ \t]+)`)
216+
}
217+
pattern.WriteString(regexp.QuoteMeta(part))
218+
}
219+
220+
re := regexp.MustCompile(pattern.String())
221+
quotedReplacement := strings.ReplaceAll(replacement, "$", "$$")
222+
return re.ReplaceAllString(input, quotedReplacement)
223+
}
224+
225+
// maskIndentedMultilineLiteral masks a registered multiline value after serializers such as
226+
// YAML have indented its continuation lines. The payload lines must still match exactly; only
227+
// indentation introduced after a newline is ignored.
228+
func maskIndentedMultilineLiteral(input, literal, replacement string) string {
229+
normalized := strings.ReplaceAll(literal, "\r\n", "\n")
230+
normalized = strings.TrimRight(normalized, "\n")
231+
if !strings.Contains(normalized, "\n") {
232+
return input
233+
}
234+
235+
lines := strings.Split(normalized, "\n")
236+
var pattern strings.Builder
237+
for i, line := range lines {
238+
if i > 0 {
239+
pattern.WriteString(`\r?\n[ \t]*`)
240+
}
241+
pattern.WriteString(regexp.QuoteMeta(line))
242+
}
243+
244+
re := regexp.MustCompile(pattern.String())
245+
quotedReplacement := strings.ReplaceAll(replacement, "$", "$$")
246+
return re.ReplaceAllString(input, quotedReplacement)
247+
}
248+
198249
// ContainsSecret reports whether value contains any registered secret literal as a
199250
// substring. This deliberately ignores the enabled flag (unlike Mask): callers use it
200251
// to prevent secrets from being written to disk (e.g. Terraform varfiles) even when

pkg/io/masker_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"testing"
1010

11+
"github.com/stretchr/testify/assert"
12+
1113
"github.com/cloudposse/atmos/pkg/schema"
1214
)
1315

@@ -266,6 +268,38 @@ func TestMasker_Mask(t *testing.T) {
266268
}
267269
}
268270

271+
func TestMasker_MasksIndentedMultilineLiteral(t *testing.T) {
272+
const secret = "-----BEGIN PRIVATE KEY-----\nAAAA\nBBBB\n-----END PRIVATE KEY-----"
273+
274+
for _, spaces := range []int{2, 4, 6} {
275+
t.Run(fmt.Sprintf("%d spaces", spaces), func(t *testing.T) {
276+
m := newMasker(nil)
277+
m.RegisterValue(secret)
278+
indent := strings.Repeat(" ", spaces)
279+
input := "value: |-\n" + indent + strings.ReplaceAll(secret, "\n", "\n"+indent) + "\n"
280+
281+
masked := m.Mask(input)
282+
assert.NotContains(t, masked, "BEGIN PRIVATE KEY")
283+
assert.NotContains(t, masked, "AAAA")
284+
assert.NotContains(t, masked, "BBBB")
285+
assert.Contains(t, masked, MaskReplacement)
286+
})
287+
}
288+
}
289+
290+
func TestMasker_MasksFoldedLongLiteral(t *testing.T) {
291+
const secret = "alpha bravo charlie delta echo foxtrot golf hotel"
292+
293+
m := newMasker(nil)
294+
m.RegisterValue(secret)
295+
input := "value: >-\n alpha bravo charlie delta\n echo foxtrot golf hotel\n"
296+
297+
masked := m.Mask(input)
298+
assert.NotContains(t, masked, "alpha bravo")
299+
assert.NotContains(t, masked, "echo foxtrot")
300+
assert.Contains(t, masked, MaskReplacement)
301+
}
302+
269303
func TestMasker_Clear(t *testing.T) {
270304
cfg := &Config{DisableMasking: false}
271305
m := newMasker(cfg)

0 commit comments

Comments
 (0)