fix(executor): always anchor custom regex patterns and harden shellAfterCompleted

Wrap regex: patterns in a non-capturing group before applying ^...$ so
  alternation cannot bypass full-string matching. Return template parse
  errors from buildShellAfterCommand, and guard nil binding/action before
  building shellAfterCompleted commands.
This commit is contained in:
jamesread 2026-07-08 10:46:13 +01:00
parent e421780c98
commit 1396184777
4 changed files with 25 additions and 8 deletions

View File

@ -385,7 +385,7 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
assert.Equal(t, expectedCanExec, actionResult.CanExec, message) assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
} }
// buildExecWithoutLogsTestConfig returns config for GHSA-jm28: user "runner" may exec but not read logs. // buildExecWithoutLogsTestConfig returns config for GHSA-jm28-2wcr-qf3h: user "runner" may exec but not read logs.
func buildExecWithoutLogsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser) { func buildExecWithoutLogsTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser) {
t.Helper() t.Helper()
cfg := config.DefaultConfig() cfg := config.DefaultConfig()

View File

@ -315,10 +315,6 @@ func typeSafetyCheckDatetime(value string) error {
} }
func anchorCustomRegexPattern(pattern string) string { func anchorCustomRegexPattern(pattern string) string {
if strings.HasPrefix(pattern, "^") && strings.HasSuffix(pattern, "$") {
return pattern
}
return "^(?:" + pattern + ")$" return "^(?:" + pattern + ")$"
} }

View File

@ -596,6 +596,13 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
value: "example.com; id", value: "example.com; id",
hasError: true, hasError: true,
}, },
{
name: "reject alternation bypass when pattern looks anchored",
field: "host",
pattern: "regex:^safe$|bad",
value: "xxxbad",
hasError: true,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -1238,8 +1238,22 @@ func stepExecAfter(req *ExecutionRequest) bool {
return true return true
} }
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) { func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
if req == nil {
return nil, false
}
if !hasBindingAndAction(req) {
return nil, false
}
if req.Binding.Action.ShellAfterCompleted == "" { if req.Binding.Action.ShellAfterCompleted == "" {
return nil, false
}
return req.Binding.Action, true
}
func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) {
action, ok := shellAfterCompletedAction(req)
if !ok {
return nil, nil, nil return nil, nil, nil
} }
@ -1248,14 +1262,14 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout,
return nil, nil, err return nil, nil, err
} }
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args) finalParsedCommand, err := tpl.ParseTemplateWithActionContext(action.ShellAfterCompleted, req.Binding.Entity, args)
if err != nil { if err != nil {
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n" msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
req.mutateLogEntry(func(entry *InternalLogEntry) { req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output += msg entry.Output += msg
}) })
log.Warn(msg) log.Warn(msg)
return nil, nil, nil return nil, nil, err
} }
cmd := wrapCommandInShell(ctx, finalParsedCommand) cmd := wrapCommandInShell(ctx, finalParsedCommand)