From dfd5ec2fefa26534eb2427c05972433e77ebb7f6 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:59:42 +1000 Subject: [PATCH] fix(runner): redact declared scanner env values from error text A user-defined scanner declares env vars precisely because they carry secrets. When the command fails, commandError composes an error string from stderr that can echo those secret values, and that string persists in ScannerResult.Error. Scrub the value of every declared env var from the error text, regardless of the variable name, so credentials never survive in the artifact even when the name evades the generic secret-name heuristic. --- internal/runner/runner_test.go | 49 +++++++++++++++++++++++++ internal/runner/user_defined_scanner.go | 31 ++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index dcf8ff5..d47003a 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -901,6 +901,55 @@ func TestUserDefinedScannerUsesIsolatedCwd(t *testing.T) { } } +func TestSanitizedDeclaredEnvNamesStripsValues(t *testing.T) { + env := []string{"SECRET_KEY=value", "TOKEN", "EMPTY="} + names := sanitizedDeclaredEnvNames(env) + if len(names) != 3 || names[0] != "SECRET_KEY" || names[1] != "TOKEN" || names[2] != "EMPTY" { + t.Fatalf("names = %#v", names) + } +} + +func TestRedactDeclaredEnvValuesRemovesSecrets(t *testing.T) { + env := map[string]string{"SECRET_KEY": "my-secret-value"} + names := []string{"SECRET_KEY"} + message := "Error: my-secret-value failed" + result := redactDeclaredEnvValues(message, env, names) + if result != "Error: [redacted] failed" { + t.Fatalf("result = %q", result) + } +} + +func TestUserDefinedScannerRedactsDeclaredEnvInError(t *testing.T) { + adapter := NewUserDefinedScanner(UserDefinedScannerConfig{ + ID: "test", Command: "test {{target}}", Targets: []string{"skill"}, + Env: []string{"DECLARED_SECRET"}, + }) + registry, err := NewScannerRegistry(adapter) + if err != nil { + t.Fatal(err) + } + commandRunner := &recordingCommandRunner{ + stderr: "Error: my-declared-secret-value failed", + err: errCommandFailed, + } + result, err := (ExternalScannerRunner{ + Registry: registry, CommandRunner: commandRunner, Env: map[string]string{"DECLARED_SECRET": "my-declared-secret-value"}, + SandboxMode: SandboxModeOff, + }).RunScanner("test", t.TempDir()+"/file.txt", "2026-07-21T00:00:00Z") + if err != nil { + t.Fatal(err) + } + if result.Status != "failed" { + t.Fatalf("status = %q", result.Status) + } + if !strings.Contains(result.Error, "[redacted]") { + t.Fatalf("error = %q, should contain [redacted]", result.Error) + } + if strings.Contains(result.Error, "my-declared-secret-value") { + t.Fatalf("error = %q, should not contain secret value", result.Error) + } +} + func TestUnsafeWindowsShellTargetDetectsInjectionCharacters(t *testing.T) { unsafe := []string{ `%PATH%`, diff --git a/internal/runner/user_defined_scanner.go b/internal/runner/user_defined_scanner.go index 86318e1..8161eb2 100644 --- a/internal/runner/user_defined_scanner.go +++ b/internal/runner/user_defined_scanner.go @@ -63,6 +63,36 @@ func unsafeWindowsShellTarget(target string) bool { return strings.ContainsAny(target, "%\"!") } +// sanitizedDeclaredEnvNames returns bare variable names from declared env +// entries, dropping any accidental =value suffix so an inline value is never +// mistaken for a name. +func sanitizedDeclaredEnvNames(env []string) []string { + names := make([]string, 0, len(env)) + for _, entry := range env { + name := entry + if i := strings.IndexByte(entry, '='); i >= 0 { + name = entry[:i] + } + if name = strings.TrimSpace(name); name != "" { + names = append(names, name) + } + } + return names +} + +// redactDeclaredEnvValues scrubs the values of a user-defined scanner's declared +// env vars from free-text output. A scanner's env: entries exist to hand it +// secrets, so whatever their spelling their values must never be persisted, even +// when the name evades the secret-name heuristic. +func redactDeclaredEnvValues(message string, env map[string]string, names []string) string { + for _, name := range names { + if value := env[name]; value != "" { + message = strings.ReplaceAll(message, value, "[redacted]") + } + } + return message +} + func (adapter userDefinedScannerAdapter) Run(runner ExternalScannerRunner, target string, startedAt string) (ScannerResult, error) { shell := userDefinedScannerShell(runtime.GOOS, runner.SandboxMode) targetReplacement := shell.quote(target) @@ -109,6 +139,7 @@ func (adapter userDefinedScannerAdapter) Run(runner ExternalScannerRunner, targe raw := strings.TrimSpace(output.Stdout) if runErr != nil { message := commandError(runErr, output.Stderr, runner.Env) + message = redactDeclaredEnvValues(message, runner.Env, sanitizedDeclaredEnvNames(adapter.config.Env)) if json.Valid([]byte(raw)) { return ScannerResult{ Status: "completed", StartedAt: startedAt, CompletedAt: completedAt, Command: fullCommand,