From 921ccd6361e16324dfa357e16ef9ab7b7a0eeea7 Mon Sep 17 00:00:00 2001 From: "Ralian.ENG" Date: Sat, 9 May 2026 01:43:22 +0900 Subject: [PATCH 1/3] fix(sandbox): surface fingerprint-erasure and probe-script failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eraseFingerprints, plantHoneypotFiles, restoreLocalBin, WriteProbeScripts, and WriteProbeScriptsMulti previously discarded the result of every docker-exec call (`_ = cmd.Run()`). A failure here — e.g. /.dockerenv removal rejected by a corrupted runtime, or a probe-script write that silently dropped — would let the scan continue against a partially prepared sandbox: /.dockerenv intact, honeypot files missing, or an import phase that runs without its OS-spoofing wrapper. Sandbox-aware malware that simply detected the gap and stayed dormant would surface as a "clean" verdict — breaking the detection contract that "clean means no malicious behavior was observed." This change makes the entire post-start setup fail loud: - dockerExecRoot / dockerWriteFile return errors wrapped with the command (or path) for diagnosis. Honeypot content is omitted from the error to avoid spilling generated credentials into logs. - The three setup helpers and both probe-script writers fail-fast on the first underlying error. - StartPaused and Start route through a shared prepareSandboxState helper that propagates any setup failure. Callers in cmd/root.go abort the scan rather than reporting clean. - TestEraseFingerprints_FailLoud / TestPlantHoneypotFiles_FailLoud / TestRestoreLocalBin_FailLoud / TestWriteProbeScripts*_FailLoud pin the contract: a failing docker command must surface as an error. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + cmd/root.go | 13 +- internal/sandbox/sandbox.go | 242 +++++++++++++++++-------- internal/sandbox/sandbox_extra_test.go | 67 ++++++- internal/sandbox/sandbox_mock_test.go | 32 ++-- 5 files changed, 260 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee840b3..a977523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation aligned with implementation: README/SPECIFICATION/SECURITY now describe the actual `--network=none` sandbox (previously claimed an isolated bridge); Python audit hook list corrected (`compile`/`exec`/`import` — `eval` is a Node.js-only event); SPECIFICATION test-data section rewritten around `probe-alpha`/`probe-npm`/`evasion-test` (the obsolete `axios-demo` entry was stale) ### Fixed +- **Sandbox preparation no longer fails silently** — `eraseFingerprints`, `plantHoneypotFiles`, `restoreLocalBin`, `WriteProbeScripts`, and `WriteProbeScriptsMulti` now return errors instead of swallowing the result of every `docker exec`. A failure during fingerprint-erasure (e.g. `/.dockerenv` removal failing) used to leave the container detectably-a-sandbox but the scan would continue and report `clean` for any sandbox-aware payload that simply stayed dormant. The errors are propagated through `Start` / `StartPaused` and surfaced to the CLI, which aborts the scan rather than producing a false-clean verdict - 21 linter errors: gofmt (15 files), importShadow (2), ifElseChain (1), godot (1), intrange (1), staticcheck De Morgan (1) ## [0.5.0] diff --git a/cmd/root.go b/cmd/root.go index a45e40f..dcf9670 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -434,7 +434,9 @@ func runBatchScreening(deps []depfile.Dep, ecosystem string) (string, error) { } // Import all packages under simulated OS identities (3 scripts total). - sb.WriteProbeScriptsMulti(ctx, pkgNames) + if err := sb.WriteProbeScriptsMulti(ctx, pkgNames); err != nil { + return "", fmt.Errorf("writing batch probe scripts: %w", err) + } importCmds := sb.ImportCommandsMulti(pkgNames) osNames := []string{"Linux", "Windows", "macOS"} @@ -966,7 +968,10 @@ func runEBPFProbe(ctx context.Context, sb *sandbox.Sandbox, _ string) (*scanResu // platform-gated payloads. Without this, eBPF mode misses every // __init__.py-resident attack — most pypi malware lives here, not // in setup.py. - sb.WriteProbeScripts(ctx) + if err := sb.WriteProbeScripts(ctx); err != nil { + _ = ep.Close() + return nil, fmt.Errorf("writing probe scripts: %w", err) + } importCmds := sb.ImportCommands() osNames := []string{"Linux", "Windows", "macOS"} for i, cmd := range importCmds { @@ -1045,7 +1050,9 @@ func runContainerStraceProbe(ctx context.Context, sb *sandbox.Sandbox, _ string) // Phase 2: Import under each simulated OS to defeat platform-gated payloads. // Write probe scripts to /tmp first (outside strace), then execute them. - sb.WriteProbeScripts(ctx) + if err := sb.WriteProbeScripts(ctx); err != nil { + return nil, fmt.Errorf("writing probe scripts: %w", err) + } importCmds := sb.ImportCommands() osNames := []string{"Linux", "Windows", "macOS"} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 75da4c0..7e45b50 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -367,6 +367,12 @@ func (s *Sandbox) Create(ctx context.Context) error { // StartPaused starts the container and immediately pauses it. // This minimizes the TOCTOU window between container start and probe attachment. +// +// Errors from restoreLocalBin / eraseFingerprints / plantHoneypotFiles are +// surfaced rather than swallowed: if the sandbox can't be brought to a +// fingerprint-erased, honeypot-planted state, sandbox-aware malware may stay +// dormant and produce a false-clean verdict. The caller is expected to abort +// the scan (and surface "inconclusive") on any error returned here. func (s *Sandbox) StartPaused(ctx context.Context) error { startCmd := execCommand(ctx, "docker", "start", s.containerID) startCmd.Stdout = io.Discard @@ -376,14 +382,9 @@ func (s *Sandbox) StartPaused(ctx context.Context) error { return fmt.Errorf("docker start failed: %w", err) } - // Restore /usr/local/bin contents that were hidden by the tmpfs overlay. - s.restoreLocalBin(ctx) - - // Erase container fingerprints that sandbox-detection code looks for. - s.eraseFingerprints(ctx) - - // Plant fake credential files to trigger credential-harvesting malware. - s.plantHoneypotFiles(ctx) + if err := s.prepareSandboxState(ctx); err != nil { + return err + } if err := s.Pause(ctx); err != nil { return fmt.Errorf("immediate pause after start: %w", err) @@ -393,7 +394,8 @@ func (s *Sandbox) StartPaused(ctx context.Context) error { } // Start creates and starts the sandbox container (convenience for strace-container mode -// which does not need the pause-before-probe pattern). +// which does not need the pause-before-probe pattern). See StartPaused for the +// rationale on surfacing prep errors. func (s *Sandbox) Start(ctx context.Context) error { if err := s.Create(ctx); err != nil { return err @@ -407,34 +409,59 @@ func (s *Sandbox) Start(ctx context.Context) error { return fmt.Errorf("docker start failed: %w", err) } - // Restore /usr/local/bin contents that were hidden by the tmpfs overlay. - s.restoreLocalBin(ctx) - - // Erase container fingerprints that sandbox-detection code looks for. - s.eraseFingerprints(ctx) - - // Plant fake credential files to trigger credential-harvesting malware. - s.plantHoneypotFiles(ctx) + return s.prepareSandboxState(ctx) +} +// prepareSandboxState runs the post-start container setup that every scan +// depends on. Each step is fail-loud: a swallowed error here would let the +// scan run in a partially-prepared sandbox (e.g. /.dockerenv still present, +// honeypot files missing) and report "clean" for malware that simply detected +// the gap and stayed quiet. +func (s *Sandbox) prepareSandboxState(ctx context.Context) error { + if err := s.restoreLocalBin(ctx); err != nil { + return fmt.Errorf("restoring sandbox tmpfs overlays: %w", err) + } + if err := s.eraseFingerprints(ctx); err != nil { + return fmt.Errorf("erasing sandbox fingerprints: %w", err) + } + if err := s.plantHoneypotFiles(ctx); err != nil { + return fmt.Errorf("planting honeypot files: %w", err) + } return nil } // restoreTmpfsOverlays copies backed-up contents into tmpfs-mounted directories // so that pip, python3, setuptools, etc. are available after the overlay hides them. // Also fixes permissions so the container user (dev) can write to site-packages. -func (s *Sandbox) restoreLocalBin(ctx context.Context) { - s.dockerExecRoot(ctx, "cp", "-a", "/usr/local/bin.bak/.", "/usr/local/bin/") - s.dockerExecRoot(ctx, "chmod", "-R", "a+rw", "/usr/local/bin") +// +// Failures are fatal: if pip/python3 are not present at the expected paths the +// install phase will fail in a confusing way, and a missing chmod can leave +// site-packages read-only so the install silently produces no events. +func (s *Sandbox) restoreLocalBin(ctx context.Context) error { + if err := s.dockerExecRoot(ctx, "cp", "-a", "/usr/local/bin.bak/.", "/usr/local/bin/"); err != nil { + return err + } + if err := s.dockerExecRoot(ctx, "chmod", "-R", "a+rw", "/usr/local/bin"); err != nil { + return err + } sitePackages := "/usr/local/lib/python" + SandboxPythonVersion + "/site-packages" - s.dockerExecRoot(ctx, "cp", "-a", sitePackages+".bak/.", sitePackages+"/") - s.dockerExecRoot(ctx, "chmod", "-R", "a+rw", sitePackages) + if err := s.dockerExecRoot(ctx, "cp", "-a", sitePackages+".bak/.", sitePackages+"/"); err != nil { + return err + } + if err := s.dockerExecRoot(ctx, "chmod", "-R", "a+rw", sitePackages); err != nil { + return err + } // npm: packageDir is mounted directly as writable /install, // so no copy is needed. Just fix ownership for the dev user. if s.ecosystem == types.EcosystemNpm { - s.dockerExecRoot(ctx, "chown", "-R", "1000:1000", "/install") + if err := s.dockerExecRoot(ctx, "chown", "-R", "1000:1000", "/install"); err != nil { + return err + } } + + return nil } // base62Chars is the character set used by real AWS/GitHub/npm tokens. @@ -515,7 +542,11 @@ func honeypotEnvVars() []string { // When malware reads these via openat, the access is detected by the // sensitive-path monitor. If it then tries to exfiltrate the contents, // the connect/sendto monitor catches the network activity. -func (s *Sandbox) plantHoneypotFiles(ctx context.Context) { +// +// Failure here is fatal: a partially-planted set of honeypots leaves the +// detection contract ("if you read .ssh/id_rsa, you tripped the monitor") +// unenforceable for the missing files. +func (s *Sandbox) plantHoneypotFiles(ctx context.Context) error { home := "/home/dev" // Generate random credentials for this scan. @@ -524,52 +555,77 @@ func (s *Sandbox) plantHoneypotFiles(ctx context.Context) { ghToken := fakeGitHubToken() sshKeyBody := randHex(64) - // SSH key pair. - s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.ssh") - s.dockerWriteFile(ctx, home+"/.ssh/id_rsa", - "-----BEGIN OPENSSH PRIVATE KEY-----\n"+ - "b3BlbnNzaC1rZXktdjEAAAAAFAAAAAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5\n"+ - "AAAAI"+sshKeyBody+"\n"+ - "-----END OPENSSH PRIVATE KEY-----\n") - s.dockerExecRoot(ctx, "chmod", "600", home+"/.ssh/id_rsa") - - // AWS credentials. - s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.aws") - s.dockerWriteFile(ctx, home+"/.aws/credentials", - "[default]\n"+ - "aws_access_key_id = "+awsKey+"\n"+ - "aws_secret_access_key = "+awsSecret+"\n") - - // Git credentials. - s.dockerWriteFile(ctx, home+"/.git-credentials", - "https://dev:"+ghToken+"@github.com\n") - s.dockerExecRoot(ctx, "chmod", "600", home+"/.git-credentials") - - // Netrc. - s.dockerWriteFile(ctx, home+"/.netrc", - "machine github.com\n"+ - "login dev\n"+ - "password "+ghToken+"\n") - s.dockerExecRoot(ctx, "chmod", "600", home+"/.netrc") - - // GitHub CLI config. - s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.config/gh") - s.dockerWriteFile(ctx, home+"/.config/gh/hosts.yml", - "github.com:\n"+ - " oauth_token: "+ghToken+"\n"+ - " user: dev\n"+ - " git_protocol: https\n") - - // Fix ownership so the container user (dev) owns the files. - s.dockerExecRoot(ctx, "chown", "-R", "1000:1000", home+"/.ssh", home+"/.aws", - home+"/.git-credentials", home+"/.netrc", home+"/.config") + steps := []func() error{ + // SSH key pair. + func() error { return s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.ssh") }, + func() error { + return s.dockerWriteFile(ctx, home+"/.ssh/id_rsa", + "-----BEGIN OPENSSH PRIVATE KEY-----\n"+ + "b3BlbnNzaC1rZXktdjEAAAAAFAAAAAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5\n"+ + "AAAAI"+sshKeyBody+"\n"+ + "-----END OPENSSH PRIVATE KEY-----\n") + }, + func() error { return s.dockerExecRoot(ctx, "chmod", "600", home+"/.ssh/id_rsa") }, + + // AWS credentials. + func() error { return s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.aws") }, + func() error { + return s.dockerWriteFile(ctx, home+"/.aws/credentials", + "[default]\n"+ + "aws_access_key_id = "+awsKey+"\n"+ + "aws_secret_access_key = "+awsSecret+"\n") + }, + + // Git credentials. + func() error { + return s.dockerWriteFile(ctx, home+"/.git-credentials", + "https://dev:"+ghToken+"@github.com\n") + }, + func() error { return s.dockerExecRoot(ctx, "chmod", "600", home+"/.git-credentials") }, + + // Netrc. + func() error { + return s.dockerWriteFile(ctx, home+"/.netrc", + "machine github.com\n"+ + "login dev\n"+ + "password "+ghToken+"\n") + }, + func() error { return s.dockerExecRoot(ctx, "chmod", "600", home+"/.netrc") }, + + // GitHub CLI config. + func() error { return s.dockerExecRoot(ctx, "mkdir", "-p", home+"/.config/gh") }, + func() error { + return s.dockerWriteFile(ctx, home+"/.config/gh/hosts.yml", + "github.com:\n"+ + " oauth_token: "+ghToken+"\n"+ + " user: dev\n"+ + " git_protocol: https\n") + }, + + // Fix ownership so the container user (dev) owns the files. + func() error { + return s.dockerExecRoot(ctx, "chown", "-R", "1000:1000", home+"/.ssh", home+"/.aws", + home+"/.git-credentials", home+"/.netrc", home+"/.config") + }, + } + + for _, step := range steps { + if err := step(); err != nil { + return err + } + } + return nil } // eraseFingerprints removes or masks signals that reveal the container -// environment to sandbox-aware malware. -func (s *Sandbox) eraseFingerprints(ctx context.Context) { +// environment to sandbox-aware malware. A failure here is fatal: leaving +// /.dockerenv intact lets sandbox-aware payloads stay dormant and produce +// a false-clean verdict. +func (s *Sandbox) eraseFingerprints(ctx context.Context) error { // 1. Remove /.dockerenv sentinel file. - s.dockerExecRoot(ctx, "rm", "-f", "/.dockerenv") + if err := s.dockerExecRoot(ctx, "rm", "-f", "/.dockerenv"); err != nil { + return err + } // 2. /etc/resolv.conf is injected via --dns=198.51.100.1 at container // creation time (RFC 5737 TEST-NET-2, guaranteed unreachable). @@ -578,14 +634,23 @@ func (s *Sandbox) eraseFingerprints(ctx context.Context) { // 3. /proc/1/cgroup and /proc/self/mountinfo are kernel-managed and // cannot be modified without gVisor. Use --runtime=runsc to mask // these remaining signals. + + return nil } -func (s *Sandbox) dockerExecRoot(ctx context.Context, args ...string) { +// dockerExecRoot runs the given command inside the sandbox as root. +// Returns an error wrapped with the sandbox-internal command (not the +// container ID, which is noise for the user) so callers can surface a +// useful message when fingerprint-erasure or honeypot-planting fails. +func (s *Sandbox) dockerExecRoot(ctx context.Context, args ...string) error { cmdArgs := append([]string{"exec", "--user=root", s.containerID}, args...) cmd := execCommand(ctx, "docker", cmdArgs...) cmd.Stdout = io.Discard cmd.Stderr = io.Discard - _ = cmd.Run() + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker exec %s: %w", strings.Join(args, " "), err) + } + return nil } // dockerWriteFile writes content into the container at path as root. @@ -595,13 +660,19 @@ func (s *Sandbox) dockerExecRoot(ctx context.Context, args ...string) { // to break out of the heredoc and execute arbitrary commands as root in // the container. path is single-quoted as defense-in-depth even though // every current caller passes a constant. -func (s *Sandbox) dockerWriteFile(ctx context.Context, path, content string) { +// +// The content is omitted from the error to avoid spilling honeypot +// credentials or large probe scripts into logs. +func (s *Sandbox) dockerWriteFile(ctx context.Context, path, content string) error { cmdArgs := []string{"exec", "-i", "--user=root", s.containerID, "sh", "-c", "cat > " + shQuote(path)} cmd := execCommand(ctx, "docker", cmdArgs...) cmd.Stdin = strings.NewReader(content) cmd.Stdout = io.Discard cmd.Stderr = io.Discard - _ = cmd.Run() + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker write %s: %w", path, err) + } + return nil } // Exec runs a command inside the sandbox container and returns the combined output. @@ -737,7 +808,11 @@ func shQuote(s string) string { // WriteProbeScriptsMulti writes one combined import probe script per OS identity. // This reduces Python/Node process launches from N*3 to just 3. -func (s *Sandbox) WriteProbeScriptsMulti(ctx context.Context, pkgs []string) { +// +// Returns an error if any script fails to land in the container. A missing +// probe script silently skips the corresponding OS-identity import phase, +// hiding platform-gated payloads — that is itself a false-clean vector. +func (s *Sandbox) WriteProbeScriptsMulti(ctx context.Context, pkgs []string) error { if s.ecosystem == types.EcosystemNpm { for _, p := range []string{"linux", "win32", "darwin"} { var requires strings.Builder @@ -751,9 +826,11 @@ func (s *Sandbox) WriteProbeScriptsMulti(ctx context.Context, pkgs []string) { p, requires.String(), ) filename := "/tmp/_kojuto_probe_all_" + p + ".js" - s.dockerWriteFile(ctx, filename, script) + if err := s.dockerWriteFile(ctx, filename, script); err != nil { + return err + } } - return + return nil } type pyPlatform struct { @@ -793,8 +870,11 @@ func (s *Sandbox) WriteProbeScriptsMulti(ctx context.Context, pkgs []string) { p.sep, p.pathsep, p.linesep, imports.String(), ) filename := "/tmp/_kojuto_probe_all_" + p.sysplatform + ".py" - s.dockerWriteFile(ctx, filename, script) + if err := s.dockerWriteFile(ctx, filename, script); err != nil { + return err + } } + return nil } // ImportCommandsMulti returns 3 import commands (one per OS identity) that import all packages. @@ -831,8 +911,9 @@ func (s *Sandbox) ImportCommands() [][]string { } // WriteProbeScripts writes the OS-simulation import scripts into the container's -// /tmp directory. Must be called before ImportCommands. -func (s *Sandbox) WriteProbeScripts(ctx context.Context) { +// /tmp directory. Must be called before ImportCommands. Returns an error if any +// script fails to land — see WriteProbeScriptsMulti for the rationale. +func (s *Sandbox) WriteProbeScripts(ctx context.Context) error { importName := strings.ReplaceAll(s.pkg, "-", "_") // Each simulated OS must be consistent across ALL platform detection APIs. @@ -878,7 +959,9 @@ func (s *Sandbox) WriteProbeScripts(ctx context.Context) { p.sep, p.pathsep, p.linesep, importName, ) filename := "/tmp/_kojuto_probe_" + p.sysplatform + ".py" - s.dockerWriteFile(ctx, filename, script) + if err := s.dockerWriteFile(ctx, filename, script); err != nil { + return err + } } jsPlatforms := []string{"linux", "win32", "darwin"} @@ -890,8 +973,11 @@ func (s *Sandbox) WriteProbeScripts(ctx context.Context) { p, s.pkg, ) filename := "/tmp/_kojuto_probe_" + p + ".js" - s.dockerWriteFile(ctx, filename, script) + if err := s.dockerWriteFile(ctx, filename, script); err != nil { + return err + } } + return nil } // faketimeEnv returns environment variable prefix that activates libfaketime. diff --git a/internal/sandbox/sandbox_extra_test.go b/internal/sandbox/sandbox_extra_test.go index 8c60b13..9df56a8 100644 --- a/internal/sandbox/sandbox_extra_test.go +++ b/internal/sandbox/sandbox_extra_test.go @@ -585,8 +585,10 @@ func TestDockerWriteFile_StructureNoHeredoc(t *testing.T) { t.Cleanup(func() { execCommand = orig }) sb := &Sandbox{containerID: "test-container"} - sb.dockerWriteFile(context.Background(), "/tmp/probe.js", - "any content with KOJUTO_EOF baked in\nstill not parsed by shell") + if err := sb.dockerWriteFile(context.Background(), "/tmp/probe.js", + "any content with KOJUTO_EOF baked in\nstill not parsed by shell"); err != nil { + t.Fatalf("dockerWriteFile: %v", err) + } want := []string{ "docker", "exec", "-i", "--user=root", "test-container", @@ -620,10 +622,69 @@ func TestDockerWriteFile_QuotesPath(t *testing.T) { t.Cleanup(func() { execCommand = orig }) sb := &Sandbox{containerID: "test-container"} - sb.dockerWriteFile(context.Background(), "/tmp/$(rm -rf /)/x", "x") + if err := sb.dockerWriteFile(context.Background(), "/tmp/$(rm -rf /)/x", "x"); err != nil { + t.Fatalf("dockerWriteFile: %v", err) + } last := captured[len(captured)-1] if !strings.Contains(last, `'/tmp/$(rm -rf /)/x'`) { t.Errorf("path not single-quoted in shell arg: %q", last) } } + +// withFailingExec replaces execCommand with one that always exits non-zero, +// simulating a docker daemon hiccup or an in-container command failure. +func withFailingExec(t *testing.T) { + t.Helper() + orig := execCommand + execCommand = func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "false") + } + t.Cleanup(func() { execCommand = orig }) +} + +// TestEraseFingerprints_FailLoud pins the contract that a failing docker +// command (e.g. `rm -f /.dockerenv` rejected by a corrupted runtime) must +// surface as an error rather than silently leaving the fingerprint in place. +// See SECURITY.md "Anti-Fingerprinting" — a swallowed error here would mean +// sandbox-aware malware could detect the container and produce a false-clean +// verdict. +func TestEraseFingerprints_FailLoud(t *testing.T) { + withFailingExec(t) + sb := &Sandbox{containerID: "test-container"} + if err := sb.eraseFingerprints(context.Background()); err == nil { + t.Fatal("eraseFingerprints returned nil despite failing docker command") + } +} + +func TestPlantHoneypotFiles_FailLoud(t *testing.T) { + withFailingExec(t) + sb := &Sandbox{containerID: "test-container"} + if err := sb.plantHoneypotFiles(context.Background()); err == nil { + t.Fatal("plantHoneypotFiles returned nil despite failing docker command") + } +} + +func TestRestoreLocalBin_FailLoud(t *testing.T) { + withFailingExec(t) + sb := &Sandbox{containerID: "test-container", ecosystem: types.EcosystemPyPI} + if err := sb.restoreLocalBin(context.Background()); err == nil { + t.Fatal("restoreLocalBin returned nil despite failing docker command") + } +} + +func TestWriteProbeScripts_FailLoud(t *testing.T) { + withFailingExec(t) + sb := &Sandbox{containerID: "test-container", pkg: "x", ecosystem: types.EcosystemPyPI} + if err := sb.WriteProbeScripts(context.Background()); err == nil { + t.Fatal("WriteProbeScripts returned nil despite failing docker command") + } +} + +func TestWriteProbeScriptsMulti_FailLoud(t *testing.T) { + withFailingExec(t) + sb := &Sandbox{containerID: "test-container", ecosystem: types.EcosystemPyPI} + if err := sb.WriteProbeScriptsMulti(context.Background(), []string{"x"}); err == nil { + t.Fatal("WriteProbeScriptsMulti returned nil despite failing docker command") + } +} diff --git a/internal/sandbox/sandbox_mock_test.go b/internal/sandbox/sandbox_mock_test.go index 30f22b2..58aeb9c 100644 --- a/internal/sandbox/sandbox_mock_test.go +++ b/internal/sandbox/sandbox_mock_test.go @@ -322,8 +322,9 @@ func TestDockerExecRoot(t *testing.T) { sb := newTestSandbox(t, types.EcosystemPyPI) sb.containerID = fakeContainerID - // Should not panic or error (errors are silently discarded). - sb.dockerExecRoot(context.Background(), "ls", "-la") + if err := sb.dockerExecRoot(context.Background(), "ls", "-la"); err != nil { + t.Fatalf("dockerExecRoot: %v", err) + } } func TestExec(t *testing.T) { @@ -369,8 +370,9 @@ func TestWriteProbeScripts(t *testing.T) { sb := newTestSandbox(t, types.EcosystemPyPI) sb.containerID = fakeContainerID - // Should not panic. - sb.WriteProbeScripts(context.Background()) + if err := sb.WriteProbeScripts(context.Background()); err != nil { + t.Fatalf("WriteProbeScripts: %v", err) + } } func TestWriteProbeScripts_Npm(t *testing.T) { @@ -378,7 +380,9 @@ func TestWriteProbeScripts_Npm(t *testing.T) { sb := New(t.TempDir(), "lodash", false, types.EcosystemNpm, "") sb.containerID = fakeContainerID - sb.WriteProbeScripts(context.Background()) + if err := sb.WriteProbeScripts(context.Background()); err != nil { + t.Fatalf("WriteProbeScripts (npm): %v", err) + } } func TestPID(t *testing.T) { @@ -476,8 +480,9 @@ func TestEraseFingerprints(t *testing.T) { sb := newTestSandbox(t, types.EcosystemPyPI) sb.containerID = fakeContainerID - // Should not panic. - sb.eraseFingerprints(context.Background()) + if err := sb.eraseFingerprints(context.Background()); err != nil { + t.Fatalf("eraseFingerprints: %v", err) + } } func TestPlantHoneypotFiles(t *testing.T) { @@ -485,8 +490,9 @@ func TestPlantHoneypotFiles(t *testing.T) { sb := newTestSandbox(t, types.EcosystemPyPI) sb.containerID = fakeContainerID - // Should not panic. - sb.plantHoneypotFiles(context.Background()) + if err := sb.plantHoneypotFiles(context.Background()); err != nil { + t.Fatalf("plantHoneypotFiles: %v", err) + } } func TestRestoreLocalBin_PyPI(t *testing.T) { @@ -495,7 +501,9 @@ func TestRestoreLocalBin_PyPI(t *testing.T) { sb.containerID = fakeContainerID sb.mountPoint = testMountPoint - sb.restoreLocalBin(context.Background()) + if err := sb.restoreLocalBin(context.Background()); err != nil { + t.Fatalf("restoreLocalBin: %v", err) + } } func TestRestoreLocalBin_Npm(t *testing.T) { @@ -505,7 +513,9 @@ func TestRestoreLocalBin_Npm(t *testing.T) { sb.mountPoint = testMountPoint // For npm, restoreLocalBin also copies node_modules. - sb.restoreLocalBin(context.Background()) + if err := sb.restoreLocalBin(context.Background()); err != nil { + t.Fatalf("restoreLocalBin (npm): %v", err) + } } func TestStart_Npm(t *testing.T) { From 459ced9abfbf29dbab88f173666ae921738b8885 Mon Sep 17 00:00:00 2001 From: "Ralian.ENG" Date: Sat, 9 May 2026 02:06:40 +0900 Subject: [PATCH 2/3] fix(sandbox): mask /.dockerenv at create time instead of post-start rm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world scan of `six` failed with the previous commit's fail-loud change: Error: starting sandbox: erasing sandbox fingerprints: \ docker exec rm -f /.dockerenv: exit status 1 Root cause: the rootfs is mounted --read-only, so `rm -f /.dockerenv` inside the container has never succeeded. The original silent-fail hid the bug; surfacing the error exposed it. Anti-fingerprinting was effectively never applied for /.dockerenv since --read-only was introduced. Fix: bind-mount an empty regular file from the host over /.dockerenv at container creation time. The mask file lives in the per-scan seccomp temp dir (already cleaned up by Cleanup), is created by writeSeccompProfile, and is wired in via `--mount=type=bind,src=...,dst=/.dockerenv,readonly`. Sandbox-aware payloads that read /.dockerenv now see empty content. A regular empty file is used (not /dev/null) so stat().S_ISREG() still returns true — a char device would itself be a fingerprint. eraseFingerprints had only this one operation, so the function and its two tests are removed; prepareSandboxState now calls only restoreLocalBin and plantHoneypotFiles. SECURITY.md is corrected to describe the actual mechanism. TestContainerArgs gains an assertion that the bind-mount flag and host-side mask file are present. Path-existence checks (`os.path.exists("/.dockerenv")`) still see something at the path; --runtime=runsc remains the recommendation for defeating those. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +- SECURITY.md | 2 +- internal/sandbox/sandbox.go | 92 +++++++++++++++----------- internal/sandbox/sandbox_extra_test.go | 14 ---- internal/sandbox/sandbox_mock_test.go | 21 +++--- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a977523..013c34a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation aligned with implementation: README/SPECIFICATION/SECURITY now describe the actual `--network=none` sandbox (previously claimed an isolated bridge); Python audit hook list corrected (`compile`/`exec`/`import` — `eval` is a Node.js-only event); SPECIFICATION test-data section rewritten around `probe-alpha`/`probe-npm`/`evasion-test` (the obsolete `axios-demo` entry was stale) ### Fixed -- **Sandbox preparation no longer fails silently** — `eraseFingerprints`, `plantHoneypotFiles`, `restoreLocalBin`, `WriteProbeScripts`, and `WriteProbeScriptsMulti` now return errors instead of swallowing the result of every `docker exec`. A failure during fingerprint-erasure (e.g. `/.dockerenv` removal failing) used to leave the container detectably-a-sandbox but the scan would continue and report `clean` for any sandbox-aware payload that simply stayed dormant. The errors are propagated through `Start` / `StartPaused` and surfaced to the CLI, which aborts the scan rather than producing a false-clean verdict +- **`/.dockerenv` masking actually works now** — the post-start `rm -f /.dockerenv` had been silently failing on every scan since `--read-only` rootfs was introduced (the rootfs is, by design, not writable). `/.dockerenv` is now masked at container creation time by bind-mounting an empty regular file from the host over the path. Sandbox-aware payloads that read `/.dockerenv` see empty content; gVisor (`--runtime=runsc`) is still recommended to also defeat path-existence checks +- **Sandbox preparation no longer fails silently** — `plantHoneypotFiles`, `restoreLocalBin`, `WriteProbeScripts`, and `WriteProbeScriptsMulti` now return errors instead of swallowing the result of every `docker exec`. A swallowed honeypot-write or probe-script-write failure used to leave the container partially prepared, and any sandbox-aware payload that detected the gap and stayed dormant would surface as `clean`. The errors propagate through `Start` / `StartPaused` and abort the scan - 21 linter errors: gofmt (15 files), importShadow (2), ifElseChain (1), godot (1), intrange (1), staticcheck De Morgan (1) ## [0.5.0] diff --git a/SECURITY.md b/SECURITY.md index 0f25b49..617f03a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,7 +60,7 @@ kojuto is a security tool that intentionally runs untrusted code in an isolated ### Anti-Fingerprinting - Host hostname, username, CPU count, and memory are mirrored into the container -- `/.dockerenv` is removed on startup +- `/.dockerenv` is masked at container creation time by bind-mounting an empty regular file from the host over it (`--read-only` rootfs makes post-start `rm` impossible, so masking is the only mechanism) - Package mount path mirrors host directory layout - `/etc/resolv.conf` is populated via `--dns=198.51.100.1` (RFC 5737 TEST-NET-2, guaranteed unreachable) so the file is non-empty even under `--network=none` — prevents the empty-resolv-conf signal that would reveal isolation. `connect()` returns `ENETUNREACH`; combine with `--runtime runsc` to mask remaining `/proc/1/cgroup` and `/proc/self/mountinfo` signals diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 7e45b50..ab396b7 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -52,7 +52,11 @@ type Sandbox struct { needsPtrace bool localMode bool // when true, install from local files (sdist/wheel) directly seccompDir string // per-instance temp dir for seccomp profile - scanPkgs []string + // dockerenvMask is the host-side empty file bind-mounted over + // /.dockerenv to mask the docker-injected sentinel. Lives inside + // seccompDir so it is cleaned up by the same Cleanup path. + dockerenvMask string + scanPkgs []string } // SetLocalMode enables local package installation mode (sdist support). @@ -103,8 +107,15 @@ func resolveRuntime() string { return RuntimeDefault } -// writeSeccompProfile writes the embedded seccomp profile to a temp file -// and returns the --security-opt flag value to pass to docker. +// writeSeccompProfile writes the embedded seccomp profile and the empty +// /.dockerenv mask file to a per-scan temp dir, then returns the +// --security-opt flag value to pass to docker. The mask path is stashed in +// s.dockerenvMask for containerArgs to bind-mount. +// +// The mask is an empty regular file (not /dev/null) so that +// stat(/.dockerenv).S_ISREG() inside the container still returns true — +// using a char device would itself be a fingerprint that distinguishes +// kojuto's sandbox from a real Docker container. func (s *Sandbox) writeSeccompProfile() (string, error) { dir, err := os.MkdirTemp("", "kojuto-seccomp-*") if err != nil { @@ -117,6 +128,12 @@ func (s *Sandbox) writeSeccompProfile() (string, error) { return "", fmt.Errorf("writing seccomp profile: %w", err) } + dockerenvMask := filepath.Join(dir, "dockerenv-mask") + if err := os.WriteFile(dockerenvMask, nil, 0o444); err != nil { + return "", fmt.Errorf("writing dockerenv mask: %w", err) + } + s.dockerenvMask = dockerenvMask + return "seccomp=" + path, nil } @@ -174,12 +191,26 @@ func (s *Sandbox) containerArgs() ([]string, error) { // Always apply the restrictive seccomp profile regardless of ptrace needs. // Without it, Docker's default seccomp allows memfd_create, userfaultfd, // open_by_handle_at, and other container-escape vectors. + // + // writeSeccompProfile also stages the host-side /.dockerenv mask file + // (s.dockerenvMask) used by the bind mount below. seccompOpt, err := s.writeSeccompProfile() if err != nil { return nil, err } args = append(args, "--security-opt="+seccompOpt) + // Mask the docker-injected /.dockerenv sentinel by bind-mounting an + // empty file from the host over it. We can't `rm /.dockerenv` post- + // start because --read-only blocks rootfs writes (which is exactly + // why the previous post-start `rm -f /.dockerenv` always returned + // exit 1 — silently, until prepareSandboxState was made fail-loud). + // Sandbox-aware payloads that read /.dockerenv now see an empty + // regular file. Path-existence checks (`os.path.exists`) still see + // something, but those are defeated by --runtime=runsc which + // virtualizes the rootfs entirely. + args = append(args, "--mount=type=bind,src="+s.dockerenvMask+",dst=/.dockerenv,readonly") + if s.needsPtrace { // Re-add SYS_PTRACE for strace, CHOWN+FOWNER for tmpfs file setup. args = append(args, "--cap-add=SYS_PTRACE", "--cap-add=CHOWN", "--cap-add=FOWNER") @@ -310,11 +341,14 @@ func getHostUsername() string { // rejects all connect()/sendto() syscalls with ENETUNREACH — there is // no network stack to exploit. // -// Anti-fingerprinting countermeasures (applied post-start via eraseFingerprints): -// - Fake /etc/resolv.conf with a plausible nameserver IP +// Anti-fingerprinting countermeasures: +// - /.dockerenv masked with an empty regular file via bind mount at +// create time (see writeSeccompProfile + containerArgs) +// - Fake /etc/resolv.conf with a plausible nameserver IP via --dns // - connect() returning ENETUNREACH is indistinguishable from a firewalled // host for most malware (only sophisticated actors check errno values) -// - /proc/net/tcp emptiness is mitigated by gVisor (--runtime runsc) +// - /proc/1/cgroup, /proc/self/mountinfo, and /proc/net/tcp emptiness +// are mitigated by gVisor (--runtime=runsc) // // Previous design used --internal bridge networks, which left Docker's // embedded DNS resolver (127.0.0.11) active inside the container. While @@ -368,11 +402,12 @@ func (s *Sandbox) Create(ctx context.Context) error { // StartPaused starts the container and immediately pauses it. // This minimizes the TOCTOU window between container start and probe attachment. // -// Errors from restoreLocalBin / eraseFingerprints / plantHoneypotFiles are -// surfaced rather than swallowed: if the sandbox can't be brought to a -// fingerprint-erased, honeypot-planted state, sandbox-aware malware may stay -// dormant and produce a false-clean verdict. The caller is expected to abort -// the scan (and surface "inconclusive") on any error returned here. +// Errors from restoreLocalBin / plantHoneypotFiles are surfaced rather than +// swallowed: if the sandbox can't be brought to a honeypot-planted state, +// sandbox-aware malware may stay dormant and produce a false-clean verdict. +// The caller is expected to abort the scan (and surface "inconclusive") on +// any error returned here. /.dockerenv masking is handled at create time +// via bind mount (see writeSeccompProfile + containerArgs). func (s *Sandbox) StartPaused(ctx context.Context) error { startCmd := execCommand(ctx, "docker", "start", s.containerID) startCmd.Stdout = io.Discard @@ -414,16 +449,18 @@ func (s *Sandbox) Start(ctx context.Context) error { // prepareSandboxState runs the post-start container setup that every scan // depends on. Each step is fail-loud: a swallowed error here would let the -// scan run in a partially-prepared sandbox (e.g. /.dockerenv still present, -// honeypot files missing) and report "clean" for malware that simply detected -// the gap and stayed quiet. +// scan run in a partially-prepared sandbox (e.g. honeypot files missing) +// and report "clean" for malware that simply detected the gap and stayed +// quiet. +// +// /.dockerenv masking is intentionally NOT here — it's done at create time +// via a bind mount in containerArgs (see writeSeccompProfile). The previous +// post-start `rm -f /.dockerenv` could never succeed under --read-only and +// silently failed for every scan. func (s *Sandbox) prepareSandboxState(ctx context.Context) error { if err := s.restoreLocalBin(ctx); err != nil { return fmt.Errorf("restoring sandbox tmpfs overlays: %w", err) } - if err := s.eraseFingerprints(ctx); err != nil { - return fmt.Errorf("erasing sandbox fingerprints: %w", err) - } if err := s.plantHoneypotFiles(ctx); err != nil { return fmt.Errorf("planting honeypot files: %w", err) } @@ -617,27 +654,6 @@ func (s *Sandbox) plantHoneypotFiles(ctx context.Context) error { return nil } -// eraseFingerprints removes or masks signals that reveal the container -// environment to sandbox-aware malware. A failure here is fatal: leaving -// /.dockerenv intact lets sandbox-aware payloads stay dormant and produce -// a false-clean verdict. -func (s *Sandbox) eraseFingerprints(ctx context.Context) error { - // 1. Remove /.dockerenv sentinel file. - if err := s.dockerExecRoot(ctx, "rm", "-f", "/.dockerenv"); err != nil { - return err - } - - // 2. /etc/resolv.conf is injected via --dns=198.51.100.1 at container - // creation time (RFC 5737 TEST-NET-2, guaranteed unreachable). - // This works on --read-only rootfs and prevents fingerprinting. - - // 3. /proc/1/cgroup and /proc/self/mountinfo are kernel-managed and - // cannot be modified without gVisor. Use --runtime=runsc to mask - // these remaining signals. - - return nil -} - // dockerExecRoot runs the given command inside the sandbox as root. // Returns an error wrapped with the sandbox-internal command (not the // container ID, which is noise for the user) so callers can surface a diff --git a/internal/sandbox/sandbox_extra_test.go b/internal/sandbox/sandbox_extra_test.go index 9df56a8..b8dd0c6 100644 --- a/internal/sandbox/sandbox_extra_test.go +++ b/internal/sandbox/sandbox_extra_test.go @@ -643,20 +643,6 @@ func withFailingExec(t *testing.T) { t.Cleanup(func() { execCommand = orig }) } -// TestEraseFingerprints_FailLoud pins the contract that a failing docker -// command (e.g. `rm -f /.dockerenv` rejected by a corrupted runtime) must -// surface as an error rather than silently leaving the fingerprint in place. -// See SECURITY.md "Anti-Fingerprinting" — a swallowed error here would mean -// sandbox-aware malware could detect the container and produce a false-clean -// verdict. -func TestEraseFingerprints_FailLoud(t *testing.T) { - withFailingExec(t) - sb := &Sandbox{containerID: "test-container"} - if err := sb.eraseFingerprints(context.Background()); err == nil { - t.Fatal("eraseFingerprints returned nil despite failing docker command") - } -} - func TestPlantHoneypotFiles_FailLoud(t *testing.T) { withFailingExec(t) sb := &Sandbox{containerID: "test-container"} diff --git a/internal/sandbox/sandbox_mock_test.go b/internal/sandbox/sandbox_mock_test.go index 58aeb9c..66f9adc 100644 --- a/internal/sandbox/sandbox_mock_test.go +++ b/internal/sandbox/sandbox_mock_test.go @@ -177,6 +177,17 @@ func TestContainerArgs(t *testing.T) { } } + // /.dockerenv must be masked at create time. Post-start `rm` cannot + // succeed on --read-only rootfs, so the bind mount is the only path. + if !strings.Contains(joined, ",dst=/.dockerenv,readonly") { + t.Errorf("containerArgs missing /.dockerenv bind mount in:\n%s", joined) + } + if sb.dockerenvMask == "" { + t.Error("expected dockerenvMask to be set by writeSeccompProfile") + } else if _, statErr := os.Stat(sb.dockerenvMask); statErr != nil { + t.Errorf("dockerenvMask file not created: %v", statErr) + } + // Cleanup seccomp temp dir. if sb.seccompDir != "" { os.RemoveAll(sb.seccompDir) @@ -475,16 +486,6 @@ func TestEnsureImage_Exists(t *testing.T) { } } -func TestEraseFingerprints(t *testing.T) { - withFakeExec(t) - sb := newTestSandbox(t, types.EcosystemPyPI) - sb.containerID = fakeContainerID - - if err := sb.eraseFingerprints(context.Background()); err != nil { - t.Fatalf("eraseFingerprints: %v", err) - } -} - func TestPlantHoneypotFiles(t *testing.T) { withFakeExec(t) sb := newTestSandbox(t, types.EcosystemPyPI) From 8fe06e1b984f06bcc48699abbc0b0c30166222b2 Mon Sep 17 00:00:00 2001 From: "Ralian.ENG" Date: Sat, 9 May 2026 11:20:28 +0900 Subject: [PATCH 3/3] fix(sandbox): clear two new lint violations from the fail-loud series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gocritic appendCombine: combine the seccomp and dockerenv-mask appends into a single args = append(...) call (introduced when the bind-mount line was added in the previous commit). - goconst: extract "test-container" into testContainerID — the new *_FailLoud tests pushed the literal's count from 3 to 7. No behaviour change. Net lint count drops by one vs. the pre-series baseline. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/sandbox/sandbox.go | 7 ++++--- internal/sandbox/sandbox_extra_test.go | 23 ++++++++++++----------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index ab396b7..85f5867 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -198,8 +198,6 @@ func (s *Sandbox) containerArgs() ([]string, error) { if err != nil { return nil, err } - args = append(args, "--security-opt="+seccompOpt) - // Mask the docker-injected /.dockerenv sentinel by bind-mounting an // empty file from the host over it. We can't `rm /.dockerenv` post- // start because --read-only blocks rootfs writes (which is exactly @@ -209,7 +207,10 @@ func (s *Sandbox) containerArgs() ([]string, error) { // regular file. Path-existence checks (`os.path.exists`) still see // something, but those are defeated by --runtime=runsc which // virtualizes the rootfs entirely. - args = append(args, "--mount=type=bind,src="+s.dockerenvMask+",dst=/.dockerenv,readonly") + args = append(args, + "--security-opt="+seccompOpt, + "--mount=type=bind,src="+s.dockerenvMask+",dst=/.dockerenv,readonly", + ) if s.needsPtrace { // Re-add SYS_PTRACE for strace, CHOWN+FOWNER for tmpfs file setup. diff --git a/internal/sandbox/sandbox_extra_test.go b/internal/sandbox/sandbox_extra_test.go index b8dd0c6..953f9ad 100644 --- a/internal/sandbox/sandbox_extra_test.go +++ b/internal/sandbox/sandbox_extra_test.go @@ -11,10 +11,11 @@ import ( ) const ( - testMountPoint = "/home/dev/projects" - envCmd = "env" - python3Bin = "python3" - nodeBin = "node" + testMountPoint = "/home/dev/projects" + envCmd = "env" + python3Bin = "python3" + nodeBin = "node" + testContainerID = "test-container" ) func TestNew(t *testing.T) { @@ -584,14 +585,14 @@ func TestDockerWriteFile_StructureNoHeredoc(t *testing.T) { } t.Cleanup(func() { execCommand = orig }) - sb := &Sandbox{containerID: "test-container"} + sb := &Sandbox{containerID: testContainerID} if err := sb.dockerWriteFile(context.Background(), "/tmp/probe.js", "any content with KOJUTO_EOF baked in\nstill not parsed by shell"); err != nil { t.Fatalf("dockerWriteFile: %v", err) } want := []string{ - "docker", "exec", "-i", "--user=root", "test-container", + "docker", "exec", "-i", "--user=root", testContainerID, "sh", "-c", "cat > '/tmp/probe.js'", } if !reflect.DeepEqual(captured, want) { @@ -621,7 +622,7 @@ func TestDockerWriteFile_QuotesPath(t *testing.T) { } t.Cleanup(func() { execCommand = orig }) - sb := &Sandbox{containerID: "test-container"} + sb := &Sandbox{containerID: testContainerID} if err := sb.dockerWriteFile(context.Background(), "/tmp/$(rm -rf /)/x", "x"); err != nil { t.Fatalf("dockerWriteFile: %v", err) } @@ -645,7 +646,7 @@ func withFailingExec(t *testing.T) { func TestPlantHoneypotFiles_FailLoud(t *testing.T) { withFailingExec(t) - sb := &Sandbox{containerID: "test-container"} + sb := &Sandbox{containerID: testContainerID} if err := sb.plantHoneypotFiles(context.Background()); err == nil { t.Fatal("plantHoneypotFiles returned nil despite failing docker command") } @@ -653,7 +654,7 @@ func TestPlantHoneypotFiles_FailLoud(t *testing.T) { func TestRestoreLocalBin_FailLoud(t *testing.T) { withFailingExec(t) - sb := &Sandbox{containerID: "test-container", ecosystem: types.EcosystemPyPI} + sb := &Sandbox{containerID: testContainerID, ecosystem: types.EcosystemPyPI} if err := sb.restoreLocalBin(context.Background()); err == nil { t.Fatal("restoreLocalBin returned nil despite failing docker command") } @@ -661,7 +662,7 @@ func TestRestoreLocalBin_FailLoud(t *testing.T) { func TestWriteProbeScripts_FailLoud(t *testing.T) { withFailingExec(t) - sb := &Sandbox{containerID: "test-container", pkg: "x", ecosystem: types.EcosystemPyPI} + sb := &Sandbox{containerID: testContainerID, pkg: "x", ecosystem: types.EcosystemPyPI} if err := sb.WriteProbeScripts(context.Background()); err == nil { t.Fatal("WriteProbeScripts returned nil despite failing docker command") } @@ -669,7 +670,7 @@ func TestWriteProbeScripts_FailLoud(t *testing.T) { func TestWriteProbeScriptsMulti_FailLoud(t *testing.T) { withFailingExec(t) - sb := &Sandbox{containerID: "test-container", ecosystem: types.EcosystemPyPI} + sb := &Sandbox{containerID: testContainerID, ecosystem: types.EcosystemPyPI} if err := sb.WriteProbeScriptsMulti(context.Background(), []string{"x"}); err == nil { t.Fatal("WriteProbeScriptsMulti returned nil despite failing docker command") }