diff --git a/internal/exec/describe_stacks_component_processor_test.go b/internal/exec/describe_stacks_component_processor_test.go index 5fdd72f42a..963145d552 100644 --- a/internal/exec/describe_stacks_component_processor_test.go +++ b/internal/exec/describe_stacks_component_processor_test.go @@ -1351,6 +1351,7 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) { tests := []struct { name string resolveSecrets bool + secretName string storeValue any storeErr error expectError error @@ -1359,17 +1360,26 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) { { name: "inspection masks without retrieving", resolveSecrets: false, + secretName: "API_KEY", expectValue: iolib.GetContext().Masker().Replacement(), }, + { + name: "inspection rejects undeclared without retrieving", + resolveSecrets: false, + secretName: "UNDECLARED_KEY", + expectError: secrets.ErrSecretNotDeclared, + }, { name: "execution fails for a missing required secret", resolveSecrets: true, + secretName: "API_KEY", storeErr: errors.New("secret not found"), expectError: secrets.ErrSecretMissing, }, { name: "execution resolves and registers the secret for masking", resolveSecrets: true, + secretName: "API_KEY", storeValue: "api-secret-value", expectValue: "api-secret-value", }, @@ -1398,7 +1408,7 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) { "API_KEY": map[string]any{"store": "app-secrets", "required": true}, }, }, - "vars": map[string]any{"api_key": "!secret API_KEY"}, + "vars": map[string]any{"api_key": "!secret " + tt.secretName}, } processor := newDescribeStacksProcessor( atmosConfig, diff --git a/pkg/io/masker.go b/pkg/io/masker.go index fa6a2091f9..fdbf978ee6 100644 --- a/pkg/io/masker.go +++ b/pkg/io/masker.go @@ -183,6 +183,8 @@ func (m *masker) Mask(input string) string { // Replace literals in order (longest first). for _, literal := range literals { masked = strings.ReplaceAll(masked, literal, m.replacement) + masked = maskIndentedMultilineLiteral(masked, literal, m.replacement) + masked = maskFoldedLiteral(masked, literal, m.replacement) } // Mask regex patterns. @@ -195,6 +197,55 @@ func (m *masker) Mask(input string) string { return masked } +// maskFoldedLiteral masks long scalar values after a YAML emitter folds an ordinary space into +// a newline plus indentation. All non-whitespace bytes must still match exactly. +func maskFoldedLiteral(input, literal, replacement string) string { + if len(literal) < 32 || !strings.ContainsAny(literal, " \t") { + return input + } + + parts := strings.FieldsFunc(literal, func(r rune) bool { return r == ' ' || r == '\t' }) + if len(parts) < 2 { + return input + } + + var pattern strings.Builder + for i, part := range parts { + if i > 0 { + pattern.WriteString(`(?:[ \t]+|\r?\n[ \t]+)`) + } + pattern.WriteString(regexp.QuoteMeta(part)) + } + + re := regexp.MustCompile(pattern.String()) + quotedReplacement := strings.ReplaceAll(replacement, "$", "$$") + return re.ReplaceAllString(input, quotedReplacement) +} + +// maskIndentedMultilineLiteral masks a registered multiline value after serializers such as +// YAML have indented its continuation lines. The payload lines must still match exactly; only +// indentation introduced after a newline is ignored. +func maskIndentedMultilineLiteral(input, literal, replacement string) string { + normalized := strings.ReplaceAll(literal, "\r\n", "\n") + normalized = strings.TrimRight(normalized, "\n") + if !strings.Contains(normalized, "\n") { + return input + } + + lines := strings.Split(normalized, "\n") + var pattern strings.Builder + for i, line := range lines { + if i > 0 { + pattern.WriteString(`\r?\n[ \t]*`) + } + pattern.WriteString(regexp.QuoteMeta(line)) + } + + re := regexp.MustCompile(pattern.String()) + quotedReplacement := strings.ReplaceAll(replacement, "$", "$$") + return re.ReplaceAllString(input, quotedReplacement) +} + // ContainsSecret reports whether value contains any registered secret literal as a // substring. This deliberately ignores the enabled flag (unlike Mask): callers use it // to prevent secrets from being written to disk (e.g. Terraform varfiles) even when diff --git a/pkg/io/masker_test.go b/pkg/io/masker_test.go index 799553e91c..deecfeba43 100644 --- a/pkg/io/masker_test.go +++ b/pkg/io/masker_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/cloudposse/atmos/pkg/schema" ) @@ -266,6 +268,38 @@ func TestMasker_Mask(t *testing.T) { } } +func TestMasker_MasksIndentedMultilineLiteral(t *testing.T) { + const secret = "-----BEGIN PRIVATE KEY-----\nAAAA\nBBBB\n-----END PRIVATE KEY-----" + + for _, spaces := range []int{2, 4, 6} { + t.Run(fmt.Sprintf("%d spaces", spaces), func(t *testing.T) { + m := newMasker(nil) + m.RegisterValue(secret) + indent := strings.Repeat(" ", spaces) + input := "value: |-\n" + indent + strings.ReplaceAll(secret, "\n", "\n"+indent) + "\n" + + masked := m.Mask(input) + assert.NotContains(t, masked, "BEGIN PRIVATE KEY") + assert.NotContains(t, masked, "AAAA") + assert.NotContains(t, masked, "BBBB") + assert.Contains(t, masked, MaskReplacement) + }) + } +} + +func TestMasker_MasksFoldedLongLiteral(t *testing.T) { + const secret = "alpha bravo charlie delta echo foxtrot golf hotel" + + m := newMasker(nil) + m.RegisterValue(secret) + input := "value: >-\n alpha bravo charlie delta\n echo foxtrot golf hotel\n" + + masked := m.Mask(input) + assert.NotContains(t, masked, "alpha bravo") + assert.NotContains(t, masked, "echo foxtrot") + assert.Contains(t, masked, MaskReplacement) +} + func TestMasker_Clear(t *testing.T) { cfg := &Config{DisableMasking: false} m := newMasker(cfg) diff --git a/pkg/secrets/resolver.go b/pkg/secrets/resolver.go index 5b4c7124b6..945ce497aa 100644 --- a/pkg/secrets/resolver.go +++ b/pkg/secrets/resolver.go @@ -17,10 +17,11 @@ const secretTag = "!secret" // Resolve resolves a `!secret NAME [| path ...] [| default ...]` expression to a value. // // Behavior (in order): -// 1. If the processing scope is an inspection command with masking enabled +// 1. It validates that the secret is declared in the component section. +// 2. If the processing scope is an inspection command with masking enabled // (stackInfo.SecretsMaskOnly), it returns the mask replacement WITHOUT retrieving the // value from the backend — no provider call, no credentials required. -// 2. Otherwise it looks up the declaration in the component section, resolves the backend +// 3. Otherwise it resolves the backend // provider, retrieves the value, applies the optional path/default modifiers, registers // the value (recursively) with the I/O masker, and returns it. func Resolve(atmosConfig *schema.AtmosConfiguration, input, currentStack string, stackInfo *schema.ConfigAndStacksInfo) (any, error) { @@ -31,11 +32,6 @@ func Resolve(atmosConfig *schema.AtmosConfiguration, input, currentStack string, return nil, err } - // Mask-without-retrieval fast path for inspection commands. - if stackInfo != nil && stackInfo.SecretsMaskOnly { - return io.GetContext().Masker().Replacement(), nil - } - component := componentName(stackInfo) var componentSection map[string]any @@ -48,6 +44,12 @@ func Resolve(atmosConfig *schema.AtmosConfiguration, input, currentStack string, return nil, fmt.Errorf("%w: %q (declare it under the component's secrets.vars)", ErrSecretNotDeclared, name) } + // Mask-without-retrieval fast path for inspection commands. Declaration lookup happens + // first so masked inspection still catches misspelled or malformed secret references. + if stackInfo != nil && stackInfo.SecretsMaskOnly { + return io.GetContext().Masker().Replacement(), nil + } + provider, err := providerFor(atmosConfig, &decl, componentSection) if err != nil { return nil, fmt.Errorf("%w (secret %q)", err, name) diff --git a/pkg/secrets/resolver_test.go b/pkg/secrets/resolver_test.go index 09d15afd18..739cc47a67 100644 --- a/pkg/secrets/resolver_test.go +++ b/pkg/secrets/resolver_test.go @@ -59,6 +59,29 @@ func TestResolve_MaskOnly_SkipsRetrieval(t *testing.T) { assert.Equal(t, iolib.GetContext().Masker().Replacement(), got) } +// TestResolve_MaskOnly_RejectsUndeclared proves that masked inspection validates the local +// declaration registry without contacting the backend. +func TestResolve_MaskOnly_RejectsUndeclared(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + cfg, componentSection := newSecretTestConfig(mockStore) + require.NoError(t, iolib.Initialize()) + + info := &schema.ConfigAndStacksInfo{ + Stack: "prod", + Component: "api", + ComponentSection: componentSection, + SecretsMaskOnly: true, + } + + _, err := Resolve(cfg, "!secret UNDECLARED_KEY", "prod", info) + require.ErrorIs(t, err, ErrSecretNotDeclared) +} + // TestResolve_RealValue retrieves the real value when masking does not skip retrieval. func TestResolve_RealValue(t *testing.T) { ctrl := gomock.NewController(t)