diff --git a/CHANGELOG.next.asciidoc b/CHANGELOG.next.asciidoc index 807dba72261..054a62d2aec 100644 --- a/CHANGELOG.next.asciidoc +++ b/CHANGELOG.next.asciidoc @@ -151,6 +151,7 @@ https://github.com/elastic/beats/compare/v8.8.1\...main[Check the HEAD diff] - Fix setuid root when running under cgroups v2. {pull}37794[37794] - Adjust State loader to only retry when response code status is 5xx {pull}37981[37981] - Reset prctl dumpable flag after cap drop. {pull}38269[38269] +- Redact synthexec cmd output. {pull}39535[39535] *Heartbeat* diff --git a/x-pack/heartbeat/monitors/browser/synthexec/synthcmd.go b/x-pack/heartbeat/monitors/browser/synthexec/synthcmd.go new file mode 100644 index 00000000000..ce6b8610a41 --- /dev/null +++ b/x-pack/heartbeat/monitors/browser/synthexec/synthcmd.go @@ -0,0 +1,44 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. +//go:build linux || darwin || synthetics + +package synthexec + +import ( + "fmt" + "os/exec" + "strings" +) + +// Variant of exec.command with redacted params and playwright options, +// which might contain sensitive information. +type SynthCmd struct { + *exec.Cmd +} + +func (cmd *SynthCmd) String() string { + b := new(strings.Builder) + b.WriteString(cmd.Path) + for i := 1; i < len(cmd.Args); i++ { + b.WriteString(" ") + a := cmd.Args[i] + switch a { + case "--params": + fallthrough + case "--playwright-options": + b.WriteString(fmt.Sprintf("%s { REDACTED }", a)) + i++ + default: + b.WriteString(a) + } + } + + return b.String() +} + +// Formatter override redacting params +func (cmd SynthCmd) Format(f fmt.State, verb rune) { + + f.Write([]byte(cmd.String())) +} diff --git a/x-pack/heartbeat/monitors/browser/synthexec/synthcmd_test.go b/x-pack/heartbeat/monitors/browser/synthexec/synthcmd_test.go new file mode 100644 index 00000000000..3c805790413 --- /dev/null +++ b/x-pack/heartbeat/monitors/browser/synthexec/synthcmd_test.go @@ -0,0 +1,66 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +//go:build linux || synthetics + +package synthexec + +import ( + "fmt" + "io" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSynthCmdStringOutput(t *testing.T) { + tests := []struct { + name string + stringer func(cmd SynthCmd) string + }{ + { + name: "fmt.Sprintf", + stringer: func(cmd SynthCmd) string { + return fmt.Sprintf("%s", cmd) + }, + }, + { + name: "fmt.Println", + stringer: func(cmd SynthCmd) string { + r, w, err := os.Pipe() + assert.NoError(t, err) + fmt.Fprint(w, cmd) + w.Close() + defer r.Close() + + o, err := io.ReadAll(r) + assert.NoError(t, err) + + return string(o) + }, + }, + { + name: "cmd.String()", + stringer: func(cmd SynthCmd) string { + return cmd.String() + }, + }, + } + + redacted := []string{"secret", "mysecrettoken", "auth", "mysecretauth"} + cmd := SynthCmd{ + exec.Command("/nil", "--params", "{'secret':'mysecrettoken'}", "--playwright-options", "{'auth':'mysecretauth'}"), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.stringer(cmd) + for _, r := range redacted { + assert.NotContains(t, s, r) + } + }) + } +} diff --git a/x-pack/heartbeat/monitors/browser/synthexec/synthexec.go b/x-pack/heartbeat/monitors/browser/synthexec/synthexec.go index 32f127de98c..3c0139557f5 100644 --- a/x-pack/heartbeat/monitors/browser/synthexec/synthexec.go +++ b/x-pack/heartbeat/monitors/browser/synthexec/synthexec.go @@ -41,7 +41,7 @@ type FilterJourneyConfig struct { // This is practically just used by synthexec_unix.go to add Sysprocattrs // It's still nice for devs to be able to test browser monitors on OSX // where these are unsupported -var platformCmdMutate func(*exec.Cmd) = func(*exec.Cmd) {} +var platformCmdMutate func(*SynthCmd) = func(*SynthCmd) {} var SynthexecTimeout struct{} @@ -57,13 +57,13 @@ func ProjectJob(ctx context.Context, projectPath string, params mapstr.M, filter return startCmdJob(ctx, cmdFactory, nil, params, filterJourneys, fields), nil } -func projectCommandFactory(projectPath string, args ...string) (func() *exec.Cmd, error) { +func projectCommandFactory(projectPath string, args ...string) (func() *SynthCmd, error) { npmRoot, err := getNpmRoot(projectPath) if err != nil { return nil, err } - newCmd := func() *exec.Cmd { + newCmd := func() *SynthCmd { bin := filepath.Join(npmRoot, "node_modules/.bin/elastic-synthetics") // Always put the project path first to prevent conflation with variadic args! // See https://github.com/tj/commander.js/blob/master/docs/options-taking-varying-arguments.md @@ -71,7 +71,7 @@ func projectCommandFactory(projectPath string, args ...string) (func() *exec.Cmd // to the end. cmd := exec.Command(bin, append([]string{projectPath}, args...)...) cmd.Dir = npmRoot - return cmd + return &SynthCmd{cmd} } return newCmd, nil @@ -79,8 +79,8 @@ func projectCommandFactory(projectPath string, args ...string) (func() *exec.Cmd // InlineJourneyJob returns a job that runs the given source as a single journey. func InlineJourneyJob(ctx context.Context, script string, params mapstr.M, fields stdfields.StdMonitorFields, extraArgs ...string) jobs.Job { - newCmd := func() *exec.Cmd { - return exec.Command("elastic-synthetics", append(extraArgs, "--inline")...) //nolint:gosec // we are safely building a command here, users can add args at their own risk + newCmd := func() *SynthCmd { + return &SynthCmd{exec.Command("elastic-synthetics", append(extraArgs, "--inline")...)} //nolint:gosec // we are safely building a command here, users can add args at their own risk } return startCmdJob(ctx, newCmd, &script, params, FilterJourneyConfig{}, fields) @@ -89,7 +89,7 @@ func InlineJourneyJob(ctx context.Context, script string, params mapstr.M, field // startCmdJob adapts commands into a heartbeat job. This is a little awkward given that the command's output is // available via a sequence of events in the multiplexer, while heartbeat jobs are tail recursive continuations. // Here, we adapt one to the other, where each recursive job pulls another item off the chan until none are left. -func startCmdJob(ctx context.Context, newCmd func() *exec.Cmd, stdinStr *string, params mapstr.M, filterJourneys FilterJourneyConfig, sFields stdfields.StdMonitorFields) jobs.Job { +func startCmdJob(ctx context.Context, newCmd func() *SynthCmd, stdinStr *string, params mapstr.M, filterJourneys FilterJourneyConfig, sFields stdfields.StdMonitorFields) jobs.Job { return func(event *beat.Event) ([]jobs.Job, error) { senr := newStreamEnricher(sFields) mpx, err := runCmd(ctx, newCmd(), stdinStr, params, filterJourneys) @@ -124,7 +124,7 @@ func readResultsJob(ctx context.Context, synthEvents <-chan *SynthEvent, enrich // the params var as a CLI argument. func runCmd( ctx context.Context, - cmd *exec.Cmd, + cmd *SynthCmd, stdinStr *string, params mapstr.M, filterJourneys FilterJourneyConfig, @@ -151,12 +151,9 @@ func runCmd( cmd.Args = append(cmd.Args, "--match", filterJourneys.Match) } - // Variant of the command with no params, which could contain sensitive stuff - loggableCmd := exec.Command(cmd.Path, cmd.Args...) //nolint:gosec // we are safely building a command here... if len(params) > 0 { paramsBytes, _ := json.Marshal(params) cmd.Args = append(cmd.Args, "--params", string(paramsBytes)) - loggableCmd.Args = append(loggableCmd.Args, "--params", fmt.Sprintf("\"{%d hidden params}\"", len(params))) } // We need to pass both files in here otherwise we get a broken pipe, even @@ -166,10 +163,10 @@ func runCmd( // see the docs for ExtraFiles in https://golang.org/pkg/os/exec/#Cmd cmd.Args = append(cmd.Args, "--outfd", "3") - logp.Info("Running command: %s in directory: '%s'", loggableCmd.String(), cmd.Dir) + logp.L().Info("Running command: %s in directory: '%s'", cmd, cmd.Dir) if stdinStr != nil { - logp.Debug(debugSelector, "Using stdin str %s", *stdinStr) + logp.L().Debug(debugSelector, "Using stdin str %s", *stdinStr) cmd.Stdin = strings.NewReader(*stdinStr) } @@ -184,7 +181,7 @@ func runCmd( go func() { err := scanToSynthEvents(stdoutPipe, stdoutToSynthEvent, mpx.writeSynthEvent) if err != nil { - logp.Warn("could not scan stdout events from synthetics: %s", err) + logp.L().Warn("could not scan stdout events from synthetics: %s", err) } wg.Done() @@ -198,7 +195,7 @@ func runCmd( go func() { err := scanToSynthEvents(stderrPipe, stderrToSynthEvent, mpx.writeSynthEvent) if err != nil { - logp.Warn("could not scan stderr events from synthetics: %s", err) + logp.L().Warn("could not scan stderr events from synthetics: %s", err) } wg.Done() }() @@ -247,7 +244,7 @@ func runCmd( err = <-cmdStarted if err != nil { - logp.Warn("Could not start command %s: %s", cmd, err) + logp.L().Warn("Could not start command %s: %s", cmd, err) return nil, err } @@ -264,7 +261,7 @@ func runCmd( err := cmd.Process.Kill() if err != nil { - logp.Warn("could not kill synthetics process: %s", err) + logp.L().Warn("could not kill synthetics process: %s", err) } }() @@ -272,19 +269,19 @@ func runCmd( go func() { err := <-cmdDone _ = jsonWriter.Close() - logp.Info("Command has completed(%d): %s", cmd.ProcessState.ExitCode(), loggableCmd.String()) + logp.L().Info("Command has completed(%d): %s", cmd.ProcessState.ExitCode(), cmd) var cmdError *SynthError = nil if err != nil { // err could be generic or it could have been killed by context timeout, log and check context // to decide which error to stream - logp.Warn("Error executing command '%s' (%d): %s", loggableCmd.String(), cmd.ProcessState.ExitCode(), err) + logp.L().Warn("Error executing command '%s' (%d): %s", cmd, cmd.ProcessState.ExitCode(), err) if errors.Is(ctx.Err(), context.DeadlineExceeded) { timeout, _ := ctx.Value(SynthexecTimeout).(time.Duration) - cmdError = ECSErrToSynthError(ecserr.NewCmdTimeoutStatusErr(timeout, loggableCmd.String())) + cmdError = ECSErrToSynthError(ecserr.NewCmdTimeoutStatusErr(timeout, cmd.String())) } else { - cmdError = ECSErrToSynthError(ecserr.NewBadCmdStatusErr(cmd.ProcessState.ExitCode(), loggableCmd.String())) + cmdError = ECSErrToSynthError(ecserr.NewBadCmdStatusErr(cmd.ProcessState.ExitCode(), cmd.String())) } } @@ -315,7 +312,7 @@ func scanToSynthEvents(rdr io.ReadCloser, transform func(bytes []byte, text stri for scanner.Scan() { se, err := transform(scanner.Bytes(), scanner.Text()) if err != nil { - logp.Warn("error parsing line: %s for line: %s", err, scanner.Text()) + logp.L().Warn("error parsing line: %s for line: %s", err, scanner.Text()) continue } if se != nil { @@ -324,7 +321,7 @@ func scanToSynthEvents(rdr io.ReadCloser, transform func(bytes []byte, text stri } if scanner.Err() != nil { - logp.Warn("error scanning synthetics runner results %s", scanner.Err()) + logp.L().Warn("error scanning synthetics runner results %s", scanner.Err()) return scanner.Err() } @@ -337,7 +334,7 @@ var stderrToSynthEvent = lineToSynthEventFactory(Stderr) // lineToSynthEventFactory is a factory that can take a line from the scanner and transform it into a *SynthEvent. func lineToSynthEventFactory(typ string) func(bytes []byte, text string) (res *SynthEvent, err error) { return func(bytes []byte, text string) (res *SynthEvent, err error) { - logp.Info("%s: %s", typ, text) + logp.L().Info("%s: %s", typ, text) return &SynthEvent{ Type: typ, TimestampEpochMicros: float64(time.Now().UnixMicro()), diff --git a/x-pack/heartbeat/monitors/browser/synthexec/synthexec_linux.go b/x-pack/heartbeat/monitors/browser/synthexec/synthexec_linux.go index c90f6083a0d..266b78ea611 100644 --- a/x-pack/heartbeat/monitors/browser/synthexec/synthexec_linux.go +++ b/x-pack/heartbeat/monitors/browser/synthexec/synthexec_linux.go @@ -7,7 +7,6 @@ package synthexec import ( "os" - "os/exec" "golang.org/x/sys/unix" @@ -16,7 +15,7 @@ import ( ) func init() { - platformCmdMutate = func(cmd *exec.Cmd) { + platformCmdMutate = func(cmd *SynthCmd) { logp.L().Warn("invoking node as:", security.NodeChildProcCred, " from: ", os.Getenv("HOME")) // Note that while cmd.SysProcAtr takes a syscall.SysProcAttr object // we are passing in a unix.SysProcAttr object diff --git a/x-pack/heartbeat/monitors/browser/synthexec/synthexec_test.go b/x-pack/heartbeat/monitors/browser/synthexec/synthexec_test.go index 762b12358a7..b4b472caf17 100644 --- a/x-pack/heartbeat/monitors/browser/synthexec/synthexec_test.go +++ b/x-pack/heartbeat/monitors/browser/synthexec/synthexec_test.go @@ -183,7 +183,7 @@ func runAndCollect(t *testing.T, cmd *exec.Cmd, stdinStr string, cmdTimeout time cmd.Dir = filepath.Join(cwd, "testcmd") ctx := context.WithValue(context.TODO(), SynthexecTimeout, cmdTimeout) - mpx, err := runCmd(ctx, cmd, &stdinStr, nil, FilterJourneyConfig{}) + mpx, err := runCmd(ctx, &SynthCmd{cmd}, &stdinStr, nil, FilterJourneyConfig{}) require.NoError(t, err) var synthEvents []*SynthEvent