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) {