diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index ba6a506a0..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 { @@ -260,6 +264,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)) @@ -281,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 b5658059a..715745fc6 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,20 @@ 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 + + // 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 @@ -591,6 +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) + 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) @@ -700,6 +719,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 +947,34 @@ 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 + } +} + +// 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 ff39aaa11..027acb4e2 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,84 @@ 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"]) +} + +// 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"