From b53d314753ea41c666f553e45f9a3dd996514767 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Tue, 10 Mar 2026 14:39:50 +0100 Subject: [PATCH] Add --confidential and --secret flags to workflow simulate In production, confidential workflows only get access to secrets declared at deploy time. The simulator now enforces the same restriction so developers catch mismatches before deploying. PRIV-372 --- cmd/workflow/simulate/secrets.go | 46 ++++++++++++++ cmd/workflow/simulate/secrets_test.go | 85 ++++++++++++++++++++++++++ cmd/workflow/simulate/simulate.go | 32 ++++++++++ cmd/workflow/simulate/simulate_test.go | 31 ++++++++++ 4 files changed, 194 insertions(+) diff --git a/cmd/workflow/simulate/secrets.go b/cmd/workflow/simulate/secrets.go index f3a96319..065ae340 100644 --- a/cmd/workflow/simulate/secrets.go +++ b/cmd/workflow/simulate/secrets.go @@ -3,6 +3,7 @@ package simulate import ( "fmt" "os" + "strings" "gopkg.in/yaml.v2" @@ -51,3 +52,48 @@ func ReplaceSecretNamesWithEnvVars(secrets []byte) ([]byte, error) { } return out, nil } + +// FilterSecretsByAllowedKeys restricts the resolved secrets YAML to only the +// keys declared via --secret flags. Returns an error if a declared key is not +// present in the secrets file. +func FilterSecretsByAllowedKeys(secrets []byte, allowedSecrets []string) ([]byte, error) { + var cfg secretsYamlConfig + if err := yaml.Unmarshal(secrets, &cfg); err != nil { + return nil, err + } + + allowed := make(map[string]bool, len(allowedSecrets)) + for _, s := range allowedSecrets { + key, _, _ := strings.Cut(s, ":") + allowed[key] = true + } + + // Verify all declared keys exist in the secrets file. + for key := range allowed { + if _, ok := cfg.SecretsNames[key]; !ok { + return nil, fmt.Errorf("declared secret %q not found in secrets.yaml", key) + } + } + + filtered := make(map[string][]string, len(allowed)) + for key, vals := range cfg.SecretsNames { + if allowed[key] { + filtered[key] = vals + } + } + + out, err := yaml.Marshal(secretsYamlConfig{SecretsNames: filtered}) + if err != nil { + return nil, fmt.Errorf("failed to marshal filtered secrets: %w", err) + } + return out, nil +} + +// secretKeys extracts just the key portion from "KEY:namespace" entries. +func secretKeys(secrets []string) []string { + keys := make([]string, len(secrets)) + for i, s := range secrets { + keys[i], _, _ = strings.Cut(s, ":") + } + return keys +} diff --git a/cmd/workflow/simulate/secrets_test.go b/cmd/workflow/simulate/secrets_test.go index 07a00dee..5c98036f 100644 --- a/cmd/workflow/simulate/secrets_test.go +++ b/cmd/workflow/simulate/secrets_test.go @@ -8,6 +8,91 @@ import ( "gopkg.in/yaml.v2" ) +func TestFilterSecretsByAllowedKeys(t *testing.T) { + tests := []struct { + name string + yamlInput string + allowedSecrets []string + wantSecrets map[string][]string + wantErr string + }{ + { + name: "filters to declared keys only", + yamlInput: `secretsNames: + API_KEY: + - val1 + DB_PASS: + - val2 + OTHER: + - val3`, + allowedSecrets: []string{"API_KEY", "DB_PASS"}, + wantSecrets: map[string][]string{ + "API_KEY": {"val1"}, + "DB_PASS": {"val2"}, + }, + }, + { + name: "KEY:namespace format extracts key correctly", + yamlInput: `secretsNames: + API_KEY: + - val1 + OTHER: + - val2`, + allowedSecrets: []string{"API_KEY:my-namespace"}, + wantSecrets: map[string][]string{"API_KEY": {"val1"}}, + }, + { + name: "declared secret not in file returns error", + yamlInput: `secretsNames: + API_KEY: + - val1`, + allowedSecrets: []string{"MISSING"}, + wantErr: `declared secret "MISSING" not found in secrets.yaml`, + }, + { + name: "invalid yaml returns error", + yamlInput: `not: valid: yaml: [`, + allowedSecrets: []string{"KEY"}, + wantErr: "yaml:", + }, + { + name: "single key allowed from many", + yamlInput: `secretsNames: + A: + - a1 + B: + - b1 + C: + - c1`, + allowedSecrets: []string{"B"}, + wantSecrets: map[string][]string{"B": {"b1"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FilterSecretsByAllowedKeys([]byte(tt.yamlInput), tt.allowedSecrets) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + + var parsed secretsYamlConfig + require.NoError(t, yaml.Unmarshal(got, &parsed)) + assert.Equal(t, tt.wantSecrets, parsed.SecretsNames) + }) + } +} + +func TestSecretKeys(t *testing.T) { + assert.Equal(t, []string{"A", "B", "C"}, secretKeys([]string{"A", "B:ns", "C"})) + assert.Equal(t, []string{}, secretKeys([]string{})) +} + func TestReplaceSecretNamesWithEnvVars(t *testing.T) { tests := []struct { name string diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 6e953dc2..b3666526 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -61,6 +61,9 @@ type Inputs struct { EVMEventIndex int `validate:"-"` // Experimental chains support (for chains not in official chain-selectors) ExperimentalForwarders map[uint64]common.Address `validate:"-"` // forwarders keyed by chain ID + // Confidential workflow simulation + Confidential bool `validate:"-"` + Secrets []string `validate:"-"` } func New(runtimeContext *runtime.Context) *cobra.Command { @@ -93,6 +96,9 @@ func New(runtimeContext *runtime.Context) *cobra.Command { simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)") simulateCmd.Flags().String("evm-tx-hash", "", "EVM trigger transaction hash (0x...)") simulateCmd.Flags().Int("evm-event-index", -1, "EVM trigger log index (0-based)") + // Confidential workflow flags + simulateCmd.Flags().Bool("confidential", false, "Simulate as a confidential workflow (restricts secret access to declared keys)") + simulateCmd.Flags().StringSlice("secret", nil, "Allowed VaultDON secret (repeatable, format: KEY or KEY:namespace)") return simulateCmd } @@ -226,10 +232,25 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) EVMTxHash: v.GetString("evm-tx-hash"), EVMEventIndex: v.GetInt("evm-event-index"), ExperimentalForwarders: experimentalForwarders, + Confidential: v.GetBool("confidential"), + Secrets: v.GetStringSlice("secret"), }, nil } func (h *handler) ValidateInputs(inputs Inputs) error { + if len(inputs.Secrets) > 0 && !inputs.Confidential { + return fmt.Errorf("--secret requires --confidential flag") + } + if inputs.Confidential && len(inputs.Secrets) == 0 { + return fmt.Errorf("--confidential requires at least one --secret flag") + } + for _, s := range inputs.Secrets { + key, _, _ := strings.Cut(s, ":") + if strings.TrimSpace(key) == "" { + return fmt.Errorf("--secret value %q has empty key", s) + } + } + validate, err := validation.NewValidator() if err != nil { return fmt.Errorf("failed to initialize validator: %w", err) @@ -308,6 +329,17 @@ func (h *handler) Execute(inputs Inputs) error { } } + if inputs.Confidential { + if inputs.SecretsPath == "" { + return fmt.Errorf("--confidential requires a secrets.yaml file in the workflow directory") + } + secrets, err = FilterSecretsByAllowedKeys(secrets, inputs.Secrets) + if err != nil { + return fmt.Errorf("confidential mode secret filtering: %w", err) + } + ui.Dim(fmt.Sprintf("Confidential mode: secrets restricted to %v", secretKeys(inputs.Secrets))) + } + // Set up context for signal handling ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGKILL) defer cancel() diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index 08d6d30a..a3b0ee87 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/spf13/viper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -15,6 +16,36 @@ import ( "github.com/smartcontractkit/cre-cli/internal/testutil" ) +func TestValidateInputs_SecretRequiresConfidential(t *testing.T) { + h := newHandler(&runtime.Context{Logger: testutil.NewTestLogger()}) + err := h.ValidateInputs(Inputs{ + Secrets: []string{"API_KEY"}, + Confidential: false, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--secret requires --confidential flag") +} + +func TestValidateInputs_ConfidentialRequiresSecret(t *testing.T) { + h := newHandler(&runtime.Context{Logger: testutil.NewTestLogger()}) + err := h.ValidateInputs(Inputs{ + Confidential: true, + Secrets: nil, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confidential requires at least one --secret flag") +} + +func TestValidateInputs_EmptySecretKey(t *testing.T) { + h := newHandler(&runtime.Context{Logger: testutil.NewTestLogger()}) + err := h.ValidateInputs(Inputs{ + Confidential: true, + Secrets: []string{":namespace"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "has empty key") +} + // TestBlankWorkflowSimulation validates that the simulator can successfully // run a blank workflow from end to end in a non-interactive mode. func TestBlankWorkflowSimulation(t *testing.T) {