Skip to content
Merged
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
183 changes: 182 additions & 1 deletion app/stacksnipe/stacksnipe.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -38,6 +39,31 @@
"node": {},
}

// sensitiveFlagFragments are the flag name fragments whose values must never leave the process.
// Validator client command lines routinely carry keystore passwords, keymanager bearer tokens and
// the paths of files holding them, none of which belong in a metric label or a log line.
var sensitiveFlagFragments = []string{
"auth",
"jwt",
"key",
"mnemonic",
"passphrase",
"password",
"secret",
"token",
}

// redactedValue replaces the value of a sensitive flag in an exported command line.
const redactedValue = "<redacted>"

// basicAuthRe matches the userinfo of a URL so a credential embedded in an otherwise innocuous
// flag value (e.g. https://user:pass@host) is redacted while the scheme, user and host stay visible.
var basicAuthRe = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^:@/?#\s]+):[^@/?#\s]+@`)

// queryParamRe matches a single URL query parameter so sensitive ones (?token=..., &jwt=...)
// can be redacted by name while the rest of the value is preserved.
var queryParamRe = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]*)`)

// StackComponent is a named process of the Ethereum validator stack running on the machine,
// whose CLI parameters (also called cmdline) is read from a /proc-like filesystem.
type StackComponent struct {
Expand Down Expand Up @@ -79,6 +105,10 @@
return
}

log.Info(ctx, "Stack component sniping enabled: command lines of detected validator clients are exported "+
"to the monitoring endpoint and debug logs, with the values of secret-shaped flags redacted",
z.Str("proc_directory", i.procPath))
Comment thread
KaloyanTanev marked this conversation as resolved.

ticker := time.NewTicker(i.interval)
defer ticker.Stop()

Expand Down Expand Up @@ -135,6 +165,157 @@
return ret, nil
}

// isSensitiveFlag reports whether name looks like a flag whose value carries secret material.
func isSensitiveFlag(name string) bool {
name = strings.ToLower(strings.TrimLeft(name, "-"))

for _, fragment := range sensitiveFlagFragments {
if strings.Contains(name, fragment) {
return true
}
}

return false
}

// redactValue scrubs secret material embedded inside a value that a flag name check misses:
// basic-auth credentials in a URL and the values of sensitive query parameters. The rest of the
// value (scheme, host, path, innocuous parameters) is preserved so telemetry stays useful.
func redactValue(value string) string {
value = basicAuthRe.ReplaceAllString(value, "${1}:"+redactedValue+"@")

value = queryParamRe.ReplaceAllStringFunc(value, func(param string) string {
groups := queryParamRe.FindStringSubmatch(param)
if groups[3] != "" && isSensitiveFlag(groups[2]) {
return groups[1] + groups[2] + "=" + redactedValue
}

return param
})

return value
}

// splitCmdlineBlob tokenises a whole command line that arrived as a single blob, honouring single
// and double quotes so a quoted value containing spaces stays one argument (and is redacted whole)
// rather than being split into leaking fragments.
func splitCmdlineBlob(blob string) []string {
var (
tokens []string
cur strings.Builder
quote rune
inTok bool
)

flush := func() {
if inTok {
tokens = append(tokens, cur.String())
cur.Reset()

inTok = false
}
}

for _, r := range blob {
switch {
case quote != 0:
if r == quote {
quote = 0
} else {
cur.WriteRune(r)
}

inTok = true
case r == '\'' || r == '"':
quote = r
inTok = true
case r == ' ' || r == '\t':
flush()
default:
cur.WriteRune(r)

inTok = true
}
}

flush()

return tokens
}

// redactCmdline redacts the value of every sensitive flag while keeping flag names and innocuous
// values, so the exported command line stays diagnostically useful. It covers the "--flag value"
// and "--flag=value" forms, secrets embedded in a URL value of an innocuous flag (see redactValue),
// and values beginning with "-". Args are normally the NUL separated /proc elements; a whole command
// line handed over as one blob is tokenised (see splitCmdlineBlob) and its sensitive values redacted
// greedily, so redaction fails safe rather than leaking.
func redactCmdline(args []string) []string {

Check failure on line 252 in app/stacksnipe/stacksnipe.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ObolNetwork_charon&issues=AaCA_bq3wyrm4IpVYvlv&open=AaCA_bq3wyrm4IpVYvlv&pullRequest=4688
greedy := false

if len(args) == 1 && strings.ContainsAny(args[0], " \t") {
args = splitCmdlineBlob(args[0])
greedy = true
}
Comment thread
KaloyanTanev marked this conversation as resolved.

redacted := make([]string, 0, len(args))

var (
redactNext bool // the following token(s) are the value of a sensitive flag
valueEmitted bool // the single marker for that value has already been appended
)

for _, arg := range args {
if redactNext {
// A long flag means the sensitive flag took no value; stop redacting and reprocess
// this token as a flag. Anything else is (part of) the value.
if !strings.HasPrefix(arg, "--") {
Comment thread
KaloyanTanev marked this conversation as resolved.
if !valueEmitted {
redacted = append(redacted, redactedValue)
valueEmitted = true
}

// A NUL separated cmdline gives one value per flag; only the ambiguous blob
// fallback keeps consuming tokens into the same value.
if !greedy {
redactNext = false
}

continue
}

redactNext = false
}

if !strings.HasPrefix(arg, "-") {
redacted = append(redacted, redactValue(arg))
continue
}

name, value, hasValue := strings.Cut(arg, "=")
if !isSensitiveFlag(name) {
if hasValue {
redacted = append(redacted, name+"="+redactValue(value))
} else {
redacted = append(redacted, arg)
}

continue
}

if hasValue {
redacted = append(redacted, name+"="+redactedValue)
continue
}

redactNext = true
valueEmitted = false

redacted = append(redacted, arg)
}

return redacted
}

// walkFunc walks a /proc-like filesystem as invoked by filepath.WalkDir, and sends entries to wb.
func walkFunc(ctx context.Context, wb chan<- StackComponent) fs.WalkDirFunc {
cmdlineDedup := make(map[string]struct{})
Expand Down Expand Up @@ -216,7 +397,7 @@
return nil
}

cmdLineStr := strings.Join(cmdLine, " ")
cmdLineStr := strings.Join(redactCmdline(cmdLine), " ")

log.Debug(ctx, "Detected stack component", z.Str("name", vcName), z.U64("host_pid", hostPID), z.Str("cmdline", cmdLineStr))

Expand Down
134 changes: 134 additions & 0 deletions app/stacksnipe/stacksnipe_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1

package stacksnipe

import (
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestRedactCmdline(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{
name: "sensitive flag space form",
args: []string{"--keystore-password", "SuperSecretPw123!"},
want: []string{"--keystore-password", redactedValue},
},
{
name: "sensitive flag equals form",
args: []string{"--keymanager-auth-token=eyJhbGciOiJSUzI1NiJ9.secret"},
want: []string{"--keymanager-auth-token=" + redactedValue},
},
{
name: "non-sensitive flags untouched",
args: []string{"--datadir", "/var/lib/lighthouse", "--debug-level", "info"},
want: []string{"--datadir", "/var/lib/lighthouse", "--debug-level", "info"},
},
{
name: "value starting with a dash is still redacted",
args: []string{"--keystore-password", "-hunter2", "--debug-level", "info"},
want: []string{"--keystore-password", redactedValue, "--debug-level", "info"},
},
{
name: "sensitive boolean flag followed by a long flag",
args: []string{"--keymanager", "--datadir", "/var/lib/teku"},
want: []string{"--keymanager", "--datadir", "/var/lib/teku"},
},
{
name: "basic-auth credentials in a URL value of an innocuous flag",
args: []string{"--beacon-nodes", "https://user:s3cret@bn.internal:5052"},
want: []string{"--beacon-nodes", "https://user:" + redactedValue + "@bn.internal:5052"},
},
{
name: "basic-auth credentials in the equals form",
args: []string{"--beacon-nodes=https://user:s3cret@bn.internal"},
want: []string{"--beacon-nodes=https://user:" + redactedValue + "@bn.internal"},
},
{
name: "sensitive query parameter in a URL value",
args: []string{"--metrics-url", "https://push.internal/ingest?token=abc123&interval=5s"},
want: []string{"--metrics-url", "https://push.internal/ingest?token=" + redactedValue + "&interval=5s"},
},
{
name: "innocuous query parameters untouched",
args: []string{"--beacon-nodes", "https://bn.internal?timeout=5s&retries=3"},
want: []string{"--beacon-nodes", "https://bn.internal?timeout=5s&retries=3"},
},
{
name: "plain host:port URL without credentials untouched",
args: []string{"--validators-external-signer-url", "https://signer.internal:9000"},
want: []string{"--validators-external-signer-url", "https://signer.internal:9000"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, redactCmdline(tt.args))
})
}
}

// TestRedactCmdlineBlob covers the fail-safe fallback for a whole command line that arrives as a
// single blob rather than the NUL separated form a real /proc exposes.
func TestRedactCmdlineBlob(t *testing.T) {
tests := []struct {
name string
blob string
wantContain []string
wantNotContain []string
}{
{
name: "unquoted multi word value redacts every token, not just the first",
blob: "lighthouse vc --keystore-password My Secret Pw --debug-level info",
wantContain: []string{"--keystore-password " + redactedValue, "--debug-level info"},
wantNotContain: []string{"My", "Secret", "Pw"},
},
{
name: "quoted value with spaces stays one argument and is redacted whole",
blob: `lighthouse vc --keystore-password "alpha beta gamma" --debug-level info`,
wantContain: []string{"--keystore-password " + redactedValue, "--debug-level info"},
wantNotContain: []string{"alpha", "beta", "gamma"},
},
{
name: "single quotes are honoured too",
blob: `teku vc --validators-keystore-password 'p a s s'`,
wantContain: []string{"--validators-keystore-password " + redactedValue},
wantNotContain: []string{"p a s s", "a s s"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strings.Join(redactCmdline([]string{tt.blob}), " ")

for _, want := range tt.wantContain {
require.Contains(t, got, want)
}

for _, notWant := range tt.wantNotContain {
require.NotContains(t, got, notWant)
}
})
}
}

func TestIsSensitiveFlag(t *testing.T) {
sensitive := []string{
"--keystore-password", "--jwt-secret", "--api-token", "--wallet-passphrase",
"--mnemonic-file", "--auth-token", "--graffiti-key",
}
for _, name := range sensitive {
require.True(t, isSensitiveFlag(name), name)
}

innocuous := []string{"--datadir", "--debug-level", "--suggested-fee-recipient", "--network"}
for _, name := range innocuous {
require.False(t, isSensitiveFlag(name), name)
}
}
Loading
Loading