Redact and cap subprocess diagnostics
This commit is contained in:
@@ -6,8 +6,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,13 +15,15 @@ import (
|
||||
|
||||
// RunRequest defines a subprocess invocation.
|
||||
type RunRequest struct {
|
||||
Executable string
|
||||
Args []string
|
||||
WorkingDir string
|
||||
EnvOverrides map[string]string
|
||||
Timeout time.Duration
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
Executable string
|
||||
Args []string
|
||||
WorkingDir string
|
||||
EnvOverrides map[string]string
|
||||
SensitiveEnvNames []string
|
||||
DiagnosticOwner string
|
||||
Timeout time.Duration
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
}
|
||||
|
||||
// RunResult captures subprocess execution details.
|
||||
@@ -54,7 +54,8 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath)
|
||||
childEnv := buildChildEnvironment(os.Environ(), req.EnvOverrides)
|
||||
logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath, req.diagnosticOwner(), sensitiveEnvironmentValues(childEnv, req.SensitiveEnvNames))
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
@@ -67,9 +68,12 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
|
||||
cmd := exec.Command(req.Executable, req.Args...)
|
||||
cmd.Dir = req.WorkingDir
|
||||
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
|
||||
cmd.Env = childEnv
|
||||
cmd.Stdout = logs.Stdout
|
||||
cmd.Stderr = logs.Stderr
|
||||
// Streaming capture uses pipes. Bound their lifetime when a leader exits
|
||||
// while a descendant still holds a stream descriptor.
|
||||
cmd.WaitDelay = forcefulTerminationWait
|
||||
|
||||
started := time.Now().UTC()
|
||||
result := RunResult{
|
||||
@@ -94,7 +98,14 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
waitCh := make(chan error, 1)
|
||||
go func() { waitCh <- cmd.Wait() }()
|
||||
|
||||
waitErr, ctxErr, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh)
|
||||
waitErr, ctxErr, captureLimit, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh, logs.Limits())
|
||||
cleanupErr = joinErrors(cleanupErr, logs.Flush())
|
||||
if captureLimit == nil {
|
||||
captureLimit = logs.Limit()
|
||||
if captureLimit != nil {
|
||||
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
||||
}
|
||||
}
|
||||
cleanupErr = joinErrors(cleanupErr, tree.Close())
|
||||
result.CompletedAt = time.Now().UTC()
|
||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||
@@ -113,9 +124,15 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
stderrTail := readRedactedTail(req.StderrLogPath, req.EnvOverrides, 2048)
|
||||
stderrTail := readDiagnosticTail(req.StderrLogPath, 2048)
|
||||
diagnostics := buildDiagnostics(req, result, stderrTail)
|
||||
|
||||
if captureLimit != nil {
|
||||
if cause := joinErrors(waitErr, cleanupErr); cause != nil {
|
||||
return result, fmt.Errorf("%w (%s): %w", captureLimit, diagnostics, cause)
|
||||
}
|
||||
return result, fmt.Errorf("%w (%s)", captureLimit, diagnostics)
|
||||
}
|
||||
if result.TimedOut {
|
||||
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
|
||||
}
|
||||
@@ -155,106 +172,6 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type logWriters struct {
|
||||
files []*os.File
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
}
|
||||
|
||||
func (l *logWriters) Close() {
|
||||
for _, f := range l.files {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func openLogWriters(stdoutPath, stderrPath string) (*logWriters, error) {
|
||||
cleanStdout := cleanLogPath(stdoutPath)
|
||||
cleanStderr := cleanLogPath(stderrPath)
|
||||
|
||||
// Keep stdout/stderr on the same file descriptor when both paths target
|
||||
// the same file to avoid descriptor aliasing surprises across runtimes.
|
||||
if cleanStdout != "" && cleanStdout == cleanStderr {
|
||||
f, err := openLogFile(cleanStdout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open shared stdout/stderr log %q: %w", cleanStdout, err)
|
||||
}
|
||||
return &logWriters{
|
||||
files: []*os.File{f},
|
||||
Stdout: f,
|
||||
Stderr: f,
|
||||
}, nil
|
||||
}
|
||||
|
||||
stdoutFile, stdoutWriter, err := logWriter(cleanStdout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open stdout log: %w", err)
|
||||
}
|
||||
stderrFile, stderrWriter, err := logWriter(cleanStderr)
|
||||
if err != nil {
|
||||
closeFile(stdoutFile)
|
||||
return nil, fmt.Errorf("open stderr log: %w", err)
|
||||
}
|
||||
|
||||
files := make([]*os.File, 0, 2)
|
||||
if stdoutFile != nil {
|
||||
files = append(files, stdoutFile)
|
||||
}
|
||||
if stderrFile != nil {
|
||||
files = append(files, stderrFile)
|
||||
}
|
||||
return &logWriters{
|
||||
files: files,
|
||||
Stdout: stdoutWriter,
|
||||
Stderr: stderrWriter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cleanLogPath(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(trimmed)
|
||||
}
|
||||
|
||||
func logWriter(path string) (*os.File, io.Writer, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
// Use a descriptor instead of io.Discard so os/exec does not create a
|
||||
// pipe and wait for a descendant that inherited it after its leader exits.
|
||||
f, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open null output: %w", err)
|
||||
}
|
||||
return f, f, nil
|
||||
}
|
||||
f, err := openLogFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, f, nil
|
||||
}
|
||||
|
||||
func openLogFile(path string) (*os.File, error) {
|
||||
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
||||
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
||||
}
|
||||
if err := f.Chmod(fileops.WorkspaceFileMode); err != nil {
|
||||
_ = f.Close()
|
||||
return nil, fmt.Errorf("set log file permissions %q: %w", path, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func closeFile(f *os.File) {
|
||||
if f != nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
|
||||
details := fmt.Sprintf(
|
||||
"executable=%q args=%v cwd=%q timeout=%s exit_code=%d timed_out=%t canceled=%t stdout_log=%q stderr_log=%q",
|
||||
@@ -292,7 +209,7 @@ func fdDiagnosticsHint(exitCode int, stderrTail string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func readRedactedTail(path string, envOverrides map[string]string, maxBytes int64) string {
|
||||
func readDiagnosticTail(path string, maxBytes int64) string {
|
||||
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -322,56 +239,5 @@ func readRedactedTail(path string, envOverrides map[string]string, maxBytes int6
|
||||
if tail == "" {
|
||||
return ""
|
||||
}
|
||||
return redactSensitiveTail(tail, envOverrides)
|
||||
}
|
||||
|
||||
func redactSensitiveTail(tail string, envOverrides map[string]string) string {
|
||||
out := tail
|
||||
for k, v := range envOverrides {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
if looksSensitiveEnvKey(k) {
|
||||
out = strings.ReplaceAll(out, v, "<redacted>")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func looksSensitiveEnvKey(key string) bool {
|
||||
k := strings.ToUpper(strings.TrimSpace(key))
|
||||
return strings.Contains(k, "KEY") ||
|
||||
strings.Contains(k, "TOKEN") ||
|
||||
strings.Contains(k, "SECRET") ||
|
||||
strings.Contains(k, "PASSWORD")
|
||||
}
|
||||
|
||||
func mergeEnv(base []string, overrides map[string]string) []string {
|
||||
if len(overrides) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
kv := make(map[string]string, len(base)+len(overrides))
|
||||
for _, item := range base {
|
||||
k, v, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
kv[k] = v
|
||||
}
|
||||
for k, v := range overrides {
|
||||
kv[k] = v
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(kv))
|
||||
for k := range kv {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+kv[k])
|
||||
}
|
||||
return out
|
||||
return tail
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user