diff --git a/metrics/client.go b/metrics/client.go index 74d823b..77865cd 100644 --- a/metrics/client.go +++ b/metrics/client.go @@ -39,6 +39,7 @@ const rpcCallLatencyBeholder = "rpc_call_latency" type RPCClientMetrics interface { // RecordRequest records latency for an RPC call (observed in nanoseconds for Prometheus and Beholder). // Failures use success="false"; derive error rate from rpc_call_latency_count{success="false"} (or equivalent). + // rpcURL is sanitized before export (userinfo and query removed; path hashed). RecordRequest(ctx context.Context, rpcURL string, isSendOnly bool, callName string, latency time.Duration, err error) } @@ -76,13 +77,14 @@ func (m *rpcClientMetrics) RecordRequest(ctx context.Context, rpcURL string, isS } sendStr := strconv.FormatBool(isSendOnly) latencyNs := float64(latency) + safeRPCURL := SanitizeRPCURL(rpcURL) - RPCCallLatency.WithLabelValues(m.chainFamily, m.chainID, rpcURL, sendStr, successStr, callName).Observe(latencyNs) + RPCCallLatency.WithLabelValues(m.chainFamily, m.chainID, safeRPCURL, sendStr, successStr, callName).Observe(latencyNs) latAttrs := metric.WithAttributes( attribute.String("chainFamily", m.chainFamily), attribute.String("chainID", m.chainID), - attribute.String("rpcUrl", rpcURL), + attribute.String("rpcUrl", safeRPCURL), attribute.String("isSendOnly", sendStr), attribute.String("success", successStr), attribute.String("rpcCallName", callName), diff --git a/metrics/sanitize_rpc_url.go b/metrics/sanitize_rpc_url.go new file mode 100644 index 0000000..6083901 --- /dev/null +++ b/metrics/sanitize_rpc_url.go @@ -0,0 +1,43 @@ +package metrics + +import ( + "crypto/sha1" //nolint:gosec // sha1 used only for URL anonymisation, not security + "fmt" + "net/url" + "strings" +) + +// SanitizeRPCURL either strips user:passwd or replaces path and params with their sha1-hex, excluding leading / if present +func SanitizeRPCURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "invalid_rpc_url" + } + + if u.User != nil { + // Strip credentials and leave everything else intact. + u.User = nil + return u.String() + } + + // Build the sensitive portion: path (without leading /) plus optional query. + sensitive := strings.TrimPrefix(u.Path, "/") + if u.RawQuery != "" { + if sensitive != "" { + sensitive += "?" + u.RawQuery + } else { + sensitive = u.RawQuery + } + } + + if sensitive == "" { + // Nothing to redact. + return u.String() + } + + //nolint:gosec + h := sha1.Sum([]byte(sensitive)) + u.Path = "/" + fmt.Sprintf("%x", h) + u.RawQuery = "" + return u.String() +} diff --git a/metrics/sanitize_rpc_url_test.go b/metrics/sanitize_rpc_url_test.go new file mode 100644 index 0000000..b37bab5 --- /dev/null +++ b/metrics/sanitize_rpc_url_test.go @@ -0,0 +1,64 @@ +package metrics + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizeRPCURL_RedactsSecrets(t *testing.T) { + cases := []struct { + input string + want string + }{ + // simple path + {"https://bsc-mainnet.core.chainstack.com/MjcwMDk3ZGFhMDA5NjJjMDM1", "https://bsc-mainnet.core.chainstack.com/b0b99e8b33b401b05251f91aec08b6a9581c86dd"}, + // less simple path + {"http://172.16.156.14:8000/MmZmNTJmOWRiNzg0NTgxNDYyNzJjMTYzNDlmNGJ/iYWEwOTVmYWE0OQ/bsc/mainnet/", "http://172.16.156.14:8000/bd161d616d46b90a248de0f0a3ebc2daf2b8bb20"}, + // path with no / is excluded from sha + {"https://anyblocks-01.mainnet.bnb.bdnodes.net?auth=MDcwMTgzODk3NzIyMjU4YzY2MTQzNGMyNTU2OWE2NGEzYjhlODM0NA", "https://anyblocks-01.mainnet.bnb.bdnodes.net/7c87697e63d8f9c049183bb4f8c171af40715b2b"}, + // path with leading / is included in sha + {"https://anyblocks-02.mainnet.bnb.bdnodes.net/somepath/?auth=2Dc8bNAqCC0X74zZfi_4ra6XzuBY8lmXcTE1ic9EO5o", "https://anyblocks-02.mainnet.bnb.bdnodes.net/22c028ea2d53fd106e2bb93bc61d838ed6b01c19"}, + // strip creds keep path + {"https://myLittleNop:YjY5MjAwOGJkMzBjNW@broadcast-mirror.fiews.io/?chain_id=56", "https://broadcast-mirror.fiews.io/?chain_id=56"}, + // even if no creds, sacrifice path for uniformity + {"https://eu-bsc.rpc.linkriver.internal/rpc", "https://eu-bsc.rpc.linkriver.internal/e64b40f2bd5c8a9560773d16476a86ede7e7c1ba"}, + // keeps protocol too + {"wss://bsc-mainnet-proxy.internal.linkpool.io/ws", "wss://bsc-mainnet-proxy.internal.linkpool.io/1457b75dc8c5500c0f1d4503cf801b60deb045a4"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.want, SanitizeRPCURL(tc.input)) + }) + } +} + +func TestSanitizeRPCURL_AlreadySanitized(t *testing.T) { + urls := []string{ + "http://10.0.1.191:8545", + "http://144.178.241.22:8545", + "http://222.106.187.14:12001", + "http://at2-bsc-main03.blockchain.fiews.net:8545", + "http://berlioz.stakesystems.io:8745", + "http://blockchains-1.shultzpro.com:8545", + "http://dfw3-bsc-main01.blockchain.fiews.net:8545", + "http://sylvester.stakesystems.io:8745", + "https://bsc-dataseed.binance.org/", + "https://chainlink-bsc.rpc.blxrbdn.com", + "https://puissant-builder.48.club", + "ws://10.0.1.191:8546", + "ws://144.76.108.206:8546", + "ws://172.16.152.140:8546", + "ws://bsc-rpc-2.piertwo.prod:8546", + "ws://bsc.rpc.cinternal.com", + "ws://sylvester.stakesystems.io:8746", + "wss://bsc-rpc.o1.wtf", + } + + for _, u := range urls { + t.Run(u, func(t *testing.T) { + assert.Equal(t, u, SanitizeRPCURL(u)) + }) + } +}