diff --git a/README.md b/README.md index 54fdb91..991371e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ If you use Slack, you can set up an "Outgoing webhook integration" to run variou 1. receive the request, 2. parse the headers, payload and query variables, 3. check if the specified rules for the hook are satisfied, - 3. and finally, pass the specified arguments to the specified command. + 3. and finally, pass the specified arguments to the specified command via + command line arguments or via environment variables. Everything else is the responsibility of the command's author. diff --git a/hook/hook.go b/hook/hook.go index 232970f..1be088b 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -25,6 +25,12 @@ const ( SourceEntireHeaders string = "entire-headers" ) +const ( + // EnvNamespace is the prefix used for passing arguments into the command + // environment. + EnvNamespace string = "HOOK_" +) + // ErrInvalidPayloadSignature describes an invalid payload signature. var ErrInvalidPayloadSignature = errors.New("invalid payload signature") @@ -234,14 +240,15 @@ func (ha *Argument) Get(headers, query, payload *map[string]interface{}) (string // Hook type is a structure containing details for a single hook type Hook struct { - ID string `json:"id"` - ExecuteCommand string `json:"execute-command"` - CommandWorkingDirectory string `json:"command-working-directory"` - ResponseMessage string `json:"response-message"` - CaptureCommandOutput bool `json:"include-command-output-in-response"` - PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"` - JSONStringParameters []Argument `json:"parse-parameters-as-json"` - TriggerRule *Rules `json:"trigger-rule"` + ID string `json:"id"` + ExecuteCommand string `json:"execute-command"` + CommandWorkingDirectory string `json:"command-working-directory"` + ResponseMessage string `json:"response-message"` + CaptureCommandOutput bool `json:"include-command-output-in-response"` + PassEnvironmentToCommand []Argument `json:"pass-environment-to-command"` + PassArgumentsToCommand []Argument `json:"pass-arguments-to-command"` + JSONStringParameters []Argument `json:"parse-parameters-as-json"` + TriggerRule *Rules `json:"trigger-rule"` } // ParseJSONParameters decodes specified arguments to JSON objects and replaces the @@ -295,7 +302,6 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter if arg, ok := h.PassArgumentsToCommand[i].Get(headers, query, payload); ok { args = append(args, arg) } else { - args = append(args, "") return args, &ArgumentError{h.PassArgumentsToCommand[i]} } } @@ -303,6 +309,23 @@ func (h *Hook) ExtractCommandArguments(headers, query, payload *map[string]inter return args, nil } +// ExtractCommandArgumentsForEnv creates a list of arguments in key=value +// format, based on the PassEnvironmentToCommand property that is ready to be used +// with exec.Command(). +func (h *Hook) ExtractCommandArgumentsForEnv(headers, query, payload *map[string]interface{}) ([]string, error) { + var args = make([]string, 0) + + for i := range h.PassEnvironmentToCommand { + if arg, ok := h.PassEnvironmentToCommand[i].Get(headers, query, payload); ok { + args = append(args, EnvNamespace+h.PassEnvironmentToCommand[i].Name+"="+arg) + } else { + return args, &ArgumentError{h.PassEnvironmentToCommand[i]} + } + } + + return args, nil +} + // Hooks is an array of Hook objects type Hooks []Hook @@ -459,7 +482,7 @@ func (r MatchRule) Evaluate(headers, query, payload *map[string]interface{}, bod return err == nil, err } } - return false, &ArgumentError{r.Parameter} + return false, nil } // CommandStatusResponse type encapsulates the executed command exit code, message, stdout and stderr diff --git a/hook/hook_test.go b/hook/hook_test.go index fbd7b0a..932395a 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -123,7 +123,7 @@ var hookExtractCommandArgumentsTests = []struct { }{ {"test", []Argument{Argument{"header", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"test", "z"}, true}, // failures - {"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail", ""}, false}, + {"fail", []Argument{Argument{"payload", "a"}}, &map[string]interface{}{"a": "z"}, nil, nil, []string{"fail"}, false}, } func TestHookExtractCommandArguments(t *testing.T) { @@ -188,8 +188,8 @@ var matchRuleTests = []struct { // failures {"value", "", "", "X", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, {"regex", "^X", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, false}, + {"value", "", "2", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, false}, // reference invalid header // errors - {"value", "", "2", "X", Argument{"header", "a"}, &map[string]interface{}{"y": "z"}, nil, nil, []byte{}, false, true}, // reference invalid header {"regex", "*", "", "", Argument{"header", "a"}, &map[string]interface{}{"a": "z"}, nil, nil, []byte{}, false, true}, // invalid regex {"payload-hash-sha1", "", "secret", "", Argument{"header", "a"}, &map[string]interface{}{"a": ""}, nil, nil, []byte{}, false, true}, // invalid hmac } @@ -262,7 +262,7 @@ var andRuleTests = []struct { "invalid rule", AndRule{{Match: &MatchRule{"value", "", "", "X", Argument{"header", "a"}}}}, &map[string]interface{}{"y": "z"}, nil, nil, nil, - false, true, + false, false, }, } @@ -317,7 +317,7 @@ var orRuleTests = []struct { {Match: &MatchRule{"value", "", "", "z", Argument{"header", "a"}}}, }, &map[string]interface{}{"y": "Z"}, nil, nil, []byte{}, - false, true, + false, false, }, } diff --git a/test/hookecho.go b/test/hookecho.go new file mode 100644 index 0000000..4ac4b4d --- /dev/null +++ b/test/hookecho.go @@ -0,0 +1,26 @@ +// Hook Echo is a simply utility used for testing the Webhook package. + +package main + +import ( + "fmt" + "os" + "strings" +) + +func main() { + if len(os.Args) > 1 { + fmt.Printf("arg: %s\n", strings.Join(os.Args[1:], " ")) + } + + var env []string + for _, v := range os.Environ() { + if strings.HasPrefix(v, "HOOK_") { + env = append(env, v) + } + } + + if len(env) > 0 { + fmt.Printf("env: %s\n", strings.Join(env, " ")) + } +} diff --git a/hooks_test.json b/test/hooks.json.tmpl similarity index 91% rename from hooks_test.json rename to test/hooks.json.tmpl index 49b9f4d..e2f696b 100644 --- a/hooks_test.json +++ b/test/hooks.json.tmpl @@ -1,9 +1,16 @@ [ { "id": "github", - "execute-command": "/bin/echo", + "execute-command": "{{ .Hookecho }}", "command-working-directory": "/", "include-command-output-in-response": true, + "pass-environment-to-command": + [ + { + "source": "payload", + "name": "pusher.email" + } + ], "pass-arguments-to-command": [ { @@ -52,7 +59,7 @@ }, { "id": "bitbucket", - "execute-command": "/bin/echo", + "execute-command": "{{ .Hookecho }}", "command-working-directory": "/", "include-command-output-in-response": true, "response-message": "success", @@ -99,7 +106,7 @@ }, { "id": "gitlab", - "execute-command": "/bin/echo", + "execute-command": "{{ .Hookecho }}", "command-working-directory": "/", "response-message": "success", "include-command-output-in-response": true, @@ -133,3 +140,4 @@ } } ] + diff --git a/webhook.go b/webhook.go index 7d92ce0..9fdc323 100644 --- a/webhook.go +++ b/webhook.go @@ -234,13 +234,20 @@ func handleHook(h *hook.Hook, headers, query, payload *map[string]interface{}, b cmd := exec.Command(h.ExecuteCommand) cmd.Dir = h.CommandWorkingDirectory + cmd.Args, err = h.ExtractCommandArguments(headers, query, payload) if err != nil { log.Printf("error extracting command arguments: %s", err) return "" } - log.Printf("executing %s (%s) with arguments %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Dir) + cmd.Env, err = h.ExtractCommandArgumentsForEnv(headers, query, payload) + if err != nil { + log.Printf("error extracting command arguments: %s", err) + return "" + } + + log.Printf("executing %s (%s) with arguments %s and environment %s using %s as cwd\n", h.ExecuteCommand, cmd.Path, cmd.Args, cmd.Env, cmd.Dir) out, err := cmd.CombinedOutput() diff --git a/webhook_test.go b/webhook_test.go index 2b71b53..74b1c2f 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -1,5 +1,3 @@ -// +build !windows - package main import ( @@ -13,18 +11,28 @@ import ( "runtime" "strings" "testing" + "text/template" "time" + + "github.com/adnanh/webhook/hook" ) func TestWebhook(t *testing.T) { - bin, cleanup := buildWebhook(t) - defer cleanup() + hookecho, cleanupHookecho := buildHookecho(t) + defer cleanupHookecho() + + config, cleanupConfig := genConfig(t, hookecho) + defer cleanupConfig() + + webhook, cleanupWebhook := buildWebhook(t) + defer cleanupWebhook() ip, port := serverAddress(t) - args := []string{"-hooks=hooks_test.json", fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"} + args := []string{fmt.Sprintf("-hooks=%s", config), fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"} - cmd := exec.Command(bin, args...) + cmd := exec.Command(webhook, args...) //cmd.Stderr = os.Stderr // uncomment to see verbose output + cmd.Env = webhookEnv() cmd.Args[0] = "webhook" if err := cmd.Start(); err != nil { t.Fatalf("failed to start webhook: %s", err) @@ -41,10 +49,8 @@ func TestWebhook(t *testing.T) { t.Errorf("New request failed: %s", err) } - if tt.headers != nil { - for k, v := range tt.headers { - req.Header.Add(k, v) - } + for k, v := range tt.headers { + req.Header.Add(k, v) } var res *http.Response @@ -73,6 +79,58 @@ func TestWebhook(t *testing.T) { } } +func buildHookecho(t *testing.T) (bin string, cleanup func()) { + tmp, err := ioutil.TempDir("", "hookecho-test-") + if err != nil { + t.Fatal(err) + } + defer func() { + if cleanup == nil { + os.RemoveAll(tmp) + } + }() + + bin = filepath.Join(tmp, "hookecho") + if runtime.GOOS == "windows" { + bin += ".exe" + } + + cmd := exec.Command("go", "build", "-o", bin, "test/hookecho.go") + if err := cmd.Run(); err != nil { + t.Fatalf("Building hookecho: %v", err) + } + + return bin, func() { os.RemoveAll(tmp) } +} + +func genConfig(t *testing.T, bin string) (config string, cleanup func()) { + tmpl := template.Must(template.ParseFiles("test/hooks.json.tmpl")) + + tmp, err := ioutil.TempDir("", "webhook-config-") + if err != nil { + t.Fatal(err) + } + defer func() { + if cleanup == nil { + os.RemoveAll(tmp) + } + }() + + path := filepath.Join(tmp, "hooks.json") + file, err := os.Create(path) + if err != nil { + t.Fatalf("Creating config template: %v", err) + } + defer file.Close() + + data := struct{ Hookecho string }{filepath.ToSlash(bin)} + if err := tmpl.Execute(file, data); err != nil { + t.Fatalf("Executing template: %v", err) + } + + return path, func() { os.RemoveAll(tmp) } +} + func buildWebhook(t *testing.T) (bin string, cleanup func()) { tmp, err := ioutil.TempDir("", "webhook-test-") if err != nil { @@ -142,6 +200,18 @@ func killAndWait(cmd *exec.Cmd) { cmd.Wait() } +// webhookEnv returns the process environment without any existing hook +// namespace variables. +func webhookEnv() (env []string) { + for _, v := range os.Environ() { + if strings.HasPrefix(v, hook.EnvNamespace) { + continue + } + env = append(env, v) + } + return +} + var hookHandlerTests = []struct { desc string id string @@ -302,7 +372,7 @@ var hookHandlerTests = []struct { }`, false, http.StatusOK, - `{"message":"","output":"1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\n","error":""}`, + `{"output":"arg: 1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\nenv: HOOK_pusher.email=lolwut@noway.biz\n"}`, }, { "bitbucket", // bitbucket sends their payload using uriencoded params. @@ -311,7 +381,7 @@ var hookHandlerTests = []struct { `payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand ","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`, true, http.StatusOK, - `{"message":"success","output":"\n","error":""}`, + `{"message":"success"}`, }, { "gitlab", @@ -361,7 +431,7 @@ var hookHandlerTests = []struct { }`, false, http.StatusOK, - `{"message":"success","output":"b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n","error":""}`, + `{"message":"success","output":"arg: b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n"}`, }, {"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`},