Skip to content

Commit 56dcd02

Browse files
committed
fix: validate masked secret declarations
1 parent ed44f4a commit 56dcd02

3 files changed

Lines changed: 43 additions & 8 deletions

File tree

internal/exec/describe_stacks_component_processor_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,7 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) {
13511351
tests := []struct {
13521352
name string
13531353
resolveSecrets bool
1354+
secretName string
13541355
storeValue any
13551356
storeErr error
13561357
expectError error
@@ -1359,17 +1360,26 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) {
13591360
{
13601361
name: "inspection masks without retrieving",
13611362
resolveSecrets: false,
1363+
secretName: "API_KEY",
13621364
expectValue: iolib.GetContext().Masker().Replacement(),
13631365
},
1366+
{
1367+
name: "inspection rejects undeclared without retrieving",
1368+
resolveSecrets: false,
1369+
secretName: "UNDECLARED_KEY",
1370+
expectError: secrets.ErrSecretNotDeclared,
1371+
},
13641372
{
13651373
name: "execution fails for a missing required secret",
13661374
resolveSecrets: true,
1375+
secretName: "API_KEY",
13671376
storeErr: errors.New("secret not found"),
13681377
expectError: secrets.ErrSecretMissing,
13691378
},
13701379
{
13711380
name: "execution resolves and registers the secret for masking",
13721381
resolveSecrets: true,
1382+
secretName: "API_KEY",
13731383
storeValue: "api-secret-value",
13741384
expectValue: "api-secret-value",
13751385
},
@@ -1398,7 +1408,7 @@ func TestProcessComponentEntry_SecretResolutionMode(t *testing.T) {
13981408
"API_KEY": map[string]any{"store": "app-secrets", "required": true},
13991409
},
14001410
},
1401-
"vars": map[string]any{"api_key": "!secret API_KEY"},
1411+
"vars": map[string]any{"api_key": "!secret " + tt.secretName},
14021412
}
14031413
processor := newDescribeStacksProcessor(
14041414
atmosConfig,

pkg/secrets/resolver.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ const secretTag = "!secret"
1717
// Resolve resolves a `!secret NAME [| path ...] [| default ...]` expression to a value.
1818
//
1919
// Behavior (in order):
20-
// 1. If the processing scope is an inspection command with masking enabled
20+
// 1. It validates that the secret is declared in the component section.
21+
// 2. If the processing scope is an inspection command with masking enabled
2122
// (stackInfo.SecretsMaskOnly), it returns the mask replacement WITHOUT retrieving the
2223
// value from the backend — no provider call, no credentials required.
23-
// 2. Otherwise it looks up the declaration in the component section, resolves the backend
24+
// 3. Otherwise it resolves the backend
2425
// provider, retrieves the value, applies the optional path/default modifiers, registers
2526
// the value (recursively) with the I/O masker, and returns it.
2627
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,
3132
return nil, err
3233
}
3334

34-
// Mask-without-retrieval fast path for inspection commands.
35-
if stackInfo != nil && stackInfo.SecretsMaskOnly {
36-
return io.GetContext().Masker().Replacement(), nil
37-
}
38-
3935
component := componentName(stackInfo)
4036

4137
var componentSection map[string]any
@@ -48,6 +44,12 @@ func Resolve(atmosConfig *schema.AtmosConfiguration, input, currentStack string,
4844
return nil, fmt.Errorf("%w: %q (declare it under the component's secrets.vars)", ErrSecretNotDeclared, name)
4945
}
5046

47+
// Mask-without-retrieval fast path for inspection commands. Declaration lookup happens
48+
// first so masked inspection still catches misspelled or malformed secret references.
49+
if stackInfo != nil && stackInfo.SecretsMaskOnly {
50+
return io.GetContext().Masker().Replacement(), nil
51+
}
52+
5153
provider, err := providerFor(atmosConfig, &decl, componentSection)
5254
if err != nil {
5355
return nil, fmt.Errorf("%w (secret %q)", err, name)

pkg/secrets/resolver_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@ func TestResolve_MaskOnly_SkipsRetrieval(t *testing.T) {
5959
assert.Equal(t, iolib.GetContext().Masker().Replacement(), got)
6060
}
6161

62+
// TestResolve_MaskOnly_RejectsUndeclared proves that masked inspection validates the local
63+
// declaration registry without contacting the backend.
64+
func TestResolve_MaskOnly_RejectsUndeclared(t *testing.T) {
65+
ctrl := gomock.NewController(t)
66+
defer ctrl.Finish()
67+
68+
mockStore := store.NewMockStore(ctrl)
69+
mockStore.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
70+
71+
cfg, componentSection := newSecretTestConfig(mockStore)
72+
require.NoError(t, iolib.Initialize())
73+
74+
info := &schema.ConfigAndStacksInfo{
75+
Stack: "prod",
76+
Component: "api",
77+
ComponentSection: componentSection,
78+
SecretsMaskOnly: true,
79+
}
80+
81+
_, err := Resolve(cfg, "!secret UNDECLARED_KEY", "prod", info)
82+
require.ErrorIs(t, err, ErrSecretNotDeclared)
83+
}
84+
6285
// TestResolve_RealValue retrieves the real value when masking does not skip retrieval.
6386
func TestResolve_RealValue(t *testing.T) {
6487
ctrl := gomock.NewController(t)

0 commit comments

Comments
 (0)