From 494f41276a9a5170aee03e3d7ac1fac2ccc293c0 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:56:09 +1000 Subject: [PATCH] fix(runner): run user-defined scanners in an isolated working directory The scanner used to run with a working directory derived from the target: the target's parent directory, or the target itself when it was a directory. That handed the scanner writable access to files sitting next to the target, and under Docker that parent became a writable bind mount. Run host scanners in a fresh os.MkdirTemp directory (removed after the run) and keep the Docker cwd empty so nothing target-derived is mounted; the container workdir is already isolated. Removes the now-unused userDefinedScannerCWD helper. --- internal/runner/runner_test.go | 31 ++++++++++++++++++++++++ internal/runner/user_defined_scanner.go | 32 +++++++++++++------------ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 6319b5f..dcf8ff5 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -870,6 +870,37 @@ func TestRunnableBenchmarkScannersFiltersUnsupportedTargetKind(t *testing.T) { } } +func TestUserDefinedScannerUsesIsolatedCwd(t *testing.T) { + adapter := NewUserDefinedScanner(UserDefinedScannerConfig{ + ID: "test", Command: "test {{target}}", Targets: []string{"skill"}, + }) + registry, err := NewScannerRegistry(adapter) + if err != nil { + t.Fatal(err) + } + commandRunner := &recordingCommandRunner{stdout: `{}`} + targetDir := t.TempDir() + result, err := (ExternalScannerRunner{ + Registry: registry, CommandRunner: commandRunner, Env: map[string]string{}, SandboxMode: SandboxModeOff, + }).RunScanner("test", filepath.Join(targetDir, "file.txt"), "2026-07-21T00:00:00Z") + if err != nil { + t.Fatal(err) + } + if result.Status != "completed" { + t.Fatalf("result = %#v", result) + } + if len(commandRunner.calls) != 1 { + t.Fatalf("calls = %#v", commandRunner.calls) + } + cwd := commandRunner.calls[0].cwd + if cwd == "" || cwd == targetDir || strings.HasPrefix(cwd, targetDir) { + t.Fatalf("cwd = %q should be isolated, not targetdir = %q", cwd, targetDir) + } + if !strings.Contains(cwd, "clawscan-scanner-") { + t.Fatalf("cwd = %q should contain clawscan-scanner prefix", cwd) + } +} + 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 962afc8..86318e1 100644 --- a/internal/runner/user_defined_scanner.go +++ b/internal/runner/user_defined_scanner.go @@ -3,9 +3,7 @@ package runner import ( "encoding/json" "fmt" - "net/url" "os" - "path/filepath" "regexp" "runtime" "strings" @@ -89,7 +87,23 @@ func (adapter userDefinedScannerAdapter) Run(runner ExternalScannerRunner, targe if timeout == 0 { timeout = 20 * time.Minute } - output, runErr := runner.CommandRunner.Run(shell.command, args, userDefinedScannerCWD(target), timeout) + // Never run with a target-derived working directory: it would hand the + // scanner writable access to files next to the target (a writable bind + // mount under Docker). Host runs get a fresh isolated dir; Docker runs keep + // "" so nothing is mounted and the container workdir is already isolated. + cwd := "" + if runner.SandboxMode != SandboxModeDocker { + isolated, err := os.MkdirTemp("", "clawscan-scanner-") + if err != nil { + return ScannerResult{ + Status: "failed", StartedAt: startedAt, CompletedAt: time.Now().UTC().Format(time.RFC3339Nano), + Error: fmt.Sprintf("User-defined scanner %s could not create an isolated working directory: %v", adapter.config.ID, err), + }, nil + } + defer os.RemoveAll(isolated) + cwd = isolated + } + output, runErr := runner.CommandRunner.Run(shell.command, args, cwd, timeout) exitCode := gateEligibleExitCode(output.ExitCode) completedAt := time.Now().UTC().Format(time.RFC3339Nano) raw := strings.TrimSpace(output.Stdout) @@ -133,15 +147,3 @@ func userDefinedScannerShell(goos string, sandboxMode string) judgeShellSpec { } var targetPlaceholderPattern = regexp.MustCompile(`\{\{\s*target\s*\}\}`) - -func userDefinedScannerCWD(target string) string { - parsed, err := url.Parse(target) - if err == nil && parsed.Scheme != "" && parsed.Host != "" { - return "" - } - info, err := os.Stat(target) - if err == nil && info.IsDir() { - return target - } - return filepath.Dir(target) -}