diff --git a/cmd/workflow/simulate/chain/utils.go b/cmd/workflow/simulate/chain/utils.go index d8291766..0df4350e 100644 --- a/cmd/workflow/simulate/chain/utils.go +++ b/cmd/workflow/simulate/chain/utils.go @@ -1,32 +1,10 @@ package chain -import ( - "fmt" - "net/url" - "strings" -) +import "github.com/smartcontractkit/cre-cli/internal/redact" // 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) - u.Path = strings.TrimRight(u.Path, "/") - 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) + return redact.URL(rawURL) } diff --git a/cmd/workflow/simulate/chain/utils_test.go b/cmd/workflow/simulate/chain/utils_test.go index 3247e477..de8dd29b 100644 --- a/cmd/workflow/simulate/chain/utils_test.go +++ b/cmd/workflow/simulate/chain/utils_test.go @@ -2,6 +2,8 @@ package chain import ( "testing" + + "github.com/smartcontractkit/cre-cli/internal/redact" ) func TestRedactURL(t *testing.T) { @@ -33,7 +35,7 @@ func TestRedactURL(t *testing.T) { { name: "invalid URL", raw: "://bad", - want: "***", + want: redact.RedactedValue, }, } diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index 2f9e2dc2..0c81d6dc 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -13,6 +13,7 @@ import ( "gopkg.in/yaml.v2" "github.com/smartcontractkit/cre-cli/internal/creconfig" + "github.com/smartcontractkit/cre-cli/internal/redact" ) type CreLoginTokenSet struct { @@ -158,7 +159,9 @@ func (c *Credentials) decodeJWTClaims() (map[string]interface{}, error) { return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err) } - c.log.Debug().Interface("claims", claims).Msg("JWT claims decoded") + if safeClaims := redact.SafeJWTClaimsForLog(claims); safeClaims != nil { + c.log.Debug().Interface("claims", safeClaims).Msg("JWT claims decoded") + } return claims, nil } diff --git a/internal/credentials/credentials_test.go b/internal/credentials/credentials_test.go index b679c12b..5d424809 100644 --- a/internal/credentials/credentials_test.go +++ b/internal/credentials/credentials_test.go @@ -1,11 +1,14 @@ package credentials import ( + "bytes" "os" "path/filepath" "strings" "testing" + "github.com/rs/zerolog" + "github.com/smartcontractkit/cre-cli/internal/creconfig" "github.com/smartcontractkit/cre-cli/internal/testutil" "github.com/smartcontractkit/cre-cli/internal/testutil/testjwt" @@ -437,3 +440,35 @@ func TestSecureRemove(t *testing.T) { } }) } + +func TestDecodeJWTClaims_SafeDebugLogging(t *testing.T) { + var buf bytes.Buffer + logger := zerolog.New(&buf).Level(zerolog.DebugLevel) + token := createTestJWT(map[string]interface{}{ + "sub": "user123", + "org_id": "org456", + "https://api.cre.chain.link/email": "test@example.com", + "https://api.cre.chain.link/organization_status": "FULL_ACCESS", + }) + + creds := &Credentials{ + AuthType: AuthTypeBearer, + Tokens: &CreLoginTokenSet{AccessToken: token}, + log: &logger, + } + + if _, err := creds.decodeJWTClaims(); err != nil { + t.Fatalf("decodeJWTClaims: %v", err) + } + + logOutput := buf.String() + if strings.Contains(logOutput, "test@example.com") { + t.Fatalf("debug log leaked email claim: %s", logOutput) + } + if !strings.Contains(logOutput, "org456") { + t.Fatalf("expected safe org_id in debug log, got: %s", logOutput) + } + if strings.Contains(logOutput, token) { + t.Fatalf("debug log leaked raw JWT token: %s", logOutput) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000..f71e6aa6 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,162 @@ +package redact + +import ( + "path/filepath" + "regexp" + "strings" +) + +const RedactedValue = "[REDACTED]" + +var ( + fullRedactFlags = map[string]struct{}{ + "env": {}, + "public-env": {}, + "http-payload": {}, + "public_key": {}, + "ledger-derivation-path": {}, + "config": {}, + } + + urlValueFlags = map[string]struct{}{ + "rpc-url": {}, + "wasm": {}, + } + + jwtSegmentPattern = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`) + bearerPattern = regexp.MustCompile(`(?i)(Bearer\s+)[^\s]+`) + apikeyPattern = regexp.MustCompile(`(?i)(Apikey\s+)[^\s]+`) + envSecretPattern = regexp.MustCompile(`(?i)(CRE_API_KEY\s*=\s*)\S+`) + privateKeyPattern = regexp.MustCompile(`(?i)\b(?:0x)?[0-9a-f]{64}\b`) + urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + + templateAddSensitivePattern = regexp.MustCompile(`(?i)(://|\?|ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)`) +) + +// Flag redacts a single CLI flag value based on flag name. +func Flag(name, value string) string { + if value == "" { + return value + } + + if _, ok := fullRedactFlags[name]; ok { + return RedactedValue + } + + if name == "limits" && !isNonSensitiveLimitsValue(value) { + return RedactedValue + } + + if _, ok := urlValueFlags[name]; ok { + return redactURLFlagValue(name, value) + } + + return value +} + +func isNonSensitiveLimitsValue(value string) bool { + switch strings.ToLower(value) { + case "default", "none": + return true + default: + return false + } +} + +func redactURLFlagValue(name, value string) string { + if name == "rpc-url" { + chainName, rpcURL, ok := strings.Cut(value, "=") + if !ok { + return RedactedValue + } + if !looksLikeURL(rpcURL) { + return value + } + return chainName + "=" + URL(rpcURL) + } + + if looksLikeURL(value) { + return URL(value) + } + return value +} + +func looksLikeURL(value string) bool { + return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") +} + +// Args applies command-specific redaction to positional arguments. +func Args(action, subcommand string, args []string) []string { + if len(args) == 0 { + return args + } + + redacted := make([]string, len(args)) + copy(redacted, args) + + switch action { + case "secrets": + for i, arg := range redacted { + redacted[i] = filepath.Base(arg) + } + case "templates": + if subcommand == "add" { + for i, arg := range redacted { + if templateAddSensitivePattern.MatchString(arg) { + redacted[i] = RedactedValue + } + } + } + } + + return redacted +} + +// ErrorMessage scrubs known secret patterns from error strings before telemetry export. +func ErrorMessage(msg string) string { + if msg == "" { + return msg + } + + msg = jwtSegmentPattern.ReplaceAllString(msg, RedactedValue) + msg = bearerPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = apikeyPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = envSecretPattern.ReplaceAllString(msg, "${1}"+RedactedValue) + msg = privateKeyPattern.ReplaceAllString(msg, RedactedValue) + msg = urlPattern.ReplaceAllStringFunc(msg, func(raw string) string { + return URL(raw) + }) + + return msg +} + +// SafeJWTClaimsForLog returns an allowlisted subset of JWT claims safe for debug logging. +func SafeJWTClaimsForLog(claims map[string]interface{}) map[string]interface{} { + if len(claims) == 0 { + return nil + } + + safe := make(map[string]interface{}) + for key, value := range claims { + switch key { + case "org_id", "sub", "exp", "iat", "iss", "aud": + safe[key] = value + default: + if strings.HasSuffix(key, "organization_status") || strings.HasSuffix(key, "organization_roles") { + safe[claimLogKey(key)] = value + } + } + } + + if len(safe) == 0 { + return nil + } + return safe +} + +func claimLogKey(key string) string { + if idx := strings.LastIndex(key, "/"); idx >= 0 { + return key[idx+1:] + } + return key +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 00000000..d8c80e99 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,194 @@ +package redact + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "masks last path segment", + raw: "https://rpc.example.com/v1/my-secret-key", + want: "https://rpc.example.com/v1/***", + }, + { + name: "removes query params", + raw: "https://rpc.example.com/v1/key?token=secret", + want: "https://rpc.example.com/v1/***", + }, + { + name: "single path segment masked", + raw: "https://rpc.example.com/key", + want: "https://rpc.example.com/***", + }, + { + name: "no path", + raw: "https://rpc.example.com", + want: "https://rpc.example.com", + }, + { + name: "invalid URL", + raw: "://bad", + want: RedactedValue, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, URL(tt.raw)) + }) + } +} + +func TestFlag(t *testing.T) { + tests := []struct { + name string + flagName string + value string + want string + }{ + {name: "env path", flagName: "env", value: "/home/user/project/.env", want: RedactedValue}, + {name: "public env path", flagName: "public-env", value: "/tmp/.env.public", want: RedactedValue}, + {name: "http payload", flagName: "http-payload", value: `{"secret":"value"}`, want: RedactedValue}, + {name: "public key flag", flagName: "public_key", value: "0xabc", want: RedactedValue}, + {name: "config path", flagName: "config", value: "./config.yaml", want: RedactedValue}, + {name: "limits default", flagName: "limits", value: "default", want: "default"}, + {name: "limits none", flagName: "limits", value: "none", want: "none"}, + {name: "limits file path", flagName: "limits", value: "./limits.json", want: RedactedValue}, + {name: "rpc url with secret", flagName: "rpc-url", value: "ethereum=https://rpc.example.com/v1/secret-key", want: "ethereum=https://rpc.example.com/v1/***"}, + {name: "wasm local path", flagName: "wasm", value: "./binary.wasm", want: "./binary.wasm"}, + {name: "wasm remote url", flagName: "wasm", value: "https://cdn.example.com/wasm/secret", want: "https://cdn.example.com/wasm/***"}, + {name: "benign flag", flagName: "verbose", value: "true", want: "true"}, + {name: "empty value", flagName: "env", value: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Flag(tt.flagName, tt.value)) + }) + } +} + +func TestArgs(t *testing.T) { + tests := []struct { + name string + action string + subcommand string + args []string + want []string + }{ + { + name: "secrets basename", + action: "secrets", + subcommand: "create", + args: []string{"/home/user/project/secrets.yaml"}, + want: []string{"secrets.yaml"}, + }, + { + name: "templates add benign repo", + action: "templates", + subcommand: "add", + args: []string{"smartcontractkit/cre-templates"}, + want: []string{"smartcontractkit/cre-templates"}, + }, + { + name: "templates add url with token", + action: "templates", + subcommand: "add", + args: []string{"https://github.com/org/repo?token=secret"}, + want: []string{RedactedValue}, + }, + { + name: "workflow get unchanged", + action: "workflow", + subcommand: "get", + args: []string{"my-workflow"}, + want: []string{"my-workflow"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Args(tt.action, tt.subcommand, tt.args)) + }) + } +} + +func TestErrorMessage(t *testing.T) { + jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + + tests := []struct { + name string + msg string + want string + }{ + { + name: "jwt segment", + msg: "auth failed: token " + jwt, + want: "auth failed: token " + RedactedValue, + }, + { + name: "bearer token", + msg: "request failed with Authorization: Bearer super-secret-token", + want: "request failed with Authorization: Bearer " + RedactedValue, + }, + { + name: "api key header", + msg: "Authorization: Apikey abc123", + want: "Authorization: Apikey " + RedactedValue, + }, + { + name: "private key hex", + msg: "invalid key ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + want: "invalid key " + RedactedValue, + }, + { + name: "rpc url in error", + msg: `dial failed: https://rpc.example.com/v1/secret-key?token=abc`, + want: `dial failed: https://rpc.example.com/v1/***`, + }, + { + name: "env api key", + msg: "missing CRE_API_KEY=super-secret", + want: "missing CRE_API_KEY=" + RedactedValue, + }, + { + name: "benign error unchanged", + msg: "workflow not found", + want: "workflow not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ErrorMessage(tt.msg)) + }) + } +} + +func TestSafeJWTClaimsForLog(t *testing.T) { + claims := map[string]interface{}{ + "sub": "user123", + "org_id": "org456", + "exp": float64(1234567890), + "https://api.cre.chain.link/email": "test@example.com", + "https://api.cre.chain.link/organization_status": "FULL_ACCESS", + "https://api.cre.chain.link/organization_roles": "ROOT", + } + + safe := SafeJWTClaimsForLog(claims) + + assert.Equal(t, "user123", safe["sub"]) + assert.Equal(t, "org456", safe["org_id"]) + assert.Equal(t, float64(1234567890), safe["exp"]) + assert.Equal(t, "FULL_ACCESS", safe["organization_status"]) + assert.Equal(t, "ROOT", safe["organization_roles"]) + assert.NotContains(t, safe, "email") + assert.NotContains(t, safe, "https://api.cre.chain.link/email") +} diff --git a/internal/redact/url.go b/internal/redact/url.go new file mode 100644 index 00000000..7666c774 --- /dev/null +++ b/internal/redact/url.go @@ -0,0 +1,28 @@ +package redact + +import ( + "fmt" + "net/url" + "strings" +) + +// URL returns a version of rawURL with the last path segment and query parameters +// masked to avoid leaking secrets that may be embedded in RPC or asset URLs. +func URL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return RedactedValue + } + u.Path = strings.TrimRight(u.Path, "/") + 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, "/") + } + u.RawQuery = "" + u.Fragment = "" + return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) +} diff --git a/internal/telemetry/collector.go b/internal/telemetry/collector.go index 1d47dd53..ad1269da 100644 --- a/internal/telemetry/collector.go +++ b/internal/telemetry/collector.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/pflag" "github.com/smartcontractkit/cre-cli/internal/creconfig" + "github.com/smartcontractkit/cre-cli/internal/redact" ) const MachineIDFile = "machine_id" @@ -80,10 +81,9 @@ func collectFlags(cmd *cobra.Command) []KeyValuePair { // Only include flags that were explicitly set by the user // This avoids cluttering telemetry with default values if flag.Changed { - value := flag.Value.String() flags = append(flags, KeyValuePair{ Key: flag.Name, - Value: value, + Value: redact.Flag(flag.Name, flag.Value.String()), }) } }) @@ -106,7 +106,7 @@ func CollectCommandInfo(cmd *cobra.Command, args []string) CommandInfo { } // Collect args (only positional arguments, not flags) - info.Args = args + info.Args = redact.Args(info.Action, info.Subcommand, args) // Collect flags as key-value pairs (only flags explicitly set by user) info.Flags = collectFlags(cmd) diff --git a/internal/telemetry/emitter.go b/internal/telemetry/emitter.go index c9bbd506..ebe52dab 100644 --- a/internal/telemetry/emitter.go +++ b/internal/telemetry/emitter.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/smartcontractkit/cre-cli/cmd/version" + "github.com/smartcontractkit/cre-cli/internal/redact" "github.com/smartcontractkit/cre-cli/internal/runtime" ) @@ -113,7 +114,7 @@ func buildUserEvent(cmd *cobra.Command, args []string, exitCode int, runtimeCtx // Extract error message if error is present (at top level) if err != nil { - event.ErrorMessage = err.Error() + event.ErrorMessage = redact.ErrorMessage(err.Error()) } // Collect actor information (only machineId, server populates userId/orgId from JWT) diff --git a/internal/telemetry/sender.go b/internal/telemetry/sender.go index d4c23250..50d89126 100644 --- a/internal/telemetry/sender.go +++ b/internal/telemetry/sender.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/client/graphqlclient" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/environments" + "github.com/smartcontractkit/cre-cli/internal/redact" ) const ( @@ -76,7 +77,7 @@ func SendEvent(ctx context.Context, event UserEventInput, creds *credentials.Cre err := client.Execute(sendCtx, req, &resp) if err != nil { - debugLog("telemetry request failed: %v", err) + debugLog("telemetry request failed: %v", redact.ErrorMessage(err.Error())) } else { debugLog("telemetry request succeeded: success=%v, message=%s", resp.ReportUserEvent.Success, resp.ReportUserEvent.Message) } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 7515c094..c5a758cb 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -1,6 +1,7 @@ package telemetry import ( + "errors" "os" "runtime" "testing" @@ -8,6 +9,8 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/redact" ) func TestCollectMachineInfo(t *testing.T) { @@ -125,6 +128,51 @@ func TestBuildUserEvent(t *testing.T) { assert.Equal(t, runtime.GOARCH, event.Machine.Architecture) } +func TestCollectCommandInfo_RedactsSensitiveFlags(t *testing.T) { + parent := &cobra.Command{Use: "workflow"} + cmd := &cobra.Command{Use: "simulate"} + parent.AddCommand(cmd) + + cmd.Flags().String("env", "", "env file") + cmd.Flags().String("http-payload", "", "payload") + cmd.Flags().String("wasm", "", "wasm path") + require.NoError(t, cmd.Flags().Set("env", "/home/user/.env")) + require.NoError(t, cmd.Flags().Set("http-payload", `{"token":"secret"}`)) + require.NoError(t, cmd.Flags().Set("wasm", "https://cdn.example.com/wasm/secret")) + + info := CollectCommandInfo(cmd, []string{"./my-workflow"}) + + assert.Equal(t, "workflow", info.Action) + assert.Equal(t, "simulate", info.Subcommand) + assert.Equal(t, []string{"./my-workflow"}, info.Args) + + flagValues := map[string]string{} + for _, flag := range info.Flags { + flagValues[flag.Key] = flag.Value + } + assert.Equal(t, redact.RedactedValue, flagValues["env"]) + assert.Equal(t, redact.RedactedValue, flagValues["http-payload"]) + assert.Equal(t, "https://cdn.example.com/wasm/***", flagValues["wasm"]) +} + +func TestCollectCommandInfo_RedactsSecretsArgs(t *testing.T) { + parent := &cobra.Command{Use: "secrets"} + cmd := &cobra.Command{Use: "create"} + parent.AddCommand(cmd) + + info := CollectCommandInfo(cmd, []string{"/home/user/project/secrets.yaml"}) + assert.Equal(t, []string{"secrets.yaml"}, info.Args) +} + +func TestBuildUserEvent_RedactsErrorMessage(t *testing.T) { + cmd := &cobra.Command{Use: "login"} + err := errors.New("auth failed: Bearer super-secret-token") + + event := buildUserEvent(cmd, []string{}, 1, nil, err) + + assert.Equal(t, "auth failed: Bearer "+redact.RedactedValue, event.ErrorMessage) +} + func TestGetOSVersion(t *testing.T) { version := getOSVersion() require.NotEmpty(t, version)