Files
gnoma/internal/hook/command.go
T
vikingowl c44db99b41 fix(hook): execute hook Exec as a binary, not via sh -c
Plugin loader resolves HookSpec.Exec as a relative path joined to the
plugin directory, and manifest.checkSafePath rejects absolute paths and
'..' traversal — Exec was always meant to be an executable path.

The hook executor was wrapping it in 'sh -c', adding a redundant shell
interpretation step that turned any space, quote, or metacharacter in
the path into command-injection surface. Switch to exec.Command(path)
with no shell wrapping.

Closes audit finding C3. Adds a regression test that fails under the
old 'sh -c' code path: a canary file created via shell sequencing
remains absent when the executor treats Exec as a literal filename.

Hook command tests now write small /bin/sh scripts to t.TempDir and
point Exec at those — matching production semantics (resolved binary
path) rather than inline shell strings.
2026-05-19 16:30:23 +02:00

73 lines
2.0 KiB
Go

package hook
import (
"bytes"
"context"
"fmt"
"os/exec"
"time"
)
// CommandExecutor runs a shell command and interprets its stdin/stdout.
type CommandExecutor struct {
def HookDef
}
// NewCommandExecutor constructs a CommandExecutor for the given definition.
func NewCommandExecutor(def HookDef) *CommandExecutor {
return &CommandExecutor{def: def}
}
// Execute runs the hook command, pipes payload to stdin, and reads stdout.
func (c *CommandExecutor) Execute(ctx context.Context, payload []byte) (HookResult, error) {
timeout := c.def.timeout()
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
start := time.Now()
// Exec is a resolved binary path (see plugin/loader.go). No shell wrapping —
// shell metacharacters in the path are treated as literal filename bytes.
cmd := exec.CommandContext(ctx, c.def.Exec)
cmd.Stdin = bytes.NewReader(payload)
var stdout bytes.Buffer
cmd.Stdout = &stdout
runErr := cmd.Run()
duration := time.Since(start)
// Determine exit code and whether it was a timeout.
exitCode := 0
if runErr != nil {
if ctx.Err() != nil {
// Context deadline exceeded — apply fail_open policy.
action := Deny
if c.def.FailOpen {
action = Allow
}
return HookResult{Action: action, Duration: duration}, fmt.Errorf("hook %q: timed out after %v", c.def.Name, timeout)
}
if exitErr, ok := runErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
// Unexpected error launching the process.
action := Deny
if c.def.FailOpen {
action = Allow
}
return HookResult{Action: action, Duration: duration}, fmt.Errorf("hook %q: %w", c.def.Name, runErr)
}
}
action, transformed, err := ParseHookOutput(stdout.Bytes(), exitCode)
if err != nil {
failAction := Deny
if c.def.FailOpen {
failAction = Allow
}
return HookResult{Action: failAction, Duration: duration}, fmt.Errorf("hook %q: %w", c.def.Name, err)
}
return HookResult{Action: action, Output: transformed, Duration: duration}, nil
}