Confine subprocess diagnostics and retain redacted tails

This commit is contained in:
2026-08-11 03:11:27 +00:00
parent 801adb385d
commit 80be8be4d6
6 changed files with 568 additions and 1063 deletions

View File

@@ -3,9 +3,11 @@ package subprocess
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
@@ -134,6 +136,166 @@ func TestRunFailureRedactsSensitiveTail(t *testing.T) {
}
}
func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
}
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
dir := t.TempDir()
targetPath := filepath.Join(dir, "outside.log")
const original = "must remain unchanged"
if err := os.WriteFile(targetPath, []byte(original), 0o600); err != nil {
t.Fatalf("WriteFile(target) error = %v", err)
}
stdoutPath := filepath.Join(dir, "stdout.log")
if err := os.Symlink(targetPath, stdoutPath); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
_, err = Run(context.Background(), RunRequest{
Executable: exe,
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "success"},
EnvOverrides: map[string]string{"GO_WANT_SUBPROCESS_HELPER": "1"},
StdoutLogPath: stdoutPath,
StderrLogPath: filepath.Join(dir, "stderr.log"),
})
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
t.Fatalf("Run() error = %v, want symbolic-link rejection", err)
}
data, readErr := os.ReadFile(targetPath)
if readErr != nil {
t.Fatalf("ReadFile(target) error = %v", readErr)
}
if string(data) != original {
t.Fatalf("target content = %q, want %q", data, original)
}
}
func TestRunFailureUsesOpenedDiagnosticAfterPathReplacement(t *testing.T) {
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
dir := t.TempDir()
readyPath := filepath.Join(dir, "ready")
releasePath := filepath.Join(dir, "release")
stderrPath := filepath.Join(dir, "stderr.log")
openedPath := filepath.Join(dir, "opened-stderr.log")
const secretValue = "replacement-api-key-value"
const commandContent = "trusted command failure"
req := RunRequest{
Executable: exe,
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "delayed-fail"},
EnvOverrides: map[string]string{
"GO_WANT_SUBPROCESS_HELPER": "1",
"API_KEY": secretValue,
"SUBPROCESS_HELPER_READY_PATH": readyPath,
"SUBPROCESS_HELPER_RELEASE_PATH": releasePath,
"SUBPROCESS_HELPER_STDERR": commandContent,
},
StdoutLogPath: filepath.Join(dir, "stdout.log"),
StderrLogPath: stderrPath,
}
resultCh := make(chan error, 1)
go func() {
_, runErr := Run(context.Background(), req)
resultCh <- runErr
}()
waitForHelperFile(t, readyPath)
if err := os.Rename(stderrPath, openedPath); err != nil {
t.Fatalf("Rename(stderr log) error = %v", err)
}
if err := os.WriteFile(stderrPath, []byte(secretValue), 0o600); err != nil {
t.Fatalf("WriteFile(replacement) error = %v", err)
}
if err := os.WriteFile(releasePath, []byte("continue"), 0o600); err != nil {
t.Fatalf("WriteFile(release) error = %v", err)
}
select {
case runErr := <-resultCh:
if runErr == nil {
t.Fatal("Run() error = nil, want command failure")
}
if strings.Contains(runErr.Error(), secretValue) {
t.Fatalf("error read replacement-path content: %q", runErr)
}
if !strings.Contains(runErr.Error(), commandContent) {
t.Fatalf("error = %q, want retained command diagnostic", runErr)
}
case <-time.After(3 * time.Second):
t.Fatal("Run() did not return after helper release")
}
openedData, err := os.ReadFile(openedPath)
if err != nil {
t.Fatalf("ReadFile(opened diagnostic) error = %v", err)
}
if !strings.Contains(string(openedData), commandContent) {
t.Fatalf("opened diagnostic = %q, want command content", openedData)
}
replacementData, err := os.ReadFile(stderrPath)
if err != nil {
t.Fatalf("ReadFile(replacement diagnostic) error = %v", err)
}
if string(replacementData) != secretValue {
t.Fatalf("replacement diagnostic = %q, want %q", replacementData, secretValue)
}
}
func TestRunRedactsSplitCredentialInSeparateAndSharedDiagnostics(t *testing.T) {
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
const secretValue = "split-super-secret-value"
for _, shared := range []bool{false, true} {
t.Run(map[bool]string{false: "separate", true: "shared"}[shared], func(t *testing.T) {
dir := t.TempDir()
stdoutPath := filepath.Join(dir, "stdout.log")
stderrPath := filepath.Join(dir, "stderr.log")
if shared {
stderrPath = stdoutPath
}
req := RunRequest{
Executable: exe,
Args: []string{"-test.run=^TestSubprocessHelper$", "--", "splitsecret"},
EnvOverrides: map[string]string{
"GO_WANT_SUBPROCESS_HELPER": "1",
"API_KEY": secretValue,
},
StdoutLogPath: stdoutPath,
StderrLogPath: stderrPath,
}
_, runErr := Run(context.Background(), req)
if runErr == nil {
t.Fatal("Run() error = nil, want command failure")
}
if strings.Contains(runErr.Error(), secretValue) || !strings.Contains(runErr.Error(), "<redacted>") {
t.Fatalf("error = %q, want redacted credential", runErr)
}
paths := map[string]struct{}{stdoutPath: {}, stderrPath: {}}
for path := range paths {
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("ReadFile(%q) error = %v", path, readErr)
}
if strings.Contains(string(data), secretValue) || !strings.Contains(string(data), "<redacted>") {
t.Fatalf("diagnostic %q = %q, want redacted credential", path, data)
}
}
})
}
}
func TestRunRedactsInheritedSensitiveEnvironment(t *testing.T) {
exe, err := os.Executable()
if err != nil {
@@ -251,6 +413,30 @@ func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
}
}
func TestDiagnosticWriterRetainsBoundedRedactedTail(t *testing.T) {
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
var output bytes.Buffer
secret := "credential-value"
writer := newDiagnosticWriter(logs, "stderr", "test", &output, 16*1024, []string{secret})
prefix := strings.Repeat("x", diagnosticTailBytes+512)
if _, err := writer.Write([]byte(prefix + secret[:7])); err != nil {
t.Fatalf("first Write() error = %v", err)
}
if _, err := writer.Write([]byte(secret[7:] + "-failure")); err != nil {
t.Fatalf("second Write() error = %v", err)
}
if err := writer.Flush(); err != nil {
t.Fatalf("Flush() error = %v", err)
}
tail := writer.Tail()
if len(tail) > diagnosticTailBytes {
t.Fatalf("retained tail length = %d, want at most %d", len(tail), diagnosticTailBytes)
}
if strings.Contains(tail, secret) || !strings.Contains(tail, "<redacted>-failure") {
t.Fatalf("retained tail = %q, want bounded redacted content", tail)
}
}
func TestRunFailureAddsBadDescriptorHint(t *testing.T) {
exe, err := os.Executable()
if err != nil {
@@ -489,6 +675,30 @@ func TestSubprocessHelper(t *testing.T) {
case "failbadfd":
_, _ = os.Stderr.WriteString("OSError: [Errno 9] Bad file descriptor\n")
os.Exit(120)
case "delayed-fail":
if err := os.WriteFile(os.Getenv("SUBPROCESS_HELPER_READY_PATH"), []byte("ready"), 0o600); err != nil {
os.Exit(4)
}
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := os.Stat(os.Getenv("SUBPROCESS_HELPER_RELEASE_PATH")); err == nil {
break
} else if !errors.Is(err, os.ErrNotExist) || time.Now().After(deadline) {
os.Exit(5)
}
time.Sleep(10 * time.Millisecond)
}
_, _ = os.Stderr.WriteString(os.Getenv("SUBPROCESS_HELPER_STDERR"))
os.Exit(6)
case "splitsecret":
secret := os.Getenv("API_KEY")
split := len(secret) / 2
for _, stream := range []*os.File{os.Stdout, os.Stderr} {
_, _ = stream.WriteString(secret[:split])
time.Sleep(20 * time.Millisecond)
_, _ = stream.WriteString(secret[split:] + "\n")
}
os.Exit(7)
case "sleep":
time.Sleep(500 * time.Millisecond)
os.Exit(0)
@@ -546,3 +756,17 @@ func TestSubprocessHelper(t *testing.T) {
os.Exit(2)
}
}
func waitForHelperFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(%q) error = %v", path, err)
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("helper file %q was not created", path)
}