From fc49f9cf62ab85ed5754dbfa50946366b040157e Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 13 Mar 2026 11:08:03 -0400 Subject: [PATCH 1/2] Add the ability to have replace from env var in project.yaml, this enable RPC Secrets in urls --- cmd/workflow/simulate/simulate.go | 2 + cmd/workflow/simulate/simulator_utils.go | 26 +++++++++ internal/settings/envresolve.go | 33 ++++++++++++ internal/settings/envresolve_test.go | 60 +++++++++++++++++++++ internal/settings/settings_get.go | 15 +++++- internal/settings/template/.env.tpl | 5 ++ internal/settings/template/project.yaml.tpl | 7 +++ internal/settings/workflow_settings.go | 9 ++++ 8 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 internal/settings/envresolve.go create mode 100644 internal/settings/envresolve_test.go diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index f5ce48ff..9cc32b26 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -133,6 +133,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) h.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) continue } + h.log.Debug().Msgf("Using RPC for %s: %s", chainName, redactURL(rpcURL)) c, err := ethclient.Dial(rpcURL) if err != nil { @@ -190,6 +191,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } // Dial the RPC + h.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, redactURL(ec.RPCURL)) c, err := ethclient.Dial(ec.RPCURL) if err != nil { return Inputs{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) diff --git a/cmd/workflow/simulate/simulator_utils.go b/cmd/workflow/simulate/simulator_utils.go index f805c8b3..4b2d5412 100644 --- a/cmd/workflow/simulate/simulator_utils.go +++ b/cmd/workflow/simulate/simulator_utils.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "net/url" "regexp" "strconv" + "strings" "time" "github.com/ethereum/go-ethereum/common" @@ -113,6 +115,30 @@ func parseChainSelectorFromTriggerID(id string) (uint64, bool) { return v, true } +// redactURL returns a version of the URL with path segments and query parameters +// masked to avoid leaking secrets that may have been resolved from environment variables. +// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". +func redactURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "***" + } + // Mask the last path segment (most common location for API keys) + if u.Path != "" && u.Path != "/" { + parts := strings.Split(u.Path, "/") + if len(parts) > 1 { + parts[len(parts)-1] = "***" + } + u.RawPath = "" + u.Path = strings.Join(parts, "/") + } + // Remove query params entirely + u.RawQuery = "" + u.Fragment = "" + // Use Opaque to avoid re-encoding the path + return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) +} + // runRPCHealthCheck runs connectivity check against every configured client. // experimentalForwarders keys identify experimental chains (not in chain-selectors). func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalForwarders map[uint64]common.Address) error { diff --git a/internal/settings/envresolve.go b/internal/settings/envresolve.go new file mode 100644 index 00000000..bbc1124a --- /dev/null +++ b/internal/settings/envresolve.go @@ -0,0 +1,33 @@ +package settings + +import ( + "fmt" + "os" + "regexp" +) + +// envVarPattern matches ${VAR_NAME} references in strings. +var envVarPattern = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) + +// ResolveEnvVars replaces all ${VAR_NAME} references in s with their +// corresponding environment variable values. It returns an error if any +// referenced variable is not set. +func ResolveEnvVars(s string) (string, error) { + var resolveErr error + result := envVarPattern.ReplaceAllStringFunc(s, func(match string) string { + if resolveErr != nil { + return match + } + varName := envVarPattern.FindStringSubmatch(match)[1] + val, ok := os.LookupEnv(varName) + if !ok { + resolveErr = fmt.Errorf("environment variable %q referenced in URL is not set; add it to your .env file or export it in your shell", varName) + return match + } + return val + }) + if resolveErr != nil { + return "", resolveErr + } + return result, nil +} diff --git a/internal/settings/envresolve_test.go b/internal/settings/envresolve_test.go new file mode 100644 index 00000000..2c265ff0 --- /dev/null +++ b/internal/settings/envresolve_test.go @@ -0,0 +1,60 @@ +package settings + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveEnvVars(t *testing.T) { + t.Run("plain URL without vars returned unchanged", func(t *testing.T) { + t.Parallel() + result, err := ResolveEnvVars("https://rpc.example.com/v1/abc123") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/v1/abc123", result) + }) + + t.Run("single var at end of URL resolves", func(t *testing.T) { + t.Setenv("TEST_RPC_KEY", "my-secret-key") + result, err := ResolveEnvVars("https://rpc.example.com/${TEST_RPC_KEY}") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/my-secret-key", result) + }) + + t.Run("multiple vars resolve", func(t *testing.T) { + t.Setenv("TEST_HOST", "rpc.example.com") + t.Setenv("TEST_KEY", "abc123") + result, err := ResolveEnvVars("https://${TEST_HOST}/v1/${TEST_KEY}") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/v1/abc123", result) + }) + + t.Run("var in middle of URL resolves", func(t *testing.T) { + t.Setenv("TEST_MID_VAR", "segment") + result, err := ResolveEnvVars("https://rpc.example.com/${TEST_MID_VAR}/endpoint") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/segment/endpoint", result) + }) + + t.Run("missing env var returns error", func(t *testing.T) { + _, err := ResolveEnvVars("https://rpc.example.com/${ENVRESOLVE_TEST_MISSING_VAR}") + require.Error(t, err) + assert.Contains(t, err.Error(), `environment variable "ENVRESOLVE_TEST_MISSING_VAR"`) + assert.Contains(t, err.Error(), "not set") + }) + + t.Run("empty env var value resolves to empty", func(t *testing.T) { + t.Setenv("TEST_EMPTY_VAR", "") + result, err := ResolveEnvVars("https://rpc.example.com/${TEST_EMPTY_VAR}") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/", result) + }) + + t.Run("dollar var without braces is not resolved", func(t *testing.T) { + t.Setenv("TEST_NO_BRACES", "value") + result, err := ResolveEnvVars("https://rpc.example.com/$TEST_NO_BRACES") + require.NoError(t, err) + assert.Equal(t, "https://rpc.example.com/$TEST_NO_BRACES", result) + }) +} diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index c5e5dc41..d96e27e8 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -61,7 +61,11 @@ func GetRpcUrlSettings(v *viper.Viper, chainName string) (string, error) { for _, rpc := range rpcs { if rpc.ChainName == chainName { - return rpc.Url, nil + resolved, resolveErr := ResolveEnvVars(rpc.Url) + if resolveErr != nil { + return "", fmt.Errorf("rpc url for chain %q: %w", chainName, resolveErr) + } + return resolved, nil } } @@ -87,6 +91,15 @@ func GetExperimentalChains(v *viper.Viper) ([]ExperimentalChain, error) { return nil, fmt.Errorf("failed to unmarshal experimental-chains: %w", err) } + for i := range chains { + resolved, resolveErr := ResolveEnvVars(chains[i].RPCURL) + if resolveErr != nil { + return nil, fmt.Errorf("experimental chain rpc-url (selector %d): %w", + chains[i].ChainSelector, resolveErr) + } + chains[i].RPCURL = resolved + } + return chains, nil } diff --git a/internal/settings/template/.env.tpl b/internal/settings/template/.env.tpl index dbed2610..0f17f640 100644 --- a/internal/settings/template/.env.tpl +++ b/internal/settings/template/.env.tpl @@ -5,3 +5,8 @@ ############################################################################### # Ethereum private key or 1Password reference (e.g. op://vault/item/field) CRE_ETH_PRIVATE_KEY={{EthPrivateKey}} + +# RPC secret keys — referenced in project.yaml via ${VAR_NAME} syntax. +# Example: +# CRE_SECRET_RPC_SEPOLIA=my-secret-api-key +# CRE_SECRET_RPC_MAINNET=my-other-api-key diff --git a/internal/settings/template/project.yaml.tpl b/internal/settings/template/project.yaml.tpl index 96b509fd..8f894a90 100644 --- a/internal/settings/template/project.yaml.tpl +++ b/internal/settings/template/project.yaml.tpl @@ -14,6 +14,13 @@ # - chain-name: ethereum-mainnet # Required if your workflow interacts with this chain # url: "