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,