Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 additions & 1 deletion internal/exec/describe_stacks_component_processor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,7 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) {
tests := []struct {
name string
resolveSecrets bool
secretName string
storeValue any
storeErr error
expectError error
Expand All @@ -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",
},
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions pkg/io/masker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions pkg/io/masker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"testing"

"github.com/stretchr/testify/assert"

"github.com/cloudposse/atmos/pkg/schema"
)

Expand Down Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions pkg/secrets/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions pkg/secrets/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading