| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223 |
- package executor
- import (
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/OliveTin/OliveTin/internal/auth"
- authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
- config "github.com/OliveTin/OliveTin/internal/config"
- "github.com/OliveTin/OliveTin/internal/entities"
- )
- func testingExecutor() (*Executor, *config.Config) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- a1 := &config.Action{
- Title: "Do some tickles",
- Shell: "echo 'Tickling {{ person }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "person",
- Type: "ascii",
- },
- },
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- return e, cfg
- }
- func TestGetLogReturnsDefensiveCopy(t *testing.T) {
- e := DefaultExecutor(config.DefaultConfig())
- e.logs["tracking-id"] = &InternalLogEntry{
- Arguments: map[string]string{"message": "original"},
- Output: "original",
- Tags: []string{"original"},
- }
- entry, found := e.GetLog("tracking-id")
- require.True(t, found)
- entry.Arguments["message"] = "changed"
- entry.Output = "changed"
- entry.Tags[0] = "changed"
- stored, found := e.GetLog("tracking-id")
- require.True(t, found)
- assert.Equal(t, "original", stored.Arguments["message"])
- assert.Equal(t, "original", stored.Output)
- assert.Equal(t, []string{"original"}, stored.Tags)
- }
- func TestCreateExecutorAndExec(t *testing.T) {
- e, cfg := testingExecutor()
- req := ExecutionRequest{
- AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"},
- Cfg: cfg,
- Arguments: map[string]string{
- "person": "yourself",
- },
- }
- // Ensure bindings are available and set the binding to the only configured action
- e.RebuildActionMap()
- if len(cfg.Actions) > 0 {
- req.Binding = e.FindBindingWithNoEntity(cfg.Actions[0])
- }
- assert.NotNil(t, e, "Create an executor")
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero")
- }
- func TestStepRequestActionPopulateLogEntryResolvesEntityTemplates(t *testing.T) {
- req := &ExecutionRequest{
- logEntry: &InternalLogEntry{},
- Binding: &ActionBinding{
- Action: &config.Action{
- Title: "Do something with {{ project.name }}",
- Icon: "{{ project.icon }}",
- },
- Entity: &entities.Entity{
- Data: map[string]any{
- "name": "foo",
- "icon": "🐰",
- },
- UniqueKey: "foo-key",
- },
- },
- }
- stepRequestActionPopulateLogEntry(req)
- assert.Equal(t, "Do something with foo", req.logEntry.ActionTitle)
- assert.Equal(t, "🐰", req.logEntry.ActionIcon)
- assert.Equal(t, "Do something with {{ project.name }}", req.logEntry.ActionConfigTitle)
- assert.Equal(t, "foo-key", req.logEntry.EntityPrefix)
- }
- func TestExecNonExistant(t *testing.T) {
- e, cfg := testingExecutor()
- req := ExecutionRequest{
- // Binding: e.FindBindingWithNoEntity("waffles"),
- logEntry: &InternalLogEntry{},
- Cfg: cfg,
- }
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.Equal(t, int32(-1337), req.logEntry.ExitCode, "Log entry is set to an internal error code")
- assert.Equal(t, "💩", req.logEntry.ActionIcon, "Log entry icon is a poop (not found)")
- }
- func TestArgumentNameCamelCase(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Do some tickles",
- Shell: "echo 'Tickling {{ personName }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "personName",
- Type: "ascii",
- },
- },
- }
- req.Arguments = map[string]string{
- "personName": "Fred",
- }
- out, err := parseActionArguments(req)
- assert.Equal(t, "echo 'Tickling Fred'", out)
- assert.Nil(t, err)
- }
- func TestArgumentNameSnakeCase(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Do some tickles",
- Shell: "echo 'Tickling {{ person_name }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "person_name",
- Type: "ascii",
- },
- },
- }
- req.Arguments = map[string]string{
- "person_name": "Fred",
- }
- out, err := parseActionArguments(req)
- assert.Equal(t, "echo 'Tickling Fred'", out)
- assert.Nil(t, err)
- }
- func TestGetLogsEmpty(t *testing.T) {
- e, cfg := testingExecutor()
- assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
- logs, paging := e.GetLogTrackingIds(0, 10)
- assert.NotNil(t, logs, "Logs should not be nil")
- assert.Equal(t, 0, len(logs), "No logs yet")
- assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
- }
- func TestGetLogsLessThanPageSize(t *testing.T) {
- e, cfg := testingExecutor()
- cfg.Actions = append(cfg.Actions, &config.Action{
- Title: "blat",
- Shell: "date",
- })
- cfg.Sanitize()
- // Rebuild action map to include newly added action
- e.RebuildActionMap()
- assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
- logEntries, paging := e.GetLogTrackingIds(0, 10)
- assert.Equal(t, 0, len(logEntries), "There should be 0 logs")
- assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- logEntries, paging = e.GetLogTrackingIds(0, 10)
- assert.Equal(t, 7, len(logEntries), "There should be 7 logs")
- assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- execNewReqAndWait(e, "blat", cfg)
- logEntries, paging = e.GetLogTrackingIds(0, 10)
- assert.Equal(t, 10, len(logEntries), "There should be 10 logs")
- assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
- }
- func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
- req := &ExecutionRequest{
- // ActionTitle: title,
- Cfg: cfg,
- }
- // Ensure we have a binding for the requested title
- e.RebuildActionMap()
- var action *config.Action
- for _, a := range cfg.Actions {
- if a.Title == title {
- action = a
- break
- }
- }
- if action != nil {
- req.Binding = e.FindBindingWithNoEntity(action)
- }
- wg, _ := e.ExecRequest(req)
- wg.Wait()
- }
- func TestGetPagingIndexes(t *testing.T) {
- assert.Zero(t, getPagingStartIndex(5, 0), "Testing start index from empty list")
- assert.Equal(t, int64(4), getPagingStartIndex(5, 10), "Testing start index from mid point")
- assert.Equal(t, int64(9), getPagingStartIndex(-1, 10), "Testing start index with negative offset")
- assert.Equal(t, int64(0), getPagingStartIndex(15, 10), "Testing start index with large offset")
- assert.Equal(t, int64(9), getPagingStartIndex(0, 10), "Testing start index with zero count")
- }
- func TestUnsetRequiredArgument(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Print your name",
- Shell: "echo 'Your name is: {{ name }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "name",
- Type: "ascii",
- },
- },
- }
- req.Arguments = map[string]string{}
- out, err := parseActionArguments(req)
- assert.Equal(t, "", out)
- assert.NotNil(t, err)
- }
- func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Print your name",
- Shell: "echo 'Your name is: {{ name }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "name",
- Type: "ascii",
- },
- {
- Name: "age",
- Type: "int",
- },
- },
- }
- req.Arguments = map[string]string{
- "name": "Fred",
- "age": "Not an integer",
- }
- out, err := parseActionArguments(req)
- assert.Equal(t, "", out)
- assert.NotNil(t, err)
- }
- // https://github.com/OliveTin/OliveTin/issues/564
- func TestMangleInvalidArgumentValues(t *testing.T) {
- e, cfg := testingExecutor()
- a1 := &config.Action{
- Title: "Validate my date without seconds because I am from an Android phone",
- Shell: "echo 'The date is: {{ date }}'",
- Arguments: []config.ActionArgument{
- {
- Name: "date",
- Type: "datetime",
- },
- },
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- // Build bindings for newly added action
- e.RebuildActionMap()
- req := ExecutionRequest{
- // Action: a1,
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- Cfg: cfg,
- Arguments: map[string]string{
- "date": "1990-01-10T12:00", // Invalid format, should be without seconds
- },
- }
- // Set binding to our appended action
- req.Binding = e.FindBindingWithNoEntity(a1)
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.NotNil(t, req.logEntry, "Log entry should not be nil")
- assert.Equal(t, req.logEntry.Output, "The date is: 1990-01-10T12:00:00\n", "Date should be mangled to a valid format")
- }
- func TestWebhookRejectsShellExecution(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- a1 := &config.Action{
- Title: "Webhook Shell Reject",
- Shell: "echo '{{ msg }}'",
- Arguments: []config.ActionArgument{
- {Name: "msg", Type: "ascii"},
- },
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- e.RebuildActionMap()
- req := ExecutionRequest{
- Tags: []string{"webhook"},
- AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
- Cfg: cfg,
- Arguments: map[string]string{"msg": "hello"},
- Binding: e.FindBindingWithNoEntity(a1),
- }
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.NotNil(t, req.logEntry)
- assert.Equal(t, int32(-1337), req.logEntry.ExitCode)
- assert.Contains(t, req.logEntry.Output, "webhooks cannot use Shell execution")
- }
- func TestWebhookAllowsExecExecution(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- a1 := &config.Action{
- Title: "Webhook Exec OK",
- Exec: []string{"echo", "{{ msg }}"},
- Arguments: []config.ActionArgument{
- {Name: "msg", Type: "ascii"},
- },
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- e.RebuildActionMap()
- req := ExecutionRequest{
- Tags: []string{"webhook"},
- AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
- Cfg: cfg,
- Arguments: map[string]string{"msg": "hello"},
- Binding: e.FindBindingWithNoEntity(a1),
- }
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.NotNil(t, req.logEntry)
- assert.Equal(t, int32(0), req.logEntry.ExitCode)
- assert.Contains(t, req.logEntry.Output, "hello")
- }
- func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- a1 := &config.Action{
- Title: "Webhook After Shell Reject",
- Exec: []string{"echo", "{{ msg }}"},
- ShellAfterCompleted: "echo after",
- Arguments: []config.ActionArgument{
- {Name: "msg", Type: "ascii"},
- },
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- e.RebuildActionMap()
- req := ExecutionRequest{
- Tags: []string{"webhook"},
- AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
- Cfg: cfg,
- Arguments: map[string]string{"msg": "hello"},
- Binding: e.FindBindingWithNoEntity(a1),
- }
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.NotNil(t, req.logEntry)
- assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
- }
- func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
- expectedMainOutput := "'; touch " + injectedPath + "; echo '"
- a1 := &config.Action{
- Title: "After completion escape",
- Shell: "printf %s \"" + expectedMainOutput + "\"",
- ShellAfterCompleted: "printf %s {{ output }}",
- }
- cfg.Actions = append(cfg.Actions, a1)
- cfg.Sanitize()
- e.RebuildActionMap()
- req := ExecutionRequest{
- AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
- Cfg: cfg,
- Binding: e.FindBindingWithNoEntity(a1),
- }
- wg, _ := e.ExecRequest(&req)
- wg.Wait()
- assert.NotNil(t, req.logEntry)
- assert.Equal(t, int32(0), req.logEntry.ExitCode)
- assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
- assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
- _, err := os.Stat(injectedPath)
- assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
- }
- func TestShellAfterCompletedExpandsQuotedPlaceholders(t *testing.T) {
- cases := []struct {
- name string
- sac string
- }{
- {"legacy single-quoted", `printf '%s' '{{ output }}'`},
- {"modern single-quoted", `printf '%s' '{{ .Arguments.output }}'`},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- cfg := config.DefaultConfig()
- executor := DefaultExecutor(cfg)
- mainOutput := "quoted-output-ok"
- action := &config.Action{
- Title: "sac-quoted-" + tc.name,
- Shell: "printf %s \"" + mainOutput + "\"",
- ShellAfterCompleted: tc.sac,
- }
- cfg.Actions = append(cfg.Actions, action)
- cfg.Sanitize()
- executor.RebuildActionMap()
- req := ExecutionRequest{
- AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
- Cfg: cfg,
- Binding: executor.FindBindingWithNoEntity(action),
- }
- wg, _ := executor.ExecRequest(&req)
- wg.Wait()
- require.NotNil(t, req.logEntry)
- assert.Equal(t, int32(0), req.logEntry.ExitCode)
- assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+mainOutput)
- })
- }
- }
- func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
- payload := func(injectedPath string) string {
- return "x; touch " + injectedPath + "; #"
- }
- cases := []struct {
- name string
- sac string
- }{
- {"legacy", "printf %s {{ output }}"},
- {"legacy compact", "printf %s {{output}}"},
- {"legacy extra spaces", "printf %s {{ output }}"},
- {"modern Arguments", "printf %s {{ .Arguments.output }}"},
- {"modern compact", "printf %s {{.Arguments.output}}"},
- {"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- cfg := config.DefaultConfig()
- executor := DefaultExecutor(cfg)
- injectedPath := filepath.Join(t.TempDir(), "injected")
- mainPayload := payload(injectedPath)
- action := &config.Action{
- Title: "sac-injection-" + tc.name,
- Shell: "printf %s \"" + mainPayload + "\"",
- ShellAfterCompleted: tc.sac,
- }
- cfg.Actions = append(cfg.Actions, action)
- cfg.Sanitize()
- executor.RebuildActionMap()
- req := ExecutionRequest{
- AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
- Cfg: cfg,
- Binding: executor.FindBindingWithNoEntity(action),
- }
- wg, _ := executor.ExecRequest(&req)
- wg.Wait()
- _, err := os.Stat(injectedPath)
- assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
- })
- }
- }
- func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
- cases := []struct {
- in string
- want string
- }{
- {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
- {`printf %s {{output}}`, `printf %s "$OUTPUT"`},
- {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
- {`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
- {`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
- {`echo {{ exitCode }}`, `echo "$EXITCODE"`},
- {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
- {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
- {`printf '%s' '{{ output }}'`, `printf '%s' ''"$OUTPUT"''`},
- {`printf '%s' '{{ .Arguments.output }}'`, `printf '%s' ''"$OUTPUT"''`},
- {`printf '%s' '{{ exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
- {`printf '%s' '{{ .Arguments.exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
- }
- for _, tc := range cases {
- assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
- }
- }
- func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
- args := map[string]string{
- "output": "evil; id",
- "exitCode": "1",
- "ot_username": "alice",
- "ot_executionTrackingId": "track-1",
- }
- templateArgs := shellAfterTemplateArgs(args)
- assert.NotContains(t, templateArgs, "output")
- assert.NotContains(t, templateArgs, "exitCode")
- assert.Equal(t, "alice", templateArgs["ot_username"])
- assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
- assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
- }
- func TestFilterToDefinedArgumentsOnly(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Filter test",
- Shell: "echo '{{ name }}'",
- Arguments: []config.ActionArgument{
- {Name: "name", Type: "ascii"},
- },
- }
- req.Arguments = map[string]string{
- "name": "Alice",
- "webhook_path": "/malicious/$(id)",
- "extra_undefined": "ignored",
- }
- filterToDefinedArgumentsOnly(req)
- assert.Equal(t, "Alice", req.Arguments["name"])
- assert.Empty(t, req.Arguments["webhook_path"])
- assert.Empty(t, req.Arguments["extra_undefined"])
- }
- func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) {
- req := newExecRequest()
- req.Binding.Action = &config.Action{
- Title: "Filter test",
- Shell: "echo test",
- Arguments: []config.ActionArgument{},
- }
- req.Arguments = map[string]string{
- "ot_executionTrackingId": "track-123",
- "ot_username": "webhook",
- }
- filterToDefinedArgumentsOnly(req)
- assert.Empty(t, req.Arguments["ot_executionTrackingId"])
- assert.Empty(t, req.Arguments["ot_username"])
- }
- func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) {
- req := newExecRequest()
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
- req.Binding.Action = &config.Action{
- Title: "Filter then inject",
- Shell: "echo test",
- Arguments: []config.ActionArgument{
- {Name: "name", Type: "ascii"},
- },
- }
- req.Arguments = map[string]string{
- "name": "Alice",
- "ot_executionTrackingId": "attacker-track",
- "ot_username": "mallory",
- "ot_custom": "polluted",
- }
- assert.True(t, stepParseArgs(req))
- assert.Equal(t, "Alice", req.Arguments["name"])
- assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"])
- assert.Equal(t, "alice", req.Arguments["ot_username"])
- assert.Empty(t, req.Arguments["ot_custom"])
- }
- func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) {
- req := newExecRequest()
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
- req.Binding.Action = &config.Action{
- Title: "No reserved prefix pollution",
- Shell: "echo test",
- Arguments: []config.ActionArgument{},
- }
- req.Arguments = map[string]string{
- "ot_custom": "polluted",
- }
- assert.True(t, stepParseArgs(req))
- env := buildEnv(req.Arguments)
- assert.False(t, containsEnvPrefix(env, "OT_CUSTOM="))
- assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com"))
- assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456"))
- }
- func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) {
- unsafeTypes := map[string]struct{}{
- "email": {},
- "password": {},
- "raw_string_multiline": {},
- "url": {},
- "very_dangerous_raw_string": {},
- }
- seen := map[string]struct{}{}
- for _, arg := range systemArgumentDefinitions {
- assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix))
- assert.NotEmpty(t, arg.Type)
- assert.True(t, arg.RejectNull)
- _, duplicate := seen[arg.Name]
- assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name)
- seen[arg.Name] = struct{}{}
- _, unsafe := unsafeTypes[arg.Type]
- assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type)
- }
- }
- func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
- req := newExecRequest()
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
- args, err := validatedSystemArgs(req)
- assert.Nil(t, err)
- assert.Len(t, args, len(systemArgumentDefinitions))
- for _, arg := range systemArgumentDefinitions {
- assert.Contains(t, args, arg.Name)
- }
- }
- func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{
- Output: "hello",
- ExitCode: 7,
- }
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
- req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"}
- args, err := buildShellAfterArgs(req)
- assert.Nil(t, err)
- assert.Len(t, args, len(systemArgumentDefinitions)+2)
- assert.Contains(t, args, "output")
- assert.Contains(t, args, "exitCode")
- for _, arg := range systemArgumentDefinitions {
- assert.Contains(t, args, arg.Name)
- }
- }
- func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{}
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
- req.Binding.Action = &config.Action{
- Title: "Email username",
- Shell: "echo test",
- Arguments: []config.ActionArgument{},
- }
- assert.True(t, stepParseArgs(req))
- assert.Equal(t, "alice@example.com", req.Arguments["ot_username"])
- }
- func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{}
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
- req.Binding.Action = &config.Action{
- Title: "Invalid system arg",
- Shell: "echo test",
- Arguments: []config.ActionArgument{},
- }
- assert.False(t, stepParseArgs(req))
- assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`)
- assert.Empty(t, req.Arguments["ot_username"])
- }
- func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{}
- req.TrackingID = "track/../../bad"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
- req.Binding.Action = &config.Action{
- Title: "Invalid tracking ID",
- Shell: "echo test",
- Arguments: []config.ActionArgument{},
- }
- assert.False(t, stepParseArgs(req))
- assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`)
- assert.Empty(t, req.Arguments["ot_executionTrackingId"])
- }
- func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{
- Output: "hello",
- ExitCode: 7,
- }
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
- req.Binding.Action = &config.Action{
- Title: "Shell after",
- ShellAfterCompleted: "echo test",
- }
- args, err := buildShellAfterArgs(req)
- assert.Nil(t, err)
- assert.Equal(t, "alice@example.com", args["ot_username"])
- assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
- assert.Equal(t, "hello", args["output"])
- assert.Equal(t, "7", args["exitCode"])
- }
- func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
- req := newExecRequest()
- req.logEntry = &InternalLogEntry{}
- req.TrackingID = "server-track-456"
- req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
- req.Binding.Action = &config.Action{
- Title: "Shell after invalid username",
- ShellAfterCompleted: "echo test",
- }
- args, err := buildShellAfterArgs(req)
- assert.Nil(t, args)
- assert.NotNil(t, err)
- assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
- }
- func containsEnvPrefix(env []string, prefix string) bool {
- for _, item := range env {
- if strings.HasPrefix(item, prefix) {
- return true
- }
- }
- return false
- }
- func TestTriggerExecutesTriggeredAction(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- helloAction := &config.Action{
- Title: "Hello world",
- Shell: "echo 'Hello World!'",
- }
- triggerAction := &config.Action{
- Title: "Simple action that triggers another action",
- Shell: "echo 'Hi'",
- Triggers: []string{"Hello world"},
- }
- cfg.Actions = append(cfg.Actions, helloAction, triggerAction)
- cfg.Sanitize()
- e.RebuildActionMap()
- finishedTitles := make(chan string, 4)
- collector := &executionFinishedCollector{ch: finishedTitles}
- e.AddListener(collector)
- req := &ExecutionRequest{
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- Cfg: cfg,
- Binding: e.FindBindingWithNoEntity(triggerAction),
- }
- wg, _ := e.ExecRequest(req)
- wg.Wait()
- var got []string
- for i := 0; i < 2; i++ {
- select {
- case title := <-finishedTitles:
- got = append(got, title)
- case <-time.After(2 * time.Second):
- t.Fatalf("timed out waiting for execution %d; got %v", i+1, got)
- }
- }
- assert.Contains(t, got, "Hello world", "triggered action must run")
- assert.Contains(t, got, "Simple action that triggers another action", "triggering action must run")
- }
- func TestTriggerUnknownActionTitleSkipsWithoutPanic(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- triggerAction := &config.Action{
- Title: "Action with bad trigger",
- Shell: "echo 'ok'",
- Triggers: []string{"Nonexistent action"},
- }
- cfg.Actions = append(cfg.Actions, triggerAction)
- cfg.Sanitize()
- e.RebuildActionMap()
- finishedTitles := make(chan string, 4)
- collector := &executionFinishedCollector{ch: finishedTitles}
- e.AddListener(collector)
- req := &ExecutionRequest{
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- Cfg: cfg,
- Binding: e.FindBindingWithNoEntity(triggerAction),
- }
- wg, _ := e.ExecRequest(req)
- wg.Wait()
- var got []string
- select {
- case title := <-finishedTitles:
- got = append(got, title)
- case <-time.After(500 * time.Millisecond):
- }
- assert.Len(t, got, 1, "only the triggering action runs; unknown trigger is skipped")
- if len(got) > 0 {
- assert.Equal(t, "Action with bad trigger", got[0])
- }
- }
- type executionFinishedCollector struct {
- ch chan string
- }
- func (c *executionFinishedCollector) OnExecutionStarted(_ *InternalLogEntry) {}
- func (c *executionFinishedCollector) OnExecutionFinished(entry *InternalLogEntry) {
- c.ch <- entry.ActionTitle
- }
- func (c *executionFinishedCollector) OnOutputChunk(_ []byte, _ string) {}
- func (c *executionFinishedCollector) OnActionMapRebuilt() {}
- func TestSanitizeLogFilename(t *testing.T) {
- tests := []struct {
- title string
- want string
- }{
- {"Echo Test", "Echo Test"},
- {"Create/update Monthly Report", "Create_update Monthly Report"},
- {`path\with\backslashes`, "path_with_backslashes"},
- {`a:b*c?d"e<f>g|h`, "a_b_c_d_e_f_g_h"},
- {"has\x00nul", "has_nul"},
- {"tab\there\nand\rreturn", "tab_here_and_return"},
- }
- for _, tt := range tests {
- assert.Equal(t, tt.want, sanitizeLogFilename(tt.title), "title=%q", tt.title)
- }
- }
- func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) {
- resultsDir := t.TempDir()
- outputDir := t.TempDir()
- started := time.Unix(1714333384, 0)
- trackingID := "5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2"
- title := "Create/update Monthly Report"
- req := &ExecutionRequest{
- Cfg: &config.Config{
- SaveLogs: config.SaveLogsConfig{
- ResultsDirectory: resultsDir,
- OutputDirectory: outputDir,
- },
- },
- Binding: &ActionBinding{
- Action: &config.Action{},
- },
- logEntry: &InternalLogEntry{
- ActionTitle: title,
- DatetimeStarted: started,
- ExecutionTrackingID: trackingID,
- Output: "report ok",
- },
- }
- assert.True(t, stepSaveLog(req))
- expectedBase := "Create_update Monthly Report.1714333384." + trackingID
- resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
- outputPath := filepath.Join(outputDir, expectedBase+".log")
- assert.FileExists(t, resultsPath)
- assert.FileExists(t, outputPath)
- resultsEntries, err := os.ReadDir(resultsDir)
- assert.NoError(t, err)
- assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
- data, err := os.ReadFile(resultsPath)
- assert.NoError(t, err)
- assert.Contains(t, string(data), title, "YAML content keeps the original action title")
- output, err := os.ReadFile(outputPath)
- assert.NoError(t, err)
- assert.Equal(t, "report ok", string(output))
- }
- func TestStepSaveLogKeepsSafeTitleFilename(t *testing.T) {
- resultsDir := t.TempDir()
- started := time.Unix(1714333384, 0)
- trackingID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
- req := &ExecutionRequest{
- Cfg: &config.Config{
- SaveLogs: config.SaveLogsConfig{
- ResultsDirectory: resultsDir,
- },
- },
- Binding: &ActionBinding{
- Action: &config.Action{},
- },
- logEntry: &InternalLogEntry{
- ActionTitle: "Echo Test",
- DatetimeStarted: started,
- ExecutionTrackingID: trackingID,
- },
- }
- assert.True(t, stepSaveLog(req))
- expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml")
- assert.FileExists(t, expectedPath)
- }
- func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
- resultsDir := t.TempDir()
- outputDir := t.TempDir()
- started := time.Unix(1714333384, 0)
- trackingID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
- title := "Bad\x00Title"
- req := &ExecutionRequest{
- Cfg: &config.Config{
- SaveLogs: config.SaveLogsConfig{
- ResultsDirectory: resultsDir,
- OutputDirectory: outputDir,
- },
- },
- Binding: &ActionBinding{
- Action: &config.Action{},
- },
- logEntry: &InternalLogEntry{
- ActionTitle: title,
- DatetimeStarted: started,
- ExecutionTrackingID: trackingID,
- Output: "nul ok",
- },
- }
- assert.True(t, stepSaveLog(req))
- expectedBase := "Bad_Title.1714333384." + trackingID
- resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
- outputPath := filepath.Join(outputDir, expectedBase+".log")
- assert.FileExists(t, resultsPath)
- assert.FileExists(t, outputPath)
- assert.NotContains(t, resultsPath, "\x00")
- assert.NotContains(t, outputPath, "\x00")
- output, err := os.ReadFile(outputPath)
- assert.NoError(t, err)
- assert.Equal(t, "nul ok", string(output))
- }
- func TestBlockedExecutionPersistsSaveLogs(t *testing.T) {
- t.Parallel()
- resultsDir := t.TempDir()
- outputDir := t.TempDir()
- action := &config.Action{
- Title: "Blocked report",
- Shell: "sleep 1",
- MaxConcurrent: 1,
- SaveLogs: config.SaveLogsConfig{
- ResultsDirectory: resultsDir,
- OutputDirectory: outputDir,
- },
- }
- e, cfg := testGroupExecutor([]*config.Action{action}, nil)
- binding := e.FindBindingWithNoEntity(action)
- wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
- Binding: binding,
- Cfg: cfg,
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- })
- waitUntilExecutionStarted(t, e, tracking1)
- wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
- Binding: binding,
- Cfg: cfg,
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- })
- wg1.Wait()
- wg2.Wait()
- snapshot, ok := e.SnapshotLog(tracking2)
- require.True(t, ok)
- require.True(t, snapshot.Blocked)
- resultsEntries, err := os.ReadDir(resultsDir)
- require.NoError(t, err)
- var resultsPath string
- for _, entry := range resultsEntries {
- if strings.Contains(entry.Name(), tracking2) {
- resultsPath = filepath.Join(resultsDir, entry.Name())
- break
- }
- }
- require.NotEmpty(t, resultsPath)
- outputEntries, err := os.ReadDir(outputDir)
- require.NoError(t, err)
- var outputPath string
- for _, entry := range outputEntries {
- if strings.Contains(entry.Name(), tracking2) {
- outputPath = filepath.Join(outputDir, entry.Name())
- break
- }
- }
- require.NotEmpty(t, outputPath)
- resultsData, err := os.ReadFile(resultsPath)
- require.NoError(t, err)
- assert.Contains(t, string(resultsData), "blocked: true")
- assert.Contains(t, string(resultsData), tracking2)
- outputData, err := os.ReadFile(outputPath)
- require.NoError(t, err)
- assert.Contains(t, string(outputData), "Blocked from executing due to concurrency limit")
- }
- func TestStepSaveLogReturnsFalseWhenDependenciesMissing(t *testing.T) {
- started := time.Unix(1714333384, 0)
- valid := &ExecutionRequest{
- Cfg: &config.Config{},
- Binding: &ActionBinding{
- Action: &config.Action{},
- },
- logEntry: &InternalLogEntry{
- ActionTitle: "Echo",
- DatetimeStarted: started,
- ExecutionTrackingID: "cccccccc-dddd-eeee-ffff-000000000000",
- },
- }
- assert.False(t, stepSaveLog(nil))
- assert.False(t, stepSaveLog(&ExecutionRequest{}))
- missingLog := *valid
- missingLog.logEntry = nil
- assert.False(t, stepSaveLog(&missingLog))
- missingBinding := *valid
- missingBinding.Binding = nil
- assert.False(t, stepSaveLog(&missingBinding))
- missingAction := *valid
- missingAction.Binding = &ActionBinding{}
- assert.False(t, stepSaveLog(&missingAction))
- missingCfg := *valid
- missingCfg.Cfg = nil
- assert.False(t, stepSaveLog(&missingCfg))
- }
- func TestLogEntryOutputAvailableWhileRunning(t *testing.T) {
- cfg := config.DefaultConfig()
- e := DefaultExecutor(cfg)
- action := &config.Action{
- Title: "Slow output",
- Shell: "echo hello-mid-run; sleep 2",
- }
- cfg.Actions = append(cfg.Actions, action)
- cfg.Sanitize()
- e.RebuildActionMap()
- binding := e.FindBindingWithNoEntity(action)
- require.NotNil(t, binding)
- wg, trackingID := e.ExecRequest(&ExecutionRequest{
- Binding: binding,
- Cfg: cfg,
- AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
- })
- var sawOutputWhileRunning bool
- require.Eventually(t, func() bool {
- snapshot, ok := e.SnapshotLog(trackingID)
- if !ok {
- return false
- }
- if snapshot.ExecutionFinished {
- return false
- }
- if strings.Contains(snapshot.Output, "hello-mid-run") {
- sawOutputWhileRunning = true
- return true
- }
- return false
- }, 2*time.Second, 10*time.Millisecond)
- wg.Wait()
- require.True(t, sawOutputWhileRunning, "expected Output to contain printed text before ExecutionFinished")
- snapshot, ok := e.SnapshotLog(trackingID)
- require.True(t, ok)
- assert.True(t, snapshot.ExecutionFinished)
- assert.Contains(t, snapshot.Output, "hello-mid-run")
- }
|