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
46 changes: 46 additions & 0 deletions cmd/workflow/simulate/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package simulate
import (
"fmt"
"os"
"strings"

"gopkg.in/yaml.v2"

Expand Down Expand Up @@ -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
}
85 changes: 85 additions & 0 deletions cmd/workflow/simulate/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions cmd/workflow/simulate/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
31 changes: 31 additions & 0 deletions cmd/workflow/simulate/simulate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,44 @@ import (
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/cre-cli/internal/runtime"
"github.com/smartcontractkit/cre-cli/internal/settings"
"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) {
Expand Down
Loading