diff --git a/build/helper.go b/build/helper.go index 3ca9dc3..da6c3ba 100644 --- a/build/helper.go +++ b/build/helper.go @@ -8,7 +8,7 @@ import ( func runExec(a *goyek.A, cmdLine string, opts ...cmd.Option) bool { a.Helper() - a.Log("Exec: ", cmdLine) + a.Log("Exec: ", cmd.Mask(cmdLine)) return cmd.Exec(a, cmdLine, opts...) } diff --git a/build/security_test.go b/build/security_test.go new file mode 100644 index 0000000..9185ebc --- /dev/null +++ b/build/security_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/goyek/goyek/v3" +) + +func TestRunExec_Masking(t *testing.T) { + sb := &strings.Builder{} + + // Middleware to capture output + mw := func(next goyek.Runner) goyek.Runner { + return func(in goyek.Input) goyek.Result { + in.Output = io.MultiWriter(in.Output, sb) + return next(in) + } + } + + f := &goyek.Flow{} + f.Define(goyek.Task{ + Name: "test", + Action: func(a *goyek.A) { + runExec(a, "SECRET=password echo hello") + }, + }) + f.Use(mw) + + _ = f.Execute(context.Background(), []string{"test"}) + + got := sb.String() + if strings.Contains(got, "password") { + t.Errorf("Secret 'password' found in logs: %s", got) + } + if !strings.Contains(got, "SECRET=[MASKED]") { + t.Errorf("Masked secret not found in logs: %s", got) + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index dceb163..afabe52 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -48,6 +48,38 @@ func Exec(a *goyek.A, cmdLine string, opts ...Option) bool { return true } +// Mask returns a masked command line where the values of leading environment variables are replaced with [MASKED]. +func Mask(cmdLine string) string { + envs, args, err := shellwords.ParseWithEnvs(cmdLine) + if err != nil || len(envs) == 0 { + return cmdLine + } + + var sb strings.Builder + for i, env := range envs { + if i > 0 { + sb.WriteByte(' ') + } + key, _, _ := strings.Cut(env, "=") + sb.WriteString(key) + sb.WriteString("=[MASKED]") + } + + for _, arg := range args { + if sb.Len() > 0 { + sb.WriteByte(' ') + } + if strings.Contains(arg, " ") { + sb.WriteByte('"') + sb.WriteString(arg) + sb.WriteByte('"') + } else { + sb.WriteString(arg) + } + } + return sb.String() +} + // Dir is an option to set the working directory. func Dir(s string) Option { return func(_ *goyek.A, cmd *exec.Cmd) { diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index cdd0bd2..a808c2c 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -175,3 +175,64 @@ func TestExec_EnvOnly(t *testing.T) { }() Exec(&goyek.A{}, "FOO=bar") } + +func TestMask(t *testing.T) { + tests := []struct { + name string + cmdLine string + want string + }{ + { + name: "empty", + cmdLine: "", + want: "", + }, + { + name: "no env vars", + cmdLine: "ls -l", + want: "ls -l", + }, + { + name: "one env var", + cmdLine: "FOO=bar ls -l", + want: "FOO=[MASKED] ls -l", + }, + { + name: "multiple env vars", + cmdLine: "FOO=bar BAZ=qux ls -l", + want: "FOO=[MASKED] BAZ=[MASKED] ls -l", + }, + { + name: "env var with space", + cmdLine: `FOO="bar baz" ls -l`, + want: "FOO=[MASKED] ls -l", + }, + { + name: "quoted arg", + cmdLine: `echo "hello world"`, + want: `echo "hello world"`, + }, + { + name: "env var and quoted arg", + cmdLine: `FOO=bar echo "hello world"`, + want: `FOO=[MASKED] echo "hello world"`, + }, + { + name: "parse error", + cmdLine: `FOO="bar`, + want: `FOO="bar`, + }, + { + name: "no trailing space", + cmdLine: "FOO=bar ls", + want: "FOO=[MASKED] ls", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Mask(tt.cmdLine); got != tt.want { + t.Errorf("Mask() = %q, want %q", got, tt.want) + } + }) + } +}