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
26 changes: 2 additions & 24 deletions cmd/workflow/simulate/chain/utils.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion cmd/workflow/simulate/chain/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package chain

import (
"testing"

"github.com/smartcontractkit/cre-cli/internal/redact"
)

func TestRedactURL(t *testing.T) {
Expand Down Expand Up @@ -33,7 +35,7 @@ func TestRedactURL(t *testing.T) {
{
name: "invalid URL",
raw: "://bad",
want: "***",
want: redact.RedactedValue,
},
}

Expand Down
5 changes: 4 additions & 1 deletion internal/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
35 changes: 35 additions & 0 deletions internal/credentials/credentials_test.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
}
}
162 changes: 162 additions & 0 deletions internal/redact/redact.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading