Compare commits

...

3 Commits

Author SHA1 Message Date
James Read
63c0142b6c Exec feature 2k (#670)
Some checks failed
Build Snapshot / build-snapshot (push) Has been cancelled
2025-10-26 23:19:44 +00:00
jamesread
b66b541f78 fmt: code formatting 2025-10-26 16:51:24 +00:00
jamesread
d020eb06fe feature: Exec array support for safer command execution
- Add Exec []string field to Action struct for direct execution
- Support exec arrays that execute commands without shell wrapping
- Add shell argument safety validation to prevent unsafe argument types
- Block URL, email, raw_string_multiline with Shell execution
- Direct exec execution is preferred over Shell for better security
- Backport of exec-feature to release/2k
2025-10-26 16:48:34 +00:00
5 changed files with 112 additions and 3 deletions

View File

@@ -11,6 +11,7 @@ type Action struct {
Title string
Icon string
Shell string
Exec []string
ShellAfterCompleted string
Timeout int
Acls []string

View File

@@ -294,3 +294,68 @@ func mangleInvalidDatetimeValues(req *ExecutionRequest, arg *config.ActionArgume
req.Arguments[arg.Name] = timestamp.Format("2006-01-02T15:04:05")
}
}
func parseActionExec(values map[string]string, action *config.Action, entityPrefix string) ([]string, error) {
for _, arg := range action.Arguments {
argName := arg.Name
argValue := values[argName]
err := typecheckActionArgument(&arg, argValue, action)
if err != nil {
return nil, err
}
log.WithFields(log.Fields{
"name": argName,
"value": argValue,
}).Debugf("Arg assigned")
}
parsedArgs := make([]string, len(action.Exec))
for i, arg := range action.Exec {
parsedArg, err := parseCommandForReplacements(arg, values)
if err != nil {
return nil, err
}
parsedArg = sv.ReplaceEntityVars(entityPrefix, parsedArg)
parsedArgs[i] = parsedArg
}
redactedArgs := redactExecArgs(parsedArgs, action.Arguments, values)
log.WithFields(log.Fields{
"actionTitle": action.Title,
"cmd": redactedArgs,
}).Infof("Action parse args - After (Exec)")
return parsedArgs, nil
}
//gocyclo:ignore
func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argumentValues map[string]string) []string {
redacted := make([]string, len(execArgs))
for i, arg := range execArgs {
redacted[i] = redactShellCommand(arg, arguments, argumentValues)
}
return redacted
}
func checkShellArgumentSafety(action *config.Action) error {
if action.Shell == "" {
return nil
}
unsafeTypes := []string{"url", "email", "raw_string_multiline", "very_dangerous_raw_string"}
for _, arg := range action.Arguments {
for _, unsafeType := range unsafeTypes {
if arg.Type == unsafeType {
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
}
}
}
return nil
}

View File

@@ -15,6 +15,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"sync"
@@ -67,6 +68,8 @@ type ExecutionRequest struct {
logEntry *InternalLogEntry
finalParsedCommand string
execArgs []string
useDirectExec bool
executor *Executor
}
@@ -405,7 +408,21 @@ func stepParseArgs(req *ExecutionRequest) bool {
mangleInvalidArgumentValues(req)
req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Action, req.EntityPrefix)
if len(req.Action.Exec) > 0 {
req.useDirectExec = true
req.execArgs, err = parseActionExec(req.Arguments, req.Action, req.EntityPrefix)
} else {
req.useDirectExec = false
err = checkShellArgumentSafety(req.Action)
if err != nil {
req.logEntry.Output = err.Error()
log.Warn(err.Error())
return false
}
req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Action, req.EntityPrefix)
}
if err != nil {
req.logEntry.Output = err.Error()
@@ -545,7 +562,13 @@ func stepExec(req *ExecutionRequest) bool {
streamer := &OutputStreamer{Req: req}
cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
var cmd *exec.Cmd
if req.useDirectExec {
cmd = wrapCommandDirect(ctx, req.execArgs)
} else {
cmd = wrapCommandInShell(ctx, req.finalParsedCommand)
}
cmd.Stdout = streamer
cmd.Stderr = streamer
cmd.Env = buildEnv(req.Arguments)

View File

@@ -21,5 +21,17 @@ func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cm
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}
func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
if len(execArgs) == 0 {
return nil
}
cmd := exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
// This is to ensure that the process group is killed when the parent process is killed.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}

View File

@@ -22,3 +22,11 @@ func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cm
return exec.CommandContext(ctx, "cmd", "/u", "/C", finalParsedCommand)
}
}
func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
if len(execArgs) == 0 {
return nil
}
return exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
}