From 8df40294f5ea58b9ac7b87b9ebc1d66c93fb9107 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Sat, 22 Aug 2026 00:02:09 +0200 Subject: [PATCH 1/7] feat(interp): tag the run span with the raw command and effective options Adds rshell.run.command (the full script text, via a new Script RunnerOption) and rshell.run.options.* tags (mode, max_execution_time, proc_path, host_prefix, allowed_paths, allow_all_commands, allowed_commands, allowed_system_services, systemd_target_configured) to the top-level "run" telemetry span, so a trace fully describes what was executed and under which policy. Co-Authored-By: Claude Sonnet 5 --- cmd/rshell/main.go | 1 + interp/api.go | 72 ++++++++++++++++++++++++++++++++++++++++++ interp/tracing_test.go | 45 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index ba6a506a0..d2fbc1b94 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -260,6 +260,7 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i // Build runner options. runOpts := []interp.RunnerOption{ interp.StdIO(stdin, stdout, stderr), + interp.Script(script), } if len(opts.allowedPaths) > 0 { runOpts = append(runOpts, interp.AllowedPaths(opts.allowedPaths)) diff --git a/interp/api.go b/interp/api.go index b5658059a..d4dcf9621 100644 --- a/interp/api.go +++ b/interp/api.go @@ -19,6 +19,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "sync/atomic" "time" @@ -102,6 +103,13 @@ type runnerConfig struct { // Defaults to "/proc" when empty. procPath string + // commandText holds the raw, unparsed shell script or command string this + // Runner will execute. It has no effect on parsing or execution: [Run] + // still only accepts a pre-parsed [syntax.Node]. It exists solely so the + // top-level "run" telemetry span can report the full source text. Set via + // [Script]; empty when the caller does not supply one. + commandText string + // remediationMode enables remediation-only capabilities, including file-target // output redirections within AllowedPaths and the restricted systemctl builtin. remediationMode bool @@ -591,6 +599,8 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span, ctx := telemetry.StartSpanFromContext(ctx, "run") span.SetTag("rshell.version", version.Version) + span.SetTag("rshell.run.command", r.commandText) + r.setRunOptionTags(span) defer func() { span.SetTag("rshell.run.exit_code", int(r.exit.code)) span.SetTag("rshell.run.commands.total", r.totalCount) @@ -700,6 +710,53 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { return nil } +// setRunOptionTags records the effective [RunnerOption] configuration of r on +// the top-level "run" span. All fields read here are set once during [New] +// and never mutated afterwards, so this is safe to call without additional +// synchronization from within Run. +func (r *Runner) setRunOptionTags(span *telemetry.Span) { + mode := ModeReadOnly + if r.remediationMode { + mode = ModeRemediation + } + span.SetTag("rshell.run.options.mode", string(mode)) + span.SetTag("rshell.run.options.max_execution_time", r.maxExecutionTime.String()) + + procPath := r.procPath + if procPath == "" { + procPath = "/proc" + } + span.SetTag("rshell.run.options.proc_path", procPath) + span.SetTag("rshell.run.options.host_prefix", r.hostPrefix) + + pathEntries := make([]string, 0, len(r.sandbox.PathAccesses())) + for _, access := range r.sandbox.PathAccesses() { + suffix := "ro" + if access.ReadWrite { + suffix = "rw" + } + pathEntries = append(pathEntries, fmt.Sprintf("%s:%s", access.Path, suffix)) + } + span.SetTag("rshell.run.options.allowed_paths", strings.Join(pathEntries, ",")) + + span.SetTag("rshell.run.options.allow_all_commands", r.allowAllCommands) + allowedCommands := make([]string, 0, len(r.allowedCommands)) + for name := range r.allowedCommands { + allowedCommands = append(allowedCommands, name) + } + sort.Strings(allowedCommands) + span.SetTag("rshell.run.options.allowed_commands", strings.Join(allowedCommands, ",")) + + allowedServices := r.allowedSystemServicesList() + serviceEntries := make([]string, 0, len(allowedServices)) + for _, op := range allowedServices { + serviceEntries = append(serviceEntries, fmt.Sprintf("%s:%s", op.Service, op.Action)) + } + span.SetTag("rshell.run.options.allowed_system_services", strings.Join(serviceEntries, ",")) + + span.SetTag("rshell.run.options.systemd_target_configured", r.systemdTargetConfigured) +} + // MaxScriptBytes is the maximum allowed byte length of a shell script passed // to [ParseScript]. Scripts larger than this are rejected before parsing to // prevent the parser from allocating unbounded memory. Unlike other per-input @@ -881,6 +938,21 @@ func ProcPath(path string) RunnerOption { } } +// Script attaches the raw, unparsed shell script or command string that this +// Runner is about to execute. It has no effect on parsing or execution — [Run] +// still requires a pre-parsed [syntax.Node], typically produced by +// [ParseScript] — and exists solely so the top-level "run" telemetry span can +// report the full source text (see [Runner.Run]). +// +// Callers that do not want the raw script recorded in telemetry should omit +// this option. +func Script(text string) RunnerOption { + return func(r *Runner) error { + r.commandText = text + return nil + } +} + // Mode controls the execution mode of a Runner. type Mode string diff --git a/interp/tracing_test.go b/interp/tracing_test.go index ff39aaa11..1f7c9e9cd 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -14,6 +14,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -84,6 +85,50 @@ func TestRunEmitsTracerSpan(t *testing.T) { assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) } +// TestRunSpanCommandAndOptions verifies that the run span records the raw +// script text supplied via [Script], plus the effective [RunnerOption] +// configuration (mode, timeout, proc path, host prefix, allowed paths, +// allowed commands, and allowed system services). +func TestRunSpanCommandAndOptions(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + dir := t.TempDir() + script := "echo hi" + r, err := New( + Script(script), + AllowedPaths([]string{dir + ":rw"}), + AllowedCommands([]string{"rshell:echo"}), + AllowedSystemServices([]SystemServiceControlGrant{ + {Service: "foo.service", Actions: []SystemServiceAction{SystemServiceRead}}, + }), + ProcPath("/custom/proc"), + HostPrefix(dir), + MaxExecutionTime(5*time.Second), + WithMode(ModeRemediation), + ) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan, "expected a run span") + + assert.Equal(t, script, runSpan.Meta["rshell.run.command"]) + assert.Equal(t, "remediation", runSpan.Meta["rshell.run.options.mode"]) + assert.Equal(t, "5s", runSpan.Meta["rshell.run.options.max_execution_time"]) + assert.Equal(t, "/custom/proc", runSpan.Meta["rshell.run.options.proc_path"]) + assert.Equal(t, dir, runSpan.Meta["rshell.run.options.host_prefix"]) + assert.Equal(t, dir+":rw", runSpan.Meta["rshell.run.options.allowed_paths"]) + assert.Equal(t, "false", runSpan.Meta["rshell.run.options.allow_all_commands"]) + assert.Equal(t, "echo", runSpan.Meta["rshell.run.options.allowed_commands"]) + assert.Equal(t, "foo.service:read", runSpan.Meta["rshell.run.options.allowed_system_services"]) + assert.Equal(t, "false", runSpan.Meta["rshell.run.options.systemd_target_configured"]) +} + // TestRunSpanOutcome verifies the outcome classification on the run span: // any script completion (zero exit, non-zero exit, explicit exit N, or // scripts that hit blocked/unknown commands along the way) is "success" From a39f5c3a6493acb1fe31b332c0e0db3a7a3bba48 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Tue, 25 Aug 2026 13:13:47 +0200 Subject: [PATCH 2/7] refactor(interp): rename disable-command-telemetry to disable-detailed-telemetry The toggle suppresses both the raw command text and the effective sandbox options tags on the run span, so "detailed telemetry" better describes its scope than "command telemetry". Co-Authored-By: Claude Sonnet 5 --- cmd/rshell/main.go | 51 ++++++++++++++++++++++++------------------ interp/api.go | 26 +++++++++++++++++++-- interp/tracing_test.go | 34 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 24 deletions(-) diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index d2fbc1b94..5758fb098 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -36,18 +36,19 @@ func main() { func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ( - command string - allowedPaths string - allowedCommands string - allowedServices string - allowAllCmds bool - timeout time.Duration - procPath string - journalDirs string - machineIDPath string - journalSocket string - managerSocket string - mode string + command string + allowedPaths string + allowedCommands string + allowedServices string + allowAllCmds bool + timeout time.Duration + procPath string + journalDirs string + machineIDPath string + journalSocket string + managerSocket string + mode string + disableDetailedTel bool ) cmd := &cobra.Command{ @@ -113,8 +114,9 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. JournalControlSocket: journalSocket, ManagerBusSocket: managerSocket, }, - systemdTargetSet: systemdTargetSet, - mode: parsedMode, + systemdTargetSet: systemdTargetSet, + mode: parsedMode, + disableDetailedTel: disableDetailedTel, } if commandSet { @@ -173,6 +175,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd.Flags().StringVar(&journalSocket, "systemd-journal-socket", "", "journald Varlink socket for an explicit systemd target") cmd.Flags().StringVar(&managerSocket, "systemd-manager-socket", "", "system D-Bus socket for an explicit systemd target") cmd.Flags().StringVar(&mode, "mode", "read-only", "shell execution mode: read-only (default) or remediation (enables file-target output redirections within :rw AllowedPaths roots and remediation-only builtins, including the restricted systemctl builtin)") + cmd.Flags().BoolVar(&disableDetailedTel, "disable-detailed-telemetry", false, "suppress the rshell.run.command and rshell.run.options.* tags on the top-level run telemetry span (on by default; set this when the raw command or effective sandbox configuration is too sensitive to report)") if err := cmd.ExecuteContext(ctx); err != nil { var status interp.ExitStatus @@ -238,14 +241,15 @@ func rejectLongCommand(rawArgs []string) error { // executeOpts holds options for the execute function. type executeOpts struct { - allowedPaths []string - allowedCommands []string - allowedServices []interp.SystemdControlGrant - allowAllCommands bool - procPath string - systemdTarget interp.SystemdTargetConfig - systemdTargetSet bool - mode interp.Mode + allowedPaths []string + allowedCommands []string + allowedServices []interp.SystemdControlGrant + allowAllCommands bool + procPath string + systemdTarget interp.SystemdTargetConfig + systemdTargetSet bool + mode interp.Mode + disableDetailedTel bool } func execute(ctx context.Context, script, name string, opts executeOpts, stdin io.Reader, stdout, stderr io.Writer) error { @@ -282,6 +286,9 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i if opts.mode != "" { runOpts = append(runOpts, interp.WithMode(opts.mode)) } + if opts.disableDetailedTel { + runOpts = append(runOpts, interp.DisableDetailedTelemetry()) + } runner, err := interp.New(runOpts...) if err != nil { diff --git a/interp/api.go b/interp/api.go index d4dcf9621..715745fc6 100644 --- a/interp/api.go +++ b/interp/api.go @@ -110,6 +110,13 @@ type runnerConfig struct { // [Script]; empty when the caller does not supply one. commandText string + // disableDetailedTelemetry, when true, suppresses the rshell.run.command + // and rshell.run.options.* tags on the top-level "run" telemetry span. + // The rshell.version, rshell.run.exit_code, and other outcome tags set in + // [Runner.Run] are unaffected. Set via [DisableDetailedTelemetry]; false + // (tags emitted) by default. + disableDetailedTelemetry bool + // remediationMode enables remediation-only capabilities, including file-target // output redirections within AllowedPaths and the restricted systemctl builtin. remediationMode bool @@ -599,8 +606,10 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span, ctx := telemetry.StartSpanFromContext(ctx, "run") span.SetTag("rshell.version", version.Version) - span.SetTag("rshell.run.command", r.commandText) - r.setRunOptionTags(span) + if !r.disableDetailedTelemetry { + span.SetTag("rshell.run.command", r.commandText) + r.setRunOptionTags(span) + } defer func() { span.SetTag("rshell.run.exit_code", int(r.exit.code)) span.SetTag("rshell.run.commands.total", r.totalCount) @@ -953,6 +962,19 @@ func Script(text string) RunnerOption { } } +// DisableDetailedTelemetry suppresses the rshell.run.command and +// rshell.run.options.* tags that [Runner.Run] would otherwise add to the +// top-level "run" telemetry span. Use this when the raw command text or +// effective sandbox configuration is too sensitive to forward to the +// telemetry backend; other span tags (rshell.version, exit code, command +// counts, outcome) are unaffected. +func DisableDetailedTelemetry() RunnerOption { + return func(r *Runner) error { + r.disableDetailedTelemetry = true + return nil + } +} + // Mode controls the execution mode of a Runner. type Mode string diff --git a/interp/tracing_test.go b/interp/tracing_test.go index 1f7c9e9cd..027acb4e2 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -129,6 +129,40 @@ func TestRunSpanCommandAndOptions(t *testing.T) { assert.Equal(t, "false", runSpan.Meta["rshell.run.options.systemd_target_configured"]) } +// TestRunSpanDetailedTelemetryDisabled verifies that [DisableDetailedTelemetry] +// suppresses rshell.run.command and every rshell.run.options.* tag, while +// leaving unrelated tags such as rshell.version and rshell.run.exit_code +// intact. +func TestRunSpanDetailedTelemetryDisabled(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + script := "echo hi" + r, err := New( + allowAllCommandsOpt(), + Script(script), + WithMode(ModeRemediation), + DisableDetailedTelemetry(), + ) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan, "expected a run span") + + _, hasCommand := runSpan.Meta["rshell.run.command"] + assert.False(t, hasCommand, "rshell.run.command should be suppressed") + for tag := range runSpan.Meta { + assert.NotContains(t, tag, "rshell.run.options.", "no rshell.run.options.* tag should be present") + } + assert.NotEmpty(t, runSpan.Meta["rshell.version"]) + assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) +} + // TestRunSpanOutcome verifies the outcome classification on the run span: // any script completion (zero exit, non-zero exit, explicit exit N, or // scripts that hit blocked/unknown commands along the way) is "success" From fb003b885faa428f6170f3de62f7bf9dfef857ab Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Tue, 25 Aug 2026 13:36:07 +0200 Subject: [PATCH 3/7] refactor(cmd/rshell): rename disableDetailedTel to disableDetailedTelemetry Co-Authored-By: Claude Sonnet 5 --- cmd/rshell/main.go | 54 +++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 5758fb098..d71d29719 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -36,19 +36,19 @@ func main() { func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ( - command string - allowedPaths string - allowedCommands string - allowedServices string - allowAllCmds bool - timeout time.Duration - procPath string - journalDirs string - machineIDPath string - journalSocket string - managerSocket string - mode string - disableDetailedTel bool + command string + allowedPaths string + allowedCommands string + allowedServices string + allowAllCmds bool + timeout time.Duration + procPath string + journalDirs string + machineIDPath string + journalSocket string + managerSocket string + mode string + disableDetailedTelemetry bool ) cmd := &cobra.Command{ @@ -114,9 +114,9 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. JournalControlSocket: journalSocket, ManagerBusSocket: managerSocket, }, - systemdTargetSet: systemdTargetSet, - mode: parsedMode, - disableDetailedTel: disableDetailedTel, + systemdTargetSet: systemdTargetSet, + mode: parsedMode, + disableDetailedTelemetry: disableDetailedTelemetry, } if commandSet { @@ -175,7 +175,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd.Flags().StringVar(&journalSocket, "systemd-journal-socket", "", "journald Varlink socket for an explicit systemd target") cmd.Flags().StringVar(&managerSocket, "systemd-manager-socket", "", "system D-Bus socket for an explicit systemd target") cmd.Flags().StringVar(&mode, "mode", "read-only", "shell execution mode: read-only (default) or remediation (enables file-target output redirections within :rw AllowedPaths roots and remediation-only builtins, including the restricted systemctl builtin)") - cmd.Flags().BoolVar(&disableDetailedTel, "disable-detailed-telemetry", false, "suppress the rshell.run.command and rshell.run.options.* tags on the top-level run telemetry span (on by default; set this when the raw command or effective sandbox configuration is too sensitive to report)") + cmd.Flags().BoolVar(&disableDetailedTelemetry, "disable-detailed-telemetry", false, "suppress the rshell.run.command and rshell.run.options.* tags on the top-level run telemetry span (on by default; set this when the raw command or effective sandbox configuration is too sensitive to report)") if err := cmd.ExecuteContext(ctx); err != nil { var status interp.ExitStatus @@ -241,15 +241,15 @@ func rejectLongCommand(rawArgs []string) error { // executeOpts holds options for the execute function. type executeOpts struct { - allowedPaths []string - allowedCommands []string - allowedServices []interp.SystemdControlGrant - allowAllCommands bool - procPath string - systemdTarget interp.SystemdTargetConfig - systemdTargetSet bool - mode interp.Mode - disableDetailedTel bool + allowedPaths []string + allowedCommands []string + allowedServices []interp.SystemdControlGrant + allowAllCommands bool + procPath string + systemdTarget interp.SystemdTargetConfig + systemdTargetSet bool + mode interp.Mode + disableDetailedTelemetry bool } func execute(ctx context.Context, script, name string, opts executeOpts, stdin io.Reader, stdout, stderr io.Writer) error { @@ -286,7 +286,7 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i if opts.mode != "" { runOpts = append(runOpts, interp.WithMode(opts.mode)) } - if opts.disableDetailedTel { + if opts.disableDetailedTelemetry { runOpts = append(runOpts, interp.DisableDetailedTelemetry()) } From 7669bba8848ffb2beb107ada6d6aa70c4d833fc1 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 26 Aug 2026 11:52:51 +0200 Subject: [PATCH 4/7] feat(interp): scrub secret-shaped substrings from telemetry command text Redact common secret shapes (password/token/API key flags and env assignments, URL-embedded credentials, curl -u, Authorization headers, bare AWS keys and JWTs) from the raw command before it is attached to the rshell.run.command telemetry tag, as defense-in-depth alongside DisableDetailedTelemetry. Co-Authored-By: Claude Sonnet 5 --- analysis/symbols_interp.go | 4 + interp/api.go | 2 +- interp/command_scrub.go | 104 +++++++++++++++++++++ interp/command_scrub_test.go | 173 +++++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 interp/command_scrub.go create mode 100644 interp/command_scrub_test.go diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index dd840a4f2..c0f7d8551 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -62,6 +62,8 @@ var interpAllowedSymbols = []string{ "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. + "regexp.MustCompile", // 🟢 compiles a regular expression, panicking on invalid syntax; pure function, no I/O. Uses RE2 engine (linear-time, no backtracking). Used by the telemetry command-text scrubber (command_scrub.go) to redact secret-shaped substrings before they reach a telemetry tag. + "regexp.Regexp", // 🟢 compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. "sort.Strings", // 🟢 sorts exact systemd grant selectors deterministically in memory; no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. @@ -217,6 +219,8 @@ var interpPerModeSymbols = map[string][]string{ "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. + "regexp.MustCompile", // 🟢 compiles a regular expression, panicking on invalid syntax; pure function, no I/O. Uses RE2 engine (linear-time, no backtracking). Used by the telemetry command-text scrubber (command_scrub.go) to redact secret-shaped substrings before they reach a telemetry tag. + "regexp.Regexp", // 🟢 compiled regular expression type; no I/O side effects. All matching methods are linear-time (RE2). "runtime.GOOS", // 🟢 current OS name constant; pure constant, no I/O. "sort.Strings", // 🟢 sorts exact systemd grant selectors deterministically in memory; no I/O. "strconv.Itoa", // 🟢 int-to-string conversion; pure function, no I/O. diff --git a/interp/api.go b/interp/api.go index 715745fc6..a82accdaf 100644 --- a/interp/api.go +++ b/interp/api.go @@ -607,7 +607,7 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span, ctx := telemetry.StartSpanFromContext(ctx, "run") span.SetTag("rshell.version", version.Version) if !r.disableDetailedTelemetry { - span.SetTag("rshell.run.command", r.commandText) + span.SetTag("rshell.run.command", scrubCommandText(r.commandText)) r.setRunOptionTags(span) } defer func() { diff --git a/interp/command_scrub.go b/interp/command_scrub.go new file mode 100644 index 000000000..e3b9b36bb --- /dev/null +++ b/interp/command_scrub.go @@ -0,0 +1,104 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import "regexp" + +// scrubReplacement is the fixed placeholder substituted for a redacted value. +// It matches the convention already used by the Datadog Agent's process +// command-line scrubber (pkg/process/procutil/data_scrubber.go). +const scrubReplacement = "********" + +// sensitiveKeyWords lists identifier fragments that, when found in a flag +// name or environment variable name, mark the value that follows as +// sensitive. Matching is substring-based (not whole-word) so that compound +// identifiers such as AWS_SECRET_ACCESS_KEY or DB_PASSWORD are still caught. +const sensitiveKeyWords = `(?:pass(?:word)?|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|credential|private[_-]?key|privatekey|client[_-]?secret|session[_-]?id|sessionid)` + +// valueAlt matches the value half of a key/value pair: a double-quoted +// string, a single-quoted string, or a bare token. The bare alternative +// stops before whitespace and before '@' so it cannot swallow the rest of a +// URL (e.g. the "@host/path" following a credential embedded in a URL like +// https://x-access-token:TOKEN@github.com/...). +const valueAlt = `("[^"]*"|'[^']*'|[^\s@]+)` + +var ( + // reURLCreds matches credentials embedded in a URL + // (scheme://user:pass@host), redacting only the password half so the + // username and host remain visible for debugging. This runs first so + // that keyword-based regexes below don't mistake a URL username (e.g. + // x-access-token) for a flag/env key and consume the rest of the URL. + reURLCreds = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^\s/:@]+):([^\s/@]+)@`) + + // reBasicAuthFlag matches curl/wget-style `-u user:pass` basic-auth + // credentials. This is intentionally broader than the keyword list above + // since "-u" carries no sensitive substring itself. + reBasicAuthFlag = regexp.MustCompile(`(-u\s+)(\S+:\S+)`) + + // reAuthHeader matches an Authorization header value that names its + // scheme (Bearer/Basic/Token), e.g. `Authorization: Bearer `. The + // value excludes quote characters so a trailing quote that closes the + // enclosing shell argument (e.g. -H "Authorization: Bearer xyz") is left + // in place rather than being swallowed into the redacted value. + reAuthHeader = regexp.MustCompile(`(?i)(authorization['"]?\s*:\s*(?:bearer|basic|token)\s+)([^\s"']+)`) + + // reKeyEquals matches `key=value` or `key:value` where key contains a + // sensitive keyword, covering env assignments (API_KEY=x), JSON-ish + // fragments, and glued long-flag values (--password=x). + reKeyEquals = regexp.MustCompile(`(?i)([\w-]*` + sensitiveKeyWords + `[\w-]*\s*[:=]\s*)` + valueAlt) + + // reFlagSpace matches a `-flag value` / `--flag value` pair where flag + // contains a sensitive keyword and the value is a separate, space + // delimited argument (--password hunter2). Restricted to arguments that + // start with a dash so plain-English text is not mistaken for a flag. + reFlagSpace = regexp.MustCompile(`(?i)(-{1,2}[\w-]*` + sensitiveKeyWords + `[\w-]*\s+)` + valueAlt) + + // reAWSAccessKey matches a bare AWS access key ID literal. + reAWSAccessKey = regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`) + + // reJWT matches a bare JWT-shaped token: three dot-separated + // base64url segments. This is the fallback for opaque bearer tokens that + // appear without a preceding keyword or scheme. + reJWT = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`) +) + +// redactQuotedValue replaces every match of re (which must have a prefix +// group at index 1 and a value group at index 2, matching valueAlt) with the +// prefix followed by the scrub placeholder, preserving the value's +// surrounding quote characters (if any) so the redacted output stays +// syntactically shaped like the input. +func redactQuotedValue(re *regexp.Regexp, text string) string { + return re.ReplaceAllStringFunc(text, func(m string) string { + sub := re.FindStringSubmatch(m) + prefix, value := sub[1], sub[2] + switch { + case len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"': + return prefix + `"` + scrubReplacement + `"` + case len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'': + return prefix + `'` + scrubReplacement + `'` + default: + return prefix + scrubReplacement + } + }) +} + +// scrubCommandText redacts common secret-shaped substrings from a raw shell +// command before it is attached to a telemetry tag. It is a best-effort, +// regex-based defense-in-depth measure, not a guarantee: it cannot catch +// every way a secret can appear (e.g. single-letter flags like mysql's +// glued `-pSECRET`, or secrets embedded in file contents the command +// references). Operators whose commands are too sensitive for any residual +// risk should use [DisableDetailedTelemetry] instead. +func scrubCommandText(text string) string { + text = reURLCreds.ReplaceAllString(text, "${1}:"+scrubReplacement+"@") + text = reBasicAuthFlag.ReplaceAllString(text, "${1}"+scrubReplacement) + text = reAuthHeader.ReplaceAllString(text, "${1}"+scrubReplacement) + text = redactQuotedValue(reKeyEquals, text) + text = redactQuotedValue(reFlagSpace, text) + text = reAWSAccessKey.ReplaceAllString(text, scrubReplacement) + text = reJWT.ReplaceAllString(text, scrubReplacement) + return text +} diff --git a/interp/command_scrub_test.go b/interp/command_scrub_test.go new file mode 100644 index 000000000..12c92a03e --- /dev/null +++ b/interp/command_scrub_test.go @@ -0,0 +1,173 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestScrubCommandText covers the common shapes of secrets we expect to see +// in a raw command line before it is attached to the rshell.run.command +// telemetry tag. Each case documents the real-world tool/pattern it mimics. +func TestScrubCommandText(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "long flag with equals value", + in: "curl --password=hunter2 https://example.com", + want: "curl --password=******** https://example.com", + }, + { + name: "long flag with space value", + in: "curl --token ghp_abcdEFGH12345678 https://example.com", + want: "curl --token ******** https://example.com", + }, + { + name: "long flag with quoted value containing spaces", + in: `mycmd --password="my secret pass" --verbose`, + want: `mycmd --password="********" --verbose`, + }, + { + name: "long flag with single-quoted value", + in: "mycmd --secret 'top secret value' --verbose", + want: "mycmd --secret '********' --verbose", + }, + { + name: "bare env assignment prefix", + in: "API_KEY=abcdef123456 ./run.sh", + want: "API_KEY=******** ./run.sh", + }, + { + name: "export env assignment", + in: "export DB_PASSWORD=hunter2", + want: "export DB_PASSWORD=********", + }, + { + name: "aws-style compound env var name", + in: "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY aws s3 ls", + want: "AWS_SECRET_ACCESS_KEY=******** aws s3 ls", + }, + { + name: "authorization bearer header", + in: `curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig" https://api.example.com`, + want: `curl -H "Authorization: Bearer ********" https://api.example.com`, + }, + { + name: "authorization basic header", + in: `curl -H "Authorization: Basic dXNlcjpwYXNz" https://api.example.com`, + want: `curl -H "Authorization: Basic ********" https://api.example.com`, + }, + { + name: "curl basic auth flag user colon pass", + in: "curl -u admin:hunter2 https://example.com", + want: "curl -u ******** https://example.com", + }, + { + name: "url embedded credentials", + in: "curl https://admin:hunter2@example.com/path", + want: "curl https://admin:********@example.com/path", + }, + { + name: "git clone with token in url", + in: "git clone https://x-access-token:ghp_abcdEFGH12345678@github.com/org/repo.git", + want: "git clone https://x-access-token:********@github.com/org/repo.git", + }, + { + name: "aws access key id literal", + in: "echo AKIAIOSFODNN7EXAMPLE", + want: "echo ********", + }, + { + name: "bare jwt looking token", + in: "echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + want: "echo ********", + }, + { + name: "nested key=value inside another flag value", + in: "kubectl create secret generic db --from-literal=password=hunter2", + want: "kubectl create secret generic db --from-literal=password=********", + }, + { + name: "no secrets present", + in: "ls -la /var/log && echo done", + want: "ls -la /var/log && echo done", + }, + { + name: "empty string", + in: "", + want: "", + }, + { + name: "credential keyword", + in: "mycmd --credential=s3cr3t123", + want: "mycmd --credential=********", + }, + { + name: "session id keyword", + in: "mycmd --session-id=abc123def456", + want: "mycmd --session-id=********", + }, + { + name: "client secret keyword", + in: "mycmd --client-secret=abc123def456", + want: "mycmd --client-secret=********", + }, + { + name: "private key keyword", + in: "mycmd --private-key=abc123def456", + want: "mycmd --private-key=********", + }, + { + name: "multiple secrets in one command", + in: "mycmd --password=hunter2 --token=ghp_abcdEFGH12345678", + want: "mycmd --password=******** --token=********", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, scrubCommandText(tt.in)) + }) + } +} + +// TestScrubCommandTextDoesNotLeakInputValues is a defence-in-depth check that, +// for every case above with a non-empty "want" difference from "in", the +// secret value itself never survives in the scrubbed output. +func TestScrubCommandTextDoesNotLeakInputValues(t *testing.T) { + secrets := []string{ + "hunter2", + "ghp_abcdEFGH12345678", + "my secret pass", + "top secret value", + "abcdef123456", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AKIAIOSFODNN7EXAMPLE", + "s3cr3t123", + } + inputs := []string{ + "curl --password=hunter2 https://example.com", + "curl --token ghp_abcdEFGH12345678 https://example.com", + `mycmd --password="my secret pass" --verbose`, + "mycmd --secret 'top secret value' --verbose", + "API_KEY=abcdef123456 ./run.sh", + "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY aws s3 ls", + "curl -u admin:hunter2 https://example.com", + "echo AKIAIOSFODNN7EXAMPLE", + "mycmd --credential=s3cr3t123", + } + for _, in := range inputs { + out := scrubCommandText(in) + for _, secret := range secrets { + assert.NotContains(t, out, secret, "scrubbed output must not contain raw secret %q; input=%q output=%q", secret, in, out) + } + } +} From e7435d54cc2b829d9378882d7eec91cff6b95ea8 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 26 Aug 2026 13:20:37 +0200 Subject: [PATCH 5/7] fix(interp): stop the scrubber's bare value match at quote/backslash chars valueAlt's bare alternative previously stopped only at whitespace and '@', so a secret value immediately followed by a shell-escaped quote (e.g. \" closing a double-quoted argument with no space before it) got swallowed into the match and silently dropped from the scrubbed output instead of just the secret being redacted. Co-Authored-By: Claude Sonnet 5 --- interp/command_scrub.go | 8 ++++++-- interp/command_scrub_test.go | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/interp/command_scrub.go b/interp/command_scrub.go index e3b9b36bb..f7a99a83d 100644 --- a/interp/command_scrub.go +++ b/interp/command_scrub.go @@ -22,8 +22,12 @@ const sensitiveKeyWords = `(?:pass(?:word)?|pwd|secret|token|api[_-]?key|apikey| // string, a single-quoted string, or a bare token. The bare alternative // stops before whitespace and before '@' so it cannot swallow the rest of a // URL (e.g. the "@host/path" following a credential embedded in a URL like -// https://x-access-token:TOKEN@github.com/...). -const valueAlt = `("[^"]*"|'[^']*'|[^\s@]+)` +// https://x-access-token:TOKEN@github.com/...). It also stops before '"', +// backslash, and single quote so it cannot swallow a shell-escaped quote (e.g. \" in a +// double-quoted argument) that immediately follows the value with no +// intervening whitespace — doing so would silently delete that quote +// character from the scrubbed output instead of merely redacting the value. +const valueAlt = `("[^"]*"|'[^']*'|[^\s@"'\\]+)` var ( // reURLCreds matches credentials embedded in a URL diff --git a/interp/command_scrub_test.go b/interp/command_scrub_test.go index 12c92a03e..91569c135 100644 --- a/interp/command_scrub_test.go +++ b/interp/command_scrub_test.go @@ -130,6 +130,11 @@ func TestScrubCommandText(t *testing.T) { in: "mycmd --password=hunter2 --token=ghp_abcdEFGH12345678", want: "mycmd --password=******** --token=********", }, + { + name: "value immediately followed by escaped quote is not swallowed", + in: `echo "curl -H \"X-Api-Key: sk_live_FAKE1234567890abcdef\" https://api.example.com"`, + want: `echo "curl -H \"X-Api-Key: ********\" https://api.example.com"`, + }, } for _, tt := range tests { From a0ec28a08aa3840e2c76bb4d67b82b8299874208 Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 26 Aug 2026 13:45:33 +0200 Subject: [PATCH 6/7] feat(interp): tag the run span with whether it was invoked via the CLI Adds an InvokedViaCLI RunnerOption and rshell.run.invoked_via_cli span tag so operators can distinguish runs launched through the cmd/rshell binary from runs where interp is embedded as a library. The tag is unconditional (like rshell.version) since it describes invocation metadata, not command content, so it survives DisableDetailedTelemetry. Co-Authored-By: Claude Sonnet 5 --- cmd/rshell/main.go | 1 + interp/api.go | 20 ++++++++++++++++++++ interp/tracing_test.go | 21 +++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index d71d29719..23f60bd18 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -265,6 +265,7 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i runOpts := []interp.RunnerOption{ interp.StdIO(stdin, stdout, stderr), interp.Script(script), + interp.InvokedViaCLI(), } if len(opts.allowedPaths) > 0 { runOpts = append(runOpts, interp.AllowedPaths(opts.allowedPaths)) diff --git a/interp/api.go b/interp/api.go index a82accdaf..564554f71 100644 --- a/interp/api.go +++ b/interp/api.go @@ -117,6 +117,13 @@ type runnerConfig struct { // (tags emitted) by default. disableDetailedTelemetry bool + // invokedViaCLI records whether this Runner was constructed by the + // standalone cmd/rshell CLI binary, as opposed to being embedded directly + // by another Go program. It is reported on the top-level "run" telemetry + // span so operators can distinguish CLI usage from library usage. Set via + // [InvokedViaCLI]; false by default. + invokedViaCLI bool + // remediationMode enables remediation-only capabilities, including file-target // output redirections within AllowedPaths and the restricted systemctl builtin. remediationMode bool @@ -606,6 +613,7 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { span, ctx := telemetry.StartSpanFromContext(ctx, "run") span.SetTag("rshell.version", version.Version) + span.SetTag("rshell.run.invoked_via_cli", r.invokedViaCLI) if !r.disableDetailedTelemetry { span.SetTag("rshell.run.command", scrubCommandText(r.commandText)) r.setRunOptionTags(span) @@ -975,6 +983,18 @@ func DisableDetailedTelemetry() RunnerOption { } } +// InvokedViaCLI marks this Runner as constructed by the standalone cmd/rshell +// CLI binary rather than embedded directly by another Go program. It sets the +// rshell.run.invoked_via_cli tag on the top-level "run" telemetry span. +// Callers embedding the interp package as a library should not use this +// option. +func InvokedViaCLI() RunnerOption { + return func(r *Runner) error { + r.invokedViaCLI = true + return nil + } +} + // Mode controls the execution mode of a Runner. type Mode string diff --git a/interp/tracing_test.go b/interp/tracing_test.go index 027acb4e2..2747c31d4 100644 --- a/interp/tracing_test.go +++ b/interp/tracing_test.go @@ -83,6 +83,27 @@ func TestRunEmitsTracerSpan(t *testing.T) { assert.Equal(t, version.Version, runSpan.Meta["rshell.version"]) assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) + assert.Equal(t, "false", runSpan.Meta["rshell.run.invoked_via_cli"]) +} + +// TestRunSpanInvokedViaCLI verifies that [InvokedViaCLI] is reported on the +// run span even when [DisableDetailedTelemetry] suppresses the command and +// options tags, since it is invocation metadata rather than command content. +func TestRunSpanInvokedViaCLI(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt(), InvokedViaCLI(), DisableDetailedTelemetry()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "true")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan, "expected a run span") + assert.Equal(t, "true", runSpan.Meta["rshell.run.invoked_via_cli"]) } // TestRunSpanCommandAndOptions verifies that the run span records the raw From c48bbb394b3bc6a211a47acf73b83ededbbd973c Mon Sep 17 00:00:00 2001 From: Jules Macret Date: Wed, 26 Aug 2026 19:31:09 +0200 Subject: [PATCH 7/7] refactor(interp): trim verbose doc comments on run-span option code Address review feedback asking for more concise comments on the new commandText/disableDetailedTelemetry/invokedViaCLI fields, setRunOptionTags, and the Script/DisableDetailedTelemetry/InvokedViaCLI RunnerOptions. Co-Authored-By: Claude Sonnet 5 --- interp/api.go | 53 ++++++++++++++++----------------------------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/interp/api.go b/interp/api.go index 564554f71..850f7e179 100644 --- a/interp/api.go +++ b/interp/api.go @@ -103,25 +103,17 @@ type runnerConfig struct { // Defaults to "/proc" when empty. procPath string - // commandText holds the raw, unparsed shell script or command string this - // Runner will execute. It has no effect on parsing or execution: [Run] - // still only accepts a pre-parsed [syntax.Node]. It exists solely so the - // top-level "run" telemetry span can report the full source text. Set via - // [Script]; empty when the caller does not supply one. + // commandText is the raw script text reported on the "run" telemetry + // span. Set via [Script]; has no effect on parsing or execution. commandText string - // disableDetailedTelemetry, when true, suppresses the rshell.run.command - // and rshell.run.options.* tags on the top-level "run" telemetry span. - // The rshell.version, rshell.run.exit_code, and other outcome tags set in - // [Runner.Run] are unaffected. Set via [DisableDetailedTelemetry]; false - // (tags emitted) by default. + // disableDetailedTelemetry suppresses the rshell.run.command and + // rshell.run.options.* tags on the "run" span. Set via + // [DisableDetailedTelemetry]. disableDetailedTelemetry bool - // invokedViaCLI records whether this Runner was constructed by the - // standalone cmd/rshell CLI binary, as opposed to being embedded directly - // by another Go program. It is reported on the top-level "run" telemetry - // span so operators can distinguish CLI usage from library usage. Set via - // [InvokedViaCLI]; false by default. + // invokedViaCLI marks the "run" span as coming from the cmd/rshell CLI + // rather than an embedding Go program. Set via [InvokedViaCLI]. invokedViaCLI bool // remediationMode enables remediation-only capabilities, including file-target @@ -727,10 +719,8 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { return nil } -// setRunOptionTags records the effective [RunnerOption] configuration of r on -// the top-level "run" span. All fields read here are set once during [New] -// and never mutated afterwards, so this is safe to call without additional -// synchronization from within Run. +// setRunOptionTags records the effective [RunnerOption] configuration of r +// on the "run" span. func (r *Runner) setRunOptionTags(span *telemetry.Span) { mode := ModeReadOnly if r.remediationMode { @@ -955,14 +945,9 @@ func ProcPath(path string) RunnerOption { } } -// Script attaches the raw, unparsed shell script or command string that this -// Runner is about to execute. It has no effect on parsing or execution — [Run] -// still requires a pre-parsed [syntax.Node], typically produced by -// [ParseScript] — and exists solely so the top-level "run" telemetry span can -// report the full source text (see [Runner.Run]). -// -// Callers that do not want the raw script recorded in telemetry should omit -// this option. +// Script attaches the raw script text to report on the "run" telemetry span. +// It has no effect on parsing or execution; omit it to keep the raw script +// out of telemetry. func Script(text string) RunnerOption { return func(r *Runner) error { r.commandText = text @@ -971,11 +956,8 @@ func Script(text string) RunnerOption { } // DisableDetailedTelemetry suppresses the rshell.run.command and -// rshell.run.options.* tags that [Runner.Run] would otherwise add to the -// top-level "run" telemetry span. Use this when the raw command text or -// effective sandbox configuration is too sensitive to forward to the -// telemetry backend; other span tags (rshell.version, exit code, command -// counts, outcome) are unaffected. +// rshell.run.options.* tags on the "run" span, for when that data is too +// sensitive to forward to the telemetry backend. func DisableDetailedTelemetry() RunnerOption { return func(r *Runner) error { r.disableDetailedTelemetry = true @@ -983,11 +965,8 @@ func DisableDetailedTelemetry() RunnerOption { } } -// InvokedViaCLI marks this Runner as constructed by the standalone cmd/rshell -// CLI binary rather than embedded directly by another Go program. It sets the -// rshell.run.invoked_via_cli tag on the top-level "run" telemetry span. -// Callers embedding the interp package as a library should not use this -// option. +// InvokedViaCLI marks this Runner as constructed by the cmd/rshell CLI +// rather than embedded directly by another Go program. func InvokedViaCLI() RunnerOption { return func(r *Runner) error { r.invokedViaCLI = true