executor_test.go 34 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223
  1. package executor
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "time"
  8. "github.com/stretchr/testify/assert"
  9. "github.com/stretchr/testify/require"
  10. "github.com/OliveTin/OliveTin/internal/auth"
  11. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  12. config "github.com/OliveTin/OliveTin/internal/config"
  13. "github.com/OliveTin/OliveTin/internal/entities"
  14. )
  15. func testingExecutor() (*Executor, *config.Config) {
  16. cfg := config.DefaultConfig()
  17. e := DefaultExecutor(cfg)
  18. a1 := &config.Action{
  19. Title: "Do some tickles",
  20. Shell: "echo 'Tickling {{ person }}'",
  21. Arguments: []config.ActionArgument{
  22. {
  23. Name: "person",
  24. Type: "ascii",
  25. },
  26. },
  27. }
  28. cfg.Actions = append(cfg.Actions, a1)
  29. cfg.Sanitize()
  30. return e, cfg
  31. }
  32. func TestGetLogReturnsDefensiveCopy(t *testing.T) {
  33. e := DefaultExecutor(config.DefaultConfig())
  34. e.logs["tracking-id"] = &InternalLogEntry{
  35. Arguments: map[string]string{"message": "original"},
  36. Output: "original",
  37. Tags: []string{"original"},
  38. }
  39. entry, found := e.GetLog("tracking-id")
  40. require.True(t, found)
  41. entry.Arguments["message"] = "changed"
  42. entry.Output = "changed"
  43. entry.Tags[0] = "changed"
  44. stored, found := e.GetLog("tracking-id")
  45. require.True(t, found)
  46. assert.Equal(t, "original", stored.Arguments["message"])
  47. assert.Equal(t, "original", stored.Output)
  48. assert.Equal(t, []string{"original"}, stored.Tags)
  49. }
  50. func TestCreateExecutorAndExec(t *testing.T) {
  51. e, cfg := testingExecutor()
  52. req := ExecutionRequest{
  53. AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"},
  54. Cfg: cfg,
  55. Arguments: map[string]string{
  56. "person": "yourself",
  57. },
  58. }
  59. // Ensure bindings are available and set the binding to the only configured action
  60. e.RebuildActionMap()
  61. if len(cfg.Actions) > 0 {
  62. req.Binding = e.FindBindingWithNoEntity(cfg.Actions[0])
  63. }
  64. assert.NotNil(t, e, "Create an executor")
  65. wg, _ := e.ExecRequest(&req)
  66. wg.Wait()
  67. assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero")
  68. }
  69. func TestStepRequestActionPopulateLogEntryResolvesEntityTemplates(t *testing.T) {
  70. req := &ExecutionRequest{
  71. logEntry: &InternalLogEntry{},
  72. Binding: &ActionBinding{
  73. Action: &config.Action{
  74. Title: "Do something with {{ project.name }}",
  75. Icon: "{{ project.icon }}",
  76. },
  77. Entity: &entities.Entity{
  78. Data: map[string]any{
  79. "name": "foo",
  80. "icon": "🐰",
  81. },
  82. UniqueKey: "foo-key",
  83. },
  84. },
  85. }
  86. stepRequestActionPopulateLogEntry(req)
  87. assert.Equal(t, "Do something with foo", req.logEntry.ActionTitle)
  88. assert.Equal(t, "🐰", req.logEntry.ActionIcon)
  89. assert.Equal(t, "Do something with {{ project.name }}", req.logEntry.ActionConfigTitle)
  90. assert.Equal(t, "foo-key", req.logEntry.EntityPrefix)
  91. }
  92. func TestExecNonExistant(t *testing.T) {
  93. e, cfg := testingExecutor()
  94. req := ExecutionRequest{
  95. // Binding: e.FindBindingWithNoEntity("waffles"),
  96. logEntry: &InternalLogEntry{},
  97. Cfg: cfg,
  98. }
  99. wg, _ := e.ExecRequest(&req)
  100. wg.Wait()
  101. assert.Equal(t, int32(-1337), req.logEntry.ExitCode, "Log entry is set to an internal error code")
  102. assert.Equal(t, "💩", req.logEntry.ActionIcon, "Log entry icon is a poop (not found)")
  103. }
  104. func TestArgumentNameCamelCase(t *testing.T) {
  105. req := newExecRequest()
  106. req.Binding.Action = &config.Action{
  107. Title: "Do some tickles",
  108. Shell: "echo 'Tickling {{ personName }}'",
  109. Arguments: []config.ActionArgument{
  110. {
  111. Name: "personName",
  112. Type: "ascii",
  113. },
  114. },
  115. }
  116. req.Arguments = map[string]string{
  117. "personName": "Fred",
  118. }
  119. out, err := parseActionArguments(req)
  120. assert.Equal(t, "echo 'Tickling Fred'", out)
  121. assert.Nil(t, err)
  122. }
  123. func TestArgumentNameSnakeCase(t *testing.T) {
  124. req := newExecRequest()
  125. req.Binding.Action = &config.Action{
  126. Title: "Do some tickles",
  127. Shell: "echo 'Tickling {{ person_name }}'",
  128. Arguments: []config.ActionArgument{
  129. {
  130. Name: "person_name",
  131. Type: "ascii",
  132. },
  133. },
  134. }
  135. req.Arguments = map[string]string{
  136. "person_name": "Fred",
  137. }
  138. out, err := parseActionArguments(req)
  139. assert.Equal(t, "echo 'Tickling Fred'", out)
  140. assert.Nil(t, err)
  141. }
  142. func TestGetLogsEmpty(t *testing.T) {
  143. e, cfg := testingExecutor()
  144. assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
  145. logs, paging := e.GetLogTrackingIds(0, 10)
  146. assert.NotNil(t, logs, "Logs should not be nil")
  147. assert.Equal(t, 0, len(logs), "No logs yet")
  148. assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
  149. }
  150. func TestGetLogsLessThanPageSize(t *testing.T) {
  151. e, cfg := testingExecutor()
  152. cfg.Actions = append(cfg.Actions, &config.Action{
  153. Title: "blat",
  154. Shell: "date",
  155. })
  156. cfg.Sanitize()
  157. // Rebuild action map to include newly added action
  158. e.RebuildActionMap()
  159. assert.Equal(t, int64(10), cfg.LogHistoryPageSize, "Logs page size should be 10")
  160. logEntries, paging := e.GetLogTrackingIds(0, 10)
  161. assert.Equal(t, 0, len(logEntries), "There should be 0 logs")
  162. assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
  163. execNewReqAndWait(e, "blat", cfg)
  164. execNewReqAndWait(e, "blat", cfg)
  165. execNewReqAndWait(e, "blat", cfg)
  166. execNewReqAndWait(e, "blat", cfg)
  167. execNewReqAndWait(e, "blat", cfg)
  168. execNewReqAndWait(e, "blat", cfg)
  169. execNewReqAndWait(e, "blat", cfg)
  170. logEntries, paging = e.GetLogTrackingIds(0, 10)
  171. assert.Equal(t, 7, len(logEntries), "There should be 7 logs")
  172. assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
  173. execNewReqAndWait(e, "blat", cfg)
  174. execNewReqAndWait(e, "blat", cfg)
  175. execNewReqAndWait(e, "blat", cfg)
  176. execNewReqAndWait(e, "blat", cfg)
  177. execNewReqAndWait(e, "blat", cfg)
  178. logEntries, paging = e.GetLogTrackingIds(0, 10)
  179. assert.Equal(t, 10, len(logEntries), "There should be 10 logs")
  180. assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
  181. }
  182. func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
  183. req := &ExecutionRequest{
  184. // ActionTitle: title,
  185. Cfg: cfg,
  186. }
  187. // Ensure we have a binding for the requested title
  188. e.RebuildActionMap()
  189. var action *config.Action
  190. for _, a := range cfg.Actions {
  191. if a.Title == title {
  192. action = a
  193. break
  194. }
  195. }
  196. if action != nil {
  197. req.Binding = e.FindBindingWithNoEntity(action)
  198. }
  199. wg, _ := e.ExecRequest(req)
  200. wg.Wait()
  201. }
  202. func TestGetPagingIndexes(t *testing.T) {
  203. assert.Zero(t, getPagingStartIndex(5, 0), "Testing start index from empty list")
  204. assert.Equal(t, int64(4), getPagingStartIndex(5, 10), "Testing start index from mid point")
  205. assert.Equal(t, int64(9), getPagingStartIndex(-1, 10), "Testing start index with negative offset")
  206. assert.Equal(t, int64(0), getPagingStartIndex(15, 10), "Testing start index with large offset")
  207. assert.Equal(t, int64(9), getPagingStartIndex(0, 10), "Testing start index with zero count")
  208. }
  209. func TestUnsetRequiredArgument(t *testing.T) {
  210. req := newExecRequest()
  211. req.Binding.Action = &config.Action{
  212. Title: "Print your name",
  213. Shell: "echo 'Your name is: {{ name }}'",
  214. Arguments: []config.ActionArgument{
  215. {
  216. Name: "name",
  217. Type: "ascii",
  218. },
  219. },
  220. }
  221. req.Arguments = map[string]string{}
  222. out, err := parseActionArguments(req)
  223. assert.Equal(t, "", out)
  224. assert.NotNil(t, err)
  225. }
  226. func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
  227. req := newExecRequest()
  228. req.Binding.Action = &config.Action{
  229. Title: "Print your name",
  230. Shell: "echo 'Your name is: {{ name }}'",
  231. Arguments: []config.ActionArgument{
  232. {
  233. Name: "name",
  234. Type: "ascii",
  235. },
  236. {
  237. Name: "age",
  238. Type: "int",
  239. },
  240. },
  241. }
  242. req.Arguments = map[string]string{
  243. "name": "Fred",
  244. "age": "Not an integer",
  245. }
  246. out, err := parseActionArguments(req)
  247. assert.Equal(t, "", out)
  248. assert.NotNil(t, err)
  249. }
  250. // https://github.com/OliveTin/OliveTin/issues/564
  251. func TestMangleInvalidArgumentValues(t *testing.T) {
  252. e, cfg := testingExecutor()
  253. a1 := &config.Action{
  254. Title: "Validate my date without seconds because I am from an Android phone",
  255. Shell: "echo 'The date is: {{ date }}'",
  256. Arguments: []config.ActionArgument{
  257. {
  258. Name: "date",
  259. Type: "datetime",
  260. },
  261. },
  262. }
  263. cfg.Actions = append(cfg.Actions, a1)
  264. cfg.Sanitize()
  265. // Build bindings for newly added action
  266. e.RebuildActionMap()
  267. req := ExecutionRequest{
  268. // Action: a1,
  269. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  270. Cfg: cfg,
  271. Arguments: map[string]string{
  272. "date": "1990-01-10T12:00", // Invalid format, should be without seconds
  273. },
  274. }
  275. // Set binding to our appended action
  276. req.Binding = e.FindBindingWithNoEntity(a1)
  277. wg, _ := e.ExecRequest(&req)
  278. wg.Wait()
  279. assert.NotNil(t, req.logEntry, "Log entry should not be nil")
  280. assert.Equal(t, req.logEntry.Output, "The date is: 1990-01-10T12:00:00\n", "Date should be mangled to a valid format")
  281. }
  282. func TestWebhookRejectsShellExecution(t *testing.T) {
  283. cfg := config.DefaultConfig()
  284. e := DefaultExecutor(cfg)
  285. a1 := &config.Action{
  286. Title: "Webhook Shell Reject",
  287. Shell: "echo '{{ msg }}'",
  288. Arguments: []config.ActionArgument{
  289. {Name: "msg", Type: "ascii"},
  290. },
  291. }
  292. cfg.Actions = append(cfg.Actions, a1)
  293. cfg.Sanitize()
  294. e.RebuildActionMap()
  295. req := ExecutionRequest{
  296. Tags: []string{"webhook"},
  297. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  298. Cfg: cfg,
  299. Arguments: map[string]string{"msg": "hello"},
  300. Binding: e.FindBindingWithNoEntity(a1),
  301. }
  302. wg, _ := e.ExecRequest(&req)
  303. wg.Wait()
  304. assert.NotNil(t, req.logEntry)
  305. assert.Equal(t, int32(-1337), req.logEntry.ExitCode)
  306. assert.Contains(t, req.logEntry.Output, "webhooks cannot use Shell execution")
  307. }
  308. func TestWebhookAllowsExecExecution(t *testing.T) {
  309. cfg := config.DefaultConfig()
  310. e := DefaultExecutor(cfg)
  311. a1 := &config.Action{
  312. Title: "Webhook Exec OK",
  313. Exec: []string{"echo", "{{ msg }}"},
  314. Arguments: []config.ActionArgument{
  315. {Name: "msg", Type: "ascii"},
  316. },
  317. }
  318. cfg.Actions = append(cfg.Actions, a1)
  319. cfg.Sanitize()
  320. e.RebuildActionMap()
  321. req := ExecutionRequest{
  322. Tags: []string{"webhook"},
  323. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  324. Cfg: cfg,
  325. Arguments: map[string]string{"msg": "hello"},
  326. Binding: e.FindBindingWithNoEntity(a1),
  327. }
  328. wg, _ := e.ExecRequest(&req)
  329. wg.Wait()
  330. assert.NotNil(t, req.logEntry)
  331. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  332. assert.Contains(t, req.logEntry.Output, "hello")
  333. }
  334. func TestWebhookRejectsShellAfterCompleted(t *testing.T) {
  335. cfg := config.DefaultConfig()
  336. e := DefaultExecutor(cfg)
  337. a1 := &config.Action{
  338. Title: "Webhook After Shell Reject",
  339. Exec: []string{"echo", "{{ msg }}"},
  340. ShellAfterCompleted: "echo after",
  341. Arguments: []config.ActionArgument{
  342. {Name: "msg", Type: "ascii"},
  343. },
  344. }
  345. cfg.Actions = append(cfg.Actions, a1)
  346. cfg.Sanitize()
  347. e.RebuildActionMap()
  348. req := ExecutionRequest{
  349. Tags: []string{"webhook"},
  350. AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"),
  351. Cfg: cfg,
  352. Arguments: map[string]string{"msg": "hello"},
  353. Binding: e.FindBindingWithNoEntity(a1),
  354. }
  355. wg, _ := e.ExecRequest(&req)
  356. wg.Wait()
  357. assert.NotNil(t, req.logEntry)
  358. assert.Contains(t, req.logEntry.Output, "webhooks cannot use shellAfterCompleted")
  359. }
  360. func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
  361. cfg := config.DefaultConfig()
  362. e := DefaultExecutor(cfg)
  363. injectedPath := filepath.Join(t.TempDir(), "olivetin-injected")
  364. expectedMainOutput := "'; touch " + injectedPath + "; echo '"
  365. a1 := &config.Action{
  366. Title: "After completion escape",
  367. Shell: "printf %s \"" + expectedMainOutput + "\"",
  368. ShellAfterCompleted: "printf %s {{ output }}",
  369. }
  370. cfg.Actions = append(cfg.Actions, a1)
  371. cfg.Sanitize()
  372. e.RebuildActionMap()
  373. req := ExecutionRequest{
  374. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  375. Cfg: cfg,
  376. Binding: e.FindBindingWithNoEntity(a1),
  377. }
  378. wg, _ := e.ExecRequest(&req)
  379. wg.Wait()
  380. assert.NotNil(t, req.logEntry)
  381. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  382. assert.True(t, strings.HasPrefix(req.logEntry.Output, expectedMainOutput))
  383. assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+expectedMainOutput)
  384. _, err := os.Stat(injectedPath)
  385. assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
  386. }
  387. func TestShellAfterCompletedExpandsQuotedPlaceholders(t *testing.T) {
  388. cases := []struct {
  389. name string
  390. sac string
  391. }{
  392. {"legacy single-quoted", `printf '%s' '{{ output }}'`},
  393. {"modern single-quoted", `printf '%s' '{{ .Arguments.output }}'`},
  394. }
  395. for _, tc := range cases {
  396. t.Run(tc.name, func(t *testing.T) {
  397. cfg := config.DefaultConfig()
  398. executor := DefaultExecutor(cfg)
  399. mainOutput := "quoted-output-ok"
  400. action := &config.Action{
  401. Title: "sac-quoted-" + tc.name,
  402. Shell: "printf %s \"" + mainOutput + "\"",
  403. ShellAfterCompleted: tc.sac,
  404. }
  405. cfg.Actions = append(cfg.Actions, action)
  406. cfg.Sanitize()
  407. executor.RebuildActionMap()
  408. req := ExecutionRequest{
  409. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  410. Cfg: cfg,
  411. Binding: executor.FindBindingWithNoEntity(action),
  412. }
  413. wg, _ := executor.ExecRequest(&req)
  414. wg.Wait()
  415. require.NotNil(t, req.logEntry)
  416. assert.Equal(t, int32(0), req.logEntry.ExitCode)
  417. assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+mainOutput)
  418. })
  419. }
  420. }
  421. func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
  422. payload := func(injectedPath string) string {
  423. return "x; touch " + injectedPath + "; #"
  424. }
  425. cases := []struct {
  426. name string
  427. sac string
  428. }{
  429. {"legacy", "printf %s {{ output }}"},
  430. {"legacy compact", "printf %s {{output}}"},
  431. {"legacy extra spaces", "printf %s {{ output }}"},
  432. {"modern Arguments", "printf %s {{ .Arguments.output }}"},
  433. {"modern compact", "printf %s {{.Arguments.output}}"},
  434. {"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
  435. }
  436. for _, tc := range cases {
  437. t.Run(tc.name, func(t *testing.T) {
  438. cfg := config.DefaultConfig()
  439. executor := DefaultExecutor(cfg)
  440. injectedPath := filepath.Join(t.TempDir(), "injected")
  441. mainPayload := payload(injectedPath)
  442. action := &config.Action{
  443. Title: "sac-injection-" + tc.name,
  444. Shell: "printf %s \"" + mainPayload + "\"",
  445. ShellAfterCompleted: tc.sac,
  446. }
  447. cfg.Actions = append(cfg.Actions, action)
  448. cfg.Sanitize()
  449. executor.RebuildActionMap()
  450. req := ExecutionRequest{
  451. AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
  452. Cfg: cfg,
  453. Binding: executor.FindBindingWithNoEntity(action),
  454. }
  455. wg, _ := executor.ExecRequest(&req)
  456. wg.Wait()
  457. _, err := os.Stat(injectedPath)
  458. assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
  459. })
  460. }
  461. }
  462. func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
  463. cases := []struct {
  464. in string
  465. want string
  466. }{
  467. {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
  468. {`printf %s {{output}}`, `printf %s "$OUTPUT"`},
  469. {`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
  470. {`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
  471. {`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
  472. {`echo {{ exitCode }}`, `echo "$EXITCODE"`},
  473. {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
  474. {`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
  475. {`printf '%s' '{{ output }}'`, `printf '%s' ''"$OUTPUT"''`},
  476. {`printf '%s' '{{ .Arguments.output }}'`, `printf '%s' ''"$OUTPUT"''`},
  477. {`printf '%s' '{{ exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
  478. {`printf '%s' '{{ .Arguments.exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
  479. }
  480. for _, tc := range cases {
  481. assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
  482. }
  483. }
  484. func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
  485. args := map[string]string{
  486. "output": "evil; id",
  487. "exitCode": "1",
  488. "ot_username": "alice",
  489. "ot_executionTrackingId": "track-1",
  490. }
  491. templateArgs := shellAfterTemplateArgs(args)
  492. assert.NotContains(t, templateArgs, "output")
  493. assert.NotContains(t, templateArgs, "exitCode")
  494. assert.Equal(t, "alice", templateArgs["ot_username"])
  495. assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
  496. assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
  497. }
  498. func TestFilterToDefinedArgumentsOnly(t *testing.T) {
  499. req := newExecRequest()
  500. req.Binding.Action = &config.Action{
  501. Title: "Filter test",
  502. Shell: "echo '{{ name }}'",
  503. Arguments: []config.ActionArgument{
  504. {Name: "name", Type: "ascii"},
  505. },
  506. }
  507. req.Arguments = map[string]string{
  508. "name": "Alice",
  509. "webhook_path": "/malicious/$(id)",
  510. "extra_undefined": "ignored",
  511. }
  512. filterToDefinedArgumentsOnly(req)
  513. assert.Equal(t, "Alice", req.Arguments["name"])
  514. assert.Empty(t, req.Arguments["webhook_path"])
  515. assert.Empty(t, req.Arguments["extra_undefined"])
  516. }
  517. func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) {
  518. req := newExecRequest()
  519. req.Binding.Action = &config.Action{
  520. Title: "Filter test",
  521. Shell: "echo test",
  522. Arguments: []config.ActionArgument{},
  523. }
  524. req.Arguments = map[string]string{
  525. "ot_executionTrackingId": "track-123",
  526. "ot_username": "webhook",
  527. }
  528. filterToDefinedArgumentsOnly(req)
  529. assert.Empty(t, req.Arguments["ot_executionTrackingId"])
  530. assert.Empty(t, req.Arguments["ot_username"])
  531. }
  532. func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) {
  533. req := newExecRequest()
  534. req.TrackingID = "server-track-456"
  535. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
  536. req.Binding.Action = &config.Action{
  537. Title: "Filter then inject",
  538. Shell: "echo test",
  539. Arguments: []config.ActionArgument{
  540. {Name: "name", Type: "ascii"},
  541. },
  542. }
  543. req.Arguments = map[string]string{
  544. "name": "Alice",
  545. "ot_executionTrackingId": "attacker-track",
  546. "ot_username": "mallory",
  547. "ot_custom": "polluted",
  548. }
  549. assert.True(t, stepParseArgs(req))
  550. assert.Equal(t, "Alice", req.Arguments["name"])
  551. assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"])
  552. assert.Equal(t, "alice", req.Arguments["ot_username"])
  553. assert.Empty(t, req.Arguments["ot_custom"])
  554. }
  555. func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) {
  556. req := newExecRequest()
  557. req.TrackingID = "server-track-456"
  558. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  559. req.Binding.Action = &config.Action{
  560. Title: "No reserved prefix pollution",
  561. Shell: "echo test",
  562. Arguments: []config.ActionArgument{},
  563. }
  564. req.Arguments = map[string]string{
  565. "ot_custom": "polluted",
  566. }
  567. assert.True(t, stepParseArgs(req))
  568. env := buildEnv(req.Arguments)
  569. assert.False(t, containsEnvPrefix(env, "OT_CUSTOM="))
  570. assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com"))
  571. assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456"))
  572. }
  573. func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) {
  574. unsafeTypes := map[string]struct{}{
  575. "email": {},
  576. "password": {},
  577. "raw_string_multiline": {},
  578. "url": {},
  579. "very_dangerous_raw_string": {},
  580. }
  581. seen := map[string]struct{}{}
  582. for _, arg := range systemArgumentDefinitions {
  583. assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix))
  584. assert.NotEmpty(t, arg.Type)
  585. assert.True(t, arg.RejectNull)
  586. _, duplicate := seen[arg.Name]
  587. assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name)
  588. seen[arg.Name] = struct{}{}
  589. _, unsafe := unsafeTypes[arg.Type]
  590. assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type)
  591. }
  592. }
  593. func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
  594. req := newExecRequest()
  595. req.TrackingID = "server-track-456"
  596. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  597. args, err := validatedSystemArgs(req)
  598. assert.Nil(t, err)
  599. assert.Len(t, args, len(systemArgumentDefinitions))
  600. for _, arg := range systemArgumentDefinitions {
  601. assert.Contains(t, args, arg.Name)
  602. }
  603. }
  604. func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
  605. req := newExecRequest()
  606. req.logEntry = &InternalLogEntry{
  607. Output: "hello",
  608. ExitCode: 7,
  609. }
  610. req.TrackingID = "server-track-456"
  611. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  612. req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"}
  613. args, err := buildShellAfterArgs(req)
  614. assert.Nil(t, err)
  615. assert.Len(t, args, len(systemArgumentDefinitions)+2)
  616. assert.Contains(t, args, "output")
  617. assert.Contains(t, args, "exitCode")
  618. for _, arg := range systemArgumentDefinitions {
  619. assert.Contains(t, args, arg.Name)
  620. }
  621. }
  622. func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) {
  623. req := newExecRequest()
  624. req.logEntry = &InternalLogEntry{}
  625. req.TrackingID = "server-track-456"
  626. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  627. req.Binding.Action = &config.Action{
  628. Title: "Email username",
  629. Shell: "echo test",
  630. Arguments: []config.ActionArgument{},
  631. }
  632. assert.True(t, stepParseArgs(req))
  633. assert.Equal(t, "alice@example.com", req.Arguments["ot_username"])
  634. }
  635. func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) {
  636. req := newExecRequest()
  637. req.logEntry = &InternalLogEntry{}
  638. req.TrackingID = "server-track-456"
  639. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
  640. req.Binding.Action = &config.Action{
  641. Title: "Invalid system arg",
  642. Shell: "echo test",
  643. Arguments: []config.ActionArgument{},
  644. }
  645. assert.False(t, stepParseArgs(req))
  646. assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`)
  647. assert.Empty(t, req.Arguments["ot_username"])
  648. }
  649. func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) {
  650. req := newExecRequest()
  651. req.logEntry = &InternalLogEntry{}
  652. req.TrackingID = "track/../../bad"
  653. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"}
  654. req.Binding.Action = &config.Action{
  655. Title: "Invalid tracking ID",
  656. Shell: "echo test",
  657. Arguments: []config.ActionArgument{},
  658. }
  659. assert.False(t, stepParseArgs(req))
  660. assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`)
  661. assert.Empty(t, req.Arguments["ot_executionTrackingId"])
  662. }
  663. func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
  664. req := newExecRequest()
  665. req.logEntry = &InternalLogEntry{
  666. Output: "hello",
  667. ExitCode: 7,
  668. }
  669. req.TrackingID = "server-track-456"
  670. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"}
  671. req.Binding.Action = &config.Action{
  672. Title: "Shell after",
  673. ShellAfterCompleted: "echo test",
  674. }
  675. args, err := buildShellAfterArgs(req)
  676. assert.Nil(t, err)
  677. assert.Equal(t, "alice@example.com", args["ot_username"])
  678. assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
  679. assert.Equal(t, "hello", args["output"])
  680. assert.Equal(t, "7", args["exitCode"])
  681. }
  682. func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
  683. req := newExecRequest()
  684. req.logEntry = &InternalLogEntry{}
  685. req.TrackingID = "server-track-456"
  686. req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"}
  687. req.Binding.Action = &config.Action{
  688. Title: "Shell after invalid username",
  689. ShellAfterCompleted: "echo test",
  690. }
  691. args, err := buildShellAfterArgs(req)
  692. assert.Nil(t, args)
  693. assert.NotNil(t, err)
  694. assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
  695. }
  696. func containsEnvPrefix(env []string, prefix string) bool {
  697. for _, item := range env {
  698. if strings.HasPrefix(item, prefix) {
  699. return true
  700. }
  701. }
  702. return false
  703. }
  704. func TestTriggerExecutesTriggeredAction(t *testing.T) {
  705. cfg := config.DefaultConfig()
  706. e := DefaultExecutor(cfg)
  707. helloAction := &config.Action{
  708. Title: "Hello world",
  709. Shell: "echo 'Hello World!'",
  710. }
  711. triggerAction := &config.Action{
  712. Title: "Simple action that triggers another action",
  713. Shell: "echo 'Hi'",
  714. Triggers: []string{"Hello world"},
  715. }
  716. cfg.Actions = append(cfg.Actions, helloAction, triggerAction)
  717. cfg.Sanitize()
  718. e.RebuildActionMap()
  719. finishedTitles := make(chan string, 4)
  720. collector := &executionFinishedCollector{ch: finishedTitles}
  721. e.AddListener(collector)
  722. req := &ExecutionRequest{
  723. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  724. Cfg: cfg,
  725. Binding: e.FindBindingWithNoEntity(triggerAction),
  726. }
  727. wg, _ := e.ExecRequest(req)
  728. wg.Wait()
  729. var got []string
  730. for i := 0; i < 2; i++ {
  731. select {
  732. case title := <-finishedTitles:
  733. got = append(got, title)
  734. case <-time.After(2 * time.Second):
  735. t.Fatalf("timed out waiting for execution %d; got %v", i+1, got)
  736. }
  737. }
  738. assert.Contains(t, got, "Hello world", "triggered action must run")
  739. assert.Contains(t, got, "Simple action that triggers another action", "triggering action must run")
  740. }
  741. func TestTriggerUnknownActionTitleSkipsWithoutPanic(t *testing.T) {
  742. cfg := config.DefaultConfig()
  743. e := DefaultExecutor(cfg)
  744. triggerAction := &config.Action{
  745. Title: "Action with bad trigger",
  746. Shell: "echo 'ok'",
  747. Triggers: []string{"Nonexistent action"},
  748. }
  749. cfg.Actions = append(cfg.Actions, triggerAction)
  750. cfg.Sanitize()
  751. e.RebuildActionMap()
  752. finishedTitles := make(chan string, 4)
  753. collector := &executionFinishedCollector{ch: finishedTitles}
  754. e.AddListener(collector)
  755. req := &ExecutionRequest{
  756. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  757. Cfg: cfg,
  758. Binding: e.FindBindingWithNoEntity(triggerAction),
  759. }
  760. wg, _ := e.ExecRequest(req)
  761. wg.Wait()
  762. var got []string
  763. select {
  764. case title := <-finishedTitles:
  765. got = append(got, title)
  766. case <-time.After(500 * time.Millisecond):
  767. }
  768. assert.Len(t, got, 1, "only the triggering action runs; unknown trigger is skipped")
  769. if len(got) > 0 {
  770. assert.Equal(t, "Action with bad trigger", got[0])
  771. }
  772. }
  773. type executionFinishedCollector struct {
  774. ch chan string
  775. }
  776. func (c *executionFinishedCollector) OnExecutionStarted(_ *InternalLogEntry) {}
  777. func (c *executionFinishedCollector) OnExecutionFinished(entry *InternalLogEntry) {
  778. c.ch <- entry.ActionTitle
  779. }
  780. func (c *executionFinishedCollector) OnOutputChunk(_ []byte, _ string) {}
  781. func (c *executionFinishedCollector) OnActionMapRebuilt() {}
  782. func TestSanitizeLogFilename(t *testing.T) {
  783. tests := []struct {
  784. title string
  785. want string
  786. }{
  787. {"Echo Test", "Echo Test"},
  788. {"Create/update Monthly Report", "Create_update Monthly Report"},
  789. {`path\with\backslashes`, "path_with_backslashes"},
  790. {`a:b*c?d"e<f>g|h`, "a_b_c_d_e_f_g_h"},
  791. {"has\x00nul", "has_nul"},
  792. {"tab\there\nand\rreturn", "tab_here_and_return"},
  793. }
  794. for _, tt := range tests {
  795. assert.Equal(t, tt.want, sanitizeLogFilename(tt.title), "title=%q", tt.title)
  796. }
  797. }
  798. func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) {
  799. resultsDir := t.TempDir()
  800. outputDir := t.TempDir()
  801. started := time.Unix(1714333384, 0)
  802. trackingID := "5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2"
  803. title := "Create/update Monthly Report"
  804. req := &ExecutionRequest{
  805. Cfg: &config.Config{
  806. SaveLogs: config.SaveLogsConfig{
  807. ResultsDirectory: resultsDir,
  808. OutputDirectory: outputDir,
  809. },
  810. },
  811. Binding: &ActionBinding{
  812. Action: &config.Action{},
  813. },
  814. logEntry: &InternalLogEntry{
  815. ActionTitle: title,
  816. DatetimeStarted: started,
  817. ExecutionTrackingID: trackingID,
  818. Output: "report ok",
  819. },
  820. }
  821. assert.True(t, stepSaveLog(req))
  822. expectedBase := "Create_update Monthly Report.1714333384." + trackingID
  823. resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
  824. outputPath := filepath.Join(outputDir, expectedBase+".log")
  825. assert.FileExists(t, resultsPath)
  826. assert.FileExists(t, outputPath)
  827. resultsEntries, err := os.ReadDir(resultsDir)
  828. assert.NoError(t, err)
  829. assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
  830. data, err := os.ReadFile(resultsPath)
  831. assert.NoError(t, err)
  832. assert.Contains(t, string(data), title, "YAML content keeps the original action title")
  833. output, err := os.ReadFile(outputPath)
  834. assert.NoError(t, err)
  835. assert.Equal(t, "report ok", string(output))
  836. }
  837. func TestStepSaveLogKeepsSafeTitleFilename(t *testing.T) {
  838. resultsDir := t.TempDir()
  839. started := time.Unix(1714333384, 0)
  840. trackingID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  841. req := &ExecutionRequest{
  842. Cfg: &config.Config{
  843. SaveLogs: config.SaveLogsConfig{
  844. ResultsDirectory: resultsDir,
  845. },
  846. },
  847. Binding: &ActionBinding{
  848. Action: &config.Action{},
  849. },
  850. logEntry: &InternalLogEntry{
  851. ActionTitle: "Echo Test",
  852. DatetimeStarted: started,
  853. ExecutionTrackingID: trackingID,
  854. },
  855. }
  856. assert.True(t, stepSaveLog(req))
  857. expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml")
  858. assert.FileExists(t, expectedPath)
  859. }
  860. func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
  861. resultsDir := t.TempDir()
  862. outputDir := t.TempDir()
  863. started := time.Unix(1714333384, 0)
  864. trackingID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
  865. title := "Bad\x00Title"
  866. req := &ExecutionRequest{
  867. Cfg: &config.Config{
  868. SaveLogs: config.SaveLogsConfig{
  869. ResultsDirectory: resultsDir,
  870. OutputDirectory: outputDir,
  871. },
  872. },
  873. Binding: &ActionBinding{
  874. Action: &config.Action{},
  875. },
  876. logEntry: &InternalLogEntry{
  877. ActionTitle: title,
  878. DatetimeStarted: started,
  879. ExecutionTrackingID: trackingID,
  880. Output: "nul ok",
  881. },
  882. }
  883. assert.True(t, stepSaveLog(req))
  884. expectedBase := "Bad_Title.1714333384." + trackingID
  885. resultsPath := filepath.Join(resultsDir, expectedBase+".yaml")
  886. outputPath := filepath.Join(outputDir, expectedBase+".log")
  887. assert.FileExists(t, resultsPath)
  888. assert.FileExists(t, outputPath)
  889. assert.NotContains(t, resultsPath, "\x00")
  890. assert.NotContains(t, outputPath, "\x00")
  891. output, err := os.ReadFile(outputPath)
  892. assert.NoError(t, err)
  893. assert.Equal(t, "nul ok", string(output))
  894. }
  895. func TestBlockedExecutionPersistsSaveLogs(t *testing.T) {
  896. t.Parallel()
  897. resultsDir := t.TempDir()
  898. outputDir := t.TempDir()
  899. action := &config.Action{
  900. Title: "Blocked report",
  901. Shell: "sleep 1",
  902. MaxConcurrent: 1,
  903. SaveLogs: config.SaveLogsConfig{
  904. ResultsDirectory: resultsDir,
  905. OutputDirectory: outputDir,
  906. },
  907. }
  908. e, cfg := testGroupExecutor([]*config.Action{action}, nil)
  909. binding := e.FindBindingWithNoEntity(action)
  910. wg1, tracking1 := e.ExecRequest(&ExecutionRequest{
  911. Binding: binding,
  912. Cfg: cfg,
  913. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  914. })
  915. waitUntilExecutionStarted(t, e, tracking1)
  916. wg2, tracking2 := e.ExecRequest(&ExecutionRequest{
  917. Binding: binding,
  918. Cfg: cfg,
  919. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  920. })
  921. wg1.Wait()
  922. wg2.Wait()
  923. snapshot, ok := e.SnapshotLog(tracking2)
  924. require.True(t, ok)
  925. require.True(t, snapshot.Blocked)
  926. resultsEntries, err := os.ReadDir(resultsDir)
  927. require.NoError(t, err)
  928. var resultsPath string
  929. for _, entry := range resultsEntries {
  930. if strings.Contains(entry.Name(), tracking2) {
  931. resultsPath = filepath.Join(resultsDir, entry.Name())
  932. break
  933. }
  934. }
  935. require.NotEmpty(t, resultsPath)
  936. outputEntries, err := os.ReadDir(outputDir)
  937. require.NoError(t, err)
  938. var outputPath string
  939. for _, entry := range outputEntries {
  940. if strings.Contains(entry.Name(), tracking2) {
  941. outputPath = filepath.Join(outputDir, entry.Name())
  942. break
  943. }
  944. }
  945. require.NotEmpty(t, outputPath)
  946. resultsData, err := os.ReadFile(resultsPath)
  947. require.NoError(t, err)
  948. assert.Contains(t, string(resultsData), "blocked: true")
  949. assert.Contains(t, string(resultsData), tracking2)
  950. outputData, err := os.ReadFile(outputPath)
  951. require.NoError(t, err)
  952. assert.Contains(t, string(outputData), "Blocked from executing due to concurrency limit")
  953. }
  954. func TestStepSaveLogReturnsFalseWhenDependenciesMissing(t *testing.T) {
  955. started := time.Unix(1714333384, 0)
  956. valid := &ExecutionRequest{
  957. Cfg: &config.Config{},
  958. Binding: &ActionBinding{
  959. Action: &config.Action{},
  960. },
  961. logEntry: &InternalLogEntry{
  962. ActionTitle: "Echo",
  963. DatetimeStarted: started,
  964. ExecutionTrackingID: "cccccccc-dddd-eeee-ffff-000000000000",
  965. },
  966. }
  967. assert.False(t, stepSaveLog(nil))
  968. assert.False(t, stepSaveLog(&ExecutionRequest{}))
  969. missingLog := *valid
  970. missingLog.logEntry = nil
  971. assert.False(t, stepSaveLog(&missingLog))
  972. missingBinding := *valid
  973. missingBinding.Binding = nil
  974. assert.False(t, stepSaveLog(&missingBinding))
  975. missingAction := *valid
  976. missingAction.Binding = &ActionBinding{}
  977. assert.False(t, stepSaveLog(&missingAction))
  978. missingCfg := *valid
  979. missingCfg.Cfg = nil
  980. assert.False(t, stepSaveLog(&missingCfg))
  981. }
  982. func TestLogEntryOutputAvailableWhileRunning(t *testing.T) {
  983. cfg := config.DefaultConfig()
  984. e := DefaultExecutor(cfg)
  985. action := &config.Action{
  986. Title: "Slow output",
  987. Shell: "echo hello-mid-run; sleep 2",
  988. }
  989. cfg.Actions = append(cfg.Actions, action)
  990. cfg.Sanitize()
  991. e.RebuildActionMap()
  992. binding := e.FindBindingWithNoEntity(action)
  993. require.NotNil(t, binding)
  994. wg, trackingID := e.ExecRequest(&ExecutionRequest{
  995. Binding: binding,
  996. Cfg: cfg,
  997. AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"),
  998. })
  999. var sawOutputWhileRunning bool
  1000. require.Eventually(t, func() bool {
  1001. snapshot, ok := e.SnapshotLog(trackingID)
  1002. if !ok {
  1003. return false
  1004. }
  1005. if snapshot.ExecutionFinished {
  1006. return false
  1007. }
  1008. if strings.Contains(snapshot.Output, "hello-mid-run") {
  1009. sawOutputWhileRunning = true
  1010. return true
  1011. }
  1012. return false
  1013. }, 2*time.Second, 10*time.Millisecond)
  1014. wg.Wait()
  1015. require.True(t, sawOutputWhileRunning, "expected Output to contain printed text before ExecutionFinished")
  1016. snapshot, ok := e.SnapshotLog(trackingID)
  1017. require.True(t, ok)
  1018. assert.True(t, snapshot.ExecutionFinished)
  1019. assert.Contains(t, snapshot.Output, "hello-mid-run")
  1020. }